Robert C. Martin 對這個原則的定義是:Clients should not be forced to depend upon interface methods that they do not use.(不應強迫客戶端依賴它不使用的方法)。
這裡的「客戶端」不是指使用者介面的終端用戶,而是指在程式碼中使用某個介面的類別或模組;當一個介面提供了十個方法,但某個實作類別只需要其中三個,ISP 說這樣的設計是有問題的。
與 OOP 的關係:抽象(Abstraction)
介面(Interface)是 OOP 抽象機制的體現,抽象的目的是「只暴露必要的能力,隱藏不必要的細節」;一個設計良好的介面應該像是一份精確的「能力聲明」:「持有這個介面的物件,保證能做 X、Y、Z。」
然而,若一個介面同時聲明了十幾個能力,而這些能力並非總是同時需要,這個介面就失去了精準性;實作者不得不提供所有能力的實作,即使其中有些對它毫無意義;呼叫端也無法從介面名稱判斷「這個物件究竟擅長做什麼」,因為它什麼都做。
ISP 的本質是對抽象品質的要求:介面應精簡、專一,只描述一個「角色(role)」。
「肥大介面」的危害
假設系統有一個 IWorker 介面:
1public interface IWorker
2{
3 void Work();
4 void Eat();
5 void TakeBreak();
6 void ReceiveSalary();
7}
對於人類員工來說,這四個方法都有意義;但如果在系統中引入了「工業機器人」(自動化設備),它同樣需要實作 IWorker(因為它也需要執行工作),但機器人不吃飯、不休息、也不領薪水,這時機器人類別就被迫實作它根本不支援的方法:
1public class IndustrialRobot : IWorker
2{
3 public void Work() { Console.WriteLine("機器人執行工序中..."); }
4
5 // 機器人不需要這些,但被強迫實作
6 public void Eat() => throw new NotSupportedException("機器人不需要進食");
7 public void TakeBreak() => throw new NotSupportedException("機器人不需要休息");
8 public void ReceiveSalary() => throw new NotSupportedException("機器人不領薪水");
9}
這個設計同時違反了 ISP 和 LSP:IndustrialRobot 宣稱自己是一個 IWorker,但呼叫 Eat() 會在執行期間報錯,呼叫端如果不特別防範就會有潛在風險。
程式碼範例
違反 ISP:多功能複合介面
1// 一個「萬能」的多媒體介面,試圖描述所有可能的媒體操作
2public interface IMediaDevice
3{
4 // 播放功能
5 void Play(string filePath);
6 void Pause();
7 void Stop();
8
9 // 錄製功能
10 void StartRecording(string outputPath);
11 void StopRecording();
12
13 // 串流功能
14 void StreamTo(string url);
15 void StopStreaming();
16
17 // 轉檔功能
18 void ConvertTo(string format, string outputPath);
19}
20
21// 問題:一個簡單的 MP3 播放器被迫實作所有功能
22public class Mp3Player : IMediaDevice
23{
24 public void Play(string filePath) { Console.WriteLine($"播放:{filePath}"); }
25 public void Pause() { Console.WriteLine("暫停"); }
26 public void Stop() { Console.WriteLine("停止"); }
27
28 // 以下這個播放器完全不支援,但被介面強制要求實作
29 public void StartRecording(string outputPath) => throw new NotSupportedException("不支援錄製");
30 public void StopRecording() => throw new NotSupportedException("不支援錄製");
31 public void StreamTo(string url) => throw new NotSupportedException("不支援串流");
32 public void StopStreaming() => throw new NotSupportedException("不支援串流");
33 public void ConvertTo(string format, string outputPath) => throw new NotSupportedException("不支援轉檔");
34}
35
36// 問題進一步惡化:若 IMediaDevice 新增一個方法(例如 SetSubtitle()),
37// 所有實作類別——包括根本不顯示字幕的 MP3 播放器——都必須更新
遵守 ISP:拆分為精簡的角色介面(Role Interfaces)
重構的方向是將 IMediaDevice 拆成多個精簡的「角色介面」,每個介面只描述一種能力,實作類別按需組合:
1// 每個介面只描述一個角色(能力)
2public interface IPlayable
3{
4 void Play(string filePath);
5 void Pause();
6 void Stop();
7}
8
9public interface IRecordable
10{
11 void StartRecording(string outputPath);
12 void StopRecording();
13}
14
15public interface IStreamable
16{
17 void StreamTo(string url);
18 void StopStreaming();
19}
20
21public interface IConvertible
22{
23 void ConvertTo(string format, string outputPath);
24}
25
26// MP3 播放器:只實作它真正支援的能力
27// 任何使用 IPlayable 的呼叫端都可以安全使用這個類別
28public class Mp3Player : IPlayable
29{
30 private bool _isPlaying = false;
31
32 public void Play(string filePath)
33 {
34 _isPlaying = true;
35 Console.WriteLine($"MP3 播放:{filePath}");
36 }
37
38 public void Pause()
39 {
40 if (_isPlaying)
41 Console.WriteLine("MP3 暫停");
42 }
43
44 public void Stop()
45 {
46 _isPlaying = false;
47 Console.WriteLine("MP3 停止");
48 }
49}
50
51// 專業錄音裝置:實作播放與錄製
52public class AudioRecorder : IPlayable, IRecordable
53{
54 public void Play(string filePath) { Console.WriteLine($"播放:{filePath}"); }
55 public void Pause() { Console.WriteLine("暫停"); }
56 public void Stop() { Console.WriteLine("停止"); }
57 public void StartRecording(string path) { Console.WriteLine($"開始錄音,輸出至:{path}"); }
58 public void StopRecording() { Console.WriteLine("停止錄音"); }
59}
60
61// 專業直播主機:實作播放、錄製與串流(但不轉檔)
62public class LiveStreamingStation : IPlayable, IRecordable, IStreamable
63{
64 public void Play(string filePath) { Console.WriteLine($"播放:{filePath}"); }
65 public void Pause() { Console.WriteLine("暫停"); }
66 public void Stop() { Console.WriteLine("停止"); }
67 public void StartRecording(string path) { Console.WriteLine($"錄製中,儲存至:{path}"); }
68 public void StopRecording() { Console.WriteLine("停止錄製"); }
69 public void StreamTo(string url) { Console.WriteLine($"直播中,推流至:{url}"); }
70 public void StopStreaming() { Console.WriteLine("停止直播"); }
71}
72
73// 呼叫端的依賴範圍精確、最小化
74// 這個 Service 只需要「可以播放」的能力,不在乎設備是否支援串流或錄製
75public class MediaPlayerService
76{
77 private readonly IPlayable _player;
78
79 public MediaPlayerService(IPlayable player) => _player = player;
80
81 public void PlayPlaylist(IEnumerable<string> files)
82 {
83 foreach (var file in files)
84 {
85 _player.Play(file);
86 // 可以傳入 Mp3Player、AudioRecorder、LiveStreamingStation...
87 // 任何 IPlayable 都行,而且行為都正確
88 }
89 }
90}
ISP 在實務中的常見場景
Repository 介面的讀寫分離
在讀寫比例差異大、或有唯讀資料需求的系統中,將 Repository 拆分為讀取介面與寫入介面是常見的做法:
1// 讀取介面:只包含查詢相關的操作
2public interface IOrderReadRepository
3{
4 Order? FindById(int id);
5 IEnumerable<Order> FindByCustomer(int customerId);
6 IEnumerable<Order> FindByDateRange(DateTime from, DateTime to);
7 int CountByStatus(OrderStatus status);
8}
9
10// 寫入介面:只包含資料修改相關的操作
11public interface IOrderWriteRepository
12{
13 void Add(Order order);
14 void Update(Order order);
15 void Delete(int orderId);
16}
17
18// 實際的資料庫實作,組合了兩個介面
19public class SqlOrderRepository : IOrderReadRepository, IOrderWriteRepository
20{
21 // 實作所有讀寫操作...
22 public Order? FindById(int id) { /* SQL 查詢 */ return null; }
23 public IEnumerable<Order> FindByCustomer(int customerId) { /* SQL 查詢 */ return Enumerable.Empty<Order>(); }
24 public IEnumerable<Order> FindByDateRange(DateTime from, DateTime to) { /* SQL 查詢 */ return Enumerable.Empty<Order>(); }
25 public int CountByStatus(OrderStatus status) { /* SQL 查詢 */ return 0; }
26 public void Add(Order order) { /* SQL INSERT */ }
27 public void Update(Order order) { /* SQL UPDATE */ }
28 public void Delete(int orderId) { /* SQL DELETE */ }
29}
30
31// 報表服務只需要讀取能力,注入 IOrderReadRepository 就夠了
32// 這個服務不會意外呼叫到 Add/Update/Delete,因為介面不提供
33public class OrderReportService
34{
35 private readonly IOrderReadRepository _repo;
36
37 public OrderReportService(IOrderReadRepository repo) => _repo = repo;
38
39 public ReportData GenerateMonthlyReport(int month, int year)
40 {
41 var from = new DateTime(year, month, 1);
42 var to = from.AddMonths(1).AddDays(-1);
43 var orders = _repo.FindByDateRange(from, to);
44 // 產生報表...
45 return new ReportData();
46 }
47}
48
49// 單元測試中,可以用只實作 IOrderReadRepository 的假物件
50public class InMemoryOrderReadRepository : IOrderReadRepository
51{
52 private readonly List<Order> _orders;
53 public InMemoryOrderReadRepository(IEnumerable<Order> orders) => _orders = orders.ToList();
54
55 public Order? FindById(int id) => _orders.FirstOrDefault(o => o.Id == id);
56 public IEnumerable<Order> FindByCustomer(int customerId) => _orders.Where(o => o.CustomerId == customerId);
57 public IEnumerable<Order> FindByDateRange(DateTime from, DateTime to)
58 => _orders.Where(o => o.CreatedAt >= from && o.CreatedAt <= to);
59 public int CountByStatus(OrderStatus status) => _orders.Count(o => o.Status == status);
60}
通知系統的能力分離
1// 各種通知管道的獨立介面
2public interface IEmailSender
3{
4 Task SendEmailAsync(string to, string subject, string body);
5}
6
7public interface ISmsSender
8{
9 Task SendSmsAsync(string phoneNumber, string message);
10}
11
12public interface IPushNotificationSender
13{
14 Task SendPushAsync(string deviceToken, string title, string body);
15}
16
17// 全功能通知服務:整合所有管道
18public class FullNotificationService : IEmailSender, ISmsSender, IPushNotificationSender
19{
20 public Task SendEmailAsync(string to, string subject, string body)
21 {
22 Console.WriteLine($"Email → {to}: {subject}");
23 return Task.CompletedTask;
24 }
25
26 public Task SendSmsAsync(string phoneNumber, string message)
27 {
28 Console.WriteLine($"SMS → {phoneNumber}: {message}");
29 return Task.CompletedTask;
30 }
31
32 public Task SendPushAsync(string deviceToken, string title, string body)
33 {
34 Console.WriteLine($"Push → {deviceToken}: {title}");
35 return Task.CompletedTask;
36 }
37}
38
39// 訂單服務只需要寄 Email 確認函,它只依賴 IEmailSender
40// 若通知系統後來新增了 Slack 整合,這個服務完全不受影響
41public class OrderConfirmationService
42{
43 private readonly IEmailSender _emailSender;
44
45 public OrderConfirmationService(IEmailSender emailSender)
46 => _emailSender = emailSender;
47
48 public async Task SendConfirmationAsync(Order order)
49 {
50 await _emailSender.SendEmailAsync(
51 order.CustomerEmail,
52 $"訂單確認 #{order.Id}",
53 $"您的訂單已確認,金額:{order.Total:C}"
54 );
55 }
56}
重點回顧
- ISP 的核心:介面應該是精簡的角色(role)描述,一個介面只描述一種能力。
- 肥大介面的危害:強迫實作者提供無意義的空實作或拋出例外,讓呼叫端的依賴比必要的更廣,也讓測試更難撰寫。
- 解決方法:將大介面拆分為多個小介面(角色介面),實作類別按需組合多個介面。
- ISP 與 SRP 是同一精神在不同層次的體現:SRP 管類別的職責邊界,ISP 管介面的能力邊界。