ข้าม​ไป​ยัง​เนื้อหา

Interface Segregation Principle

อย่า​บังคับ​ให้​ผู้​ใช้​พึ่งพา method ที่​ไม่​ได้​ใช้

Interface Segregation Principle (ISP) คือ​หลักการ​ตัว​ที่​สาม-สี่​ใน​กลุ่ม SOLID ที่ Robert C. Martin (“Uncle Bob”) ตั้ง​ชื่อ​และ​ให้​นิยาม​ไว้​ว่า

“Clients should not be forced to depend upon interfaces that they do not use.”

กล่าว​คือ ผู้​ใช้ (client) ไม่​ควร​ถูก​บังคับ​ให้​พึ่งพา method ที่​พวก​เขา​ไม่​ได้​ใช้ interface ควร​ออกแบบ​มา​เพื่อ​ผู้​ใช้ ไม่ใช่​เพื่อ​ความ​สะดวก​ของ library หรือ​ลำดับ​ชั้น class นัก​พัฒนา​ควร​เลือก​ใช้ interface ที่​บาง​และ​มุ่ง​เฉพาะ (thin, focused) มากกว่า interface แบบ “อ้วน” (fat) ที่​รวม function หลากหลาย​เกิน​กว่า​ที่ client หนึ่ง ๆ ต้องการ

Martin คิด​หลักการ​นี้​ขึ้น​ระหว่าง​เป็น​ที่ปรึกษา​ให้ Xerox ใน​โครงการ​ซอฟต์แวร์​ควบคุม​เครื่องพิมพ์​รุ่น​ใหม่ ระบบ​เดิม​มี class Job ตัว​เดียว​ที่​ทุก feature (พิมพ์ เย็บ​เล่ม สแกน ฯลฯ) ต้อง​พึ่งพา ทำให้การ​แก้ code เล็ก ๆ จุด​หนึ่ง​กระเทือน​ไป​ทั้ง​ระบบ และ​การ build/deploy แต่ละ​ครั้ง​กิน​เวลา​เป็น​ชั่วโมง ทาง​แก้​คือ​แทรก interface บาง ๆ เฉพาะ​บทบาท (เช่น PrintJobInterface, StapleJobInterface) คั่น​ระหว่าง client กับ class Job ที่ implement ทุก interface เหล่า​นั้น — client แต่ละ​ตัว​จะ​เห็น​แค่ interface ที่​ตัวเอง​ต้อง​ใช้ interface ลักษณะ​นี้​บาง​ครั้ง​เรียก​ว่า role interface เพราะ​สะท้อน​บทบาท​ของ​ผู้​ใช้​แต่ละ​กลุ่ม ไม่ใช่​โครงสร้าง​ภายใน​ของ implementation

ใน​หลาย​ภาษา เช่น C# interface สามารถ​สืบทอด​จาก interface อื่น​ได้​หลาย​ตัว ดังนั้น​หาก​บาง​ส่วน​ของ application ต้องการ interface ที่​ใหญ่​กว่า แต่​ส่วน​อื่น​ไม่​ต้องการ ก็​สามารถ​ประกอบ interface ใหญ่​ขึ้น​จาก interface เล็ก​หลาย​ตัว​ได้ วิธี​นี้​ยัง​เหมาะ​เมื่อ​กำลัง refactor codebase เก่า​ที่​มี interface ขนาด​ใหญ่​ซึ่ง​แตก​ออก​ไม่​ได้​ทันที

1. ลด​การ recompile/redeploy ที่​ไม่​จำเป็น — เมื่อ interface อ้วน​ถูก​แก้ (เพิ่ม method ใหม่) ทุก class ที่ implement มัน​ต้อง​ถูก​แตะ​ต้อง แม้​จะ​ไม่​ได้​ใช้ method ใหม่​นั้น​เลย นี่​คือ​ปัญหา​ต้นตอ​ที่ Martin เจอ​ที่ Xerox — small change แต่ ripple effect กว้าง

2. ลด​ความ​เสี่ยง​ละเมิด Liskov Substitution Principle — interface ที่​เล็ก​ลง implement ได้​ครบถ้วน​ง่าย​กว่า จึง​มี​โอกาส​น้อย​ที่​จะ​เกิด​การ implement เพียง​บาง​ส่วน​แล้ว throw NotSupportedException ทิ้ง method ที่​ไม่​รองรับ​ไว้ ซึ่ง​เป็น​สัญญาณ​คลาสสิก​ของ​การ​ละเมิด LSP

3. เพิ่ม​ความ​ยืดหยุ่น​ของ implementation — เพราะ​แต่ละ​ส่วน​ของ interface ใหญ่​สามารถ implement ได้​ต่าง​วิธี​กัน ลอง​พิจารณา Repository pattern ซึ่ง​มัก​มี​ทั้ง method สำหรับ​อ่าน​และ​เขียน รูปแบบ​เพิ่ม​ประสิทธิภาพ​ที่​พบ​บ่อย​สำหรับ​การ​อ่าน​คือ​เพิ่ม​ชั้น caching แต่​นั่น​สม​เหตุ​สม​ผล​เฉพาะ​ฝั่ง​อ่าน​เท่านั้น ใน​ทำนอง​เดียวกัน scalability มัก​ดี​ขึ้น​ได้​ด้วย​การ​เข้า​คิว​คำ​สั่ง​เขียน​แทน​การ​ทำ​ทันที แต่​คง​ไม่​เข้า​คิว query การ​มี IRepository ที่​ประกอบ​จาก IReadOnlyRepository และ IWriteRepository จึง​เปิด​ทาง​ให้​มี implementation พื้นฐาน​ที่​คุย​กับ data store ตรง ๆ และ implementation แยก​ที่​ใช้ caching หรือ queuing ได้​โดย​ไม่​กระทบ​กัน

4. ทำให้ testing และ mocking ง่าย​ขึ้น — mock หรือ stub ของ interface บาง​ที่​มี method ไม่​กี่​ตัว​เขียน​และ​อ่าน​ง่าย​กว่า mock ของ interface ที่​มี 20 method ซึ่ง test หนึ่ง ๆ ใช้​แค่ 2 ตัว

5. ขยาย​ไป​ถึง​ระบบ distributed ด้วย — แนวคิด​เดียวกัน​นี้​ปรากฏ​ใน high-cohesion principle ของ GRASP และ​เป็น​หนึ่ง​ใน​หลักการ IDEALS สำหรับ​การ​ออกแบบ microservice คือ service แต่ละ​ตัว​ควร​เปิด API เฉพาะ​ที่​ผู้บริโภค​แต่ละ​กลุ่ม​ต้อง​ใช้​จริง ไม่ใช่ endpoint รวมมิตร​ที่​ผู้บริโภค​ส่วน​ใหญ่​ไม่​แตะ

interface แบบ “อ้วน” ที่​มี​แนวโน้ม​โต​เกิน​ควบคุม

public interface IMembership
{
bool Login(string username, string password);
void Logout(string username);
Guid Register(string username, string password, string email);
void ForgotPassword(string username);
}

ไม่​ยาก​เลย​ที่​จะ​จินตนาการ​ว่า interface เช่น​นี้​จะ​โต​จน​ควบคุม​ไม่​ได้​และ​มี function มากกว่าที่ class ใด class หนึ่ง​จะ​ต้องการ เช่น ฟอร์ม login หน้า​เว็บ​ต้องการ​แค่ Login/Logout แต่​ถ้า​พึ่งพา IMembership ตรง ๆ มัน​ก็​ต้อง​รู้จัก Register และ ForgotPassword ไป​ด้วย​ทั้ง​ที่​ไม่​เคย​เรียก​ใช้ — และ​ถ้า​ใคร​มา​เพิ่ม method ResetPasswordWithSecurityQuestion เข้าไป​ใน IMembership ฟอร์ม login ก็​ต้อง recompile ไป​ด้วย​ทั้ง​ที่​ไม่​เกี่ยวข้อง​เลย

แยก interface เฉพาะ​สำหรับ login แล้ว​ให้​ตัว​เดิม​สืบทอด

public interface ILogin
{
bool Login(string username, string password);
void Logout(string username);
}
public interface IMembership : ILogin
{
Guid Register(string username, string password, string email);
void ForgotPassword(string username);
}

ตอน​นี้​ฟอร์ม login (client) พึ่งพา​แค่ ILogin เท่านั้น — ไม่รู้จัก​และ​ไม่​สนใจ​ว่า​มี Register หรือ ForgotPassword อยู่​ด้วย​หรือ​ไม่

ขยาย​ต่อ​ไป​ได้​อีก​จน​จบ​ลง​ที่ interface “อ้วน” ตัว​เดิม​ซึ่ง​เหลือ​ไว้​เพียง​ด้วย​เหตุผล​ด้าน legacy และ​ประกอบ​ขึ้น​จาก interface บาง​ล้วน ๆ:

ประกอบ interface อ้วน​ขึ้น​จาก interface ย่อย​ล้วน ๆ

public interface ILogin
{
bool Login(string username, string password);
void Logout(string username);
}
public interface IRegister
{
Guid Register(string username, string password, string email);
}
public interface IForgotPassword
{
void ForgotPassword(string username);
}
// ยังคงมี IMembership ไว้เพื่อ backward compatibility
// แต่ client ใหม่ ๆ ควรพึ่งพา interface ย่อยโดยตรง
public interface IMembership : ILogin, IRegister, IForgotPassword
{
}

ตัวอย่าง​ที่​สอง — เครื่องพิมพ์​อเนกประสงค์ ใกล้​เคียง​กับ​กรณี​ดั้งเดิม​ของ Martin ที่ Xerox

// ก่อนแก้: interface อ้วนตัวเดียวบังคับให้ทุกเครื่องพิมพ์ implement ทุกความสามารถ
public interface IMultiFunctionDevice
{
void Print(Document doc);
void Scan(Document doc);
void Fax(Document doc);
void Staple(Document doc);
}
// เครื่องพิมพ์ธรรมดาที่พิมพ์ได้อย่างเดียว ถูกบังคับให้ throw ทิ้ง method ที่ทำไม่ได้
public class BasicPrinter : IMultiFunctionDevice
{
public void Print(Document doc) { /* พิมพ์เอกสาร */ }
public void Scan(Document doc) => throw new NotSupportedException();
public void Fax(Document doc) => throw new NotSupportedException();
public void Staple(Document doc) => throw new NotSupportedException();
}
// หลังแก้: แยกบทบาทเป็น interface บาง ๆ ตามความสามารถจริง
public interface IPrinter
{
void Print(Document doc);
}
public interface IScanner
{
void Scan(Document doc);
}
public interface IFax
{
void Fax(Document doc);
}
// เครื่องพิมพ์ธรรมดา implement เฉพาะ interface ที่ตรงกับความสามารถจริง
public class BasicPrinter : IPrinter
{
public void Print(Document doc) { /* พิมพ์เอกสาร */ }
}
// เครื่องอเนกประสงค์ implement หลาย interface ตามที่มันทำได้จริง
public class MultiFunctionPrinter : IPrinter, IScanner, IFax
{
public void Print(Document doc) { /* พิมพ์เอกสาร */ }
public void Scan(Document doc) { /* สแกนเอกสาร */ }
public void Fax(Document doc) { /* ส่งแฟกซ์ */ }
}

client ที่​ต้องการ​แค่​พิมพ์ ก็​พึ่งพา IPrinter เท่านั้น ไม่​ต้อง mock Scan/Fax/Staple ใน unit test และ​ไม่​ต้อง​กังวล​ว่า BasicPrinter จะ throw NotSupportedException เพราะ​มัน​ไม่​เคย implement method ที่​ทำ​ไม่​ได้​ตั้งแต่​แรก

แผนภาพ​ด้าน​ล่าง​แสดง​ทิศทางการ​พึ่งพา: client แต่ละ​กลุ่ม​พึ่งพา​เฉพาะ interface บทบาท​ของ​ตัวเอง ส่วน class ที่​ทำได้​หลาย​อย่าง​จึง​ค่อย implement หลาย interface

flowchart LR
    LoginClient["ฟอร์ม login"] --> ILogin
    RegisterClient["หน้าสมัครสมาชิก"] --> IRegister
    ForgotClient["หน้าลืมรหัสผ่าน"] --> IForgotPassword
    ILogin -.-> Membership["MembershipService"]
    IRegister -.-> Membership
    IForgotPassword -.-> Membership
  • method ที่ throw NotImplementedException หรือ NotSupportedException — สัญญาณ​ชัดเจน​ที่สุด​ว่า class ถูก​บังคับ​ให้ implement สิ่ง​ที่​ไม่​เกี่ยวข้อง​กับ​มัน
  • interface มี method จำนวน​มาก​ที่ client ส่วน​ใหญ่​ใช้​แค่ 1-2 ตัว — บ่ง​ชี้​ว่า interface รวม​หลาย​บทบาท​เข้า​ด้วย​กัน​โดย​ไม่​จำเป็น
  • การ​แก้ interface ตัว​เดียว​ทำให้​ต้อง recompile/redeploy module ที่​ไม่​เกี่ยวข้อง — เหมือน​ปัญหา​ต้นทาง​ที่ Xerox เจอ
  • mock/stub ใน​เทส​ต้อง​เติม method ปลอม​จำนวน​มาก​ที่ test ไม่​ได้​ใช้​เลย — เพียง​เพื่อ​ให้ compile ผ่าน​ตาม interface
  • ชื่อ interface กว้าง​และ​กำกวม เช่น IManager, IService, IHandler ที่​รวม​ทุก​อย่าง​เกี่ยว​กับ entity หนึ่ง​ไว้​ใน​ที่​เดียว แทนที่​จะ​แยก​ตาม​บทบาท​ของ​ผู้​ใช้​แต่ละ​กลุ่ม
  • การ​เพิ่ม feature ใหม่​ให้​ระบบ​หนึ่ง ทำให้​ทีม​อื่น​ที่​ไม่​เกี่ยวข้อง​ต้อง implement method เพิ่ม​ใน class ของ​ตัวเอง เพียง​เพราะ implement interface อ้วน​ร่วม​กัน​อยู่