LSP (Liskov Substitution Principle) เป็นหลักการที่สามของ SOLID ซึ่งกำหนดเงื่อนไขสำหรับการสืบทอดที่ถูกต้องในการเขียนโปรแกรมเชิงวัตถุ หลักการนี้ถูกกำหนดโดยบาร์บารา ลิสคอฟในปี 1987 และทำให้เป็นทางการดังนี้: ถ้า S เป็นชนิดย่อยของ T ดังนั้นวัตถุชนิด T สามารถถูกแทนที่ด้วยวัตถุชนิด S โดยไม่เปลี่ยนคุณสมบัติของโปรแกรม ดังที่ระบุไว้ในหนังสือของโรเบิร์ต มาร์ติน Clean Architecture (2017), หลักการแทนที่ กำหนดให้คลาสย่อยต้องไม่ทำให้สัญญาของคลาสพื้นฐานอ่อนแอลง
ประเด็นสำคัญ
LSP (Liskov Substitution Principle) เป็นหลักการแทนที่ที่ถูกกำหนดโดยบาร์บารา ลิสคอฟในการประชุม OOPSLA ปี 1987 นิยามอย่างเป็นทางการ: ให้ q(x) เป็นคุณสมบัติที่พิสูจน์ได้ของวัตถุ x ชนิด T ดังนั้น q(y) ต้องพิสูจน์ได้สำหรับวัตถุ y ชนิด S โดยที่ S เป็นชนิดย่อยของ T พูดง่ายๆ: วัตถุของคลาสย่อยต้องมีพฤติกรรมที่ทำให้โค้ดที่ทำงานกับคลาสพื้นฐานยังคงทำงานได้อย่างถูกต้องกับคลาสย่อยเช่นกัน
ในทางปฏิบัติ LSP หมายความว่า คลาสย่อยต้องไม่ละเมิดสัญญา ของคลาสพื้นฐาน สัญญาประกอบด้วยเงื่อนไขก่อน (สิ่งที่จำเป็นสำหรับการเรียกเมธอด) เงื่อนไขหลัง (สิ่งที่รับประกันหลังการเรียก) และค่าคงที่ (เงื่อนไขที่คงอยู่ตลอดอายุของวัตถุ) คลาสย่อยอาจทำให้เงื่อนไขก่อนแข็งแกร่งขึ้นหรือทำให้เงื่อนไขหลังอ่อนแอลง — นี่คือการละเมิด LSP
ตัวอย่างคลาสสิกของการละเมิด LSP คือสี่เหลี่ยมจัตุรัสที่สืบทอดจากสี่เหลี่ยมผืนผ้า เมธอด setWidth ของสี่เหลี่ยมผืนผ้ากำหนดความกว้าง ในขณะที่สี่เหลี่ยมจัตุรัสกำหนดทั้งความกว้างและความสูง ไคลเอนต์ที่คาดหวังพฤติกรรมของสี่เหลี่ยมผืนผ้า (การเปลี่ยนด้านหนึ่งไม่ส่งผลต่ออีกด้าน) จะได้รับผลลัพธ์ที่ไม่คาดคิด สี่เหลี่ยมจัตุรัสไม่ใช่ชนิดย่อยที่ถูกต้องของสี่เหลี่ยมผืนผ้า
LSP กำหนดสามเงื่อนไข สำหรับการสืบทอดที่ถูกต้อง: เงื่อนไขก่อนของคลาสย่อยต้องไม่แข็งแกร่งกว่าเงื่อนไขก่อนของคลาสพื้นฐาน (คลาสย่อยไม่ต้องการมากกว่า) เงื่อนไขหลังของคลาสย่อยต้องไม่อ่อนแอกว่าเงื่อนไขหลังของคลาสพื้นฐาน (คลาสย่อยรับประกันไม่น้อยกว่า) และค่าคงที่ของคลาสพื้นฐานต้องถูกรักษาไว้ในคลาสย่อย เงื่อนไขเหล่านี้รู้จักกันในชื่อ กฎการออกแบบตามสัญญา ของเบอร์ทรานด์ เมเยอร์
หากอย่างน้อยหนึ่งเงื่อนไขถูกละเมิด โค้ดที่ใช้พหุสัณฐานอาจล้มเหลว คอมไพเลอร์ไม่ตรวจสอบสัญญาเชิงความหมาย มีเพียงสัญญาเชิงวากยสัมพันธ์ ดังนั้น LSP จึงเป็นเรื่องของวินัยทางสถาปัตยกรรม ไม่ใช่การกำหนดชนิดคงที่
กลไก LSP ขึ้นอยู่กับความเข้ากันได้เชิงพฤติกรรมของชนิด ถ้าคลาส S สืบทอดจากคลาส T โค้ดไคลเอนต์ควรสามารถใช้ S ในที่ที่คาดหวัง T โดยไม่เปลี่ยนพฤติกรรม ซึ่งรวมถึงไม่เพียงแต่ลายเซ็นเมธอดเท่านั้น แต่ยังรวมถึงความหมายของเมธอดด้วย
LSP ไม่ห้ามคลาสย่อยเพิ่มพฤติกรรมใหม่ แต่ห้ามละเมิดความคาดหวัง ของโค้ดที่เขียนสำหรับคลาสพื้นฐาน ถ้าคลาสพื้นฐานรับประกันว่าเมธอด save ไม่โยนข้อยกเว้น คลาสย่อยต้องไม่โยนมัน ถ้าคลาสพื้นฐานส่งค่าที่ไม่เป็นลบ คลาสย่อยต้องไม่ส่งค่าลบ
ในโปรเจกต์จริง LSP มักถูกละเมิดมากที่สุดเมื่อเพิ่มตรรกะแบบมีเงื่อนไขในเมธอดของคลาสย่อย: «ถ้าเงื่อนไข — โยนข้อยกเว้น», «ถ้าเงื่อนไข — ส่งค่า null» «ความประหลาดใจ» แต่ละอย่างนี้บั่นทอนพหุสัณฐานและบังคับให้โค้ดไคลเอนต์ตรวจสอบชนิดวัตถุก่อนเรียก — ซึ่งขัดแย้งกับแนวคิดของการออกแบบเชิงวัตถุ
ในโปรเจกต์มือถือ การละเมิด LSP ทั่วไปเกิดขึ้นเมื่อสร้าง ViewModel พื้นฐาน ถ้า BaseViewModel รับประกันว่าเมธอด onCleared ปล่อยทรัพยากรทั้งหมด และคลาสย่อยแทนที่เมธอดนี้ให้ว่างเปล่า — โค้ดใดๆ ที่พึ่งพาการทำความสะอาดทรัพยากรผ่านการเรียก onCleared แบบพหุสัณฐานจะทำงานไม่ถูกต้อง LSP กำหนดให้คลาสย่อยเรียก super.onCleared() หรือทำงานเดียวกันด้วยตนเอง องค์ประกอบผ่าน LifecycleObserver เป็นทางเลือกที่กำจัดการละเมิด LSP ในการจัดการวงจรชีวิต
ตัวบ่งชี้หลัก ของการละเมิด LSP ได้แก่: การตรวจสอบชนิดวัตถุผ่าน instanceof หรือ is ก่อนเรียกเมธอด, การนำเมธอดไปใช้เปล่าๆ (stub), การโยน NotImplementedError หรือ UnsupportedOperationException, การส่งค่า null แทนค่า แต่ละรูปแบบเหล่านี้บ่งชี้ว่าคลาสย่อยไม่ใช่ชนิดย่อยที่ถูกต้อง
สัญญาณทั่วไปอีกอย่างคือการสืบทอดเพื่อนำโค้ดกลับมาใช้ใหม่แทนที่จะสร้างความสัมพันธ์แบบ «เป็น» (is-a) คลาส Bird มีเมธอด fly() คลาส Penguin สืบทอดจาก Bird และแทนที่ fly() ให้ว่างเปล่าหรือโยนข้อยกเว้น นี่คือการละเมิด LSP: เพนกวินไม่ใช่ชนิดย่อยที่ถูกต้องของนก
ในการพัฒนาแอปมือถือ LSP ถูกละเมิดเมื่อสร้างคลาสพื้นฐาน ViewHolder, Fragment หรือ ViewController ที่มีเมธอด stub ถ้าคลาสย่อยไม่ใช้เมธอดครึ่งหนึ่งของคลาสพื้นฐาน — แสดงว่าเลือกการสืบทอดผิด องค์ประกอบหรือการแยกอินเทอร์เฟซ แก้ปัญหาได้ถูกต้องกว่า
การทดสอบง่ายๆ สำหรับตรวจสอบ LSP: เขียน unit test สำหรับคลาสพื้นฐานที่ตรวจสอบสัญญา (ค่าที่ส่งกลับ, ข้อยกเว้น, ผลข้างเคียง) รันเทสต์นี้สำหรับทุกคลาสย่อย ถ้าเทสต์ล้มเหลว — แสดงว่า LSP ถูกละเมิด วิธีการนี้เรียกว่า «การทดสอบผ่านสัญญาของคลาสพื้นฐาน»
ในโปรเจกต์ Android เทสต์ดังกล่าวมีประโยชน์สำหรับ ViewModel และ Repository ถ้า BaseViewModel รับประกันสถานะ Loading ก่อนข้อผิดพลาด และคลาสย่อยโยนข้อผิดพลาดโดยไม่มี Loading — เทสต์จะตรวจจับการละเมิด LSP ในขั้นตอน CI
มาดู ตัวอย่าง Android กับการจัดการ ClickListener การละเมิด LSP เกิดขึ้นเมื่อการนำไปใช้พื้นฐานรับประกันบางอย่างและคลาสย่อยละเมิดมัน
// คลาสพื้นฐานพร้อมการรับประกัน: onClick จะถูกเรียก
open class BaseClickListener {
open fun onClick(view: View) {
// การจัดการพื้นฐาน
}
}
// การละเมิด LSP: คลาสย่อยเพิ่มเงื่อนไขที่โยนข้อยกเว้น
class RestrictedClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (!isLoggedIn) {
throw IllegalStateException("Not logged in")
}
super.onClick(view)
}
}
// วิธีการแก้ไขที่ถูกต้อง: สัญญาไม่ถูกละเมิด
class ConditionalClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (isLoggedIn) {
super.onClick(view)
}
}
}
ตัวอย่าง iOS กับโปรโตคอล DataSource แสดงการละเมิด LSP โดยการส่ง nil แทนข้อมูล:
// โปรโตคอลพร้อมสัญญา: ส่งข้อมูลหรือข้อผิดพลาด
protocol DataProvider {
func fetchData() async throws -> [String]
}
// การละเมิด LSP: ส่ง nil โดยไม่มีข้อผิดพลาด
class SilentFailProvider: DataProvider {
func fetchData() async throws -> [String] {
return [] // อาร์เรย์ว่างแทนข้อผิดพลาด
}
}
// การปฏิบัติตาม LSP ที่ถูกต้อง
class NetworkProvider: DataProvider {
func fetchData() async throws -> [String] {
throw NetworkError.timeout
}
}
กฎปฏิบัติ: ถ้าคลาสย่อยไม่สามารถปฏิบัติตามสัญญาของคลาสพื้นฐานได้ มันไม่ควรเป็นคลาสย่อย ทางเลือกคือแยกอินเทอร์เฟซที่มีสัญญาขั้นต่ำและนำไปใช้ในแต่ละชนิดตามวิธีของตนเอง
องค์ประกอบ ดีกว่าการสืบทอดในสถานการณ์ที่ความสัมพันธ์แบบ «เป็น» (is-a) คลุมเครือหรือมีเงื่อนไข ตัวอย่างคลาสสิก: Manager เป็น Employee หรือไม่? ใช่ แต่ Square เป็น Rectangle ที่ถูกต้องหรือไม่? LSP บอกว่า «ไม่» ถ้าคุณสงสัยในความถูกต้องของการสืบทอด — เลือกองค์ประกอบ
ในการพัฒนาแอปมือถือ องค์ประกอบมักใช้ผ่าน การฉีดพึ่งพา: แทนที่จะสืบทอดพฤติกรรมจากคลาสพื้นฐาน คลาสจะได้รับมันผ่านคอนสตรักเตอร์ ViewModel ไม่สืบทอดจาก Repository แต่ยอมรับมันเป็นพึ่งพา สิ่งนี้กำจัดการละเมิด LSP โดยนิยาม — ไม่มีการสืบทอด ไม่มีการละเมิดสัญญา
สัญญาณที่ควรแทนที่การสืบทอดด้วยองค์ประกอบ: คลาสย่อยไม่ใช้บางเมธอดของคลาสพื้นฐาน, คลาสย่อยแทนที่เมธอดด้วย stub ว่างเปล่า, โค้ดไคลเอนต์ตรวจสอบชนิดวัตถุผ่าน instanceof ในกรณีเหล่านี้ การสืบทอดถูกเลือกผิดและ LSP ถูกละเมิด
อินเทอร์เฟซ แก้ปัญหา LSP โดยไม่ต้องสืบทอด: แต่ละชนิดนำไปใช้เฉพาะเมธอดที่ต้องการ แทนที่จะมีคลาสพื้นฐาน Bird ทั่วไปที่มีเมธอด fly() (ที่เพนกวินบินไม่ได้) — อินเทอร์เฟซ Flyable ที่นกที่บินได้เท่านั้นที่นำไปใช้ Penguin นำไปใช้ Bird โดยไม่มีเมธอด fly() — LSP ไม่ถูกละเมิด
ในสถาปัตยกรรม Android วิธีการนี้ถูกนำไปใช้ผ่าน อินเทอร์เฟซ UseCase ที่แยกออกจากกัน: แทนที่จะมี UseCase ขนาดใหญ่หนึ่งอันที่มีเมธอด getAll, getById, save, delete — แยกอินเทอร์เฟซ GetItemsUseCase, SaveItemUseCase ไคลเอนต์ขึ้นอยู่กับอินเทอร์เฟซที่ต้องการเท่านั้น และคลาสใดๆ ที่นำอินเทอร์เฟซนั้นไปใช้ก็ถูกต้องจากมุมมองของ LSP
คำถามที่พบบ่อย
การสืบทอด เป็นกลไกของภาษา; LSP เป็นกฎสำหรับการใช้กลไกนั้นอย่างถูกต้อง การสืบทอดรับประกันความเข้ากันได้ของลายเซ็น (วากยสัมพันธ์); LSP ต้องการความเข้ากันได้เชิงพฤติกรรม (ความหมาย) การสืบทอดโดยไม่มี LSP ให้พหุสัณฐานที่พังที่รันไทม์
ถ้า คลาสพื้นฐาน รับประกันการส่งค่าที่ไม่เป็น null — ใช่ ถ้าสัญญาอนุญาต null (ค่าเลือกได้) — ไม่ LSP ไม่ได้ห้าม null; แต่มันห้ามการทำให้สัญญาอ่อนแอลง ศึกษาเอกสารของคลาสพื้นฐานและตรวจสอบว่าสัญญาของคลาสย่อยเข้ากันได้หรือไม่
LSP ใช้กับโปรโตคอล เช่นเดียวกับคลาส การนำโปรโตคอลไปใช้ต้องปฏิบัติตามสัญญาเชิงความหมาย: ถ้าโปรโตคอลกำหนดเมธอดเป็น non-throwing การนำไปใช้ต้องไม่โยนข้อผิดพลาด Swift ไม่ตรวจสอบสิ่งนี้ที่ระดับคอมไพเลอร์ — ความรับผิดชอบอยู่ที่นักพัฒนา
Sealed class ใน Kotlin เป็นกรณีพิเศษเพราะลำดับชั้นถูกปิดและคอมไพเลอร์รู้จัก LSP ใช้กับ sealed class ในขอบเขตที่น้อยกว่าเพราะชนิดย่อยทั้งหมดถูกระบุอย่างชัดเจนในนิพจน์ when ข้อผิดพลาดของคลาสย่อย sealed จะเป็นเฉพาะที่ ไม่ใช่ข้อผิดพลาดพหุสัณฐานที่ซ่อนอยู่
เขียน parameterized test สำหรับคลาสพื้นฐานที่รันสำหรับคลาสย่อยทั้งหมดของมัน เทสต์ตรวจสอบสัญญาเชิงพฤติกรรมหลัก: ค่าที่ส่งกลับ, ข้อยกเว้น, สถานะ ถ้าเทสต์ล้มเหลวในคลาสย่อยใดคลาสหนึ่ง — LSP ถูกละเมิด ใน CI เทสต์ดังกล่าวป้องกันการถดถอยของโค้ดพหุสัณฐาน
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ