Python 에서 추상화는 어떻게 작성할까?

sudal·2023년 9월 6일

개요

객체지향 프로그래밍을 할때
요구사항이 자주 변경되지 않는 개념적인 부분과 자주 변경되는 구체적인 구현 부분을 분리하는 것은 중요합니다.
왜냐하면 분리를 함으로써 구체적인 구현은 캡슐화가 가능해지고, 개념적인 부분은 인터페이스를 작성함으로써 추상화 상속 다형성이 가능해져 결국 재사용 가능한 설계가 이루어지기 때문입니다.

이 글을 작성하면서 개념적으로 일반화된 부분을 추상화라고 지칭하겠습니다.
Java의 경우 추상화는 Interface 와 Abstract Class 를 이용하여 작성하는데 Python에서는 이를 어떻게 작성하는지 살펴보겠습니다.

덕 타이핑은 불필요한 계층구조를 제거한다.

"어떤 새가 오리처럼 걷고, 오리처럼 헤엄치며, 오리처럼 꽥꽥 소리를 낸다면 나는 이새를 오리라고 부를 것이다"
James Whitcom Riley

Python은 정적 타입 언어가 아닌 동적 타입 언어이기 때문에 프로그램의 실행 시점에 데이터의 타입이 결정됩니다.
이러한 동적 타입 언어의 특성은 덕 타이핑을 가능하게 해주고 Python은 모든 클래스의 인스턴스에 대해 덕 타이핑이 가능합니다. (정적 타입 언어중에서도 일부 덕 타이핑이 가능한 언어어도 있습니다.)

덕 타이핑(Duck typing) 이란
객체의 타입은 그 객체가 정의한 메서드 보다 중요하지 않으므로, 객체의 타입을 확인하는 것이 아니라 객체가 가진 메서드의 존재 유무를 확인한다는 개념입니다.
이러한 개념을 바탕으로 미래의 설계자는 모든 가능한 타입에 대한 상속 계층을 지정하지 않고도 새로운 유형의 타입을 생성할수 있게 되며, 원래 설계자가 계획하지 않은 완전히 다른 즉흥적인 동작을 만들어 개발자가 설계를 확장할 수 있게 해줍니다.

일부 언어에서는 추상화 정의가 필수이지만, Python에서는 덕 타이핑 때문에 다형성을 지원하기 위해 복잡한 상속계층을 구현하지 않아도 됩니다. 따라서 추상화는 선택사항입니다.
설계 의도를 명확히 하고 싶은 경우에는 추상화를 사용하고, 오버헤드가 많이 생길것 같으면 추상화를 하지 않는 것이 좋은 방향일 수 있습니다.

예제-1) 코드를 통해, 덕 타이핑을 이용하면 상속계층을 구현하지 않아도 되는 이유를 살펴봅시다.

from collections.abc import Container


class EvenIntegers:
    def __contains__(self, x: int) -> bool:
        return x % 2 == 0
        
########실행########
>>> even = EvenIntegers()
>>> isinstance(even, EvenIntegers)
True
>>> issubclass(EvenIntegers, Container)
True
>>> 1 in even
False
>>> 2 in even
True

예제-1 에서 Container 클래스는 Python의 'in' 연산자에 대한 기능을 정의하도록 강제하는 Python의 내장 추상 클래스로 __contains__ 메서드를 구현하도록 강제합니다.
예제-1 코드는 EvenIntegers 객체를 인스턴스화할 수 있으며, Container를 상속하지 않았지만 클래스가 container 객체로서 작동하는지 확인할 수 있습니다.
그리고 상속 또는 다중 상속을 설정하기 위해 코드를 작성하는 오버헤드 없이 is-a 관계를 만들 수도 있습니다.
이러한 특징은 불필요한 코드의 작성을 줄여주고 유연한 설계를 가능하게합니다.

믹스인은 기능을 동적으로 재사용 가능하게 한다.

믹스인이란
그 자체로는 존재 의미가 없지만 추가 기능을 제공하기 위해 코드를 다른 코드안에 섞어 넣는 기법을 가리키는 용어입니다.
Python에서는 다중상속과 덕타이핑을 이용하여 믹스인 클래스를 구현할 수 있습니다.

예제-2) 믹스인클래스 구현

from typing import Protocol


class Emailable(Protocol):
    email: str


class EmailSenderMixin(Emailable):
    def send(self, message: str) -> None:
        print(f"{self.email} 주소로 메일 전송")


class Contact:
    def __init__(self, name: str, email: str) -> None:
        self.name = name
        self.email = email


class EmailableContact(Contact, EmailSenderMixin):
    pass


c = EmailableContact("아무개", "test@example.com")
c.send(message="전송할 메시지 내용")

########실행########
test@example.com 주소로 메일 전송

예제-2 를 보면 Contact 클래스는 Emailable 클래스를 상속하지 않았음에도 불구하고 EmailableContact 클래스의 인스턴스는 send 메서드를 호출할 수 있는 것을 알수 있습니다.
호출이 가능한 이유는 인스턴스에게 email 속성이 존재하는지만 확인하는 덕 타이핑의 규칙 때문입니다.
믹스인 클래스를 잘 활용하면 새로운 추상화 클래스를 작성하거나 상속받지 않고, 기능 구현은 아무것도 하지 않은채 다중상속을 이용하여 새로운 유형의 클래스를 쉽게 생성할 수 있습니다.

Protocol 은 설계의도를 명확하게 한다.

Protocol은 Python 3.8 버전 부터 도입된 타입 힌트 기능으로, 정적 타입 체커가 런타임시의 덕 타이핑을 처리할 수 있는 기능을 제공합니다.
이러한 기능은 동료 개발자들에게 더욱 명확한 설계의도를 전달할 수 있게 됩니다.
코드를 통해 살펴보겠습니다.

타입 힌트(type hint)란
변수,메서드, 함수의 파라메터 또는 반환 값 등의 타입을 클라이언트에게 알려주는 기능 입니다.
클라이언트에게 알려준다는 것은 프로그램 실행시 에러를 발생시키는 것은 아닙니다.
단지 가독성을 위해서만 존재합니다.
하지만 mypy와 같은 정적 타입체크 툴을 이용하면 타입힌트와 다른 값이 들어오게되는 경우 에러를 발생시킬 수 있습니다.
타입스크립트 와 같이 타입을 명시적으로 선언하는 언어들의 인기가 높아지면서 Python에서 mypy의 사용 빈도가 높아지고 있는것 같습니다.

예제-2 를 보면 Protocol을 상속받은 Emailable 클래스를 볼 수 있는데, Emailable 클래스는 아무것도 구현하지 않은채 email 이라는 클래스 변수를 가지고 있는 것을 볼 수 있습니다.
이런식으로 완전한 기능 구현이 되어있지 않은 불완전한 클래스이지만 어떠한 기능을 구현하기 위해 공통적으로 필요한 속성 또는 메서드를 정의하는 이런 종류의 타입 힌트를 Protocol 이라고 합니다.

예제-3) 프로토콜을 활용한 구현을 더 살펴보겠습니다.

from typing import List, Protocol, runtime_checkable


class Product:
    def __init__(self, name: str, quantity: float, price: float):
        self.name = name
        self.quantity = quantity
        self.price = price


@runtime_checkable
class Item(Protocol):
    quantity: float
    price: float


class NoteBooks(Product):
    """노트북 클래스에서 추가적으로 필요한 메서드와 속성 정의"""

    pass


class Snacks(Product):
    """스낵 클래스에서 추가적으로 필요한 메서드와 속성 정의"""

    pass


def calculate_total(items: List[Item]) -> float:
    return sum([item.quantity * item.price for item in items])


notebooks = NoteBooks("노트북", quantity=1, price=200000)
snacks = Snacks("새우깡", quantity=5, price=3000)

assert isinstance(snacks, Item)

result = calculate_total([notebooks, snacks])
print(result)

########실행########
>> 215000

예제-3 을 보면 Protocol 은 Java의 Interface와 비슷하지만 상속 계층을 구현하지 않는 형태를 볼 수 있습니다. 이것은 덕 타이핑 때문에 가능해집니다.
또한 calculate_total 함수의 파라메터 타입 힌트로 Item 을 선언하여 파라미터로 전달할 객체가 어떤 데이터를 가져야 하는지 명확하게 전달할 수 있습니다.
Item 클래스의 @runtime_checkable 데코레이터는 isinstance, issubclass 메서드를 호출하면 __hasattr__ 메서드를 내부적으로 호출하여 런타임시의 타입 체크를 가능하게 해줍니다.

내장모듈은 추상클래스의 구현에 관련된 기능을 제공한다.

추상클래스와 관련된 내장 모듈은 abc,collections.abc 두 가지 모듈이 존재합니다.
정적 타입체커(예를들면 mypy)와 타입 힌트를 적절하게 사용한다면 abc 모듈은 거의 사용할 일이 없을것이라고 생각됩니다.
하지만 mypy를 사용하지 않는 경우, 자식 클래스에서 구현을 강제해야할 때 사용할 수 있습니다.

예제-4) abc 모듈 사용 예제

import abc


class Animal(abc.ABC):
    @abc.abstractmethod
    def say(self):
        ...


class Dog(Animal):
    pass


class Cat(Animal):
    def say(self):
        print("야옹")

########실행########
>>> cat = Cat()
>>> dog = Dog()
>>> cat.say()
야옹
>>> dog.say()
TypeError: Can't instantiate abstract class Dog with abstract method say

예제-4 코드에서 dog.say() 는 호출이 불가능한 것을 볼 수 있습니다.
이것은 abc.ABC 클래스를 사용하게 되면 완전히 정의 되지 않은 클래스의 인스턴스를 생성하는 것을 방지하기 위해 metaclass에 대한 확장이 포함되어 있기 때문입니다.
이 처럼 정적 타입체커 없이 구현을 강제해야 할 때 abc 모듈을 사용할 수 있습니다.

len,for,in 등과 같은 Python 기본 연산자의 작동하는 방식을 새롭게 정의하는 클래스를 만들기 위해
collections.abc 모듈을 사용할 수도 있습니다.

0개의 댓글