클래스와 객체를 보다 실용적으로 활용하는 두 가지 방법 상속과 구성을 소개하면서 객체 지향 프로그래밍(OOP)의 기본 디자인 원칙을 살펴본다.
이번 장에서는 먼저 Person 클래스를 정의하고, 사람의 기본 속성을 물려받는 Hero 클래스를 만드는 방식으로
상속의 필요성과 장단점을 살펴본다.
15.1인스턴스 생성 기술의 단점¶
객체 지향 프로그래밍에서는 필요한 자료형을 클래스로 정의하고, 그 클래스로부터 여러 개의 인스턴스를 생성하여 사용할 수 있다.
먼저 사람을 나타내는 가장 단순한 Person 클래스를 정의하자.
여기서는 설명을 단순하게 하기 위해 사람의 속성을 이름, 나이, 성별로 제한한다.
예제: 사람 클래스
class Person:
def __init__(self, name, age, gender):
self.name = name
self.age = age
self.gender = gender
def introduction(self):
print("안녕하세요. 제 이름은 %s입니다." % self.name)
print("나이는 %d살이고, 성별은 %s입니다." % (self.age, self.gender))아래 코드는 두 명의 사람 객체를 생성한다.
kim = Person("김민수", 20, "남성")
lee = Person("이영희", 21, "여성")introduction() 메서드는 사람 객체의 자기소개를 출력한다.
kim.introduction()이제 일반 사람이 아니라 영웅 객체를 만들고 싶다고 하자. 영웅은 사람과 마찬가지로 이름, 나이, 성별을 갖지만, 추가로 파워, 공격력, 장비 같은 정보와 공격 기능도 필요하다.
나쁜 방법: 비슷한 코드를 다시 작성하기
가장 단순한 방법은 Person 클래스와 비슷한 코드를 다시 작성하여 Hero 클래스를 새로 만드는 것이다.
하지만 이렇게 하면 이름, 나이, 성별을 저장하고 자기소개를 하는 코드가 중복된다.
class Hero:
def __init__(self, name, age, gender, power, damage, inventory):
self.name = name
self.age = age
self.gender = gender
self.power = power
self.damage = damage
self.inventory = inventory
def introduction(self):
print("안녕하세요. 제 이름은 %s입니다." % self.name)
print("나이는 %d살이고, 성별은 %s입니다." % (self.age, self.gender))
def attack(self, other):
print("%s: %s 공격하기!" % (self.name, other.name))
attack_power = self.damage * 0.1
other.power -= attack_power영웅 객체를 생성하고 사용할 수는 있다.
ironman = Hero("아이언맨", 45, "남성", 100, 200, {"suit": 500, "weapon": "레이저"})
hulk = Hero("헐크", 42, "남성", 400, 300, {"suit": 0, "weapon": "주먹"})
ironman.introduction()
ironman.attack(hulk)
print("헐크 파워:", hulk.power)하지만 이 방식은 좋은 설계가 아니다.
Person과 Hero가 공유하는 코드가 중복되며, 사람의 기본 정보 처리 방식이 바뀌면
두 클래스를 모두 수정해야 한다.
이런 문제를 해결하기 위해 상속inheritance을 사용할 수 있다.
15.2상속¶
클래스를 선언할 때 다른 클래스의 속성과 메서드를 상속해서 활용할 수 있다. 상속을 받는 클래스를 자식 클래스 또는 하위 클래스, 상속을 하는 클래스를 부모 클래스 또는 상위 클래스라고 부른다.
상속을 이용하여 새로운 클래스를 정의하는 방식은 다음과 같다.
class 자식클래스(부모클래스):
클래스 본문상속은 기존 클래스에서 선언된 속성과 기능을 필요에 따라 재활용하거나 새로운 속성과 기능을 추가해서 보다 효율적으로 객체와 데이터를 관리하기 위해 사용되는 OOP의 핵심 기술 중 하나이다.
클래스를 상속할 때의 가장 큰 장점은 다음과 같다.
기존에 작성된 코드를 필요한 만큼 재활용할 수 있다.
자식 클래스의 인스턴스들 사이의 관계를 더 잘 이해할 수 있다.
공통 기능은 부모 클래스에 두고, 특별한 기능은 자식 클래스에 둘 수 있다.
반면 상속을 사용할 때는 부모 클래스를 신중하게 선택해야 한다. 부모 클래스의 기능을 자식 클래스가 모두 물려받기 때문이다.
Hero는 특별한 능력을 가진 사람이므로 Person 클래스를 상속하는 자식 클래스로 선언할 수 있다.
이제 Hero 클래스는 사람의 기본 속성과 기능을 물려받으면서,
영웅에게 필요한 속성과 메서드만 추가하면 된다.
class Hero(Person):
def __init__(self, name, age, gender, power, damage, inventory):
super().__init__(name, age, gender)
self.power = power
self.damage = damage
self.inventory = inventory
def attack(self, other):
print("%s: %s 공격하기!" % (self.name, other.name))
attack_power = self.damage * 0.1
other.power -= attack_power15.2.1부모클래스와 자식클래스¶
Hero 클래스는 Person 클래스를 상속한다.
따라서 Hero의 인스턴스는 영웅 객체이면서 동시에 사람 객체이기도 하다.
ironman = Hero("아이언맨", 45, "남성", 100, 200, {"suit": 500, "weapon": "레이저"})
isinstance(ironman, Hero), isinstance(ironman, Person)Hero는 Person의 메서드를 물려받으므로 introduction() 메서드를 바로 사용할 수 있다.
ironman.introduction()하지만 모든 사람이 영웅인 것은 아니다. 부모 클래스의 인스턴스는 자식 클래스에 새로 추가된 기능을 알지 못한다.
park = Person("박지민", 19, "여성")
try:
park.attack(ironman)
except AttributeError:
print("일반 사람은 attack() 메서드를 갖고 있지 않습니다.")15.2.2메서드 상속과 재정의¶
__init__() 메서드 상속과 super()
Hero 클래스의 초기 설정 메서드는 먼저 부모 클래스인 Person의 초기 설정 메서드를 호출한다.
super().__init__(name, age, gender)super()는 부모 클래스를 가리키며, 부모 클래스가 담당하는 속성 초기화를 재사용할 수 있게 해 준다.
이때 self는 직접 전달하지 않는다.
그런 다음 Hero 클래스는 영웅에게만 필요한 power, damage, inventory 속성을 추가한다.
부모 클래스에서 상속받은 메서드를 자식 클래스에서 다시 정의할 수도 있다. 이를 메서드 오버라이딩method overriding이라고 부른다.
예를 들어 Hero의 자기소개는 일반 사람의 자기소개에 파워와 무기 정보를 더 포함하는 편이 자연스럽다.
class Hero(Person):
def __init__(self, name, age, gender, power, damage, inventory):
super().__init__(name, age, gender)
self.power = power
self.damage = damage
self.inventory = inventory
def introduction(self):
super().introduction()
print("현재 파워는 %s입니다." % self.power)
print("사용하는 무기는 %s입니다." % self.inventory["weapon"])
def attack(self, other):
print("%s: %s 공격하기!" % (self.name, other.name))
attack_power = self.damage * 0.1
other.power -= attack_powerironman = Hero("아이언맨", 45, "남성", 100, 200, {"suit": 500, "weapon": "레이저"})
hulk = Hero("헐크", 42, "남성", 400, 300, {"suit": 0, "weapon": "주먹"})
ironman.introduction()
ironman.attack(hulk)
print("헐크 파워:", hulk.power)15.2.3상속과 *args, **kwargs 매개변수 활용¶
*args와 **kwargs는 함수를 정의할 때 인자의 수를 미리 지정하지 않을 때 사용하는 표현식이다.
*args: 임의의 개수의 위치 인자를 의미함**kwargs: 임의의 개수의 키워드 인자를 의미함
이 방식을 이용하면 자식 클래스의 __init__() 메서드에서 부모 클래스의 초기 설정 인자를
그대로 전달할 수 있다.
class Hero(Person):
def __init__(self, *args, power, damage, inventory, **kwargs):
super().__init__(*args, **kwargs)
self.power = power
self.damage = damage
self.inventory = inventory
def introduction(self):
super().introduction()
print("현재 파워는 %s입니다." % self.power)
print("사용하는 무기는 %s입니다." % self.inventory["weapon"])
def attack(self, other):
print("%s: %s 공격하기!" % (self.name, other.name))
attack_power = self.damage * 0.1
other.power -= attack_powerironman = Hero(
"아이언맨",
45,
"남성",
power=100,
damage=200,
inventory={"suit": 500, "weapon": "레이저"},
)
ironman.introduction()15.2.4사용자 정의 메서드 재정의¶
부모 클래스에서 선언된 메서드뿐만 아니라, 자식 클래스에서 이미 정의한 메서드도 필요에 따라 다시 정의할 수 있다.
예를 들어 영웅의 파워가 300 이상이면 공격 효과가 두 배가 되도록 attack() 메서드를 다시 정의하자.
class Hero(Person):
def __init__(self, *args, power, damage, inventory, **kwargs):
super().__init__(*args, **kwargs)
self.power = power
self.damage = damage
self.inventory = inventory
def introduction(self):
super().introduction()
print("현재 파워는 %s입니다." % self.power)
print("사용하는 무기는 %s입니다." % self.inventory["weapon"])
def attack(self, other):
print("%s: %s 공격하기!" % (self.name, other.name))
if self.power >= 300:
attack_power = self.damage * 0.2
else:
attack_power = self.damage * 0.1
other.power -= attack_powerironman = Hero("아이언맨", 45, "남성", power=100, damage=200, inventory={"suit": 500, "weapon": "레이저"})
hulk = Hero("헐크", 42, "남성", power=400, damage=300, inventory={"suit": 0, "weapon": "주먹"})
print("아이언맨 파워:", ironman.power)
print("헐크 파워:", hulk.power)
ironman.attack(hulk)
print("헐크 파워:", hulk.power)
hulk.attack(ironman)
print("아이언맨 파워:", ironman.power)15.3구성¶
구성composition은 다른 클래스의 인스턴스를 인스턴스 속성으로 지정하여 사용하는 기법을 의미한다.
상속은 공통 기능을 재활용하는 데 유용하지만, 모든 확장을 상속만으로 해결하려고 하면 클래스의 종류가 너무 많아질 수 있다. 특히 기능이 실행 중에 바뀌어야 하는 경우에는 구성이 더 자연스럽다.
15.3.1프로그램 확장성¶
예를 들어 영웅마다 비행 방식이 다를 수 있다.
아이언맨: 로켓 장치 사용
팔콘: 날개 사용
토르: 특별한 장치 없이 스스로 비행
가장 단순한 방식은 비행 방식에 따라 RocketFlyingHero, WingFlyingHero, SelfFlyingHero 같은
여러 자식 클래스를 만드는 것이다. 하지만 이 방식은 비행 방식이 객체 생성 시점에 고정된다는 단점이 있다.
상황에 따라 비행 방식을 동적으로 바꿀 수 있어야 한다면, 비행 기능을 별도 객체로 분리하고 영웅 객체가 그 비행 객체를 속성으로 갖도록 구성하는 편이 낫다.
15.3.2추상 클래스¶
추상 클래스abstract class는 구현되지 않은 메서드를 포함하는 클래스이다. 구현되지 않은 메서드를 추상 메서드abstract method라고 부른다.
추상 클래스와 추상 메서드를 선언하는 기본 과정은 다음과 같다.
abc모듈로부터ABC클래스와abstractmethod장식자를 불러온다.ABC클래스를 상속한다.@abstractmethod장식자를 이용하여 추상 메서드를 선언한다.
예를 들어 비행 방식과 관련된 Flying 추상 클래스를 아래와 같이 정의할 수 있다.
from abc import ABC, abstractmethod
class Flying(ABC):
@abstractmethod
def fly(self):
pass추상 클래스는 인스턴스 생성을 허용하지 않는다. 먼저 모든 추상 메서드를 구현한 자식 클래스를 만든 후에야 인스턴스를 생성할 수 있다.
try:
flying = Flying()
except TypeError:
print("주의: 먼저 모든 추상 메서드를 구현해야 합니다.")15.3.3구상 클래스¶
추상 메서드를 전혀 포함하지 않은 클래스를 구상 클래스concrete class라고 부른다.
여기서는 Flying 추상 클래스를 상속하는 구상 클래스 세 개를 비행 방식에 따라 구현한다.
로켓 장치 비행
class RocketFlying(Flying):
def fly(self):
print("로켓으로 날아요!")날개 비행
class WingFlying(Flying):
def fly(self):
print("날개가 멋져요!")스스로 비행
class SelfFlying(Flying):
def fly(self):
print("스스로 날아요!")예제: 동적으로 비행 방식 변경하기
이제 Hero 객체가 비행 방식을 직접 구현하지 않고,
지정된 Flying 객체에 비행 기능을 위임하도록 만든다.
변경 사항은 다음과 같다.
__init__()메서드: 비행 방식을 인스턴스 속성으로 지정하는 기능 추가fly()메서드: 직접 구현하지 않고 지정된 비행 객체에 기능 위임setFlying()메서드: 동적으로 비행 방식 변경
class Hero(Person):
def __init__(self, *args, power, damage, inventory, flying=None, **kwargs):
super().__init__(*args, **kwargs)
self.power = power
self.damage = damage
self.inventory = inventory
self.flying = flying
def introduction(self):
super().introduction()
print("현재 파워는 %s입니다." % self.power)
print("사용하는 무기는 %s입니다." % self.inventory["weapon"])
def attack(self, other):
print("%s: %s 공격하기!" % (self.name, other.name))
if self.power >= 300:
attack_power = self.damage * 0.2
else:
attack_power = self.damage * 0.1
other.power -= attack_power
def fly(self):
if self.flying is None:
print("%s: 저는 날 수 없어요." % self.name)
else:
self.flying.fly()
def setFlying(self, flying):
self.flying = flyingthor = Hero(
"토르",
1500,
"남성",
power=500,
damage=300,
inventory={"suit": 200, "weapon": "망치"},
flying=SelfFlying(),
)
thor.fly()
thor.setFlying(RocketFlying())
thor.fly()15.4OOP 디자인 패턴¶
OOP의 기본 아이디어는 클래스를 활용하는 캡슐화encapsulation이다. 확장성이 높은 프로그램을 구현하려면 적절한 캡슐화를 이용해야 하며, 가장 기본적인 디자인 원칙은 다음 두 가지이다.
디자인 원칙 1: 변하는 부분과 변하지 않는 부분을 구분하라.
영웅 객체를 구현할 때 이름, 나이, 성별처럼 사람으로서 갖는 기본 정보는 비교적 안정적이다. 반면 비행 방식은 영웅마다 다를 수 있고, 상황에 따라 바뀔 수도 있다. 이런 부분은 하나의 클래스 안에 고정하기보다 독립적으로 다루는 편이 좋다.
디자인 원칙 2: 상속보다 구성에 집중하라.
상속은 강력하지만 모든 문제를 상속으로 해결하려고 하면 클래스가 지나치게 많아지고 유연성이 떨어질 수 있다. 기능별로 객체를 나누고 필요한 객체를 속성으로 갖게 만들면, 기존 클래스와 코드를 크게 수정하지 않고 기능을 바꿀 수 있다.
지금까지 설명한 내용은 OOP를 이용하여 확장성이 높은 프로그램을 작성하는 여러 디자인 패턴 중에서 가장 기본적인 내용을 담고 있다. 상속은 공통 기능을 재활용하는 도구이며, 구성은 변하는 기능을 유연하게 교체하는 도구이다.