클래스에 대해서 알아보자

클래스에 대해서 알아보자

Class는 객체지향 프로그래밍에서 가장 중요하고도 까다롭다.
흔히 말하는 상속이 무엇인지, 어떤 상황에서 상속을 하는지, 상속을 할 수 없을 때는
객체 관계를 어떻게 표현하는지 알아보자.

클래스 관계

클래스 관계를 나타내는 방법으로 IS-A와 HAS-A가 있다.

1.1 IS-A : 상속

IS-A는 ‘은 ~의 한 종류다’를 말한다. 노트북과 컴퓨터를 예를 들어보자. 노트북은 컴퓨터의 한 종류일까?
그렇다. 이런 관계일 경우 Computer와 laptop 클래스는 IS-A관계라고 말할 수 있다.
IS-A관계 인지 아닌지 분간이 안된다면, ‘한 종류다’라는 의미가 있는지 생각해 보자.

이런 IS-A관계를 프로그램에서 표현할 때는 상속을 사용한다. 상속은 IS-A관계에서 설계가 쉽다.

상속을 하는 클래스와 상속을 받는 클래스를 나눠 볼 수 있는데 표현은 다음과 같다.

상속을 하는 클래스

  • 기본 클래스
  • 부모 클래스
  • 슈퍼 클래스

상속을 받는 클래스

  • 파생 클래스
  • 자식 클래스
  • 서브 클래스

코드로 laptop과 computer 클래스를 설계해 보자.

1
2
3
4
5
6
7
8
9
10
class Computer:
def __init__(self, cpu, ram):
self.CPU = cpu
self.RAM = ram

def browse(self):
print('browse')

def work(self):
print('work')

이 코드에서 인스턴스 멤버는 CPU와 RAM이다. 인스턴스 메서드는 browse()와 일을 하는 work()이다.
노트북은 컴퓨터의 모든 멤버와 메서드를 가진다. 노트북에도 CPU와 RAM이 있고, 같은 일을 하기 때문이다.
어떤 객체가 다른 객체의 모든 특성과 기능을 가진 상태에서 그 외에 다른 특성이나 기능을 가지고 있다면 상속해서 쓰는게 편하다.

노트북 클래스를 설계해보자

1
2
3
4
5
6
7
class Laptop(Computer):
def __init__(self, cpu, ram, battery):
super().__init__(cpu, ram)
self.battery = battery

def move(self, to):
print('move to {}'.format(to))

노트북의 클래스 옆에 Computer가 붙은게 보인다. 이는 컴퓨터 클래스를 상속하겠다는 뜻이다.
이렇게 되면 노트북은 컴퓨터 클래스가 가진 모든 멤버와 메서드를 가지게 된다. 노트북도 browse()와
work()가 가능하다는 말이다. 확실히 손이 덜 피곤하다는 게 느껴질 것이다.

super는 무엇일까? 이것은 기본 클래스를 의미한다. 기본 클래스는 위에서 써놨듯이, 상속을 하는 클래스, 즉
컴퓨터 클래스를 가리킨다. CPU와 RAM은 기본 클래스의 생성자를 이용해 초기화가 되었기 때문에 남은 한 멤버인 battery만 할당해 주면 된다.

그리고 노트북에만 있는 move메서드를 입력해준다. 이렇게 되면, 노트북만의 메서드를 하나 갖게 된다.

테스트 코드는 다음과 같다.

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
if __name__ == "__main__":
lap = Laptop('intel', 16, 'powerful')
lap.browse()
lap.work()
lap.move('office')
````

### 1.2 HAS-A : 합성 또는 통합
HAS-A관계는 '~이 ~을 가진다 혹은 포함한다'를 의미한다. Computer는 CPU와 RAM을 가지는데,
여기서 이 관계를 HAS-A관계라고 부를 수 있다.

경찰과 총의 관계를 생각해보자. 경찰은 총을 가지고 있다. 경찰과 총은 HAS-A관계가 성립한다.
주의해야할 점이 있는데, HAS-A관계에는 합성과 통합이라는 표현방법이 존재한다.

컴퓨터와 CPU의 관계를 합성으로 표현하고, 경찰과 총의 관계를 통합으로 표현해보자.
``` python
class CPU :
pass

class RAM :
pass

class Computer :
def __init__(self):
self.cpu = CPU()
self.ram = RAM()

Computer는 인스턴스 멤버 cpu를 가진다. 생성자에서 CPU 객체를 생성해서 멤버 cpu에게 할당한다.
이렇게 되면 Computer라는 객체가 생성이 될 때, CPU와 RAM이 같이 생성이 되고, 사라질때 같이 사라지게 된다.

이 둘의 관계는 매우 강한 관계를 맺고 있다고 할 수 있다. 이런 관계를 합성이라고 한다.

경찰과 총의 관계를 살펴보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Gun :
def __init__(self, kind):
self.kind = kind

def bang(self):
print('bang bang')

class Police :
def __init__(self):
self.gun = None

def acquire_gun(self, gun):
self.gun = gun

def release_gun(self):
gun = self.gun
self.gun = None
return gun

def shoot(self):
if self.gun:
self.gun.bang()
else :
print("Unable to shoot")

이 관계에서는 Police 객체가 만들어질 때 Gun 객체를 가지고 있지 않다. 이후 acquire_gun()메서드를
통해서 Gun 객체를 멤버로 가지게 된다. 이 관계 역시 HAS-A이다. 또한 release_gun()으로 가진 총을
반납할 수도 있다. 이 두 메서드를 이용해 총을 가진 경찰, 총이 없는 경찰 모두를 표현할 수 있다.

하지만 컴퓨터 클래스와 다른 점은, 경찰은 언제든지 Gun을 가질 수 있고 해제할 수 있다는 점이다.
관계가 컴퓨터에 비해 훨씬 약하다는 느낌이 들 것이다. 이런 약한 관계를 통합이라고 부른다.

2. 메서드 오버라이딩과 다형성(Polymorphism)

OOP에서 가장 중요한 개념은 다형성이다(polymorphism). 나는 이 ‘폴리몰피즘’에 대해 노이로제가 걸렸었던 적이 있다.
고려대에서 진행한 Bigdata X Campus 교육에서였다. 파이썬 강의를 들으면서 강사는 “뽈리몰피즘! 뽈리몰피즘이 중요하죠!” 라고 열변을 토했고, 매 강의마다 항상 강조되었었다.
‘도대체 polymorphism이 뭐길래’ 라는 생각이 들었었고, 이 책을 보면서 그 갈증이 어느정도 해결이 되었다.

다형성이란 ‘상속 관계에 있는 다양한 클래스의 객체에서 같은 이름의 메서드를 호출할 때, 각 객체가 서로 다르게 구현된 메서드를 호출함으로써 서로 다른 행동, 기능, 결과를 가져오는 것’을 의미한다. 이를 구현하기 위해서는 파생 클래스 안에서 상속받은 메서드를 다시 구현하는 메서드 오버라이딩이라고 부른다.

2.1 메서드 오버라이딩

먼저 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class CarOwner:
def __init__(self, name):
self.name = name

def concentrate(self):
print('{} can not do anything else'.format(self.name))


class Car:
def __init__(self, owner_name):
self.owner = CarOwner(owner_name)

def drive(self):
self.owner.concentrate()
print('{} is driving now.'.format(self.owner.name))

drive()메서드를 보면 Car 객체는 반드시 차 주인인 CarOwner객체가 운정해야 하고 차 주인은 운전에만
집중해야 한다. drive()메서드가 나오자마자 CarOwner객체의 concentrate()메서드를 호출해서 차 주인이 운전외에는 아무것도 못하게 한다.

이번에는 자율주행차를 만들어보자

1
2
3
class SelfDrivingCar(Car):
def drive(self):
print('Car is driving by itself')

자율주행차에는 상속받은 drive가 어울리지 않는다. 새롭게 바꿔줄 필요가 있다. drive()메서드를 제외하고는 나머지 멤버와 메서드는 그대로 사용한다. 이러한 경우에는 drive()메서드만 클래스 안에서 재정의해준다.

이렇게 클래스 안에서, 맘에 들지 않는 메서드를 재정의 하는 것을 메서드 오버라이딩이라고 한다.
자율주행차의 차 주인은 더 이상 운전에 집중하지 않아도 된다. 따라서 오버라이딩된 drive()메서드에서는
concentrate()를 호출하지 않는다.

여기서 정리해보자면, drive()메서드는 같은 이름이지만 객체에 따라 다른 기능을 하게 된다. 이처럼
같은 이름의 메서드를 호출해도 호출한 객체에 따라 다른 결과를 내는 것을 다형성이라고 한다.

2.2 다형성

다형성에 대해 좀 더 깊이 알아보자.

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
class Animal:
def eat(self):
print('eat something')


class Lion(Animal):
def eat(self):
print('eat meat')


class Cow(Animal):
def eat(self):
print('eat grass')


class Human(Animal):
def eat(self):
print('eat meat and grass')


if __name__ == "__main__":
animals = []
animals.append(Lion())
animals.append(Cow())
animals.append(Human())

for animal in animals:
animal.eat()

이 코드의 Animal 클래스에는 eat()메서드가 있다. 모든 동물은 반드시 먹어야 한다는 가정이다.
하지만 동물마다 먹는 종류는 다르기 때문에 육식 동물의 대표로 사자를 설정했고, 초식 동물의 대표로 소를
설정했다. 그리고 잡식 동물로 사람을 설정했다. 나는 소고기를 쌈싸먹는 것을 좋아한다.

코드의 반복문의 마지막 부분에서 animal.eat()은 다형성을 구현한 부분이다. animals 리스트에서 객체를 하나씩 불러와 eat()메서드를 호출할 때, 메서드를 호출한 쪽에서는 육식동물인지 초식동물인지 잡식인지 고민할 필요가 없다. 각 객체는 오버라이딩된 메서드를 호출하기 때문이다.

그렇게 되면 여기서는 그냥 무엇인가를 먹는 동물은 없다. 그러니까 Animal은 eat something하는 게 있는데 사용하는 동물이 아무도 없다. 안써버리자니 문제가 되고, 뭔가 낭비같다.

이럴 때는 Animal 클래스를 추상 클래스로 만들면 된다.

추상 클래스는 독자적으로 인스턴스를 만들 수 없고 함수의 몸체가없는 추상 메서드를 하나 이상 가지고 있어야 한다. 또한 이 클래스를 상속받는 파생 클래스는 추상 메서드를 반드시 오버라이딩 해야한다. 당연히 아무것도 없으니까!

Animal을 추상 클래스로 변경해보자.

1
2
3
4
5
6
7
from abc import *

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

먼저 abc모듈을 가져온다.(abstract base class) 그 후 @abstractmehod 데코레이터를 붙여준다.
여기서 메서드 구현하는 부분을 pass로 비워두면 eat()은 추상 메서드가 된다.
이제 Animal을 상속받는 모든 파생 클래스는 eat()을 오버라이딩 해야한다.

클래스 설계 예제

클래스를 설계할 때느느 다음 두가지를 고려해야 한다.

  • 공통 부분을 기본 클래스로 묶는다.
  • 부모가 추상클래스인 경우를 제외하고, 파생 클래스에서 기본 클래스의 여러 메서드를 오버라이딩한다면 파생 클래스는 만들지 않는 것이 좋다.

Character 클래스 만들기

게임 캐릭터를 만들어보면서 클래스를 정리해보자.
게임에 등장하는 캐릭터는 플레이어 우리 자신과 몬스터이다.
모든 캐릭터(추상 클래스)는 다음과 같은 특성을 지닌다.

  • 인스턴스 멤버 : 이름, 체력, 공격력을 가진다.
  • 인스턴스 메서드 : 공격, 공격당했을 때는 피해를 입는다.(모두 추상 메서드로 구현한다.)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from abc import *

class Character(metaclass = ABDMeta):
def __init__(self, name, hp, power):
self.name = name
self.hp = hp
self.power = power

@abstractmethod
def attack(self, other, attack_kind):
pass

@abstractmethod
def get_damage(self, power, attack_kind):
pass

def __str__(self):
return '{} : {}'.format(self.name, self.HP)

3.2 Player 클래스 만들기

플레이어는 다음과 같은 특성이 있다.

  • 추가되는 멤버 : 플레이어는 다양한 공격 종류를 담을 수 있는 기술 목록이 있다.
  • attack : 플레이어는 공격할 때 공격 종류가 기술 목록 안에 있다면 상대 몬스터에게 피해를 입힌다.
  • get_damage : 플레이어가 피해를 입을 때 몬스터의 공격 종류가 플레이어의 기술 목록에 있다면 몬스터의 공격력이 반감되어 hp의 절반만 깎인다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Player(Character):
def __init__(self, name = 'player', hp = 100, power = 10, *attack_kinds):
super().__init__(name, hp, power)

self.skills = []

for attack_kind in attack_kinds:
self.skills.append(attack_kind)

def attack(self, other, attack_kind):
if attack_kind in self.skills:
other.get_damage(self.power, attack_kind)

def get_damage(self, power, attack_kind):
if attack_kind in self.skills:
self.HP -= (poewr/2)

else :
self.HP -= power

코드에서 살펴보면 플레이어는 캐릭터를 상속했다.

3.3 Monster, IceMonster, FireMonster 클래스 만들기

몬스터에는 불 몬스터와 얼음 몬스터가 있으며 다음과 같은 특징이 있다.

  • 추가되는 멤버 : 공격 종류를 가진다. 불 몬스터는 Fire, 얼음 몬스터는 Ice를 가진다.
  • 공통 메서드 : 두 몬스터는 같은 행동을 한다.
    • attack : 공격 종류가 몬스터의 속성과 같다면 공격한다.
    • get_damage : 몬스터는 자신과 속성이 같은 공격을 당하면 체력이 오히려 공격력만큼 증가한다. 그렇지 않으면 체력이 공격력만큼 감소한다.

여기서 고민해야 할 점이 있다. FireMonster 클래스와 IceMonster 클래스를 Character 클래스에서 상속받아 구현할지, Moster 클래스라는 부모 클래스를 따로 만들어야 할지.
설명에 따르면 추가되는 멤버도 겹치고, fireball()메서드를 제외한 나머지 메서드도 겹친다. 그러면 공통되는 부분을 기본 클래스로 만들고 이를 상속받는 게 좋을 것 같다.

몬스터를 만들고 몬스터는 캐릭터 클래스를 상속받을 것이다. 그리고 몬스터 클래스를 상속받아 각 몬스터를 만들어보자.

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
class Monster(Character):
def __init__(self, name, hp, power):
super().__init__(name, hp, power):
self.attack_kind = 'None'

def attack(self, other, attack_kind):
if self.attack_kind == attack_kind:
other.get_damage(self.power, attack_kind)

def get_damage(self, power, attack_kind):
if self.attack_kind == attack_kind:
self.HP += power
else :
self.HP -= power

def get_attack_kind(self):
return self.attack_kind


class IceMonster(Monster):
def __init__(self, name = 'Ice monster', hp = 50, power = 10)
super().__init__(name, hp, power)
self.attack_kind = 'ICE'


class FireMonster(Monster):
def __init__(self, name = 'Fire monster', hp= 50, power = 20)
super().__init__.(name, hp, power)
self.attack_kind = 'FIRE'

def firebreath(self):
print('firebreath')

불 몬스터와 얼음 몬스터는 몬스터 클래스를 상속 받았고 추가되거나 변경되는 부분만 수정했다.

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
if __name__ == "__main__":
player = Player('sword master', 100, 30, 'ICE')
monsters = []
monsters.append(IceMonster())
monsters.append(FireMonster())

for monster in monsters :
print(monster)

for monster in monsters:
player.attack(monster, 'ICE')

print('after the plater attacked')

for monster in monsters:
print(monster)
print('')

print(player)

for monster in monsters:
monster.attack(player, monster.get_attack_kind())
print('after monsters attacked')

print(player)

4. 연산자 오버로딩

연산자 오버로딩은 클래스 안에서 메서드로 연산자를 새롭게 구현하는 것으로 다형성의 특별한 형태이다.
연산자 오버로딩을 사용하면 다른 객체나 일반적인 피연산자와 연산을 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Point:
def __init__(self, x=0, y=0):
self.x = x
self.y = y

def set_point(self, x, y):
self.x = x
self.y = y

def get_point(self):
return self.x, self,y

def __str__(self):
return '({x}, {y})'.format(x = self.x, y = self.y)

if __name__ == "__main__":
p1 = Point(2,2)
p2 = p1 + 3
print(p2)

결과를 실행해 보면 에러가 발생할 것이다. Point와 int 객체 사이는 덧셈을 할 수 없다고 나온다.

1
2
3
4
5
6
7
8
9
	def __add__(self, n):
x = self.x + n
y = self.y + n
return Point(x,y)

if __name__ == "__main__":
p1 = Point(2,2)
p2 = p1 + 3
print(p2)

이렇게 add메서드를 추가해보자. 예약한 함수를 사용해서 x좌표와 y좌표에 인자 n을 더한 새로운 x와 y로
새로운 객체를 만들어 반환한다.
실행 결과는 (5,5)가 나오게 된다.

1
2
3
4
if __name__ == "__main__":
p1 = Point(2,2)
p2 = 3 + p1
print(p2)

계산이 안된다. int와 Point의 순서가 바뀌면 에러가 발생한다.
연산자 오버로딩을 하나 더 해주자.

1
2
3
4
5
6
7
8
9
	def __radd__(self, n):
x = self.x + n
y = self.y + n
return Point(x,y)

if __name__ == "__main__":
p1 = Point(2,2)
p2 = 3 + p1
print(p2)
1
(5,5)

__radd__()메서드를 이용해서 연산이 돌아가도록 만들었다.