지난 시간에는 프로그래밍의 부모 자식 관계인 상속에 대해 배워봤습니다.

이번 시간에는 객체 지향 프로그래밍의 네 기둥 중 마지막, 다형성에 대해 함께 알아봅시다.

🤹‍♀️ 클래스 다형성

우리가 흔히 사용하는 그림판 프로그램을 만들어보겠습니다. 그림판에는 여러 도형이 있었죠? 먼저, 을 나타내는 클래스 하나를 만들겠습니다.

원의 둘레를 구할 때, 원주율 파이라는 것을 배웠죠? Python에서는 이 파이 값을 기본으로 가지고 있는데요. 모듈을 통해 파이 값을 불러옵시다.

from math import pi

이제 원 클래스를 작성해보겠습니다.

class Circle:
    """원 클래스"""
    def __init__(self, radius):
        self.radius = radius
        
    def area(self):
        """원의 넓이를 리턴한다"""
        return pi * self.radius * self.radius
        
    def perimeter(self):
        """원의 둘레를 리턴한다"""
        return 2 * pi * self.radius
        
    def __str__(self):
        """원의 정보를 문자열로 리턴한다"""
        return f"반지름 {self.radius}인 원"

Circle 클래스는 반지름을 나타내는 인스턴스 변수 radius와 pi를 사용해 넓이와 둘레를 계산해 주는 인스턴스 메소드 area와 perimeter를 가지고 있습니다.

다음으로 직사각형 클래스도 하나 만들어볼까요?

class Rectangle:
    """직사각형 클래스"""
    def __init__(self, width, height):
        self.width = width
        self.height = height
        
    def area(self):
        """직사각형의 넓이를 리턴한다"""
        return self.width * self.height
        
    def perimeter(self):
        """직사각형의 둘레를 리턴한다"""
        return 2*self.width + 2*self.height
        
    def __str__(self):
        """직사각형의 정보를 문자열로 리턴한다"""
        return f"밑변 {self.width}, 높이 {self.height}인 직사각형"

Rectangle 클래스는 밑변과 높이를 나타내는 인스턴스 변수 width와 height, 그리고 이 둘을 사용해 넓이와 둘레를 계산해 주는 인스턴스 메소드 area와 perimeter를 가지고 있습니다.

필요한 도형들의 클래스가 완성되었으니 이번에는 그림판 클래스를 만들어보겠습니다.

class Paint:
    """그림판 프로그램 클래스"""
    def __init__(self):
        self.shapes = []
        
    def add_shape(self, shape):
        """그림판에 도형을 추가한다"""
        self.shapes.append(shape)
        
    def total_area_of_shapes(self):
        """그림판에 있는 모든 도형의 넓이와 합을 구한다"""
        return sum([shape.area() for shape in self.shapes])
        
    def total_perimeter_of_shapes(self):
        """그림판에 있는 모든 도형의 둘레의 합을 구한다"""
        return sum([shape.perimeter() for shape in self.shapes])

    def __str__(self):
        """그림판에 있는 각 도형들의 정보를 출력한다"""
        res_str = "그림판 안에 있는 도형들:\n\n"
        for shape in self.shapes:
           res_str += str(shape) + "\n"
        return res_str

Paint 클래스에는 shapes라는 리스트가 있는데요. add_shapes 메소드를 통해 해당 리스트 안에 도형을 추가할 수 있습니다.

total_area_of_shapes 메소드는 그림판에 있는 모든 도형의 넓이와 합을 구하는 역할을 합니다. 그런데 이 메소드 속 구문이 낯설죠? 이 구문은 list comprehension인데요. 사실 우리는 이전에 이 구문에 대해 배운 적이 있습니다.

리스트를 자세히 살펴보면 for문이 보입니다. 이 구문은 for문의 헤더 부분과 마찬가지로 shapes라는 리스트를 돌며 각 요소를 shape에 담으라는 뜻을 가지고 있습니다.

그리고 매번 shape에 area 메소드를 호출합니다. area는 도형의 넓이를 구하는 메소드였죠? 도형들의 넓이의 값으로 이루어진 리스트가 완성되면 바깥의 sum 함수가 실행됩니다. sum 함수로 인해 리스트 속 모든 원소의 합이 반환됩니다. 결과적으로 그림판에 있는 모든 도형의 넓이 합이 리턴됩니다.

마찬가지 원리로 total_perimeter_of_shapes는 그림판에 추가된 모든 도형들의 둘레의 합을 구합니다.

지금부터 작성한 클래스들을 사용해봅시다. 인스턴스부터 생성해볼까요?

circle = Circle(2)
rectangle = Rectangle(4, 6)

페인트 프로그램을 만들고 원과 직사각형을 추가하겠습니다.

paint_program = Paint()
paint_program.add_shape(circle)
paint_program.add_shape(rectangle)

다음으로 그림판에 추가된 모든 도형들의 넓이의 합, 둘레의 합을 구해볼게요.

print(paint_program.total_area_of_shapes())
print(paint_program.total_perimeter_of_shapes())
36.56637061435917
32.56637061435917

이제 다형성이 무엇인지 알아봅시다.

total_area_of_shapes 메소드에서는 shape.area()를 통해 각 도형의 넓이를 구하고 total_perimeter_of_shapes에서는 shape.perimeter()를 통해 각 도형의 둘레를 구합니다.

여기서 문제가 하나 있습니다. 리스트의 요소 shape에는 원이 들어올 수도, 직사각형이 들어올 수도 있는데 위 코드에서는 shape이 어떤 인스턴스인지 확인하지 않고 위 두 메소드를 호출합니다. 그럼에도 실행은 정상적으로 됩니다. 그 이유는 두 도형 클래스 모두 area와 perimeter 메소드를 가지고 있기 때문입니다.

shape은 카멜레온처럼 어떨 때는 원으로, 어떨 때는 직사각형이 됩니다. 이렇게 shape처럼 하나의 변수가 서로 다른 인스턴스를 가리키고 있는 성질다형성이라고 합니다.

그리고 다시 한 번 말씀 드리자면, shape가 다형성을 가질 수 있는 이유는 Rectangle 클래스와 Circle 클래스가 공통적으로 area 메소드와 perimeter 메소드를 가지고 있기 때문입니다.

🤹‍♀️ 상속 없는 다형성의 한계

이번에는 원통 클래스를 추가해봅시다.

class Cylinder:
    """원통 클래스"""
    def __init__(self, radius, height):
        self.radius = radius
        self.height = height
        
    def __str__(self):
    """원통의 정보를 문자열로 리턴하는 메소드"""
        return f"밑면 반지름 {self.radius}, 높이 {self.height}인 원기둥"

원통은 입체 도형이기 때문에 Cylinder 클래스에는 넓이, 둘레를 구하는 메소드를 두지 않았습니다.

원통 인스턴스도 그림판 프로그램에 추가해보겠습니다.

cylinder = Cylinder(8, 2)

그리고 프로그램을 실행해봅시다.

paint_program.add_shape(cylinder)
paint_program.add_shape(circle)
paint_program.add_shape(rectangle)

print(paint_program.total_perimeter_of_shapes())
print(paint_program.total_area_of_shapes())

위 코드를 실행하면, 에러가 납니다! 그 이유는 Cylinder 클래스에 area와 perimeter 메소드가 없기 때문입니다. 어찌보면 너무 당연한 결과이죠?

이 문제를 한 번 해결해봅시다. 상속에서 배웠던 isinstance 함수 기억하시나요? 이 함수를 사용해서 리스트 shapes에 도형을 추가하기 전에 인스턴스가 Rectangle 클래스로부터 왔거나 Circle 클래스로부터 왔는지를 확인하고, 맞는 경우에만 추가하도록 해보겠습니다. 이렇게 하면 shapes에 추가되는 모든 인스턴스가 area와 perimeter 메소드를 가지고 있을 것이므로 에러가 나오지 않겠죠?

def add_shape(self, shape):
    """그림판에 도형을 추가한다"""
    if isinstance(shape, Circle) or isinstance(shape, Rectangle):
        self.shapes.append(shape)
    else:
        print("넓이, 둘레를 구하는 메소드가 없는 도형은 추가할 수 없습니다!")

당장은 이렇게 문제를 해결할 수 있겠지만 새로운 도형들이 계속해서 추가된다면 이 방법에도 한계가 있습니다. 즉, 새로운 도형 클래스를 만들 때마다 add_shape 메소드에 특정 메소드를 가진 클래스인지 확인하는 isinstance 함수를 추가해야 하므로 효율이 떨어집니다.

🤹‍♀️ 상속과 다형성 - 일반 상속

그렇다면 좀 더 효율적이고 안정적으로 다형성을 적용할 수 있는 방법은 없을까요?

두 클래스가 공통 메소드를 가지는 경우, 익숙하지 않으신가요? 바로 지난 시간에 배웠던 상속의 조건이 떠오릅니다. Rectangle 클래스와 Circle 클래스 모두 area와 perimeter라는 공통 메소드를 가지고 있습니다. 이를 토대로 이 두 클래스의 부모 클래스 Shape를 한 번 만들어보겠습니다.

class Shape:
    """도형 클래스"""
    def area(self):
        """도형의 넓이를 리턴한다: 자식 클래스가 오버라이딩 할 것"""
        pass
    
    def perimeter(self):
        """도형의 둘레를 리턴한다: 자식 클래스가 오버라이딩 할 것"""
        pass

이렇게 Shape 클래스를 정의하면 새로운 종류의 도형 클래스를 만들 때, Shape 클래스를 상속받도록 하면 됩니다. 원통 같은 경우는 입체 도형이기 때문에 상속 받을 필요가 없겠죠?

다만, Shape의 인스턴스를 생성할 일은 없을 겁니다. 그림판에는 구체적으로 사각형, 원, 삼각형 등이 활용되는데 도형이라는 것은 추상적인 개념이기 때문이죠.

이때, 각 메소드의 내용을 생략한 이유는 도형마다 넓이와 둘레를 계산하는 공식이 다르기 때문에 자식 클래스에서 그 내용을 알맞게 정의하는 즉, 오버라이딩을 하도록 둔 것입니다.

이제 두 클래스가 Shape를 상속받도록 하겠습니다.

class Circle(Shape):
class Rectangle(Shape):

이미 두 클래스에는 각각 area와 perimeter 메소드가 정의되어 있으므로 따로 오버라이딩 할 일은 없겠네요.

다음으로 해야할 일은 Paint 클래스에서 그림판에 도형을 추가하는 add_shape 메소드를 수정할 차례입니다. 이번에도 isinstance를 활용할 거지만 모든 도형 종류마다 사용하지는 않겠습니다. 그냥 shape이 Shape 클래스의 인스턴스인지만 확인하면 되니까요.

def add_shape(self, shape):
    """그림판에 도형을 추가한다"""
    if isinstance(shape, Shape):
        self.shapes.append(shape)
    else:
        print("넓이, 둘레를 구하는 메소드가 없는 도형은 추가할 수 없습니다!")
    

우리는 상속을 배울 때, 자식 클래스의 인스턴스가 부모 클래스의 인스턴스이기도 하다는 사실을 알았습니다. Rectangle 인스턴스와 Circle 인스턴스도 마찬가지로 Shape의 인스턴스입니다. Shape 인스턴스라면 area, perimeter 메소드를 가지고 있다는 뜻이겠죠.

다시 한 번, 원통 인스턴스를 가지고 프로그램을 실행해보겠습니다.

paint_program.add_shape(cylinder)
paint_program.add_shape(circle)
paint_program.add_shape(rectangle)

print(paint_program.total_perimeter_of_shapes())
print(paint_program.total_area_of_shapes())
넓이, 둘레를 구하는 메소드가 없는 도형은 추가할 수 없습니다!
36.56637061435917
32.56637061435917

Cylinder 클래스가 Shape 클래스를 상속받지 않기 때문에 cylinder 인스턴스는 Shape 클래스의 인스턴스가 아닙니다. 그렇기 때문에 그림판에는 추가될 수 없고 위 실행 결과와 같은 실패 문구를 출력합니다.

지금까지 배운 내용을 정리해보겠습니다. 어떤 변수가 여러 종류의 인스턴스를 가리키게 해서 다형성을 가질 수 있습니다. 하지만 그 인스턴스에 어떤 메소드를 호출했을 때 인스턴스가 해당 메소드를 가지고 있어야만 다형성이 성립됩니다. 가지고 있지 않다면 에러가 발생하죠.

이를 방지하기 위해서 isinstance 함수를 통해 어떤 클래스의 인스턴스인지를 미리 확인해야 하는데요. 이때, 클래스 종류가 많으면 isinstance 함수가 많아진다는 한계가 있습니다. 이를 해결하기 위해서는 상속을 활용하면 됩니다.

상속을 활용한 후 isinstance를 정의하면,

  1. shape는 Shape 클래스의 인스턴스인가?
  2. shape는 area 메소드와 perimeter 메소드를 가지고 있는가?

라는 조건을 통해서 단 한 줄로 다형성을 적용할 수 있습니다.

🤹‍♀️ 일반 상속의 문제점

이번에는 정삼각형 클래스를 만들어보겠습니다.

class EquilateralTriangle(Shape):
    """정삼각형 클래스"""
    def __init__(self, side):
        self.side = side

정삼각형 클래스는 Rectangle 클래스와 Circle 클래스처럼 Shape 클래스를 상속 받고 있습니다. 하지만 차이점이 하나 있는데요. 두 클래스와 달리 area 메소드와 perimeter 메소드를 오버라이딩을 하지 않았습니다.

원칙대로라면, Shape의 상속을 받고 있기 때문에 정삼각형 클래스 또한 add_shape에 추가가 가능합니다. 정삼각형 인스턴스를 생성하고 프로그램을 실행해보겠습니다.

triangle = EquilateralTriangle(3)
paint_program = Paint()
paint_program.add_shape(triangle)

print(paint_program.total_perimeter_of_shapes())
print(paint_program.total_area_of_shapes())

에러가 발생합니다! 정삼각형 클래스는 오버라이딩을 하지 않았기 때문에 부모 클래스의 area 메소드와 perimeter 메소드를 그대로 물려받았습니다.

문제는 해당 메소드들에 내용이 정의되지 않아서 리턴값이 아무 것도 없습니다. 따라서, 페인트 클래스의 메소드들은 리턴값이 있다고 생각하고 sum 함수를 더하려다 아무 값도 없으니 에러를 발생시킨 겁니다.

따라서, 그림판에 추가될 도형들은 area 메소드와 perimeter 메소드를 무조건 오버라이딩해야 합니다. 그래야 에러가 발생하는 걸 막을 수 있으니까요.

🤹‍♀️ 상속과 다형성 - 추상 클래스

그렇다면 Shape 클래스의 자식 클래스가 두 메소드를 오버라이딩 하도록 강제하는 방법은 없을까요?

이를 해결하는 방법은 바로 추상 클래스입니다. 추상 클래스는 여러 클래스의 공통점을 추상화해서 모아놓은 클래스입니다.

어떤 클래스를 추상 클래스로 정의하게 되면 자식 클래스들이 추상 클래스에서 상속받는 메소드를 오버라이딩 하도록 강제할 수 있습니다. Shape 클래스를 추상 클래스로 정의해봅시다.

현재 Shape 클래스에 있는 두 메소드는 그 내용을 구현하지는 않고 정의만 되었습니다. 이는 추상적으로 '도형 클래스는 넓이와 둘레를 어떻게든 계산할 줄 아는 어떤 것'이라고 정의를 해둔 것인데요.

Python에서 추상 클래스를 정의하기 위해서는 몇 가지 문법이 필요합니다.

먼저, abc라는 모듈에서 ABC라는 클래스와 abstractmethod라는 데코레이터 함수를 가져와야 합니다.

from abc import ABC, abstractmethod

이때, ABC는 'Abstract Base Class'의 약어로, 우리말로는 추상화 기초 클래스라고 합니다. 이 클래스를 상속 받으면 추상 클래스로 만들 수 있습니다.

class Shape(ABC)

이에 더해, 추상 메소드라는 것이 필요한데요. 추상 메소드는 자식 클래스가 반드시 오버라이딩 해야 하는 메소드입니다. abstractmethod 데코레이터 함수를 사용하면 특정 메소드를 추상 메소드로 만들 수 있습니다.

@abstractmethod
def area(self):
@abstractmethod
def perimeter(self):

이렇게 하면 area 메소드와 perimeter 메소드는 추상 메소드가 된 겁니다. 그리고 Shape를 상속받는 클래스는 반드시 이 두 메소드를 오버라이딩 해야 합니다.

요컨대, 추상 클래스는 ABC 클래스를 상속 받고 적어도 하나 이상의 추상 메소드를 가져야 합니다.

한 가지 더 알아두어야 하는 것은 추상 클래스로는 인스턴스 생성이 불가능하다는 점입니다.

shape = Shape()

위 코드를 실행하면 에러가 나는데요.

TypeError: Can't instantiate abstract class Shape with abstract methods area, perimeter

위 에러 문구를 해석하면 추상 메소드 area, perimeter가 있는 추상 클래스 Shape로는 인스턴스를 만들 수 없다고 나옵니다.

이는 추상 클래스가 인스턴스를 생성하기 위해 존재하는 것이 아니라 여러 클래스들의 공통 부분을 담아 두어 해당 클래스들이 상속 받을 수 있는 부모 클래스가 될 목적으로 존재하기 때문입니다.

🤹‍♀️ 추상 클래스 활용하기

다시 정삼각형 클래스로 돌아가 봅시다. 정삼각형 클래스는 추상 클래스인 Shape를 상속받고 있죠. Shape 클래스 또한 ABC 클래스를 상속 받고 있습니다. 결국 정삼각형 클래스는 한 다리 건너 ABC 클래스를 상속 받고 있는 셈입니다. 추상 메소드는 오버라이딩 하지 않으면 그대로 추상 메소드로 남습니다. 따라서, 정삼각형 클래스는 추상 메소드를 가지고 있기도 합니다.

결국 정삼각형 클래스ABC를 상속받고 추상 메소드를 가지고 있기 때문추상 클래스입니다. 추상 클래스인 상태로는 인스턴스를 생성할 수 없기 때문에 코드를 실행해도 여전히 에러가 날 겁니다.

추상 클래스를 일반 클래스로 만들기 위해서는 추상 메소드를 오버라이딩 해야 합니다. 정삼각형 클래스의 area 메소드와 perimeter 메소드를 오버라이딩 해보겠습니다.

정삼각형의 넓이를 구할 때에는 제곱근을 구할 수 있는 함수가 필요합니다. Python의 math 모듈에는 sqrt라는 제곱근 함수가 있습니다. 이를 가져오겠습니다.

from math import pi, sqrt

그리고 오버라이딩을 해주면 됩니다.

def area(self):
    """정삼각형의 넓이를 리턴한다"""
    return sqrt(3) * self.side * self.side / 4
    
def perimeter(self):
    """정삼각형의 둘레를 리턴한다"""
    return 3 * self.side

이제 프로그램을 실행해봅시다.

triangle = EquilateralTriangle(3)
paint_program = Paint()
paint_program.add_shape(triangle)

print(paint_program.total_perimeter_of_shapes())
print(paint_program.total_area_of_shapes())
3.897114317029974
9

잘 실행되네요.

여기서 오버라이딩을 강제한다는 말이 와닿지 않는 분들이 있을 겁니다. 정삼각형 클래스의 오버라이딩을 강제하지 않았을 때, 어떤 일이 일어났었나요? 네, 맞습니다. 에러가 났죠.

앞서 정삼각형 클래스가 추상 클래스인 Shape를 상속 받음으로써 추상 클래스가 되었던 것을 기억하시나요? 그 상태로 오버라이딩을 하지 않으면 정삼각형 클래스는 인스턴스조차 생성하지 못하는 추상 클래스인채로 남게 됩니다. 이를 해결하기 위해서는 반드시 추상 메소드를 오버라이딩 해야했고 이것이 바로 오버라이딩을 강제하는 방법입니다.

프로그램이 문제 없이 돌아간다는 것에는 한 가지 더 알아야 할 사실이 있습니다. 바로 add_shape의 isinstance 함수를 통과했다는 것인데요. 이 함수를 통과하는 조건은 특정 클래스가 공통 메소드를 가진 부모 클래스를 상속 받고 있는지 여부였죠? 정삼각형 클래스는 추상 클래스를 상속받음으로써 이 부분을 통과할 수 있게 된 것입니다.

마지막으로 Shape 클래스의 두 메소드가 비워져있던 사실을 기억하시나요? 이렇게 메소드를 비워두면 파라미터나 리턴값을 알 수 없으므로 앞서 배웠던 type hinting을 통해 이 둘을 명시해주도록 합시다.

@abstractmethod
def area(self) -> float:
@abstractmethod
def perimeter(self) -> float:

이처럼 오버라이딩의 방향성을 설정해주기 위해 추상 메소드에는 type hinting을 해주는 것을 권장합니다.


이번 시간에는 객체 지향 프로그래밍의 마지막 기둥 다형성에 대해 함께 알아봤습니다. 하나의 변수가 여러 가지 인스턴스를 가질 때 생길 수 있는 문제점들을 하나씩 해결해봤는데요.

다음 시간에는 다형성의 더 다양한 활용법에 대해 함께 알아봅시다.

* 이 강의는 CODEIT의 '객체 지향 프로그래밍' 강의를 기반으로 작성되었습니다.
profile
There's Only One Thing To Do: Learn All We Can

0개의 댓글