Is A, Has A

중학교 2학년이던 2016년, 한창 자바를 공부하며 상속에 대해서 알아갈 때였습니다. 어느 책이었는지 정확히 기억은 나지 않지만, 저자가 “나는 다른 사람들이 설명하는 상속이 마음에 들지 않는다”라고 말했던 것이 기억이 납니다.

처음에는 무슨 말인지 몰랐습니다. Animal 클래스가 cry 함수를 가지고, DogCatAnimal을 상속하여 이를 오버라이딩하는 전형적인 상속 예제에 고개를 끄덕이던 저였거든요.

그런데 최근에 전문가를 위한 파이썬디자인 패턴에 뛰어들기 읽고 상속을 바라보는 시각이 달라지기 시작했습니다.


일반적인 상속

from abc import ABCMeta, abstractmethod

class Animal(metaclass=ABCMeta):
	@abstractmethod
	def cry(self):
		pass

위 코드에서는 Animal 이라는 추상 클래스를 정의하고 있습니다. cry라는 추상 메소드를 정의한 것이 보이네요.

class Dog(Animal):
	def cry(self):
		print("woff")

class Cat(Animal):
	def cry(self):
		print("meow")

Animal을 상속한 DogCat 클래스는 이렇게 정의할 수 있을 것입니다. 여기까지는 아무런 어색함이 없는 코드입니다.


확장해보기

“사람은 동물이다” 여러분은 이 명제에 동의하시나요? 동의하신다면, Animal을 상속하여 Human 클래스를 만들어보면 어떨까요?

class Human(Animal):
	def cry(self):
		print("Waah")

“사람은 동물이지. 사람도 우니까 이 클래스는 아무런 문제가 없어” 라고 생각할 수도 있을 것 같습니다. 지금까지는 아무런 문제가 없었지만, 아래와 같은 함수를 만드려고 한다면 어떻게 해야할까요?

def take_a_walk_with(human: Human, animals: list[Animal]):
	...

HumanAnimal을 상속하는 맥락에서는, 위와 같은 함수는 굉장히 이상하게 느껴집니다. 동물들을 산책시키는 함수라고 생각하게 되지만 animalsHuman 인스턴스도 들어갈 수 있게 되면서 함수 자체가 어색해지기 때문이죠.

“사람은 동물을 산책시킨다” 어색한 부분이 없는 것처럼 느껴집니다. 그렇다면 무엇이 문제일까요?


is-A 문제

“사람은 동물이다”와 “사람은 동물을 산책시킨다”의 “동물”은 다른 의미로 보입니다. “사람은 동물이다”에서의 “동물”은 과학적 분류상의 동물을, “사람은 동물을 산책시킨다”에서의 “동물”은 반려동물로서의 동물을 말합니다.

“사람은 동물이다”에 동의하는 사람은 많지만 정작 “동물이다!”라고 외쳤을 때 그것이 “사람”이라고 생각할 사람은 극히 드물 것입니다. is-A 문제도 이와 같습니다. B가 A를 상속한다는 말은, 즉 B를 A로 생각해도 무리가 없다는 말입니다. 즉, 사람이 동물은 상속한다는 말은, 사람을 동물로 생각해도 무리가 없다는 말이 됩니다.

하지만 코드를 작성하다보면 그렇지 않을 때가 훨씬 많을 것입니다. Animal이 인자로 주어지는데, 그것이 Human일 수도 있다고 생각하는 개발자는 거의 없을테니깐요.

그렇기에 HumanAnimal을 상속하는 것은 부적절합니다. 단순 포함관계를 떠나서, HumanAnimal로 생각하면 안될 때가 많기 때문이죠.


has-A 관계

HumanAnimal을 상속하면 안된다는 것은 알았습니다. 상속 관계를 제거하니 공통 메서드인 cry가 눈에 거슬리기 시작합니다. 이러한 경우에 바로 interface를 활용하면 됩니다. 파이썬에는 interface가 존재하지 않기 때문에 아래와 같이 다중 상속과 ABC 모듈을 적절히 활용하여 구현할 수 있습니다.

from abc import ABCMeta, abstractmethod


class Cryable(metaclass=ABCMeta):
    @abstractmethod
    def cry(self):
        pass


class Animal(Cryable, metaclass=ABCMeta):
    pass


class Cat(Animal):
    def cry(self):
        print("Meow")


class Dog(Animal):
    def cry(self):
        print("Woff")


class Human(Cryable):
    def cry(self):
        print("Waah")

interface는 주로 has-A 관계를 구현할 때 많이 사용합니다. has-A란 B가 A를 implement 할 때, B는 A의 모든 것을 가지고 있어야 함을 의미합니다. 즉 Cat, Dog, HumanCryable를 상속한다는 뜻은, 각각의 클래스에 Cryable의 모든 프로퍼티가 존재한다는 말이 됩니다. (자명합니다)


인터페이스에 대해 프로그래밍하기

이제 본격적으로 인터페이스를 활용해서 코드를 짜보려고 합니다. 아래 코드는 Cryable의 리스트를 받아서 .cry()를 실행시키는 cry_all이라는 함수입니다.

def cry_all(cryables: list[Cryable]):
    for cryable in cryables:
        cryable.cry()


cry_all([Cat(), Dog(), Human()])

"""Output
Meow
Woff
Waa
"""

적절히 추상화되어있고, 코드도 잘 작동합니다. 아무런 문제가 없어 보이지만 코드 베이스의 사이즈가 커지면 또 다른 문제가 발생합니다. 바로 인터페이스를 일일이 만들고, 다시 이를 일일이 상속하는 것이 굉장히 귀찮은 작업이라는 것입니다.

class Cryable:
	...

class Walkable:
	...

class Runnable:
	...

# 인터페이스가 많아지면 많아질수록 코드는 더욱 길어지고, 반복될 것입니다.
class Cat(Cryable, Walkable, Runnable, ...):
	...

이 문제는 바로 다음에 나올 Duck Typing을 활용하여 어느정도 해결할 수 있습니다.

Duck Typing

여태까지는 Cryables를 상속해야만 cry_all() 함수의 인자가 될 수 있다고 보았습니다. 하지만 cry_all이 하는 역할은 대상 인스턴스의 .cry() 메서드를 호출하는 것 외에는 없습니다. 그렇다면 별도의 인터페이스를 만들 것이 아니라, 단순히 해당 객체의 .cry()메서드가 구현되어 있는지만 확인해봐도 되지 않을까요?

바로 이러한 관점의 타이핑을 Duck Typing이라고 합니다. (“오리처럼 행동하는 것은 오리가 아니더라도 오리로 보겠다”는 뜻입니다)

파이썬에서 Duck Typing은 매우 빈번히 사용되고 있습니다. 특히 내장함수들의 호출 원리가 Duck Typing에 기반을 두고 있습니다.

>>> len(l) # l.__len__() 을 호출합니다
>>> str(s) # s.__str__() 을 호출합니다

위 예제처럼 파이썬은 객체가 __len__ 메소드를 구현하고 있다면 len() 함수의 인자가 될 수 있다고 판단하고, __str__ 메소드를 구현하고 있다면 str() 함수의 인자가 될 수 있다고 생각합니다. 이를 참고하여, Cryables를 사용한 이전 예제를 변형해봅시다.

class Animal(metaclass=ABCMeta):
    pass


class Cat(Animal):
    def cry(self):
        print("Meow")


class Dog(Animal):
    def cry(self):
        print("Woff")


class Human:
    def cry(self):
        print("Waah")

class CryableProtocol(Protocol):
	def cry(self):
		...

def cry_all(cryables: list[CryableProtocol]):
    for cryable in cryables:
        cryable.cry()


cry([Cat(), Dog(), Human()])

"""Output
Meow
Woff
Waa
"""

CryableProtocol에 주목해봅시다. 이전 예제에서는 Cat, Dog, Human 각각이 Cryable을 상속하게 만들었습니다. 그런데, 지금 예제의 CryableProtocolcry_all의 인자 타이핑에만 사용되고 있고, 클래스 정의에는 사용되고 있지 않습니다. 즉, CryableProtocol의 명세를 만족한다면 어떤 클래스던 상관없이 인자로 허용하겠다는 말입니다.


마무리

예전의 저는 추상화를 해야한다면 오로지 상속만을 떠올렸습니다.

  1. DogCat이 모두 cry 함수를 가지고 있다고? -> Animal 클래스를 만들고 cry 함수를 두자
  2. Humancry 함수를 가지고 있다고? -> HumanAnimal 클래스를 상속하게 하자

위와 같이 생각의 흐름대로 코드를 짜다보니 옛날 옛적의 제 코드들은 상속 투성이었습니다. 그리고 이러한 구조는 나중에 유지보수를 하려고 하거나, 확장하려고 할 때 걸림돌이 되었죠.

제가 프로그래밍에 관한 책을 많이 읽지는 않았습니다만, 디자인 패턴 관련된 책에 자주 등장하는 말이 있습니다. “확장에는 열려있어야 하고, 변경에는 닫혀있어야 한다.”