-
리스코프 치환 원칙(Liscov Substitution Principle)기록/OOP 2023. 1. 30. 12:49
SOLID의 리스코프 치환 원칙(Liscov substitution principle)은 자료형 S가 자료형 T의 서브 타입이라면 프로그램 속성의 변경 없이 자료형 T의 객체를 자료형 S의 객체로 치환할 수 있어야 함을 말합니다.
부모 클래스의 기능을 확장해서 사용하기 위해 상속하는 경우가 많은데, 무턱대고 상속하다가 자식 클래스가 부모 클래스가 정의해놓은 동작을 수행하지 못하거나, 뒤에서 나올 MeleeMinion의 speak() 메서드처럼 에러를 던지는 등, 퇴화한다면 상속이 제대로 되지 않고 있다는 의미입니다. 이런 상황에서 자료형 T의 객체를 자료형 S의 객체로 치환하게 되면, 올바르게 작업을 수행하지 않게 됩니다. 리스코프 치환 원칙은 상속 개념에 있어서 중요한 원칙이며, 상속할 때 리스코프 치환 원칙을 적용해서 이 상속이 옳은지, 옳지 않은지 판단할 수 있습니다.
다음과 같이 Jayce, Lucian이라는 게임 캐릭터와 MeleeMinion, CasterMinion이라는 몬스터를 상속으로 구현한다면, 이는 리스코프 치환 원칙을 위배하는 사례입니다.
// Base class / Superclass class Champion { private let name: String private let dialogue: String private var hitPoint: Int init(name: String, dialogue: String, hitPoint: Int) { self.name = name self.dialogue = dialogue self.hitPoint = hitPoint } func speak() -> (String, String) { (name, dialogue) } // hitPoint를 가지고 뭔가 하는 로직 // ... } // Subclasses class Jayce: Champion { } class Lucian: Champion { } class MeleeMinion: Champion { override func speak() -> (String, String) { fatalError("저는 말을 못해요.") } } class CasterMinion: Champion { override func speak() -> (String, String) { fatalError("저는 말을 못해요.") } } func speak(champion: Champion) { let speak = champion.speak() print("나는 \(speak.0). \(speak.1)") } let jayce = Jayce(name: "Jayce", dialogue: "안녕하세요.", hitPoint: 590) speak(champion: jayce) let lucian = Lucian(name: "Lucian", dialogue: "싸우자.", hitPoint: 641) speak(champion: lucian) let meleeMinion = MeleeMinion(name: "Melee minion", dialogue: "", hitPoint: 477) speak(champion: meleeMinion) // fatal error 발생 let casterMinion = CasterMinion(name: "Caster minion", dialogue: "", hitPoint: 296) speak(champion: casterMinion) // Prints // 나는 Jayce. 안녕하세요. // 나는 Lucian. 싸우자. // __lldb_expr_46/Champion.playground:23: Fatal error: 저는 말을 못해요.
MeleeMinion은 name이나 hitPoint 프로퍼티와 관련 로직을 사용할 것이므로 Champion으로부터 상속받았습니다. 하지만 구현상 말을 못 하므로 부모 클래스에서 정의한 speak() 메서드가 의도대로 작동하는 것을 거부합니다(자료형 T의 객체를 자료형 S의 객체로 치환할 수 없음).
에러를 던지지 않기 위해서 부모 클래스의 dialogue 프로퍼티를 옵셔널 값으로 만들어주고 분기를 할 수도 있겠지만, 그것 또한 예외 처리이고 미니언이라는 특별한 타입 때문에 부모 클래스에서 처리를 해주고 수정을 해줄 필요는 없어 보입니다. 또한 이런 특별한 상황으로 확장된다면 개방-폐쇄 원칙을 어기게 됩니다. 그래서 리스코프 치환 원칙은 개방-폐쇄 원칙을 지킬 수 있도록 만들어주는 원칙이라고 합니다.
Swift에서는 상속보다는 protocol을 사용해서 위의 문제를 해결하는 것이 자연스럽다고 생각합니다. Champion과 Minion을 protocol로 만들어주고 protocol + extension을 통해 수평으로 확장하여 각각 중복되는 코드를 줄일 수 있습니다. 공격받으면 hitPoint를 감소시키는 메서드를 추가, 확장해봤습니다.
Champion과 Minion protocol이 채택하고 준수할 GameUnit protocol
protocol GameUnit: AnyObject { var name: String { get set } var attackPoint: Int { get set } var hitPoint: Int { get set } var represent: (String, Int, Int) { get } func comeUnderAttack(attacker: GameUnit) } extension GameUnit { var represent: (String, Int, Int) { (name, attackPoint, hitPoint) } func comeUnderAttack(attacker: GameUnit) { hitPoint -= attacker.attackPoint } }
Champion
protocol Champion: GameUnit, AnyObject { var dialogue: String { get } init(name: String, attackPoint: Int, hitPoint: Int, dialogue: String) func speak() -> (String, String) } extension Champion { func speak() -> (String, String) { (name, dialogue) } }
Minion
protocol Minion: GameUnit, AnyObject { init(name: String, attackPoint: Int, hitPoint: Int) }
구체 타입에서 프로토콜 채택 및 준수
class Jayce: Champion { var name: String var attackPoint: Int var hitPoint: Int var dialogue: String required init(name: String, attackPoint: Int, hitPoint: Int, dialogue: String) { self.name = name self.attackPoint = attackPoint self.hitPoint = hitPoint self.dialogue = dialogue } } class MeleeMinion: Minion { var name: String var attackPoint: Int var hitPoint: Int required init(name: String, attackPoint: Int, hitPoint: Int) { self.name = name self.attackPoint = attackPoint self.hitPoint = hitPoint } } func speak(champion: Champion) { let speak = champion.speak() print("나는 \(speak.0). \(speak.1)") } func represent(unit: GameUnit) { print("이름: \(unit.represent.0) 공격력: \(unit.represent.1) 체력: \(unit.represent.2)") } func attack(attacker: GameUnit, defender: GameUnit) { print("\(attacker.name)이(가) \(defender.name)를 공격") defender.comeUnderAttack(attacker: attacker) represent(unit: defender) } let jayce = Jayce(name: "Jayce", attackPoint: 57, hitPoint: 590, dialogue: "안녕하세요.") speak(champion: jayce) let meleeMinion = MeleeMinion(name: "Melee minion", attackPoint: 12, hitPoint: 477) attack(attacker: meleeMinion, defender: jayce) // Prints // 나는 Jayce. 안녕하세요. // Melee minion이(가) Jayce를 공격 // 이름: Jayce 공격력: 57 체력: 578
코드
'기록 > OOP' 카테고리의 다른 글
의존성 역전 원칙(Dependency Inversion Principle) (0) 2023.01.31 인터페이스 분리 원칙(Interface Segregation Principle) (0) 2023.01.31 개방-폐쇄 원칙(Open-Closed Principle) (0) 2023.01.28 단일 책임 원칙(Single Responsibility Principle) (0) 2023.01.28