파이써닉한 파이썬을 배워보자 - 3일차 객체, 타입, 프로토콜

0

pythonic

목록 보기
3/10

객체, 타입, 프로토콜

파이썬의 모든 타입은 객체이다. 숫자, 문자열, 리스트, 집합, 사전 같은 내장 타입들도 사실은 객체이다. 이를 활용하여 우리는 객체를 만듦으로서 타입을 만들 수 있는 것이다.

객체는 고유값(identity), 타입(class)과 값을 가진다. 가령 a = 42라고 쓰면, 42라는 값을 갖는 정수 객체가 생성된다. 객체의 고유값은 메모리에 저장된 위치를 가리키는 숫자이다. a는 그자체로 객체의 일부는 아니며, 메모리의 특정 위치를 가리키는 이름이다.

a = 42 # a --> int객체 (고유값:객체가 저장된 메모리 주소, 타입:int)

b = TempType()
b.temp() # b ---> TempType객체(고유값:객체가 저장된 메모리 주소, 타입:TempType)

c언어나 golang으로 생각하면 모든 타입이 pointer라는 것이다

객체의 타입은 객체 내부 데이터 표현과 객체가 지원하는 복수의 메서드를 정의한다. 특정 타입의 객체가 생성되면 생성된 그 객체는 그 특정 타입의 인스턴스라고 부른다. 인스턴스가 일단 생성되면 고유값은 변경할 수 없다. 객체의 값을 변경할 수 있으면 그 객체는 immutable하다고 한다. 다른 객체에 대한 참조를 담는 객체를 container라고 한다.

객체의 속성(attribute)는 객체를 특징 짓는다. 속성은 객체에 연결된 값으로 속성(.)으로 접근이 가능하다. 만약 속성이 일부 연산을 호출하기위한 함수라면 이를 메서드라고 부른다.

객체의 고유값과 타입

내장 함수 id()는 객체의 고유값을 반환한다. 고유값은 정수로 보통 객체의 메모리 내 위치에 해당한다. isis not은 두 객체의 고유값(메모리 위치)을 비교한다. type은 객체의 타입을 반환한다. 객체의 타입끼리 is, is not으로 비교가 가능하다. 다음 예제는 두 객체를 비교하는 여러가지 방법을 보여준다.

def compare(a,b):
    if a is b:
        print('same object')
    if a == b:
        print('same value')
    if type(a) is type(b):
        print('same type')

a = [1,2,3] 
b = [1,2,3]
compare(a,a)
# same object
# same value
# same type

compare(a,b)
# same value
# same type
compare(a,[4,5,6])
# same type

객체의 타입은 객체의 클래스임과 동시에 그 자체도 객체인 것이다. 즉, 객체의 타입은 str도 사실은 타입인 것이다. 이 객체는 고유하게 정의되며 지정된 타입에서 모든 인스턴스는 언제나 동일하다. 클래스는 인스턴스를 생성하고 타입 검사를 수행하며 타입 힌트를 제공할 때 사용할 수 있다.

다음의 예를 살펴보자

items = list()

if isinstance(items, list):
    items.append(item)

def removeall(items: list, item) -> list:
    return [i for i in items if i != item]

자식 타입(subtype, 하위 타입)은 상속으로 정의한 타입이다. 이 타입은 원래 타입의 모든 기능은 물론, 추가하거나 새롭게 정의한 메서드도 제공한다. 다음은 리스트의 자식 타입을 정의하면서 새로운 메서드를 추가하는 예이다.

class mylist(list):
    def removeall(self, val):
        return [i for i in self if i != val]

items = mylist([5, 8, 2, 7, 2, 13, 9])
x = items.removeall(2)
print(x) # [5, 8, 7, 13, 9]

isinstance(instance, type)함수는 자식 타입을 인식하므로 타입값을 알아볼 때 선호하는 방법이다. 즉, 최상위 클래스 타입을 적어주면 그 하위의 타입을 가지는 인스턴스의 타입을 확인해준다. 또한 isinstance는 가능한 여러 타입을 확인할 수도 있다. 다음 예를 살펴보자.

if isinstance(items, (list, tuple)):
    maxval = max(items)

프로그램에서 타입 검사를 수행할 수 있지만, 타입 검사는 성능을 크게 떨어뜨린다. 그리고 프로그램에서 언제나 상속 계층에 꼭 들어맞도록 객체를 정의하지 않는다. 가령, isinstance(items,list)문의 목적이 items가 리스트와 유사한지 검사하기 위해서라면 리스트와 동일한 인터페이스를 지니지만 내장 타입 list에서 직접 상속받지 않은 타입은 제대로 동작하지 않을 것이다. collection모듈의 deque가 그 예이다.

참조횟수와 가비지 컬렉션

파이썬은 자동으로 동작하는 가비지 컬렉션으로 객체를 관리한다. 객체는 모두 참조 횟수가 계산된다. 객체가 새로운 이름에 할당되거나 리스트, 튜플, 사전 같은 컨테이너에 추가될 때 참조 횟수가 하나 증가한다.

a = 37 # 값 37을 가지는 객체 생성
b = a  # 37에 대한 참조 횟수 증가
c = []
c.append(b) # 37에 대한 참조 횟수 증가

객체의 현재 참조 횟수는 sys.getrefcount()함수로 얻을 수 있다. 다음은 그 예이다.

a = 37
import sys
print(sys.getrefcount(a)) # 9

참조횟수가 9가 아니라 더 적거나 많을 수 있다. 참조 횟수는 예상보다 훨씬 큰 경우가 많다. 숫자나 문자열 같은 변경이 불가능한 객체는 인터프리터가 메모리를 절약하기 위해 이들을 프로그램의 여러 곳에서 최대한 공유한다. 객체를 변경하는 게 불가능하기 때문에 눈치채지 못할 뿐이다.

객체의 참조 횟수가 0이 되면 가비지 컬렉션이 수행된다. 그러나 더 이상 사용하지 않는 객체에 순환 의존성(circular dependency)이 존재하는 경우가 있다.

a = {}
b = {}
a['b'] = b # a는 b에 대한 참조를 담고 있다.
b['a'] = a # b는 a에 대한 참조를 담고 있다.
del a
del b

위에서 del문은 ab의 참조 횟수를 하나씩 줄이고 내부 객체를 가리키는 이름을 파괴한다. 하지만 둘 다 서로를 참조하고 있으므로 참조 횟수가 0이 되지 못하며 이들은 할당된 채 그대로 남게된다. 인터프리터는 메모리 누수(memory leak)를 일으키지 않지만, 순환 참조 감지기(cycle detector)가 참조할 수 없는 객체를 찾고 삭제할 때까지 객체의 파괴는 지연된다. 인터프리터가 실행되는 과정에서 메모리를 점점 더 많이 쓰게 되면, 순환 참조 감지 알고리즘이 주기적으로 실행된다. 사용자는 가비지 컬렉션이 동작하는 방식을 gc 표준 라이브러리 함수를 사용해 조정, 제어할 수 있다. gc.collect()함수는 순환 가비지 컬럭터(cyclic garbage collector)를 즉각적으로 호출하려 할 때 사용한다.

때때로 객체를 수동으로 파괴하는 게 상황에 따라 합리적일 수 있다. 다음의 상황을 보도록 하자.

def some_calculation():
    data = create_giant_data_structure()
    # 계산의 일부에 data를 사용
    ...
    # data 해제
    del data
    # 계산을 이어서 수행

이 코드에서 del data문은 더 이상 data변수가 필요 없다는 것을 의미한다. 이에 따라 참조횟수가 0에 도달하면 해당 지점에서 객체의 가비지 컬렉션이 시작된다. del문이 없으면 data변수가 함수를 벗어날 때까지 data객체는 지속된다. 이는 프로그램이 예상보다 더 많은 메모리를 사용하는 것을 인지하여 그 원인을 알려고 할 때 비로소 깨닫는다.

참조와 복사

b = a와 같은 할당이 이루어지면 a에 대한 새로운 참조가 생긴다. 이 할당이 숫자나 문자열 같이 변경 불가능한 객체를 대상으로 수행되면 a에 대한 복사본이 생성되는 것처럼 동작한다. 하지만 리스트나 사전과 같이 변경 가능한 객체를 대상으로 할당이 이루어지면 다소 다른 결과를 보여준다.

a = [1,2,3,4]
b = a
print(b is a) # True
b[2] = -100
print(b) # [1, 2, -100, 4]

위 예제에서 ab는 동일한 객체를 참조하기 때문에 두 변수 중 하나에 가해진 변화가 다른 변수에도 반영되는 것을 볼 수 있다. 이를 방지하려면 객체에 대한 참조가 아닌 복사본을 생성해야 한다.

list나 dict와 같이 mutable한 컨테이너 객체에 적용되는 복사 연산에는 얕은 복사(shallow copy), 깊은 복사(deep copy) 두 가지가 있다. 얕은 복사는 새로운 객체를 생성하지만, 새 객체 항목을 원 객체 항목의 참조로 채운다. 다음 예를 보자.

a = [1,2,[3,4]]
b = list(a) # a에 대한 얕은 복사본 생성
print(b is a) # False
b.append(100)
print(b) # [1, 2, [3, 4], 100]
print(a) # [1, 2, [3, 4]]
b[2][0] = -100
print(b) # [1, 2, [-100, 4], 100]
print(a) # [1, 2, [-100, 4]] <- a가 변함

이 예제에서 ab는 별개의 리스트 객체이지만 그 안의 요소는 서로 공유된다. 따라서 b의 한 요소가 변경되면 a의 한 요소도 변경된다.

깊은 복사는 새로운 객체를 생성하고 원래 담고 있던 객체를 재귀적으로 모두 복사한다. 객체에 대해 깊은 복사본을 만들기 위한 내장 연산자는 없다. 깊은 복사를 수행하기 위해서는 표준 라이브러리에 있는 copy.deepcopy()를 사용하면 된다.

import copy
a = [1,2,[3,4]]
b = copy.deepcopy(a) # a에 대한 얕은 복사본 생성
b[2][0] = -100
print(b) # [1, 2, [-100, 4]]
print(a) # [1, 2, [3, 4]]

대부분의 프로그램에서 deepcopy()를 권장하지 않는다. 객체 복사는 느리고 불필요하기 때문이다. 데이터를 변경해야 하는데 원본 객체에 영향을 끼치는 것을 원치 않아서 복사본이 실제로 필요한 경우에만 deepcopy()를 사용하자. 또한, 시스템 또는 런타임 상태와 관련된 객체(파일, 네트워크, 스레드, 제너레이터 등)에서는 deepcopy()를 시도하면 실패한다는 점에 유념하자.

1급 객체

파이썬에서 객체는 모두 1급 객체(first-class)이다. 이름에 할당되는 객체는 모두 데이터로 취급될 수 있다는 뜻이다. 데이터처럼 객체는 변수에 저장되고 인수로 전달되며 함수에서 반환되고, 다른 객체와 비교할 수도 있다. 다음 예는 두 개의 값을 담은 간단한 dict를 보여준다.

items = {
    'number': 42,
    'text': "Hello World"
}

참고로 dict안에는 함수, 모듈, 예외 타입, 객체, 객체 메서드 등 모두 다 넣을 수 있다. 이는 객체가 first-class이기 때문이다.

즉, 파이썬의 모든 것들은 객체이기 때문에 모든 것이 1급으로 처리된다. 이는 매우 간결하고 유연한 코드를 작성할 수 있다.

"ACME, 100, 490.10"이라는 텍스트가 있다면 이를 적절한 타입에 맞게 변환된 리스트 값으로 바꾸고 싶다고 하자. 다음 예제는 리스트 타입(1급 객체)를 생성하고 몇 가지 간단한 리스트 처리 연산을 수행하여 이전의 문자열을 영리하게 변환하는 법을 보여준다.

line = 'ACME, 100, 490.10'
col_type = [str, int, float]
parts = line.split(',')
row = [ty(val) for ty, val in zip(col_type, parts)]
print(row) # ['ACME', 100, 490.1]

dict에 함수나 클래스를 담는 것은 복잡한 if-elif-else 문을 없애려고 흔히 사용하는 방법이다. 가령 다음과 같은 코드가 있다고 하자.

if format == 'text':
    formatter = TextFormatter()
elif format == 'csv':
    formatter = CSVFormatter()
elif format == 'html':
    formatter = HTMLFormatter()
else:
    raise RuntimeError('Bad format')

너무 많은 if-elif-else문은 가독서을 떨어뜨린다. 이를 dict를 사용하여 해결할 수 있다.

_formats = {
    'text': TextFormatter,
    'csv': CSVFormatter,
    'html': HTMLFormatter
}

if format in _formats:
    formatter = _formats[format]()
else:
    raise RuntimeError('Bad format')

후자의 방법은 if-elif-else문 블록을 수정하지 않고 사전에 더 많은 항목을 삽입해 새로운 사례를 추가할 수 있으므로 휠씬 유연하다.

선택 사항 또는 누락된 값에 대한 None사용

NoneFalsy하기 때문에 False로 평가된다. 또한 None은 singleton으로 작성되었기 때문에 None 객체는 한 개 밖에 없다. 따라서 is연산이 가능하다.

if value is None:
    pass

객체 프로토콜과 데이터 추상화

파이썬 언어의 특징은 대부분 프로토콜로 정의된다는 데 있다.

def compute_cost(unit_price, num_units):
    return unit_price * num_units

해당 함수에는 정수, 실수 특수한 수와 배열 등등 다양한 객체가 들어갈 수 있다. 그러나 특정 타입의 조합은 동작하지 않을 수 있다.

정적 언어용 컴파일러와 달리 파이썬은 프로그램이 올바르게 동작할 지 사전에 확인하지 않는다. 대신 객체의 동작 방식은 special 또는 magic 메서드라 부르는 디스패치(dispatch, 동적으로 실행되는 메서드)를 포함하는 동적 프로세스가 결정한다. 이러한 special 메서드의 앞 뒤에는 언제나 이중 밑줄()이 나온다. 이 메서드는 인터프리터가 프로그램을 실행 할 때 자동으로 동작한다. 가령, x * y연산은 `x.mul__(y)메서드로 수행된다. 이러한 메서드의 이름과 해당 연산자는 정해져 있다. 특정 객체의 동작 방식은 전적으로 객체가 구현한special` 메서드에 따라 다르다.

다음은 다양한 범주의 핵심 인터프리터 기능과 관련된 special 메서드를 설명한다. 이러한 범주를 protocol이라고 한다. 사용자가 정의한 클래스를 포함하여, 객체는 이러한 기능의 조합을 정의하여 객체가 여러 가지 방식으로 동작하도록 만들 수 있다.

객체 프로토콜

아래의 메서드들은 객체를 전박적으로 관리하는 기능과 관련되어 있다. 이들은 객체 생성, 초기화, 파괴, 표현을 포함한다.

  • 객체 관리를 위한 메서드
    | 메서드 | 설명 |
    |----------------------------------|-------------------------------|
    |new(cls, [,args [,**kwargs]]) | 새로운 인스턴스를 생성하기 위해 호출되는 정적 메서드 |
    |init(self, [,
    args [,**kwargs]]) | 생성된 후 새로운 인스턴스를 초기화하기 위해 호출됨
    |del(self) | 인스턴스가 파괴될 때 호출됨 |
    |repr(self)| 문자열 표현을 생성 |

__new__()__init__()메서드는 인스턴스를 생성하고 초기화할 때 함께 사용된다. SomeClass(args)를 호출해 객체를 생성하면, 이 객체는 다음과 같은 단계로 변환된다.

x = SomeClass.__new__(SomeClass, args)
if isinstance(x, SomeClass):
    x.__init__(args)

클래스에서 가장 흔히 구현되는 메서드는 __init__()이다. __new__()를 사용하고 있다면 거의 대부분 인스턴스 생성과 관련된 고급 기법이 쓰이고 있음을 나타낸다. 가령, __new__()__init__()을 우회하려는 클래스 메서드에서 사용되거나 싱글톤 또는 캐싱과 같은 특정 생성 디자인 패턴에서 사용된다.

__new__()구현에서 반드시 해당 클래스의 인스턴스를 반환할 필요는 없다. 반환하지 않으면 생성할 때 __init__()에 대한 후속 호출을 건너뛴다.

__del__()메서드는 인스턴스가 가비지 컬렉션 될 때 호출된다. 이 메서드는 객체가 더 이상 사용되지 않을 때만 호출된다. del x문은 객체의 참조 횟수를 감소 시킬 뿐, 반드시 __del__() 함수의 호출로 이어지지 않는다. __del__()은 객체 파괴를 위한 추가적인 자원 관리 작업이 필요한 경우에만 정의된다.

내장 repr()함수로 호출되는 __repr__() 메서드는 디버깅과 출력에 유용한 객체의 문자열 표현을 생성한다. 또한 이 메서드는 대화형 인터프리터에서 변수를 살펴볼 때 표시되는 값의 출력을 생성할 책임이 있다. __repr__()eval()을 사용하여 객체를 다시 생성할 수 있는 표현식 문자열을 반환하는 것이 관례다.

a = [2,3,4,5]
s = repr(a)
print(s) # '[2, 3, 4, 5]'
b = eval(s)
print(b) # [2, 3, 4, 5]

만약 객체를 적절한 문자열 표현으 하기 어렵다면 __repr__()에서 <...메시지...>형태로 문자열을 반환하는 것이 관례이다.

f = open('foo.txt')
a = repr(f)
# a = "<_io.TextIOWrapper name='foo.txt' mode='r' encoding='UTF-8'>

숫자, 비교 프로토콜

아래의 표는 수학 연산을 지원하기 위해 구현해야할 special method들이다.

  • 수학 연산을 위한 메서드
    |메서드 | 연산 |
    |----------------------------------|-----------------------------|
    | add(self, other) | self + other |
    | sub(self, other) | self - other |
    | mul(self, other) | self * other |
    | truediv(self, other) | self / other |
    | floordiv(self, other) | self // other |
    | mod(self, other) | self % other |
    | matmul(self, other) | self @ other |
    | divmod(self, other]) | divmod(self, other) |
    | pow(self, other [, modulo) | self ** other, pow(self, other, modulo) |

이외에도 정말 다양하게 있으므로, 필요할 때 찾아서 정의하도록 하자.

또한 비교 프로토콜도 정의할 수 있다. is와 같은 연산자는 객체의 고유값을 비교하는 연산자이기 때문에 따로 재정의할 수는 없다. 때문에 다른 비교 메서드를 재정의하여 비교할 수 있도록 하는 것이 좋다.

  • 인스턴스 비교아 해싱을 위한 메서드
    |메서드 | 설명 |
    |-----------------------------|------------------------|
    |bool(self) | 진리값 검사를 위해 False, True를 반환|
    |eq(self, other) | self == other |
    |ne(self,other) | self != other |
    |lt(self, other) | self < other |
    |le(self, other) | self <= other |
    |gt(self, other) | self > other |
    |ge(self, other) | self >= other |
    |hash(self) | 정수 해시 인덱스를 계산 |

__bool__()메서드가 정의되어 있다면 이 메서드는 객체가 조건 또는 조건 표현식의 일부로 테스트될 때 진리값을 결정하게 된다.

if a: # a.__bool__() 실행
    ...
else:
    ...

객체에 __bool__()메서드가 정의되어 있지않으면 __len()__메서드가 대비책으로 사용되고 __bool()__()__len()__둘 다 정의되어 있지 않다면 객체는 True로 간주된다.

__eq__()메서드는 ==!=연산자와 함께 기본 동등성(equality)을 결정할 때 사용된다. __eq__()의 기본 구현은 is연산자를 사용하여 고유값으로 객체를 비교한다. __ne__메서드가 정의되어있다면 !=를 구현하는 데 사용할 수 있다.

ordering(순서 매기기)는 __lt__()__gt__()와 같은 메서드에서 사용되는 관계 연산자(<, >, <=, >=)로 결정된다. 다른 수학 연산과 마찬가지로 평가 규칙은 미묘하다. 인터프리터는 a < b를 평가하기 위해 ba의 자식 타입인 경우를 제외하고 먼저 a.__lt__(b)메서드를 실행한다. 이 메서드가 정의되어 있지 않거나 NotImplemented를 반환한다면 인터프리터는 b.__gt__(a)를 호출하여 역 비교를 한다.

각가의 비교 메서드는 두 개의 인수를 받아 boolean값, 리스트 또는 파이썬에서 제공하는 여타 타입을 비롯해 모든 종류 값을 반환하는 것을 허용한다. 비교할 수 없는 경우는 내장 객체 NotImplemented를 반환해야한다. 이는 NotImplementedError 예외와 일치하지 않는다. NotImplementedError은 그냥 메서드 자체가 구현되지 않은 것으로 생각하면 된다.

객체에 ordering을 하고싶다면 굳이 비교 연산을 모두 구현할 필요는 없다. 객체를 정렬하거나 min, max 와 같은 함수를 사용하려고 한다면 __lt__()만으로 최소한으로 정의해야한다. 사용자 정의 클래스에서 비교 연산자를 추가하는 경우, functools모듈의 @total_ordering 클래스 데코레이터가 좀 더 유용할 수 있다. 해당 데코레이터는 최소한 __eq__()와 다른 비교 메서드 중 하나만 구현해도 비교 메서드를 모두 생성할 수 있다.

__hash__()메서드는 집합에 축되거나 매핑(사전)에서 키로 사용되는 인스턴스에서 정의된다. 이 함수의 반환값은 정수이며, 같다고 비교되는 두 인스턴스에서는 같은 값이어야 한다. 때문에 __eq__()__hash__()와 함계 정의되어야 한다. 이는 두 메서드가 함께 동작하기 때문이다. __hash__()에서 반환된 값은 일반적으로 다양한 데이터 구조의 내부 구현 상세정보로 사용된다. 그러나 잠재적 충돌(collision)을 해결하기위해 __eq__()메서드가 필요하다.

변환 프로토콜

때로는 객체를 문자열 또는 숫자와 같은 내장 타입으로 변환해야한다. 아래는 이러한 목적을 위해 정의된 메서드이다.

  • 변환을 위한 메서드
    |메서드 | 설명 |
    |--------------------------------|------------------------------|
    |str(self) | 문자열로 변환 |
    |bytes(self) | 바이트로 변환 |
    |format(self, format_spec) | 포맷된 표현식을 생성 |
    |bool(self) | bool(self) |
    |int(self) | int(self) |
    |float(self) | float(self) |
    |complex(self) | complex(self) |
    |index(self) | 정수 인덱스 [self]로 변환 |
    __str__()메서드는 내장 함수 str() 또는 출력과 관련된 함수에서 호출된다. __format__()메서드는 format()함수 또는 문자열의 format() 메서드로 호출된다. format_spec인수는 포맷 지정자를 담는 문자열이다. 이 문자열은 format() 함수의 format_spec 인수와 동일하다.
f'{x:spec}' # x.__format__('spec')을 호출
format(x, 'spec') # x.__format__('spec')을 호출
'x is {0:spec}'.format(x)  # x.__format__('spec')을 호출

format_spec인수인 포맷 지정자 문법은 따로 프로토콜이 있는 것은 아니지만, 내장 타입에 대해서는 표준 변환 규칙이 있다. 이는 추후에 알아보자.

__bytes__()메서드는 인스턴스가 bytes()에 전달되는 경우 바이트 표현을 생성할 때 사용된다.

수치 변환 메서드 __bool__(), __int__(), __float()__(), __complex__() 내장 타입과 일치하는 값을 생성할 것으로 예상된다.

파이썬은 이러한 메서드를 이용하여 암묵적인 타입 변환을 수행하지 않는다. 따라서 정수가 아닌 객체인 x에 __int__()을 구현해도 정수를 더하기 연산에서 연산이 되지 않고 TypeError를 생성한다. 즉, 암묵적인 변환을 하지 않기 때문이다.

__index__() 메서드는 정수값을 요구하는 연산에서 사용할 때 객체의 정수 변환을 수행한다. 여기에는 시퀸스 연산의 인덱싱이 포함된다. 예를 들어 items가 리스트였어서 items[x]와 같은 연산을 수행하는 경우 x가 정수가 아니더라도 items[x.__index__()]를 실행하려 시도할 것이다. __index__()oct(x)hex(x)와 같은 다양한 진수 변환에도 사용된다.

컨테이너 프로토콜

아래의 메서드는 list, dict, set 등과 같은 다양한 종류의 컨테이너를 구현하려는 객체에서 사용된다.

  • 컨테이너를 위한 메서드
    | 메서드 | 설명 |
    |------------------------------------|-----------------------|
    |len(self) | self의 길이를 반환 |
    |getitem(self, key) | self[key]를 반환 |
    |setitem(self, key, value) | self[key] = value 설정 |
    |delitem(self,key) | self[key]를 삭제 |
    |contains(self, obj) | obj in self |

다음은 한 예이다.

a = [1,2,3,4,5,6] 
print(len(a)) # a.__len__()
x = a[2] # x = a.__getitem__(2))
a[1] = 7 # a.__setitem__(1, 7)
del a[2] # a.__delitem__(2)
5 in a # a.__contains__(5)

__len__()메서드는 내장 함수 len으로 호출되며 음이 아닌 길이를 반환한다.

개별 항목에 접근할 때는 __getitem__()메서드는 key로 항목을 찾아 반환한다. key는 어떤 파이썬 객체도 될 수 있지만, 리스트나 배열과 같은 순서가 있는 시퀸스에서 보통 정수가 쓰인다. __setitems__() 메서드는 요소에 값을 할당한다. __delitem__()메서드는 단일 요소에 del연산을 적용할 때마다 호출된다. __contains__()메서드는 in연산자를 구현하는 데 사용된다.

x = s[i:j]와 같은 슬라이스 연산도 결국 __getitem__(), __setitem__(), __delitem__()으로 구현된다. 슬라이에서는 특수한 슬라이스 인스턴스가 key로 전달된다. 이 인스턴스에는 요청된 슬라이스의 범위를 설명하는 속성이 있다.

a = [1,2,3,4,5,6]
x = a[1:5] # x = a.__getitem__(slice(1,5,None))
a[1:3] = [10,11,12] # a.__setitem__(slice(1,3,None), [10,11,12])
del a[1:4] # a.__delitem__(slice(1,4,None))

위 예제의 slice객체의 마지막은 stride값이다.

파이썬의 문자열, 튜플, 리스트는 확장 슬라이스의 일부 기능을 지원한다. 다만 파이썬 또는 표준 라이브러리의 어떤 부분도 다차원 슬라이싱 또는 줄임표를 사용하지 않는다. 이러한 기능은 서드파티 라이브러리와 프레임워크용으로만 사용하도록 되어있다. 가령 numpy와 같은 라이브러리에서 이를 자주 볼 수 있다.

반복 프로토콜

특정 인스턴스인 obj가 반복을 지원하는 obj는 iterator를 반환하는 obj.__iter__()메서드를 제공한다. iter iterator는 그 다음으로 단일 메서드인 iter.__next__()를 구현하는데 이 메서드는 다음 객체를 반환하거나 반복의 끝을 알리는 StopIteration 예외를 일으킨다.

이 메서드는 for문의 구현 뿐만 아니라 암묵적으로 반복을 수행하는 다른 연산을 구현할 때 사용된다. 가령 for x in s문은 다음 예제와 동일한 단계를 수행한다.

_iter = s.__iter__()
while True:
    try:
        x = _iter.__next__()
    except StopIteration:
        break
    # for loop 본문에 있는 문장들을 실행

객체가 __reversed__() sepecial method를 구현하면 선택적으로 역방향 iterator를 제공할 수 있다. 이것 또한 StopIteration을 일으키는 __next__() 메서드가 있는 iterator를 반환해야한다.

for x in reversed([1,2,3]):
    print(x) # 3 2 1

반복을 위한 일반적인 구현 기술은 yield를 포함하는 제너레이터를 사용하는 것이다.

class FRange:
    def __init__(self, start, stop, step):
        self.start = start
        self.stop = stop
        self.step = step
    
    def __iter__(self):
        x = self.start
        while x < self.stop:
            yield x
            x += self.step

nums = FRange(0.0, 1.0, 0.1)
for x in nums:
    print(x)

결과는 다음과 같다.

0.0
0.1
0.2
0.30000000000000004
0.4
0.5
0.6
0.7
0.7999999999999999
0.8999999999999999
0.9999999999999999

이 예제 코드는 제너레이터가 반복 프로토콜 자체를 준수하기 때문에 동작한다. 이 제너레이터로 반복을 구현하는 것이 더 쉬운데 __iter__() 메서드만 고려하면 되기 때문이다. 반복의 나머지 부분은 제너레이터에서 담당한다. 즉, __iter__()을 하면 yield한 제너레이터 변수가 반환되는 것이다.

속성 프로토콜

메서드는 . 속성 연산자나 del 연산자를 사용해서 객체의 속성을 읽거나 쓰거나 삭제할 때 사용한다.

  • 속성 접근을 위한 메서드
    | 메서드 | 설명
    |---------------------------------------|-----------------------------|
    |getattribite(self, name) | self.name 속성을 반환 |
    |getattr(self, name) | getattribute()로 찾을 수 없는 경우 실행된다. self.name이 속성을 반환 |
    |setattr(self, name, value) | self.name = value 속성을 설정 |
    |delattr(self, name) | del self.name 속성을 석제 |

속성에 접근할 때마다 __getattribute__()메서드가 호출된다. 해당 속성을 찾으면 해당하는 값이 반환된다. 그렇지 않은 경우 __getattr__()메서드가 호출된다. __getattr__()의 기본 동작 방식은 AttributeError 예외를 발생시키는 것이다. __setattr__()메서드는 속성을 설정할 떄마다 항상 호출되고, __delattr__()메서드는 속성을 삭제할 때마다 항상 호출된다.

사용자 정의 클래스에서는 속성 접근을 더욱 세밀하게 제어할 수 있는 속성 및 descriptor(기술자)를 정의할 수 있다.

함수 프로토콜

객체는 __call()__메서드로 함수를 흉내낼 수 있다. 객체 x가 이 메서드를 제공하면 함수처럼 호출이 가능하다. 즉 객체 xx(arg1, arg2, ...)x.__call__(arg1, arg2, ...)를 호출한다.

함수 호출을 지원하는 많은 내장 타입들이 있다. 가령 타입은 __call__()을 구현하여 새로운 인스턴스를 생성한다. bound method는 __call__()을 구현하여 self인수를 인스턴스 메서드에 전달하는 것이다. 참고로 bound method는 self를 첫번째 입력 인수로 구현한 메서드이다. 이에 대해서는 추후에 알아보도록 하자.

컨텍스트 관리자 프로토콜

with문은 컨텍스트 관리자(context manager)로 알려진 인스턴스의 제어 안에서 일련의 문장을 실행할 때 사용한다. 일반적인 문법은 다음과 같다.

with context [as 변수]:
    # statement

context객체는 다음의 메서드를 구현해야한다.

  • 컨텍스트 관리자를 위한 메서드
메서드설명
enter(self)새로운 컨텍스트에 진입할 때 호출된다. 반환값은 with문에서 as지정자로 나열된 변수에 배치된다.
exit(self, type, value, tb)컨텍스트를 종료할 때 호출된다. 예외가 발생하면 type, value, tb에는 각각 예외 타입, 예외값, traceback 정보가 포함된다.

__enter__()메서드는 with 문을 실행할 때 호출된다. 이 메서드의 반환값이 선택적으로 as var 형식으로 지정된다.

__exit__()메서드는 제어 흐름이 with문과 연관된 문장 블록을 벗어날 때 호출된다. __exit__()은 예외가 발생하게 되면 현재의 예외 타입, 예외 값, traceback 정보를 인수로 넘겨받는다. 만약 어떠한 에러도 발생하지 않는다면 3개 모두 None으로 설정된다.

__exit__() 메서드는 발생한 예외가 처리되었는지 아닌지 알려주기 위해 True혹은 False를 반환해야한다. True를 반환하면 보류 중인 예외가 삭제되고, 프로그램 실행이 with블록 다음의 첫번째 문장에서 정상적으로 계속된다.

컨텍스트 관리 인터페이스의 주된 용도는 열린 파일, 네트워크 연결 및 lock과 같은 시스템 상태와 관련된 객체에게 단순한 자원 제어 기능을 제공하기 위해서이다. 이 인터페이스를 구현하면 사용하고 있는 컨텍스트에서 실행을 종료할 때 객체는 안전하게 자원을 정리할 수 있다.

파이써닉한 파이썬

파이써닉하게 코드를 만들기위해서는 파이썬의 관용 표현을 따르도록 하는 것이다. 이는 컨테이너, 반복 가능한 객체, 자원 관리 등과 같은 파이썬 프로토콜을 알고 있다는 것을 의미한다. 파이썬에서 널리 사용되는 프레임워크가 이러한 프로토콜을 사용하여 사용자에게 좋은 경험을 제공해준다.

여러 프로토콜 중 3가지 프로토콜은 널리 사용되기 때문에 주목해야한다.

  1. __repr__()메서드를 사용하여 적절한 객체 표현을 만들도록 하자
  2. 반복은 정말 많이 사용되므로 객체가 반복을 지원하면 파워풀한 코드를 만들 수 있다.
  3. with을 통해 context를 관리하는 패턴이다.

0개의 댓글