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 管介面的能力邊界。