파이써닉한 파이썬을 배워보자 - 7일차 파이썬의 클래스와 객체지향1

0

pythonic

목록 보기
7/10

클래스와 객체지향 프로그래밍

클래스는 새로운 종류의 객체를 생성할 때 사용한다.

객체

파이썬의 거의 모든 코드는 객체를 만들고 동작하게 하는 내용이다. 객체는 항상 연결된 타입이 있다. 연결된 타입은 type()을 사용하여 살펴볼 수 있다.

names = []
type(names)

<class 'list'>가 출력된다.

class문

class문을 사용하여 새로운 객체를 정의한다. 클래스는 보통 메서드를 만드는 함수의 모음으로 구성된다.

class Account:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
    
    def __repr__(self):
        return f'Account({self.owner!r}, {self.balance!r})'

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount
    
    def inquiry(self):
        return self.balance

account = Account("name", 100)
print(account) # Account('name', 100)

class문 자체는 클래스의 어떠한 인스턴스도 생성하지 않는다. 단지 클래스는 청사진과도 같다.

클래스 안에서 정의되는 함수를 메서드라고 한다. 그 중 인스턴스 메서드는 인스턴스에서 동작하는 함수인데, 첫 번째 인수로 클래스의 인스턴스가 전달된다. 따라서 첫 번째 인수로 self를 관례적으로 사용하는 것이다.

__init__(), __repr__()은 스페셜 메서드 또는 매직 메서드의 예이다. __init__은 새로운 인스턴스를 생성할 때 상태를 초기화하고, __repr__은 메서드는 객체를 살펴보기 위한 문자열을 반환한다.

클래스 정의에 타입 힌트를 추가적으로 작성할 수 있다.

class Account:
    '''
    simple bank account
    '''
    owner:str
    balance:float
    
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
    
    def __repr__(self) -> str:
        return f'Account({self.owner!r}, {self.balance!r})'

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount
    
    def inquiry(self):
        return self.balance

account = Account("name", 100)
print(account) # Account('name', 100)

타입 힌트는 클래스 동작과 관련하여 어떤 것도 변경하지 않는다. 즉 타입 힌트로 추가 검사 또는 유효성 검사가 수행되지 않는다.

인스턴스

클래스의 인스터스는 클래스를 함수처럼 호출하여 생성한다. 그러면 새로운 인스턴스가 생성되고 클래스의 __init__()메서드에 이 인스턴스를 전달한다.

account = Account("name", 100)
account1 = Account("hello", 200)

__init__() 안에서 속성(attribute)는 self에 할당되어 인스턴스에 추가된다. 가령 self.owner = ownerowner 속성을 인스턴스에 추가한다.

인스턴스는 자신의 상태를 볼 수 있는데, vars()함수를 사용하여 인스턴스의 변수를 볼 수 있다.

account = Account("name", 100)
account.deposit(100)
print(vars(account)) # {'owner': 'name', 'balance': 200}

여기에는 메서드를 표시하지 않는다는 것을 주목하자.

대신 메서드는 클래스에서 살펴볼 수 있다. 인스턴스는 모두 연결된 타입을 통해 클래스와 링크되어 있는 것이다.

account = Account("name", 100)
account.deposit(100)
print(type(account)) # <class '__main__.Account'>
print(type(account).deposit) # <function Account.deposit at 0x7fd4bf9c7880>
print(type(account).inquiry) # <function Account.inquiry at 0x7fd4bf9dc0e0>

메서드는 클래스에 바인딩되어 있다는 것을 확인할 수 있다.

속성 접근

파이썬의 모든 것은 제한이 거의 없는 동적 프로세스이다. 객체가 생성된 후 생성된 객체에 새로운 속성을 자유롭게 추가할 수 있다. 다음은 새로운 속성을 추가하는 예이다.

account = Account("name", 100)
account.creation_date = '2019-02-14'
print(account.creation_date) # 2019-02-14

속성 연산자를 사용하는 대신 getattr(), setattr(), delattr() 함수에 문자열 속성 이름을 제공하여 속성 가져오기, 설정하기, 삭제하기를 수행할 수 있다. hasattr()함수는 속성이 존재하는 지 테스트한다.

account = Account("name", 100)
print(getattr(account, 'owner')) # name
setattr(account, 'balance', 1000)
print(getattr(account, 'balance')) # 1000
print(getattr(account, 'inquiry')()) # 메서드 호출 1000

위와 같이 메서드도 가져올 수 있다.

메서드를 속성처럼 접근하면 다음과 같이 바운드 메서드(bound method)로 알려진 객체를 얻을 수 있다.

account = Account("name", 100)
query = account.inquiry
withdraw = account.withdraw
print(query) # <bound method Account.inquiry of Account('name', 100)>
withdraw(10)
print(query())  # 90

바운드 메서드는 인스턴스(self)와 메서드를 구현하는 함수, 둘 다 포함하는 객체이다. 괄호와 인수를 추가하여 바운드 메서드를 호출하면 bounded된 인스턴스에 영향이 간다.

유효 범위 규칙(scoping rule)

클래스를 작성할 때 속성이나 메서드에 대한 참조는 항상 완전히 한정(fully qualified)되어야 한다. 가령 메서드에서는 언제나 self를 통해 인스턴스 속성을 참조한다. 그렇기 때문에 앞에서 살펴본 예에서 balance를 살펴보지 않고 self.balance를 사용했었다. 이는 어떤 메서드에서 다른 메서드를 호출할 때도 동일하게 적용된다.

class Account:
    '''
    simple bank account
    '''
    owner:str
    balance:float

    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
    
    def __repr__(self) -> str:
        return f'Account({self.owner!r}, {self.balance!r})'

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount
    
    def inquiry(self):
        return self.balance

즉, 클래스에서 따로 네임스페이스를 가져 유효 범위가 있는 게 아니라, self라는 객체에 인스턴스 자체의 유효 범위를 제한하는 것이다.

클래스 수준 유효 범위를 생성하지 않는 것이 파이썬이 c++이나 java와 다른 점이다. c++,java에서의 this가 바로 파이썬의 self이다. 단, 파이썬은 언제나 이를 명시적으로 사용해야한다는 것이다.

연산자 오버로딩과 프로토콜

새로운 클래스를 정의할 때 일반적으로 스페셜 메서드의 일부를 정의한다. Account클래스 내에 있는 __repr__() 메서드는 디버깅 출력을 보강하기 위해 사용하는 메서드이다.

사용자 정의 컨테이너와 같이 더 복잡한 클래스를 생성하는 경우, 스페셜 메서드를 더 많이 정의할 수 있다. 다음과 같이 계좌 포트폴리오를 구축한다고 하자.

class AccountPortfolio:
    def __init__(self):
        self.accounts = []
    
    def add_account(self, account):
        self.accounts.append(account)
    
    def total_funds(self):
        return sum(account.inquiry() for account in self.accounts)
    
    def __len__(self):
        return len(self.accounts)
    
    def __getitem__(self, index):
        return self.accounts[index]
    
    def __iter__(self):
        return iter(self.accounts)
    
port = AccountPortfolio()
port.add_account(Account("Guido", 1000.0))
port.add_account(Account("Eva", 50.5))
print(port.total_funds()) # 1050.5
print(len(port)) # 2

for account in port:
    print(account) 
# Account('Guido', 1000.0)
# Account('Eva', 50.5)

print(port[1].inquiry()) # 50.5

__len()__, __getitem__(), __iter__() 메서드와 같이 이 예제의 마지막에 나타난 스페셜 메서드들은 AccountPortfolio 클래스를 인덱스와 반복 같은 파이썬 연산자와 함께 동작하도록 만든다.

이 코드는 '파이써닉하다'라는 말과 같이 '파이써닉'이라는 단어는 일반적으로 객체가 파이썬 환경과 잘 어울리는 지 표현하는 말이다. 즉 반본, 인덱스 및 기타 작업 같은 파이썬의 핵심 기능을 합리적인 범위 내에서 지원한다는 것을 뜻한다.

상속(inheritance)

상속이란 기존 클래스의 동작을 특수화하거나 변경해 새 클래스를 만드는 매커니즘이다. 원본 클래스는 base class, super class또는 parent class라 한다. 새로운 하위 클래스는 derived class, child class, sub class 또는 sub type이라고 부른다.

클래스가 상속으로 생성될 때, 클래스는 기본 클래스가 정의한 속성을 상속받는다. 하지만 파생 클래스는 상속받은 속성을 재정의하고, 자신만의 새로운 속성을 가질 수 있다.

특별한 기본 클래스가 없다면 object에서 상속받는다. 따라서 object는 모든 파이썬 객체의 조상 클래스가 되는 것이다. 이 클래스는 __str__()__repr__()메서드와 같은 스페셜 메서드의 기본 구현을 제공한다.

상속의 한 가지 용도로 새로운 메서드를 사용하여 기존 클래스를 확장하는 것이다. 다음의 예는 잔고를 모두 인출하는 panic()메서드를 Account 클래스에 추가한 코드이다.

class MyAccount(Account):
    def panic(self):
        self.withdraw(self.balance)

a = MyAccount("Guido", 1000.0)
a.withdraw(23.0)
print(a.inquiry()) # 50.5
a.panic() # 977.0
print(a.inquiry()) # 0.0

Account 클래스에 panic이라는 메서드를 추가한 새로운 클래스 MyAccount 객체를 만들어 코드를 재사용하고 리팩토링에 좋은 효과를 얻을 수 있다.

상속은 또한 기존 메서드의 동작 방식을 재정의 할 수도 있다. 가령 아래의 코드는 inquiry() 메서드를 재정의하는 Account의 특수 버전을 보여준다. inquiry() 메서드는 주기적으로 사용자의 잔고를 부풀려서 보고한다. 이는 자신의 잔고에 관심이 없는 사용자가 자기 계좌에서 돈을 초과 인출하도록 유도하여 추후에 문제를 일으키도록 만들 수 있다.

class EvilAccount(Account):
    def inquiry(self):
        if random.randint(0,4) == 1:
            return self.balance * 1.1
        else:
            return self.balance

account = EvilAccount('Guido', 1000.0)
account.deposit(10)
print(account.inquiry())

이 예에서 EvilAccount의 인스턴스는 inquiry()메서드를 다시 정의할 것 말고는 Account의 인스턴스와 같다.

파생 클래스(derived class)는 메서드를 다시 구현할 수 있지만, 때에 따라서 원래(original) 메서드를 호출할 필요가 있다. 원래 메서드는 super()를 사용하여 명시적으로 호출할 수 있다.

class EvilAccount(Account):
    def inquiry(self):
        if random.randint(0,4) == 1:
            return super().inquiry() * 1.1
        else:
            return super().inquiry()

위 예에서 super()를 사용하면 이전에 정의한 메서드에 접근할 수 있다. super().inquiry()로 호출하면 EvilAccount에서 재정의하기이전의 정의, 즉 원래의 정의인 inquiry()를 사용한다.

흔치 않지만, 상속을 사용하여 인스턴스에서 새로운 속성을 추가할 수도 있다. 이예제의 인자 1.10을 조정할 수 있는 인스턴스 수준의 속성을 만드는 방법은 다음과 같다.

class EvilAccount(Account):
    def __init__(self, owner, balance, factor):
        super().__init__(owner, balance)
        self.factor = factor
    
    def inquiry(self):
        if random.randint(0,4) == 1:
            return super().inquiry() * 1.1
        else:
            return super().inquiry()

속성을 추가할 때 기존의 __init__()메서드를 다루는 것은 까다롭다. 이예에서 추가 인스턴스 변수인 factor를 포함해서 새로운 버전의 __init__()을 정의한다.

__init__()을 재정의할 때 super().__init__()을 사용하여 부모를 초기화하는 것은 자식의 역할이다. 이 작업을 잊어버리면 객체는 반만 초기화되고 모든 것이 잘못된다. 부모 초기화에는 추가 인수가 필요하므로 이러한 인수는 자식 __init__()메서드에서 전달해야한다.

상속은 미묘한 방법으로 코드를 망칠 수 있다. Account클래스의 __repr__()메서드를 살펴보자.

class Account:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
    
    def __repr__(self) -> str:
        return f'Account({self.owner!r}, {self.balance!r})'
    ...

__repr__메서드를 살펴보면 Account라고 적혀있는 부분이 있다. 이를 상속한 자식 클래스는 __repr__가 호출되는 print함수를 호출하면 Account 로그가 나오게되어 디버깅이 어려워 질 수 있다.

class EvilAccount(Account):
    pass

account = EvilAccount('Guido', 1000.0)
print(account) # Account('Guido', 1000.0)

위와 같이 파생 클래스에서 부모의 __repr__클래스가 호출되어 잘못된 정보가 디버깅되는 수가 있다.

이를 고치려면 적절한 타입 이름을 사용할 수 있도록 __repr__() 메서드를 수정해야한다. 다음을 살펴보자.

class Account:
    ...
    def __repr__(self) -> str:
        return f'{type(self).__name__}({self.owner!r}, {self.balance!r})'
    ...

이제 좀 더 정확한 출력을 할 수 있다.

상속은 자식 클래스가 부모 클래스로 타입 검사를 수행하는 타입 시스템에서 관계를 설정한다. 다음 예를 보자.

account = EvilAccount('Guido', 1000.0)
print(type(account)) # <class '__main__.EvilAccount'>
print(isinstance(account, Account)) # True

type은 다르지만 isinstance에서는 같다고 나온다. 이는 상속이 is-a관계이기 때문이다. 즉, EvilAccountAccount라는 것이다. is-a 상속 관계는 객체 타입 ontology(사람의 지식, 관렴을 컴퓨터로 옮기는 일)나 분류 체계(taxonomy)를 정의할 때 사용하기도 한다.

class Food:
    pass

class Sandwich(Food):
    pass

class RoastBeef(Sandwich):
    pass

class GrilledCheese(Sandwich):
    pass

class Taco(Food):
    pass

실제로 이 방식으로 객체를 구성하는 것은 매우 어렵고 위험이 크다. 왜냐하면 새로운 클래스를 추가할 때 정확히 어떤 계층에 넣어야할 지 모호하기 때문이다. 가령 HotDot클래스를 추가한다고 한다면 Sandwich에 넣어야할지 Food에 넣어야할 지 모호하다. 또한 어떠한 모양이냐에 따라 Taco 가까울 수도 있다.

class HotDog(Sandwich, Taco):
    pass

이러면 코드가 더욱 복잡해지고 어려워진다.

컴포지션을 통한 상속 피하기

상속에는 구현 상속(implementation inheritance)이라고 알려진 문제가 있다. 가령 push, pop 연산을 수행하는 스택을 만들어보자. 이를 빠르게 구현하는 방법은 리스트로부터 상속받아 새로운 메서드를 추가하는 방법이다.

class Stack(list):
    def push(self, item):
        self.append(item)

s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s.pop()) # 3
print(s.pop()) # 2

이 자료구조는 스택처럼 동작하며 삽입, 정렬, 슬라이스 재할당 등 리스트의 다른 기능도 모두 갖추고 있다. 이를 구현 상속(implementation inheritance)라고 한다. 상속을 이용하여 다른 기능이 구현된 코드를 재사용할 수 있지만, 실제로 풀고자 하는 문제와 관련 없는 기능도 많이 있다. 사용자는 그 객체를 이상하게 생각할 것이다. 가령 스택에 정렬 메서드가 있을 필요가 없기 때문이다.

더 나은 방법으로는 composition(조합)이다. 리스트에서 상속받아 스택을 만드는 대신, 리스트를 내부에 포함하는 독립 클래스로 스택을 만들어야 한다. 내부에 리스트가 있다는 것은 구현 세부 사항이다. 다음의 예를 보자.

class Stack:
    def __init__(self):
        self._items = list()
    
    def push(self, item):
        self._items.append(item)
    
    def pop(self):
        return self._items.pop()

    def __len__(self):
        return len(self._items)

s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s.pop()) # 3
print(s.pop()) # 2

이 스택은 이전에 본 것과 동일하게 동작하지만 스택에만 초점을 맞춘다. 관련 없는 리스트나 메서드나 스택 기능이 아닌 것들이 없다. 출고자 하는 문제의 목적에 훨씬 부합한다.

이 구현을 약간 확장하여 내부 list클래스를 추가 인수로 받을 수 있다.

class Stack:
    def __init__(self, *, container=None):
        if container is None:
            container = list()
        self._items = container
    
    def push(self, item):
        self._items.append(item)
    
    def pop(self):
        return self._items.pop()

    def __len__(self):
        return len(self._items)

이 방식의 한 가지 이점은 구성 요소들의 느슨한 결합(loosely coupling)을 촉진한다는 것이다. 가령 리스트 대신 타입 배열(typed array)로 항목을 저장하는 스택을 만들고 싶다면 다음과 같이 하면 된다.

s = Stack(container=array.array('i'))
s.push(42)
s.push(23)
s.push('a lot') # TypeError

이는 의존성 주입(dependency injection)으로 알려진 케이스이다. Stacklist를 사용하도록 직접코드를 작성하는 대신, 필요한 인터페이스를 구현하여 사용자가 전달한 컨테이너로 Stack을 만들 수 있다.

좀 더 넓게보면 내부 리스트를 숨겨진 구현 세부 사항으로 만드는 것은 데이터 추상화(data abstraction)과 관련이 있다. 다음에 리스트를 사용하지 않기로 하는 경우, 이 설계는 이를 쉽게 바꿀 수 있다. 가령 구현을 다음과 같이 연쇄 튜플(linked tuples)로 변경할지라도 Stack사용자는 알지 못한다.

class Stack:
    def __init__(self):
        self._items = None
        self._size = 0
    
    def push(self, item):
        self._items = (item, self._items)
        self._size += 1
    
    def pop(self):
        (item, self._items) = self._items
        self._size -= 1
        return item

    def __len__(self):
        return self._size

import array

s = Stack()
s.push(42)
s.push(23)
print(s.pop()) # 23
print(s.pop()) # 42

즉, 사용자 입장에서는 구현이 어떻게 되든 간에 추상적으로 자신이 사용자하고하는 객체의 기능만 되면 같은 결과를 받기 때문에 문제가 없는 것이다.

함수를 통한 상속 피하기

때로는 사용자 정의가 필요한 메서드가 있는 클래스를 만들 때가 있다.

class DataParser:
    def parse(self, lines):
        records = []
        for line in lines:
            row = line.split(',')
            record = self.make_record(row)
            records.append(record)
        return records
    
    def make_record(self, row):
        raise NotImplementedError()

다음의 DataParser클래스는 make_record 메서드가 구현되어 있지않다. 이 경우는 상속하는 클래스에서 make_record 메서드 부분을 구현해주어야 한다.

class PortfolioDataParser(DataParser):
    def make_record(self, row):
        return {
            'name': row[0],
            'shares': int(row[1]),
            'price': float(row[2])
        }

parser = PortfolioDataParser()
data = parser.parse(open('portfolio.scv')

잘 만든 것처럼 보이지만 여기에는 너무 많은 상속이 있다. 즉, 불필요한 상속인 plumbing(상속이 계속 수행되는 현상)이 있다는 것이다.

함수 사용을 고려해보자. 다음은 한 예이다.

def parse_data(lines, make_record):
    records = []
    for line in lines:
        row = line.split(',')
        record = make_record(row)
        records.append(record)
    return records

def make_dict(row):
    return {
            'name': row[0],
            'shares': int(row[1]),
            'price': float(row[2])
        }

data = parse_data(open('portfolio.csv'), make_dict)

이 코드는 훨씬 간단하고 유연하다. 테스트하기도 쉽고 원한다면 추후에 얼마든지 클래스로 확장이 가능하다. 상속은 굉장히 리스크가 있는 기능이기 때문에 잘 사용해야한다.

동적 바인딩과 덕 타이핑

동적 바인딩(dynamic binding)은 파이썬이 객체의 속서을 찾을 때 사용하는 런타임 매커니즘이다. 동적 바인딩은 파이썬이 타입과 관계없이 인스턴스를 사용할 수 있게 해준다. 파이썬에서 변수 이름은 연관된 타입이 없다. 따라서 속성을 바인딩하는 과정은 객체 obj가 실제로 어떤 타입인지와 아무런 상관이 없다.

obj.name처럼 조회하면 name속성이 있는 객체 obj에서 모두 동작한다. 이러한 행동을 보고 duck typing이라고 부른다. 이는 '오리처럼 생겼고 오리처럼 꽥꽥울면 그것은 오리다'라는 격언을 따르는 것이다.

가령, 기존 객체의 사용자 정의버전을 만들려면 기존 객체에서 상속받는 방법이 있고, 모양과 동작은 기존 객체와 비슷하지만 전혀 관련이 없는 완전히 새로운 객체를 생성할수도 있다. 후자의 접근법은 프로그램 구성 요소의 느슨한 결합을 유지하기 위해 사용된다. 가령, 코드는 특정 메서드 집합을 가지고 있는 한, 어떤 객체에서도 동작하도록 할 수 있다. 대표적으로 표준 라이브러리에 정의된 반복 가능한 객체를 사용하는 것이다. 리스트, 파일, 제너레이터, 문자열 등 값을 생성하기 위해 for-loop와 함께 동작하는 객체들이 있다. 하지만 이들 중 어느 것도 특별한 Iterable기본 클래스에서 상속받지 않는다. 이들은 단지 반복을 수행하는 데 필요한 메서드를 구현할 뿐이다.

내장 타입에서 상속으 ㅣ위험성

파이썬의 내장 타입은 int, float 등 모두 객체로 이루어져 있기 때문에 이들을 상속받을 수 있지만 여기에는 몇가지 위험이 존재한다. 가령 키를 모두 대문자로 만들기위해 dict의 하위 클래스를 만들기로 한다면 다음과 같이 상속받고 __setitem__()메서드를 재정의할 수 있다.

class udict(dict):
    def __setitem__(self, key, value):
        super().__setitem__(key.upper(), value)

u = udict()
u['name'] = 'Guido'
u['number'] = 37
print(u) # {'NAME': 'Guido', 'NUMBER': 37}

잘 동작하는 것처럼 보이지만, 사실은 동작하는 것처럼 보일 뿐 실제로는 의도한 대로 전혀 동작하지 않는다.

u = udict(name='Guido', number=37)
print(u) # {'name': 'Guido', 'number': 37}
u.update(color='blue')
print(u) # {'name': 'Guido', 'number': 37, 'color': 'blue'}

다른 방식으로 설정하면 전혀 원하는 대로 동작하지 않는다. 사실 파이썬 내장 타입은 일반 파이썬 클래스처럼 구현되어 있지 않고 C로 구현되어 있다는 것이 문제다. 따라서 대부분 메서드는 C에서 동작한다. 가령 dict.update() 메서드는 앞서 사용자가 지정한 udict클래스에서 재정된 __setitem__() 메서드를 거치지 않고 직접 dict 데이터를 C에서 다룬다.

collections모듈에는 dict, list, str 타입의 하위 클래스를 안전하게 만들 때 이용하는 특수 클래스 UserDict, UserList, UserString 등이 있다. 가령 다음과 같은 솔루션이 훨씬 잘 작동한다.

from collections import UserDict

class udict(UserDict):
    def __setitem__(self, key, item):
        super().__setitem__(key.upper(), item)

u = udict(name='Guido', number=37)
print(u) # {'NAME': 'Guido', 'NUMBER': 37}
u.update(color='blue')
print(u) # {'NAME': 'Guido', 'NUMBER': 37, 'COLOR': 'blue'}

대부분의 경우 내장 타입을 하위 클래스로 만드는 것은 권장하지 않는다. 가령 새로운 컨테이너를 만들 때 새로운 클래스를 만다는 것이 더 나을 수 있다. 내장 타입에서 상속받는 하위 클래스가 필요하다면 생각보다 더 많은 작업이 필요할 것이다.

클래스 변수와 메서드

클래스 정의에서 함수는 모두 인스턴스에서 동작하는 것을 가정한다. 이 때문에 첫번째 매개변수로서 항상 self가 전달된다. 하지만 클래스 자체도 상태를 전달하고 조작할 수 있는 객체이다. 가령 클래스 변수 num_accounts를 사용하면 생성된 인스턴스 수가 얼마나 되는 지 추적할 수 있다.

class Account:
    num_accounts = 0

    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
        Account.num_accounts +=1 
    
    def __repr__(self) -> str:
        return f'{type(self).__name__}({self.owner!r}, {self.balance!r})'

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount
    
    def inquiry(self):
        return self.balance

Account('name', 100)
Account('name2', 100)
print(Account.num_accounts) # 2

클래스 변수는 self를 사용하지 않고 클래스를 통해 직접 접근한다.

조금 이상하긴하지만 인스턴스를 통해 조회도 가능하다.

a = Account('name', 100)
b = Account('name2', 100)
print(Account.num_accounts) # 2
print(a.num_accounts) # 2

물론 좋은 방법은 아니다. 이것이 동작하는 이유는 인스턴스의 속성을 조회할 때 인스턴스 자체에 일치하는 속성이 없으면 연결된 클래스를 확인하기 때문이다. 파이썬이 메서드를 찾는 것과 같은 매커니즘이다.

또한, 클래스 메서드(class method)로 알려진 것을 정의할 수 있다. 클래스 메서드는 인스턴스가 아닌 클래스 자체에서 동작하는 메서드이다. 클래스 메서드는 보통 인스턴스 생성자(instance constructor) 정의 대신 사용된다. 예를 들어, xml 입력 포맷으로 Account 인스턴스를 생성하라고 한다면 다음과 같이 할 수 있다.

클래스 메서드는 클래스 내에 메서드를 정의하고 @classmethod라는 데코레이터를 붙여주면 된다.

class Account:
    num_accounts = 0

    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
        Account.num_accounts +=1 
    
    def __repr__(self) -> str:
        return f'{type(self).__name__}({self.owner!r}, {self.balance!r})'

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount
    
    def inquiry(self):
        return self.balance

    @classmethod
    def from_xml(cls, data):
        from xml.etree.ElementTree import XML
        doc = XML(data)
        return cls(doc.findtext('owner'), float(doc.findtext('amount')))

data = '''
<account>
    <owner>Guido</owner>
    <amount>1000.1</amount>
</account>
'''

a = Account.from_xml(data)
print(a.balance) # 1000.1

다른 클래스 외부에 함수를 만들어 xmld를 받고 Account 클래스를 생성하도록 할 수 있지만, 이는 유지보수성에 좋지 않다. 그런데 클래스 메서드로 만들주면 관심사가 분산되지 않기 때문에 유지 보수성에 더 좋은 코드가 만들어진 것이다.

클래스 메서드의 첫 번째 인수는 항상 클래스 자신이다. 때문에 이름을 cls라고 한다. 이 예에서는 Account가 된다. 새로운 인스턴스를 만드는 것이 클래스 메서드의 목적이라면 명시적인 단계를 수행해야 한다. 예제 마지막 줄의 cls(...,...) 호출은 두 인수와 함께 Account(...,...)를 호출하는 것과 같다.

클래스가 인수로 전달된다는 사실은 상속과 관련해 중요한 문제를 해결한다. Account의 하위 클래스를 정의하고 해당 하위 클래스의 인스턴스를 생성한다고 하자. 다음과 같이 동작하는 것을 확인할 수 있다.

class EvilAccount(Account):
    pass

e = EvilAccount.from_xml(data) # 'EvilAccount'를 생성

이 코드가 동작하는 이유는 EvilAccountcls로 전달되기 때문이다. 따라서 from_xml()클래스 메서드의 마지막 문장은 EvilAccount인스턴스를 생성한다.

때때로클래스 변수와 클래스 메서드를 같이 사용하여 인스턴스 동작 방식을 구성하고 제어하는 경우가 있다. 다음의 Date클래스를 보자.

import time

class Date:
    datefmt = '{year}-{month:02d}-{day:02d}'
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

    def __str__(self):
        return self.datefmt.format(year=self.year, month=self.month, day=self.day)

    @classmethod
    def from_timestamp(cls, ts):
        tm = time.localtime(ts)
        return cls(tm.tm_year, tm.tm_mon, tm.tm_mday)

    @classmethod
    def today(cls):
        return cls.from_timestamp(time.time())

이 클래스에는 __str__()메서드의 출력 결과를 조정하는 클래스 변수 datefmt가 있다. 이는 상속을 사용해 다음과 같이 사용자가 원하는 방식으로 바꿀 수 있다.

class MDYDate(Date):
    datefmt = '{month}/{day}/{year}'

class DMYDate(Date):
    datefmt = '{day}/{month}/{year}'

a = Date(1967, 4, 9)
print(a) # 1967-04-09

b = MDYDate(1967, 4, 9)
print(b) # 4/9/1967

c = DMYDate(1967, 4, 9)
print(c) # 9/4/1967 

이 예제처럼 클래스 변수와 상속을 사용한 구성은 인스턴스의 동작을 조정하기 위한 일반적인 수단이다. 클래스 메서드를 사용하는 것은 적절한 종류의 객체를 생성하도록 보장하기 때문에, 인스턴스를 동작하게 하는 데 매우 중요하다. 다음 예를 살펴보자.

a = MDYDate.today()
b = DMYDate.today()
print(a) # 2/9/2023
print(b) # 9/2/2023

클래스 메서드는 인스턴스를 생성하는 다른 방법으로 사용하기도 한다. 인스턴스를 만드는 클래스 메서드는 from_timestamp() 처럼 from_과 같은 접두사를 사용하는 이름 규약을 따른다. 표준 라이브러리와 서드파티 패키지 여기저기에서 클래스 메서드에 사용되는 이름 규익을 볼 수 있다. 가령, dict에는 키 집합에서 미리 초기화된 사전을 만드는 클래스 메서드가 있다.

print(dict.fromkeys(['a', 'b', 'c'], 0)) # {'a': 0, 'b': 0, 'c': 0}

클래스 메서드에 대한 한 가지 주의사항이 있다. 파이썬에서는 클래스 메서드를 인스턴트 메서드와 분리된 네임스페이스로 관리하지 않는다. 결과적으로 인스턴스에서 계속 호출할 수 있다. 다음 예를 보자.

d = Date(1967, 4, 9)
b = d.today() # Date.today:() 호출

print(b) # 2023-02-09

d.today()를 호출하는 것은 인스턴스와는 아무런 관련이 없기 때문에 매우 혼란스러울 수 있다. 결과적으로는 인스턴스에서 클래스 메서드를 호출할 수 있다. 단지 이를 혼용해서 쓰는 것은 좋지못하다.

정적 메서드

클래스는 @staticmethod를 이용해 정적 메서드(static method)로 선언된 함수의 네임스페이스로 사용되기도 한다. 일반 메서드 또는 클래스 메서드와 달리 정적 메서드는 추가 selfcls인수를 갖지 않는다. 정적 메서드는 클래스에서 정의된 일반 함수이다.

class Ops:
    @staticmethod
    def add(x,y):
        return x + y

    @staticmethod
    def sub(x,y):
        return x - y

일반적으로 이러한 클래스의 인스턴스는 생성하지 않는다. 대신 클래스를 통해 직접 함수를 호출한다.

print(Ops.add(2,3)) # 5
print(Ops.sub(4,5)) # -1

간혹 다른 클래스가 '교환 가능(swappable)' 또는 '구성 가능(configurable)' 동작을 구현하거나 모듈 불러오기 동작을 느슨하게 흉내내기 위해 이와 같은 정적 메서드 모음을 사용할 수 있다.

import random

class Account:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance 
    
    def __repr__(self) -> str:
        return f'{type(self).__name__}({self.owner!r}, {self.balance!r})'

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount
    
    def inquiry(self):
        return self.balance

class EvilAccount(Account):
    def deposit(self, amount):
        self.balance += 0.95 * amount

    def inquiry(self):
        if random.randint(0, 4) == 1:
            return 1.10 * self.balance
        else:
            return self.balance

여기서 상속을 사용하는 것은 다소 이상하다. Account 인스턴스를 EvilAccount로 변경할 때가 있을 것이다. 즉, 평범함 Account가 못된 account가 된다면 EvilAccount가 될 것이다. 그런데, 파이썬에서는 사용자가 만든 인스턴스의 타입을 다른 타입으로 변경할 수 없다. 따라서 위의 상속은 인스턴스 타입을 변경하는 작업있을 경우를 생각해보면 사용이 불가능 코드인 것이다. 대신 일종의 Account policy으로 evil계정을 어떻게 처리할 지에 대한 policy를 두는 것이 더 나을 수도 있다. 다음은 정적 메서드를 사용해 Account를 변경한 것이다.

import random

class StandartPolicy:
    @staticmethod
    def deposit(account, amount):
        account.balance += amount
    
    @staticmethod
    def withdraw(account, amount):
        account.balance -= amount
    
    @staticmethod
    def inquiry(account):
        return account.balance

class EvilPolicy(StandartPolicy):
    @staticmethod
    def deposit(account, amount):
        account.balance += 0.95 * amount
    
    @staticmethod
    def inquiry(self):
        if random.randint(0, 4) == 1:
            return 1.10 * self.balance
        else:
            return self.balance
        
class Account:
    def __init__(self, owner, balance, *, policy=StandartPolicy):
        self.owner = owner
        self.balance = balance
        self.policy = policy
    
    def __repr__(self):
        return f'Account({self.policy}) ({self.owner!r}, {self.balance!r})'

    def deposit(self, amount):
        self.policy.deposit(self, amount)

    def withdraw(self, amount):
        self.policy.withdraw(self, amount)
    
    def inquiry(self):
        return self.policy.inquiry(self)

위 코드에서 생성된 인스턴스는 Account뿐이다. 하지만 다양한 메서드 구현 기능을 제공하는 특별한 policy속성이 있다. 필요에 따라 기존 Account 인스턴스에서 policy를 동적으로 변경할 수 있다.

a = Account('Guido', 1000.0)
print(a.policy) # <class '__main__.StandartPolicy'>
a.deposit(500)
print(a.inquiry()) # 1500.0
a.policy = EvilPolicy
a.deposit(500)
print(a.inquiry()) # 1975.0

@staticmethod가 있기 때문에 StandartPolicy 또는 EvilPolicy인스턴스를 따로 생성할 필요가 없었다. 이 클래스의 주요 목적은 Account와 관련된 추가 인스턴스 데이터를 저장하는 것이 아니라, 다수의 메서드를 조직하는 것이다. 그런데 파이썬이 가진 느슨한 결합 특성으로 인해 자체 데이터를 보유하도록 policy 객체를 변경할 수 있다. 다음 코드와 같이 정적 메서드를 일반 인스턴스 메서드로 변경할 수 있다.

class EvilPolicy(StandartPolicy):
    def __init__(self, deposit_factor, inquiry_factor):
        self.deposit_factor = deposit_factor
        self.inquiry_factor = inquiry_factor

    def deposit(self, account, amount):
        account.balance += self.deposit_factor * amount
    
    def inquiry(self):
        if random.randint(0, 4) == 1:
            return self.inquiry_factor * account.balance
        else:
            return self.balance

a = Account('Guido', 1000.0, policy=EvilPolicy(0.95, 1.10))

위의 코드와 같이 EvilPolicy에 맴버 변수를 두어 상태를 갖게 할 수 있다. 이전에 @staticmethod의 경우는 상태를 저장하는 self가 없었기 때문에 이를 구현할 수 없었지만 일반 class를 사용하면 상태를 담을 수 있게 된다.

메서드를 위임받아 클래스를 지원하는 접근 방식은 상태 기계(state machines)와 유사 객체에 대한 일반적인 구현 전략이다. 각각의 동작 상태는 자기 클래스의 메서드(대부분 정적)으로 캡슐화되어 있다. 앞선 예제 코드에서 policy속성과 같은 변경 가능한 인스턴스 변수는 현재 동작 상태와 관련된 세부 구현 정보를 담을 때 사용할 수 있다.

데이터 캡슐화와 비공개 속성

파이썬에서 클래스의 attribute(속성,맴버변수)와 메서드는 모두 공개(public)이다. 이는 내부 구현을 숨기거나 캡슐화해야하는 객체 지향 프로그래밍에서 바람직하지 않다.

이 문제를 해결하기 위해 파이썬은 네이밍 컨벤션을 이용한다. 단어 앞에 _로 시작한다면 이는 private하게 사용하겠다는 것이다. 다음은 Account클래스의 잔액인 balance를 비공개(private) 속성으로 변경한 코드이다.

class Account:
    def __init__(self, owner, balance):
        self.owner = owner
        self._balance = balance
    
    def __repr__(self):
        return f'Account({self.owner!r}, {self.balance!r})'

    def deposit(self, amount):
        self._balance += amount

    def withdraw(self, amount):
        self._balance -= amount
    
    def inquiry(self):
        return self._balance

이 코드에서 _balance속성은 내부 구현을 의미한다. 물론 강제가 아니라 사용자가 직접 접근하는 것을 막을 순 없다.

또한, 하위 클래스에서도 부모 클래스의 속성(맴버변수)에 접근하는 것이 가능하다.

class EvilAccount(Account):
    def inquiry(self):
        if random.randint(0,4) == 1:
            return 1.10 * self._balance
        else:
            return self._balance

파이썬에서는 일반적으로 가능하다. IDE 및 기타 도구에서도 이 속성을 보여줄 가능성이 높다.

더 비공개 속성이 필요하다면 이름 앞에 두 개의 밑줄 __을 붙인다. 이는 __name와 같이 두 개의 밑줄이 붙은 모든 이름은 자동으로 _Classname__name형식의 새 이름으로 바뀐다. 이렇게하면 부모 클래스에서 사용하는 비공개 이름을 자식 클래스가 동일한 이름으로 덮어쓰지 않는다. 다음은 설명한 동작을 보여주는 예이다.

class A:
    def __init__(self):
        self.__x = 3

    def __spam(self):
        print('A.__spam', self.__x)
    
    def bar(self):
        self.__spam()

class B(A):
    def __init__(self):
        A.__init__(self)
        self.__x = 37

    def __spam(self):
        print('B.__spam', self.__x)
    
    def grok(self):
        self.__spam()

b = B()
b.bar() # A.__spam 3
b.grok() # B.__spam 37

print(vars(b)) # {'_A__x': 3, '_B__x': 37}
b._A__spam() # A.__spam 3
b._B__spam() # B.__spam 37

위 코드에서는 __x속성에 대한 두 가지 다른 할당이 있다. 또한 클래스 B가 상속을 통해 __spam()메서드를 다시 정의하는 것처럼 보이지만, 그렇지 않다.

각각의 정의에서 사용했던 고유한 이름으로 이름 변형이 이루어지는 것이다

위의 주석을 통해 변형된 이름을 볼 수 있다.

이 방법으로 데이터 은닉(data hiding)을 할 수 있을 것 같지만, 실제로 클래스의 비공개 속성에 접근하는 것을 막을 방법은 없다. 위의 방법도 이름을 바꿔줄 뿐 완전히 비공개가 아니다.

이름 변형은 실제로 클래스가 정의될 때 한 번만 처리된다. 메서드를 실행하는 동안 추가적인 작업이 필요하지 않으므로 프로그램 실행 중에는 추가 부하가 발생하지 않는다. 속성 이름을 문자열로 받는 getattr(), hasattr(), setattr(), delattr()과 같은 함수에서는 이름 변형을 해주지 않는다. 따라서, 위 함수로 특정 속성에 접근하려면 변형된 이름인 _ClassName__name과 같이 변형된 이름을 직접 써주어야 한다.

실제로 너무 프라이버시를 지나치게 생각하지 않는 것이 좋다. 밑줄 하나는 자주쓰이지만 이중 밑줄은 거의 쓰이지 않는다.

프로퍼티(property)

파이썬은 속성값(맴버 변수)이나 타입에 런타임 제한을 두지 않는다. 하지만 속성을 property의 관리 아래에 두면 제한을 가할 수 있다. 프로퍼티는 데코레이터를 이용하여 메서드 위에 @property라고 쓰면 해당 메서드의 이름을 가진 속성값(맴버 변수)를 만들어준다. 그리고 여기에 접근 시에 항상 불리는 메서드들을 만들 수 있다.

프로퍼티는 속성 접근을 가로채 사용자 정의 메서드로 이를 처리하는 특별한 종류의 속성이다.

이 메서드를 사용하면 적합한 형태로 속성을 자유롭게 관리할 수 있다.

import string

class Account:
    def __init__(self, owner, balance):
        self.owner = owner
        self._balance = balance

    @property
    def owner(self):
        return self._owner

    @owner.setter
    def owner(self, value):
        if not isinstance(value, str):
            raise TypeError("Expected str")
        if not all(c in string.ascii_uppercase for c in value):
            raise ValueError("Must be uppercase ASCII")
        if len(value) > 10:
            raise ValueError("Must be 10 characters or less")
        self._owner = value

여기서 owner속성은 대문자 ASCII문자열 10자로 제한된다. 클래스를 한 번 동작시켜보자.

a = Account("GUIDO", 1000.0)
a.owner = 'EVA' # ValueError: Must be 10 characters or less
a.owner = 42 # TypeError: Expected str

a.owner = 'Carol' # ValueError: Must be uppercase ASCII
a.owner = 'RENEE' # ValueError: Must be 10 characters or less

a.owner = 'RAMAKRISHNAN' # ValueError: Must be 10 characters or less

@property 데코레이터는 속성을 프로퍼티로 설정할 때 사용한다. 이 예에서 owner 속성이 프로퍼티에 해당한다. 이 데코레이터는 항상 속성값을 가져오는 메서드에 우선 적용된다. 이 경우 메서드는 비공개 속성 _owner 에 저장된 실제값을 반환한다. 이어서 나오는 @owner.setter 데코레이터는 속성값을 설정하기 위한 메서드를 선택적으로 구현할 때 사용된다. 이 메서드는 비공개 _owner 속성의 값을 저장하기 전에 다양한 타입과 값 검사를 수행한다.

프로퍼티의 중요한 특징은 owner처럼 연결된 이름이 마법처럼 동작한다는 것이다. 즉 해당 속성에 사용하게 되면 사용자가 구현한 getter/setter 메서드로 자동으로 전송된다. 이 작업을 수행하기 위해서 기존 코드를 변경할 필요가 없다.

가령, Account__init__()메서드를 변경할 필요가 없다. 이는 사용자가 놀랄만한데, __init__()이 비공개 속성 self._owner를 사용하는 대신 self.owner = owner을 사용하면 자동적으로 propery의 검사가 적용되기 때문이다. 즉, owner property의 핵심은 속성값의 유효성을 검증하는 것이다. 사용자는 인스턴스를 생성할 때 반드시 이를 수행하기를 원할 것이다.

a = Account("GUdsE", 1000.0) # ValueError: Must be uppercase ASCII

다음과 같이 생성자에서 부터 검사가 진행된다는 것을 알 수 있다.

프로퍼티 속성에 매번 접근할 때마다 자동으로 메서드를 호출하므로, 실제값은 다른 이름으로 저장해야한다. 이것이 getter/setter 메서드 내에서 _owner를 사용한 이유이다. owner를 사용하면 무한 재귀현상이 발생할 것이다.

일반적으로 프로퍼티를 사용하여 특정 속성 이름을 가로챌 수 있다. 즉 메서드를 구현하여 속성값을 가져오거나 설정, 또는 삭제할 수 있다.

class SomeClass:
    @property
    def attr(self):
        print('Getting')
    
    @attr.setter
    def attr(self, value):
        print('Setting', value)

    @attr.deleter
    def attr(self):
        print('Deleting')

s = SomeClass()
s.attr # Getting
s.attr = 13 # Setting 13
del s.attr # Deleting

property의 요소를 모두 구현할 필요는 없다. 실제로 읽기 전용으로 계산 데이터 속성을 구현하기 위해 프로퍼티를 사용하는 일이 많다. 다음은 그 예이다.

class Box(object):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    @property
    def area(self):
        return self.width * self.height
    
    @property
    def perimeter(self):
        return 2*self.width + 2*self.height

# 사용 예제
b = Box(4,5)
print(b.area) # -> 20
print(b.perimeter) # -> 18
b.area = 5 # 에러 속성을 설정할 수 없음

클래스를 정의할 때 고려해야 할 한 가지는 클래스에 대한 프로그래밍 인터페이스는 가급적 일정하게 만들어야 한다는 것이다. 이 예제에서 일부 값은 b.width, b.height와 같이 프로퍼티 없이 간단한 속성으로 접근하는 반면, 다른 값은 b.area()b.perimeter()와 같은 메서드로 접근한다. extra()를 추가한다고 할 때, 속성으로 접근할 지 또는 메서드로 접근할 지 확인하는 것은 불필요한 혼란을 만들 수 있다. 프로퍼티는 이 문제를 해결할 수 있다.

사실, 메서드 자체가 일종의 property로 처리되곤한다.

class SomeClass:
    def yow(self):
        print('Yow!')

s = SomeClass()
print(s.yow) # <bound method SomeClass.yow of <__main__.SomeClass object at 0x7f0f58b296d0>>

s = SomeClass()와 같은 인스턴스를 생성하고 s.yow로 접근하면 원래의 함수 객체 yow가 반환되지않고 대신 bound method를 얻게 된다.

함수가 클래스에 있으면 프로퍼티와 유사하게 동작한다. 특히 클래스는 함수에 대한 속성 접근을 가로채어 바운드 메서드를 생성하는 것이다.

@staticmethod@classmethod를 사용해 정적 메서드 또는 클래스 메서드를 정의할 때 실제로 이 프로세스가 변경된다. @staticmethod는 특별한 wrapping처리 없이 메서드 함수를 있는 그대로 반환한다. 즉, bound method가 아닌 함수 그 객체 자체를 반환한다는 것이다.

0개의 댓글