파이썬(PYTHON) 클린코드 #8_ SOLID, 리스코프 치환 원칙(LSP)
안녕하세요. 문범우입니다.
이번 포스팅에서는 SOLID 원칙 중, 리스코프 치환 원칙(Liskov Substitution Principle)에 대해서 알아보도록 하겠습니다.
1. LSP(Liskov Substitution Principle)
리스코프 치환 원칙은, SOLID 원칙 중에서도 많은 분들이 헷갈려하거나 어려워하는 원칙이다.
리스코프 치환 원칙에 대한 정의(LISKOV 01)는 다음과 같다.
만약 S가 T의 하위 타입이라면, 프로그램을 변경하지 않고 T타입의 객체를 S타입의 객체로 치환 가능해야 한다.
이것이 어떤 의미일까?
필자가 공부하고 있는 "파이썬 클린코드" 서적에서는 다음과 같이 이야기한다.
LSP의 주된 생각은 어떤 클래스에서든 클라이언트는 특별한 주의를 기울이지 않고도 하위 타입을 사용할 수 있어야 한다는 것이다.
다시 말해, 기반 클래스의 작업을 하위 클래스의 인스턴스로 작업할 수 있어야 한다는 것이다.
말보다는 코드로써 이해하는 것이 편할 수 있다.
먼저 리스코프 치환 원칙을 지키지 못하고 있는 첫번째 예제를 살펴보자.
이는 생각보다 간단하다.
1 2 3 4 5 6 7 8 9 10 11 12 13 | # LSP를 지키지 못한 예제 1 class Event: """Super class: Event class""" def __init__(self, event_data: dict): this.event_data = event_data def meet_condition(self, event_data: dict) -> bool: return False class LoginEvent(Event): """Sub class: LoginEvent class""" def meet_condition(self, event_data: list) -> bool: return event_data[0] == 'login' | cs |
code: https://github.com/doorBW/python_clean_code
위의 코드를 살펴보면 Event 클래스를 상속받는 LoginEvent 클래스가 있다.
하지만 재정의한 메서드, meet_condition 함수를 살펴보면 부모 클래스에서 정의한 파라미터와 다른 타입을 사용하고 있다. 이러한 경우, Event 개체에 대해서 LoginEvent 개체로 치환시에 에러가 발생할 것이다.
이를 해소하기 위해서는 아래와 같이 변경되어야 한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 | # LSP를 적용한 예제 1 class Event: """Super class: Event class""" def __init__(self, event_data: dict): this.event_data = event_data def meet_condition(self, event_data: dict) -> bool: return False class LoginEvent(Event): """Sub class: LoginEvent class""" def meet_condition(self, event_data: dict) -> bool: return event_data['before']["session"] == 0 and event_data['after']["session"] == 1 | cs |
code: https://github.com/doorBW/python_clean_code
하지만 이 또한 문제가 있다.
보다 구체적으로 살펴보기 위해 몇가지 클래스들을 추가해서 살펴보자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | # LSP를 지키지 못한 예제 2 class Event: """Super class: Event class""" def __init__(self, event_data: dict): self.event_data = event_data @staticmethod def meets_condition(event_data: dict) -> bool: return False @staticmethod def meets_condition_pre(event_data: dict) -> bool: assert isinstance(event_data, dict), f"{event_data!r} is not a dictionary." for data in ["before","after"]: assert data in event_data, f"{data} is not in {event_data}." class LoginEvent(Event): """Sub class: LoginEvent class""" @staticmethod def meets_condition(event_data: dict) -> bool: # assert "session" in event_data["before"] and "session" in event_data["after"] return event_data['before']["session"] == 0 and event_data['after']["session"] == 1 class UnknownEvent(Event): def meet_condition(self, event_data: dict) -> bool: return True class SystemMonitor: def __init__(self, event_data): self.event_data = event_data def identify_event(self): Event.meets_condition_pre(self.event_data) event_cls = next( (event_cls for event_cls in Event.__subclasses__() if event_cls.meets_condition(self.event_data)), UnknownEvent ) return event_cls(self.event_data) | cs |
code: https://github.com/doorBW/python_clean_code
이전의 코드에 UnknownEvent를 추가하고 입력받은 event_data가 어떠한 것인지 구별하는 SystemMonitor 클래스를 추가하였다.
여기서, LoginEvent 클래스의 meets_condition 함수에 추가된 주석을 신경쓰길 바란다. 해당 함수의 리턴값을 살펴보면 실제로 주석처리된 assert가 통과해야 함을 알 수 있다.
우선 이상태로 해당 코드를 실행시켜보면 다음과 같은 결과를 얻을 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | l1 = SystemMonitor({"before":{"session":0}, "after":{"session":1}}) print(f"l1 is {l1.identify_event().__class__.__name__!r}") l2 = SystemMonitor({"before":{"session":1}, "after":{"session":0}}) print(f"l2 is {l2.identify_event().__class__.__name__!r}") l3 = SystemMonitor({"before":{}, "after":{"session":0}}) print(f"l3 is {l3.identify_event().__class__.__name__!r}") l1 is 'LoginEvent' l2 is 'UnknownEvent' --------------------------------------------------------------------------- KeyError Traceback (most recent call last) <ipython-input-29-889278fd8833> in <module>() 6 7 l3 = SystemMonitor({"before":{}, "after":{"session":0}}) ----> 8 print(f"l3 is {l3.identify_event().__class__.__name__!r}") <ipython-input-28-0ef7b0926e45> in identify_event(self) 35 event_cls = next( 36 (event_cls for event_cls in Event.__subclasses__() if event_cls.meets_condition(self.event_data)), ---> 37 UnknownEvent 38 ) 39 return event_cls(self.event_data) <ipython-input-28-0ef7b0926e45> in <genexpr>(.0) 34 Event.meets_condition_pre(self.event_data) 35 event_cls = next( ---> 36 (event_cls for event_cls in Event.__subclasses__() if event_cls.meets_condition(self.event_data)), 37 UnknownEvent 38 ) <ipython-input-28-0ef7b0926e45> in meets_condition(event_data) 20 def meets_condition(event_data: dict) -> bool: 21 # assert "session" in event_data["before"] and "session" in event_data["after"] ---> 22 return event_data['before']["session"] == 0 and event_data['after']["session"] == 1 23 24 KeyError: 'session' | cs |
code: https://github.com/doorBW/python_clean_code
앞에서 걱정한 바와 같이, 실제로 데이터에 "session"이 존재하지 않는 l3 에서 KeyError가 발생한 것을 확인할 수 있다. 이러한 경우 처음 살펴보았던 예제와 마찬가지로, Event 개체가 LoginEvent 개체로 치환될 경우 에러가 발생할 수 있다.
이러한 케이스가 발생하지 않기 위해, 하위 클래스가 따라야하는 계약 조건이 있다.
1. 하위 클래스는 부모 클래스에 정의된 것보다 사전조건을 엄격하게 만들면 안 된다.
2. 하위 클래스는 부모 클래스에 정의된 것보다 약한 사후조건을 만들면 안된다.
방금 살펴본 예제는 위의 조건 중 1번에 대해 위반을 한 것이다. 비록 주석처리를 해두었지만, 하위클래스(LoginEvnet class)가 에러없이 실행되려면 데이터에 "session"이 존재해야 함을 기반으로 한다. 하지만 이러한 사전조건은 부모클래스(Event class)보다 엄격한 것이므로, 살펴본 것과 같이 에러가 발생할 수 있는 것이다.
이러한 코드에 대해 LSP 원칙을 적용하면 다음과 같이 .get() 함수를 이용하여 변경 가능하다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | # LSP를 적용한 예제 2 class Event: """Super class: Event class""" def __init__(self, event_data: dict): self.event_data = event_data @staticmethod def meets_condition(event_data: dict) -> bool: return False @staticmethod def meets_condition_pre(event_data: dict) -> bool: assert isinstance(event_data, dict), f"{event_data!r} is not a dictionary." for data in ["before","after"]: assert data in event_data, f"{data} is not in {event_data}." class LoginEvent(Event): """Sub class: LoginEvent class""" @staticmethod def meets_condition(event_data: dict) -> bool: return event_data['before'].get("session") == 0 and event_data['after'].get("session") == 1 class UnknownEvent(Event): def meet_condition(self, event_data: dict) -> bool: return True class SystemMonitor: def __init__(self, event_data): self.event_data = event_data def identify_event(self): Event.meets_condition_pre(self.event_data) event_cls = next( (event_cls for event_cls in Event.__subclasses__() if event_cls.meets_condition(self.event_data)), UnknownEvent ) return event_cls(self.event_data) | cs |
code: https://github.com/doorBW/python_clean_code
그리고 해당 코드에 대한 실행 결과는 다음과 같다.
1 2 3 4 5 6 7 8 9 10 11 12 | l1 = SystemMonitor({"before":{"session":0}, "after":{"session":1}}) print(f"l1 is {l1.identify_event().__class__.__name__!r}") l2 = SystemMonitor({"before":{"session":1}, "after":{"session":0}}) print(f"l2 is {l2.identify_event().__class__.__name__!r}") l3 = SystemMonitor({"before":{}, "after":{"session":0}}) print(f"l3 is {l3.identify_event().__class__.__name__!r}") l1 is 'LoginEvent' l2 is 'UnknownEvent' l3 is 'UnknownEvent' | cs |
code: https://github.com/doorBW/python_clean_code
리스코프 치환 원칙은, 한번에 이해하거나 바로 받아들이기 어려울 수 있다.
필자가 책을 참고하여 언급한 예제 이외에도 검색을 통해 다양한 예제와 설명을 만나볼 수 있으니 함께 참고하면서 공부하기를 바란다.
무엇보다, 리스코프 치환 원칙은 부모클래스 타입과 하위클래스 타입, 두가지 타입의 개체를 서로 치환하더라도 에러가 발생하는 등의 문제가 발생하면 안된다는 것을 기억하기를 바란다.