객체지향에서 상속(inheritance, 물려받기)은 이미 만들어 둔 클래스의 속성과 메서드를 새 클래스가 그대로 이어받는 장치다. 부모 클래스(상위 클래스)에 공통 기능을 한 번 정의해 두면, 자식 클래스(하위 클래스)는 그것을 다시 쓰지 않고 물려받아 쓴다. 상속을 쓸지 말지 가르는 기준은 하나다. 자식이 부모의 한 종류인가, 즉 자식 is-a 부모가 자연스러운가. 개는 동물의 한 종류이므로 Dog가 Animal을 상속하는 것은 자연스럽다. 이 문장이 어색하면 상속을 쓰면 안 된다.
상속을 쓰면 곧바로 super()라는 함수를 만난다. super()는 자식 안에서 부모의 메서드를 불러 쓰는 통로다. 부모가 이미 해 둔 일을 자식이 다시 짜지 않고 super()로 넘겨받은 뒤, 자기 몫만 덧붙인다. 이번 편은 중복 코드를 상속으로 걷어내는 과정과 super()의 쓰임을 코드의 before, after로 비교하며 다룬다.
![[OOP 05] 상속과 super()](https://img.thenullpage.com/posts/6624/6624_1_282fb6.webp)
05.1 중복이라는 신호
같은 코드가 두 클래스에 똑같이 들어 있으면 그것은 상속으로 묶으라는 신호다. 개와 고양이를 각각 클래스로 만든다고 하자. 짖고 우는 소리는 서로 다르지만, 먹는 동작 eat은 완전히 같다. 상속 없이 만들면 이 eat이 두 곳에 그대로 복제된다.
class DogNoInherit:
def __init__(self, name):
self.name = name
def eat(self):
return f"{self.name} 먹는다"
def bark(self):
return "멍멍"
class CatNoInherit:
def __init__(self, name):
self.name = name
def eat(self):
return f"{self.name} 먹는다"
def meow(self):
return "야옹"
print(DogNoInherit("바둑이").eat(), CatNoInherit("나비").eat())
바둑이 먹는다 나비 먹는다
두 클래스의 eat이 글자 하나 다르지 않게 겹친다. 지금은 두 줄이라 눈에 잘 안 띄지만, 먹는 규칙이 복잡해지고 동물이 열 종류로 늘면 같은 코드가 열 벌 복제된다. 먹는 방식을 한 번 바꾸려면 열 곳을 똑같이 고쳐야 하고, 그중 한 곳을 빠뜨리면 그대로 버그가 된다.
05.2 공통을 부모로 올리기
해법은 겹치는 eat과 __init__을 부모 클래스 Animal로 끌어올리고, 개와 고양이가 그것을 상속하는 것이다. class Dog2(Animal)에서 괄호 안 Animal이 부모를 가리킨다. 이 한 줄이 Dog2는 Animal을 상속한다는 선언이다.
class Animal:
def __init__(self, name):
self.name = name
def eat(self):
return f"{self.name} 먹는다"
class Dog2(Animal):
def bark(self):
return "멍멍"
class Cat2(Animal):
def meow(self):
return "야옹"
print(Dog2("바둑이").eat(), Dog2("바둑이").bark())
print(Cat2("나비").eat(), Cat2("나비").meow())
바둑이 먹는다 멍멍
나비 먹는다 야옹
Dog2와 Cat2 어디에도 eat과 __init__이 없지만 둘 다 문제없이 먹는다. 부모 Animal에서 물려받았기 때문이다. eat이 이제 Animal 한 곳에만 있으니, 먹는 방식을 바꾸려면 그 한 곳만 고치면 되고 모든 자식이 자동으로 따라온다. 새 동물을 추가할 때도 eat을 다시 쓸 필요가 없다. 중복이 사라지고 고칠 자리가 하나로 모인 것이 상속이 준 이득이다.
05.3 super()로 부모 생성자 확장
자식이 부모의 초기화에 더해 자기 속성을 추가로 갖는 경우가 흔하다. 자동차는 차량의 브랜드에 더해 문 개수를 갖는다. 이때 부모의 __init__을 자식이 통째로 다시 쓰지 않고, super()로 불러 재사용한 뒤 자기 것만 덧붙인다.
class Vehicle:
def __init__(self, brand):
self.brand = brand
def info(self):
return f"브랜드: {self.brand}"
class Car(Vehicle):
def __init__(self, brand, doors):
super().__init__(brand)
self.doors = doors
def info(self):
return super().info() + f", 문 {self.doors}개"
car = Car("현대", 4)
print(car.info())
브랜드: 현대, 문 4개
super().__init__(brand)는 브랜드를 세팅하는 일을 부모에게 맡긴다. 자식은 doors만 챙기면 된다. info()도 마찬가지로 super().info()로 부모가 만든 문자열을 받아 그 뒤에 문 개수를 덧붙인다. 브랜드를 다루는 부모의 방식이 바뀌어도 자식 코드는 손댈 필요가 없다. 부모가 하던 일을 자식이 베끼지 않고 넘겨받는 것, 이것이 super()의 핵심이다.
05.4 부모와 자식의 관계 확인
상속으로 맺어진 관계는 isinstance와 issubclass로 확인한다. isinstance(객체, 클래스)는 그 객체가 해당 클래스의 인스턴스인지 묻고, issubclass(A, B)는 A가 B의 자식인지 묻는다.
print(isinstance(car, Car))
print(isinstance(car, Vehicle))
print(issubclass(Car, Vehicle))
print(issubclass(Vehicle, Car))
True
True
True
False
눈여겨볼 곳은 isinstance(car, Vehicle)이 True라는 점이다. car는 Car로 만들었지만, Car가 Vehicle의 자식이므로 car는 Vehicle이기도 하다. 자식 객체가 부모 타입으로도 인정되는 이 성질이 다형성의 토대가 된다. 반대로 Vehicle은 Car의 자식이 아니므로 issubclass(Vehicle, Car)는 False다. 상속은 한 방향으로만 흐른다.
05.5 super()를 빠뜨렸을 때
자식이 __init__을 새로 정의하면 부모의 __init__은 저절로 불리지 않는다. 이 사실을 모르고 super().__init__()을 빠뜨리면, 부모가 세팅하던 속성이 아예 만들어지지 않는다.
class Base:
def __init__(self):
self.ready = True
class ChildBad(Base):
def __init__(self, x):
self.x = x
cb = ChildBad(5)
print("x는 있음:", cb.x)
try:
print(cb.ready)
except AttributeError as e:
print("ready 없음:", e)
x는 있음: 5
ready 없음: 'ChildBad' object has no attribute 'ready'
ChildBad는 x는 갖지만 ready는 없다. 부모 Base의 __init__이 한 번도 불리지 않았기 때문이다. cb.ready를 읽는 순간 AttributeError(속성 오류)가 난다. 자식이 생성자를 새로 쓸 때 부모의 초기화가 필요하면, super().__init__()을 반드시 손으로 불러 줘야 한다. 이 한 줄을 빠뜨리는 실수가 상속을 처음 배울 때 가장 자주 밟는 함정이다.
05.6 정리
상속은 자식 is-a 부모가 성립할 때만 쓰는 코드 재사용 장치다. 공통 기능을 부모에 한 번 정의하면 여러 자식이 물려받아, 중복이 사라지고 고칠 자리가 한 곳으로 모인다. super()는 부모의 메서드를 자식이 재사용하는 통로이며, 특히 자식 생성자에서 부모의 초기화가 필요하면 super().__init__()을 반드시 불러야 한다. isinstance로 확인되는 자식은 부모다라는 성질은 다형성으로 이어진다. 오늘 본 네 예제를 직접 쳐 보며 부모에 둔 코드가 자식에서 어떻게 살아 움직이는지 눈으로 확인해 보기 바란다.