프로필사진

IT Anthology/encyclopedia

[객체 지향 프로그래밍] 다형성(Polymorphism)

다각 2022. 3. 16. 23:40

* 이 글은 코드잇의 <객체 지향 프로그래밍> 코스를 수강하고 정리한 글입니다.
* 나중에라도 제가 참고하기 위해 정리해 두었으며, 모든 내용을 적은 것이 아닌,
필요하다고 생각되는 부분만 추려서 정리한 것임을 미리 밝힙니다.


다형성이란?

다형성의 원래 정의: 여러가지의 형태를 갖는 성질
객체 지향 프로그래밍에서의 다형성: 하나의 변수가 서로 다른 클래스의 인스턴스를 가리킬 수 있는 성질
예시
Circle이라는 클래스와 Rectangle이라는 클래스가 있을 때, 각각의 클래스가 모두 넓이를 계산하는 area라는 메소드를 가지고 있다고 해보자. 한편, 추가된 모든 도형의 넓이 총합을 계산해주는 클래스가 다음과 같이 있다고 할 때,

class Canvas:
    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])

일단 다음과 같이 네모 인스턴스와 동그라미 인스턴스를 생성한 후에

rectangle = Rectangle(3, 7)
circle = Cicle(4)

캔버스에 도형을 추가한다.

canvas = Canvas()
canvas.add_shape(rectangle)
canvas.add_shape(circle)

그리고 canvas.total_area_of_shapes()를 실행하면, 클래스 Canvas 내부의 메소드 total_area_of_shapes가 실행되며 sum([shape.area() for shape in self.shapes])가 실행된다. 이때, shape라는 변수에 Circle 클래스에서 생성된 인스턴스와 Rectangle 클래스에서 생성된 인스턴스가 번갈아가면서 들어가게 되는데 이렇게 다양한 클래스의 인스턴스가 하나의 변수에 들어갈 구 있는 성질을 다형성이라고 한다.

한편, 이렇게 된다면 area 메소드가 없는 원통 도형일 경우에는 에러가 발생하게 된다.
이 경우, 상속을 활용하면 된다.
즉, Rectangle이나 Circle과 같은 평면 도형을 아우르는 Shape 클래스를 설정해놔서 상속받도록 한다.
여기서 Shape는 Canvas에 추가할 수 없는 추상적인 계층이며, 캔버스에 추가되는 도형은 직사각형이나 원과 같은 구체적인 도형일 것이다.
이를 코딩으로 구현하면 다음과 같다.

from math import pi

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

class Rectangle(Shape):
    """직사각형 클래스"""
    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 "밑변 {}, 높이 {}인 직사각형".format(self.width, self.height)

class Circle(Shape):
    """원 클래스"""
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        """원의 넓이를 리턴한다"""
        return pi * self.radius * self.radius

    def perimeter(self):
        """원의 둘레를 리턴한다"""
        return 2 * pi * self.radius

class Cylinder:
    """원통 클래스"""
    def __init__(self, radius, height):
        self.radius = radius
        self.height = height

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

class Canvas:
    def __init__(self):
        self.shapes = []

    def add_shape(self, shape):
        """캔버스에 도형을 추가한다"""
        if instance(shpe, Shape):
            self.shapes.append(shape)
        else:
            print("넓이, 둘레를 구하는 메소드가 없는 도형은 추가할 수 없습니다.")
        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

cylinder = Cylinder(7, 4)
rectangle = Rectangle(3, 7)
circle = Cicle(4)

canvas = Canvas()
canvas.add_shape(cylinder)
canvas.add_shape(rectangle)
canvas.add_shape(circle)

print(canvas.total_area_of_shapes())
print(canvas.total_perimeter_of_shapes())

하지만 상속을 받는다고 해도 area 메소드를 오버라이딩하지 않고 클래스를 작성할 경우 해당 에러가 발생하게 된다.
이때 메소드를 작성(오버라이딩)하도록 강제하는 것이 추상클래스이다.

추상클래스

: 구체적으로 정의하지 않고 추상적으로 골조를 정의해둔 것 + 파이썬에서 특정 문법 추가

모듈 임포트

from abc import ABC, abstractmethod

Abstract
Base
Class의 준말이 ABC이다. 추상화 기초 클래스인 것.
이 클래스를 상속받으면 추상클래스로 만들 수 있는 것이다.

추상메소드: 자식 클래스가 반드시 오버라이딩해야하는 메소드
위의 두 방법을 사용하여 Shape 클래스를 추상 클래스로 재정의하면 다음과 같다.

from abc import ABC, abstractmethod
class Shape(ABC):
    """도형 클래스"""
    @abstractmethod
    def area(self):
        """도형의 넓이를 리턴한다: 자식 클래스가 오버라이딩할 것"""
        pass

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

여기서, 추상클래스는 인스턴스를 생성할 수 없게 된다! 그야말로 완벽한 추상 클래스가 된 것...!
만약 여기서 인스턴스를 생성하는 코드를 shape = Shape()를 작성할 경우 에러가 발생한다.

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

한편, ABC를 상속하지 않고 추상메소드 데코레이션만 사용하여 작성할 경우 추상클래스로 인스턴스 생성도 되고, 데코레이터도 잘 듣지 않는다.
즉, 제대로 추상화되지 않는다.
따라서 두가지 요소 (1) 추상 클래스 생성 시 ABC 상속, (2) 추상메소드를 작성할 때는 @abstractmethod 데코레이터 사용을 둘다 지켜주도록 하자.
위의 예시에서 추상화를 끝마친 Shape 클래스를 상속받는 또다른 구체적인 도형 클래스를 생성해보자.

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

와 같은 또 다른 클래스를 생성할 때, @abstractmethod가 표시된 추상 메소드를 오버라이딩하지 않았으므로 에러가 발생하게 된다.
그런데 그 에러를 가만히 살펴보면, 추상클래스 Shape의 인스턴스를 생성할 때와 비슷한 에러가 뜬다.

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

이는, 정삼각형 클래스가 현재 추상화 기초 클래스(ABC)를 상속받은 Shape를 상속받은 자식클래스이기 때문에 추상화 클래스로 존재하기 때문이다! 이때 추상 메소드 areaperimeter를 오버라이딩해주면 정삼각형 클래스는 더이상 추상메소드를 보유하지 않게 되고, (추상클래스를 상속받고 있기 때문에 여전히 @abstractmethod 데코레이터가 작동할 수 있는 성질은 지니지만) 일반클래스처럼 행동이 가능해지고, 결과적으로 인스턴스도 정상적으로 생성할 수 있게 된다.

Tip 1 추상화 클래스를 작성할 시에, type hinting을 해주어 변수, 메소드의 파라미터나 리턴값에 대한 힌트를 표시해주자!
그럼 오버라이딩 시에 더 수월할 것😚

class Shape(ABC):
    """도형 클래스"""
    @abstractmethod
    def area(self) -> float:
        """도형의 넓이를 리턴한다: 자식 클래스가 오버라이딩할 것"""
        pass

    @abstractmethod
    def perimeter(self) -> float :
        """도형의 둘레를 리턴한다: 자식 클래스가 오버라이딩할 것"""
        pass

Tip 2 첫번째 시간으로 돌아가 '추상화'의 정의에 대해 떠올려보면, 추상화란 변수, 함수, 클래스를 사용해 사용자가 꼭 알아야만 하는 부분만 겉으로 드러내는 것이다. 추상 클래스 역시 이 '추상화'의 기본 정신, 스피릿을 이어받았다!
추상 클래스는 서로 관련있는 클래스들의 공통 부분을 묶어서 추상화한다.

Tip 3 추상메소드에도 내용을 채우는 경우가 있다! 모든 자식 클래스에서 공통적으로 사용할 부분을 추상 메소드의 내용으로 써주고 자식클래스에서 이를 super함수로 접근한다...

 from abc import ABC, abstractmethod

class Shape(ABC):
    """도형 클래스"""
    @abstractmethod
    def area(self) -> float:
        """도형의 넓이를 리턴한다: 자식 클래스가 오버라이딩할 것"""
        print("도형의 넓이 계산 중!")   # ---------------- 추가된 코드

    @abstractmethod
    def perimeter(self) -> float:
        """도형의 둘레를 리턴한다: 자식 클래스가 오버라이딩할 것"""
        pass

 class Rectangle(Shape):
    """직사각형 클래스"""
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        """직사각형의 넓이를 리턴한다"""
        super().area() # ---------------- 부모의 메소드를 가져다 씀
        return self.width * self.height

    def perimeter(self):
        """직사각형의 둘레를 리턴한다"""
        return 2*self.width + 2*self.height

Tip 4 추상클래스로 '메소드'뿐만 아니라, '변수'도 갖도록 강제할 수 있다.

class Shape(ABC):
    """도형 클래스"""
    @abstractmethod
    def area(self) -> float:
        """도형의 넓이를 리턴한다: 자식 클래스가 오버라이딩할 것"""
        print("도형 넓이 계산 중!")

    @abstractmethod
    def perimeter(self) -> float:
        """도형의 둘레를 리턴한다: 자식 클래스가 오버라이딩할 것"""
        pass

    def __str__(self):
        return "추상 클래스라고 해서 모든 메소드가 추상 메소드일 필요는 없습니다!"

    @property
    @abstractmethod
    def x(self):
        """도형의 x 좌표 getter 메소드"""
        pass

    @property
    @abstractmethod
    def y(self):
        """도형의 y 좌표 getter 메소드"""
        pass

위와 같이 @property@abstractmethod를 동시에 쓰면, 이 메소드는 getter메소드이자 추상 메소드가 된다.
한편, 상속받는 자식 클래스에서는 아래와 같이 getter/ setter 메소드를 작성하여 변수를 세팅하는 것과 유사하게 할 수 있다.

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

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

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

    @property
    def x(self):
        """_x getter 메소드"""
        return self._x

    @x.setter
    def x(self, value):
        """_x setter 메소드"""
        self._x = value

    @property
    def y(self):
        """_y getter 메소드"""
        return self._y

    @y.setter
    def y(self, value):
        """_y setter 메소드"""
        self._y = value

equilateral_triangle = EquilateralTriangle(5, 6, 4) # 에러가 나지 않는다
equilateral_triangle.x = 10
print(equilateral_triangle.x) # 출력: 10

equilateral_triangle.y = 5
print(equilateral_triangle.y) # 출력: 5

Tip 5 추상클래스의 다중상속

함수/ 메소드의 다형성

  1. 옵셔널 파라미터(optional parameter) 사용
    : 기본값(default)을 미리 지정해놓은 파라미터
    : 옵셔널 파라미터는 가장 뒤에 몰아서 배열해야 한다.
  2. 함수 호출 시에 파라미터 이름을 명시
  3. 개수가 확정되지 않은 파라미터
    : 개수를 확정하지 않은 파라미터 앞에 *를 붙여주면 튜플에 담긴다.

LBYL 코딩 스타일
: Look Before You Leap
: 뛰기 전에 살펴보라 (돌다리도 두드려보고 건너라)
EAFP 코딩 스타일
: Easier to Ask for Forgiveness than Permission
: 허락보다 용서가 쉽다!
: 일단 해보고 문제가 생기면 그때 해결하자~😆

예를 들어, Canvas를 LBYL 스타일로 작성하면 아래와 같다.

class Canvas:
    def __init__(self):
        self.shapes = []

    def add_shape(self, shape):
        """캔버스에 도형을 추가한다"""
        if instance(shpe, Shape):
            self.shapes.append(shape)
        else:
            print("넓이, 둘레를 구하는 메소드가 없는 도형은 추가할 수 없습니다.")
        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

이를 EAFP 스타일로 작성해보면 아래와 같다.


class Canvas:
    def __init__(self):
        self.shapes = []

    def add_shape(self, shape: Shape):
        """캔버스에 도형 인스턴스 shape를 추가한다. 단, shape는 추상 클래스 Shape의 인스턴스여야 한다."""
        self.shapes.append(shape)

    def total_area_of_shapes(self):
        """캔버스에 있는 모든 도형의 넓이의 합을 구한다"""
        total_area = 0

        for shape in self.shapes:
            try:
                total_area += shape.area()
            except (AttributeError, TypeError):
                print("캔버스에 area 메소드가 없거나 잘못 정의되어 있는 인스턴스 {}가 있습니다.".format(shape))
        return total_area

    def total_perimeter_of_shapes(self):
        """캔버스에 있는 모든 도형의 둘레의 합을 구한다"""
        total_perimeter = 0
        for shape in self.shapes:
            try:
                total_perimeter += shape.perimeter()
            except (AttributeError, TypeError):
                print("캔버스에 perimeter 메소드가 없거나 잘못 정의되어 있는 인스턴스 {}가 있습니다.".format(shape))

        return total_perimeter

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

파이썬 문화에 따르면 EAFP 코딩 스타일이 조금 더 적합하다고는 하지만, 내 코딩 스타일은 LBYL 코딩 스타일에 조금 더 가까운듯 하다.
무엇보다 애초에 추가할 때 isinstance 한번만 해주면 될걸 굳이 try/ except 구문을 번거롭게 사용해 가면서 해야겠냐고...