상위 목록: 하위 목록: 작성 날짜: 읽는 데 15 분 소요

컨테이너 메서드(Container Method)

컨테이너(Container)란 다른 객체들을 담고 참조하는 객체를 의미합니다.

문자열, 튜플, 리스트, 사전, 집합 등은 여러 요소를 담는 컨테이너(Container) 형식이며, 정수, 실수, 복소수 등은 하나의 값만 표현하는 단일 값(Scalar) 형식입니다.

컨테이너 메서드(Container Method)는 사용자 정의 클래스가 위와 같은 컨테이너처럼 동작하도록 만드는 매직 메서드(Magic Method)를 뜻합니다.

컨테이너 메서드를 정의하면 len(), obj[key], del obj[key], in, for, reversed() 등의 문법을 사용자 정의 객체에도 사용할 수 있습니다.


제 35강과 제 36강에서 +, == 같은 연산자가 실제로는 __add__(), __eq__() 같은 매직 메서드 호출이라는 것을 다루었습니다.

컨테이너 문법도 같은 원리입니다. len(obj)라고 쓰면 Python은 obj.__len__()을 호출하고, obj[key]라고 쓰면 obj.__getitem__(key)를 호출합니다. 즉, 문법은 같고 실제 동작은 클래스가 정의한 메서드가 결정합니다.

컨테이너 메서드를 구현하면 사용자는 클래스 내부가 리스트인지 사전인지 알 필요 없이 익숙한 문법으로 데이터를 다룰 수 있고, sorted(), sum(), list() 같은 내장 함수와도 자연스럽게 연결됩니다.

이번 강좌에서는 각 컨테이너 메서드가 어떤 문법에서 호출되는지, 정의하지 않았을 때 Python이 어떤 메서드로 대신 처리하는지를 메서드별 예제로 살펴보고, 마지막에 여러 메서드를 함께 구현한 종합 예제와 collections.abc를 활용하는 방법을 다룹니다.


문법 호출되는 메서드 정의하지 않았을 때
len(obj) __len__() TypeError
bool(obj), if obj: __bool__() → __len__() 항상 참
operator.length_hint(obj) __len__() → __length_hint__() 기본값
obj[key] __getitem__(key) TypeError
obj[key] = value __setitem__(key, value) TypeError
del obj[key] __delitem__(key) TypeError
dict 하위 클래스의 obj[없는 키] __missing__(key) KeyError
for, iter(obj) __iter__() __getitem__()으로 0부터 색인
reversed(obj) __reversed__() __len__() + __getitem__()
item in obj __contains__(item) __iter__()로 순회하며 비교



__len__ : 길이 반환 메서드

len(obj)를 호출하면 실행되는 메서드로, 컨테이너에 담긴 요소의 개수를 반환합니다.

길이는 반복문 횟수를 정하거나, 비어 있는지 검사하거나, 결과를 담을 공간을 미리 확보하는 등 여러 곳에서 사용되므로 가장 기본이 되는 컨테이너 메서드입니다.


class Playlist:
    def __init__(self, *songs):
        self.songs = list(songs)

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


p = Playlist("A", "B", "C")
empty = Playlist()

print(len(p), len(empty))
print(bool(p), bool(empty))

if not empty:
    print("재생 목록이 비어 있습니다.")
결과
3 0
True False
재생 목록이 비어 있습니다.

len(p)는 p.__len__()을 호출하며, 내부 리스트의 길이 3을 반환합니다. 노래가 없는 empty는 0을 반환합니다.

len()을 호출해 객체의 길이를 반환합니다. 반환값은 0 이상의 정수여야 합니다.

__bool__() 메서드를 정의하지 않은 채 bool()을 호출하면 __len__ 반환 값에 의존합니다.

즉, len()의 반환값이 0이라면 False가 반환되며, 0이 아니라면 True를 반환합니다.

그러므로 두 번째 줄은 True False가 되고, if not empty:처럼 리스트와 같은 방식으로 비어 있는지 검사할 수 있습니다.

__len__()과 __bool__()이 모두 없는 객체는 항상 참으로 취급됩니다.


class Wrong:
    def __init__(self, n):
        self.n = n

    def __len__(self):
        return self.n


for n in (-1, 2.5):
    try:
        len(Wrong(n))
    except (ValueError, TypeError) as e:
        print(type(e).__name__, ":", e)
결과
ValueError : __len__() should return >= 0
TypeError : ‘float’ object cannot be interpreted as an integer

__len__()이 음수를 반환하면 ValueError, 정수가 아닌 값을 반환하면 TypeError가 발생합니다.

len() 함수는 __len__()의 반환값을 그대로 전달하지 않고 0 이상의 정수인지 검사한 뒤 반환합니다. 길이를 계산하는 과정에서 나눗셈(/)을 사용했다면 실수가 되므로 //나 int()로 정수를 만들어 반환합니다.



__length_hint__ : 예상 길이 반환 메서드

operator.length_hint()을 호출해 객체의 예상 길이를 반환합니다.

length_hint() 함수는 len() 함수와 기능과 역할이 흡사하지만, 정확한 길이가 아닌 추정값을 반환할 수 있습니다.

반복자처럼 끝까지 꺼내 보기 전에는 정확한 길이를 알 수 없는 객체가 “대략 몇 개 남았다”는 정보를 알려 줄 때 사용합니다. list() 등은 이 값을 참고해 메모리를 미리 확보합니다.


import operator


class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1

    def __length_hint__(self):
        return self.current


c = Countdown(5)
next(c)
print(operator.length_hint(c))
print(operator.length_hint(iter(range(10))))
print(operator.length_hint(object(), 100))

try:
    len(c)
except TypeError as e:
    print(type(e).__name__, ":", e)
결과
4
10
100
TypeError : object of type ‘Countdown’ has no len()

Countdown(5)에서 값을 하나 꺼냈으므로, __length_hint__()는 남은 개수 4를 반환합니다.

len() 함수는 range_iterator와 같은 반복자의 길이를 반환하지 못하지만, length_hint()는 남은 요소의 개수를 추정해 반환할 수 있습니다. 두 번째 줄처럼 range의 반복자는 남은 개수 10을 알려 줍니다.

length_hint(객체, 기본값)처럼 기본값을 전달하면, 길이 정보를 전혀 알 수 없는 객체에 대해 기본값을 반환합니다. 세 번째 줄의 object()가 이 경우이며, 기본값을 생략하면 0입니다.

마지막 줄처럼 __length_hint__()만 정의한 객체에 len()을 사용하면 TypeError가 발생합니다. 추정값은 len()을 대신하지 않습니다.

__len__() 메서드와 같이 정의돼 있다면, 우선적으로 __len__() 메서드를 실행합니다.

__len__() 메서드가 정의되어 있지 않다면, 그 다음으로 __length_hint__() 메서드를 통해 값을 반환합니다.



__getitem__ : 호출 메서드

self[key]로 key 값을 호출할 때 실행되는 메서드입니다.

리스트처럼 정수 색인으로 접근하는 시퀀스(Sequence)와 사전처럼 키로 접근하는 매핑(Mapping) 모두 이 메서드 하나로 구현합니다. 대괄호 안에 무엇을 넣었는지는 key 매개변수로 그대로 전달됩니다.


class Week:
    days = ["월", "화", "수", "목", "금", "토", "일"]

    def __getitem__(self, index):
        print(f"__getitem__({index!r})")
        return self.days[index]


w = Week()
print(w[0])
print(w[-1])
print(w[1:3])

try:
    w[7]
except IndexError as e:
    print(type(e).__name__, ":", e)
결과
__getitem__(0)
월
__getitem__(-1)
일
__getitem__(slice(1, 3, None))
[‘화’, ‘수’]
__getitem__(7)
IndexError : list index out of range

w[0]과 w[-1]은 정수 0과 -1을 그대로 __getitem__()에 전달합니다. 음수 색인의 처리는 내부 리스트가 대신해 주었습니다.

w[1:3]처럼 슬라이싱을 사용하면 정수가 아니라 slice(1, 3, None) 객체가 전달됩니다. 슬라이싱을 지원하려면 isinstance(key, slice)로 구분해 처리하거나, 예제처럼 내부 리스트에 그대로 전달합니다.

범위를 벗어난 w[7]은 내부 리스트에서 발생한 IndexError가 그대로 전달됩니다.

__getitem__() 메서드를 재정의한다면, 잘못된 형식의 키에는 TypeError, 범위를 벗어난 색인에는 IndexError, 존재하지 않는 키에는 KeyError를 발생시키도록 구현해야 합니다.

for 문, in 연산자 등은 이 예외를 기준으로 동작하므로, 다른 예외를 발생시키거나 None을 반환하면 내장 기능이 올바르게 동작하지 않습니다.

  • Tip : 예외를 처리할 때는 except:처럼 모든 예외를 잡지 말고, except KeyError:처럼 처리할 예외를 명시하는 것이 안전합니다.


class Week:
    days = ["월", "화", "수"]

    def __getitem__(self, index):
        return self.days[index]


w = Week()
for d in w:
    print(d, end=" ")
print()
print("화" in w, list(w))
결과
월 화 수
True [‘월’, ‘화’, ‘수’]

__iter__()가 없어도 __getitem__()만 있으면 for 문으로 순회할 수 있습니다.

for 문은 __iter__()를 찾지 못하면 __getitem__(0), __getitem__(1), __getitem__(2), …를 차례로 호출하고, IndexError가 발생하면 반복을 끝냅니다. 이를 시퀀스 프로토콜(Sequence Protocol)이라 합니다.

그러므로 IndexError 대신 다른 예외를 발생시키면 반복이 정상적으로 끝나지 않습니다. 사전처럼 키로 접근하는 클래스라면 __iter__()를 함께 정의합니다.



__setitem__ : 할당 메서드

self[key] = value로 key에 대한 value로 값을 할당할 때 실행되는 메서드입니다.

값을 저장하기 전에 형식이나 범위를 검사하거나, 키를 일정한 형태로 변환해서 저장하고 싶을 때 사용합니다.


class Scores:
    def __init__(self):
        self._data = {}

    def __getitem__(self, name):
        return self._data[name]

    def __setitem__(self, name, score):
        if not isinstance(score, int):
            raise TypeError(f"점수는 정수여야 합니다 : {score!r}")
        if not 0 <= score <= 100:
            raise ValueError(f"점수는 0~100 사이여야 합니다 : {score}")
        self._data[name] = score


s = Scores()
s["철수"] = 90
s["철수"] += 5
print(s["철수"])

for value in ("A", 120):
    try:
        s["영희"] = value
    except (TypeError, ValueError) as e:
        print(type(e).__name__, ":", e)
결과
95
TypeError : 점수는 정수여야 합니다 : ‘A’
ValueError : 점수는 0~100 사이여야 합니다 : 120

s["철수"] = 90은 s.__setitem__("철수", 90)을 호출합니다. 검사를 통과했으므로 내부 사전에 저장됩니다.

s["철수"] += 5는 __getitem__("철수")로 90을 읽고, 5를 더한 95를 __setitem__("철수", 95)로 다시 저장합니다. 즉, 복합 대입은 두 메서드를 모두 사용합니다.

문자열 "A"는 정수가 아니므로 TypeError, 120은 범위를 벗어났으므로 ValueError가 발생하며 값은 저장되지 않습니다.

호출 메서드와 동일하게 __setitem__() 메서드를 재정의한다면, 잘못된 키나 색인에 대해 TypeError, IndexError, KeyError 등을 발생시키도록 구현해야 합니다.

__setitem__()은 값을 반환하지 않습니다. 반환값이 있어도 대입문에서는 무시됩니다.


class ReadOnly:
    def __getitem__(self, key):
        return key * 2


r = ReadOnly()
print(r[3])

try:
    r[3] = 10
except TypeError as e:
    print(type(e).__name__, ":", e)

try:
    del r[3]
except TypeError as e:
    print(type(e).__name__, ":", e)
결과
6
TypeError : ‘ReadOnly’ object does not support item assignment
TypeError : ‘ReadOnly’ object doesn’t support item deletion

__getitem__()만 정의한 객체는 읽기 전용 컨테이너가 됩니다.

r[3]은 정상적으로 6을 반환하지만, __setitem__()이 없으므로 대입은 TypeError, __delitem__()이 없으므로 삭제도 TypeError가 발생합니다.

튜플이나 문자열에 값을 대입할 수 없는 것도 같은 이유입니다.



__delitem__ : 제거 메서드

del self[key]로 key에 대한 값을 제거할 때 실행되는 메서드입니다.

삭제 전에 조건을 검사하거나, 삭제와 함께 기록을 남기는 등의 동작을 추가할 수 있습니다.


class Inventory:
    def __init__(self):
        self._items = {"사과": 3, "배": 0, "귤": 5}

    def __delitem__(self, name):
        if self._items[name] > 0:
            raise ValueError(f"{name} 재고가 남아 있어 삭제할 수 없습니다.")
        del self._items[name]
        print(f"{name} 삭제")

    def __repr__(self):
        return repr(self._items)


inv = Inventory()
del inv["배"]
print(inv)

for name in ("사과", "포도"):
    try:
        del inv[name]
    except (ValueError, KeyError) as e:
        print(type(e).__name__, ":", e)
결과
배 삭제
{‘사과’: 3, ‘귤’: 5}
ValueError : 사과 재고가 남아 있어 삭제할 수 없습니다.
KeyError : ‘포도’

del inv["배"]는 inv.__delitem__("배")를 호출합니다. 재고가 0이므로 내부 사전에서 삭제되고 배 삭제가 출력됩니다.

재고가 남아 있는 사과는 직접 정의한 조건에 의해 ValueError가 발생합니다.

없는 키인 포도는 self._items[name]을 읽는 단계에서 KeyError가 발생합니다. 사전과 같은 동작이므로 따로 처리하지 않고 그대로 전달했습니다.

호출 메서드와 동일하게 __delitem__() 메서드를 재정의한다면, 잘못된 키나 색인에 대해 TypeError, IndexError, KeyError 등을 발생시키도록 구현해야 합니다.

  • Tip : del obj[key]는 컨테이너의 요소를 지우는 __delitem__()을 호출하고, del obj는 변수 이름을 지울 뿐 컨테이너 메서드를 호출하지 않습니다.



__missing__ : 누락 메서드

self[key]로 key 값을 호출할 때 사전(dictionary)에 key 값이 없을 때 실행되는 메서드입니다.

없는 키를 조회했을 때 KeyError 대신 기본값을 반환하고 싶을 때 사용합니다. 다른 컨테이너 메서드와 달리 Python 문법이 직접 호출하는 것이 아니라, dict의 __getitem__()이 키를 찾지 못했을 때 호출합니다.


class Counter(dict):
    def __missing__(self, key):
        print(f"__missing__({key!r})")
        return 0


c = Counter(a=1)
print(c["a"])
print(c["b"])
print(c.get("b"), "b" in c, c)

c["b"] += 1
print(c)
결과
1
__missing__(‘b’)
0
None False {‘a’: 1}
__missing__(‘b’)
{‘a’: 1, ‘b’: 1}

c["a"]는 키가 있으므로 __missing__()이 호출되지 않고 1을 반환합니다.

c["b"]는 키가 없으므로 __missing__("b")가 호출되어 0을 반환합니다. 이때 __missing__()은 값을 반환할 뿐, 사전에 키를 추가하지 않습니다.

세 번째 줄처럼 get() 메서드와 in 연산자는 __missing__()을 호출하지 않으므로, get("b")는 None을, "b" in c는 False를 반환하고 사전에는 여전히 a만 있습니다.

c["b"] += 1은 __missing__()이 반환한 0에 1을 더해 __setitem__()으로 저장하므로, 그제야 b가 1로 추가됩니다. 이 방식으로 키가 처음 등장할 때 초기화 코드 없이 개수를 셀 수 있습니다.

dict 클래스의 __getitem__() 메서드에 의해 호출되며, __getitem__()를 재정의 했다면, 아래 종합 예제와 같이 직접 호출해야합니다.

__missing__() 메서드가 자동으로 호출되려면, 예제와 같이 dict 자료형을 상속받아야 합니다.

  • Tip : 사전(dict) 자료형 이외의 시퀀스 자료형은 key가 아닌 index로 value를 호출합니다. 그러므로, KeyError가 아닌 IndexError가 발생합니다.

  • Tip : __missing__()은 obj[key] 형태의 호출에서만 사용되며, get() 메서드에서는 호출되지 않습니다. 표준 라이브러리의 collections.defaultdict가 이 방식으로 구현되어 있습니다.


from collections import defaultdict

d = defaultdict(int)
print(d["x"], dict(d))
결과
0 {‘x’: 0}

defaultdict(int)는 없는 키를 조회하면 int()를 호출해 0을 만들고, 직접 만든 Counter와 달리 그 값을 사전에 저장한 뒤 반환합니다. 그러므로 조회만 했는데도 x가 추가되었습니다.

조회만으로 키가 추가되는 것을 원하지 않는다면 직접 __missing__()을 구현하거나 get() 메서드를 사용합니다.



__iter__ : 반복자 메서드

iter()를 호출하거나 for 반복문을 사용할 때 반복자(iterator)를 반환합니다.

제 41강에서 다룬 것처럼 반복자는 값을 하나씩 꺼내 주는 객체이며, 제 42강의 생성자를 사용하면 __iter__()를 가장 간단하게 구현할 수 있습니다.


class Team:
    def __init__(self, *members):
        self.members = list(members)

    def __iter__(self):
        for m in self.members:
            yield m.upper()


t = Team("kim", "lee", "park")

for m in t:
    print(m, end=" ")
print()
print(list(t), sorted(t), "LEE" in t)
a, b, c = t
print(a, b, c)
결과
KIM LEE PARK
[‘KIM’, ‘LEE’, ‘PARK’] [‘KIM’, ‘LEE’, ‘PARK’] True
KIM LEE PARK

__iter__() 안에서 yield를 사용했으므로, iter(t)가 호출될 때마다 새로운 생성자 객체가 반환됩니다. 그러므로 t는 몇 번이든 다시 순회할 수 있습니다.

for 문뿐만 아니라 list(), sorted(), in 연산자, a, b, c = t 같은 언패킹도 모두 __iter__()를 사용합니다. 메서드 하나로 많은 기능을 얻을 수 있습니다.

컨테이너 자료형은 iterator 형식의 자료형을 반환해야하며, 사전(dict)의 경우 Key로 구성된 iterator를 반환합니다.

  • Tip : __iter__()에서 return self처럼 자기 자신을 반환하면 한 번만 순회할 수 있는 반복자가 됩니다. 컨테이너는 매번 새 반복자를 반환하도록 구현합니다.



__reversed__ : 역순 반복자 메서드

reversed()를 호출해 컨테이너의 요소를 역순으로 순회하는 반복자를 반환합니다.

reversed()는 순서를 뒤집은 새 리스트를 만드는 것이 아니라 뒤에서부터 꺼내는 반복자를 반환하므로, 큰 컨테이너도 복사 없이 역순으로 순회할 수 있습니다.


class Week:
    days = ["월", "화", "수"]

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

    def __getitem__(self, index):
        return self.days[index]


class Log:
    def __init__(self):
        self.lines = ["시작", "처리", "종료"]

    def __iter__(self):
        return iter(self.lines)

    def __reversed__(self):
        print("__reversed__ 호출")
        return reversed(self.lines)


print(list(reversed(Week())))
print(list(reversed(Log())))

class OnlyIter:
    def __iter__(self):
        return iter([1, 2, 3])

try:
    reversed(OnlyIter())
except TypeError as e:
    print(type(e).__name__, ":", e)
결과
[‘수’, ‘화’, ‘월’]
__reversed__ 호출
[‘종료’, ‘처리’, ‘시작’]
TypeError : ‘OnlyIter’ object is not reversible

Week에는 __reversed__()가 없지만, __len__()과 __getitem__()이 있으므로 reversed()가 마지막 색인 2부터 0까지 차례로 __getitem__()을 호출해 역순으로 순회합니다.

__reversed__()가 정의되어 있지 않다면, reversed()는 __len__()과 __getitem__()을 이용해 색인 기반으로 역순 순회를 시도합니다.

Log는 __reversed__()를 직접 정의했으므로, 그 메서드가 반환한 반복자를 사용합니다.

OnlyIter처럼 __iter__()만 있는 객체는 끝을 알 수 없어 역순으로 순회할 수 없으므로 TypeError가 발생합니다. 이때는 reversed(list(obj))처럼 리스트로 변환한 뒤 사용합니다.

  • Tip : Python 3.7 이상에서 사전은 삽입 순서를 유지하며, Python 3.8 이상에서는 reversed(사전)으로 키를 역순으로 순회할 수 있습니다.



__contains__ : 포함 확인 메서드

item in self 또는 operator.contains(self, item)을 호출해 자료형에 해당 item 값이 존재하는지 확인합니다.

모든 요소를 순회하지 않고도 계산으로 포함 여부를 알 수 있거나, “포함”의 의미를 직접 정하고 싶을 때 정의합니다.


class Interval:
    def __init__(self, start, end):
        self.start = start
        self.end = end

    def __contains__(self, value):
        return self.start <= value <= self.end


r = Interval(1.5, 3.5)
print(2 in r, 4 in r, 4 not in r)
print(3.14 in r)
결과
True False True
True

Interval은 요소를 저장하지 않고 구간의 양 끝만 저장합니다. 2 in r은 r.__contains__(2)를 호출하며, 1.5 <= 2 <= 3.5이므로 True가 됩니다.

not in 연산자는 __contains__()의 결과를 뒤집어 사용하므로 따로 정의할 필요가 없습니다.

3.14처럼 저장할 수 없을 만큼 많은 실수도 비교 한 번으로 포함 여부를 확인할 수 있습니다. range 객체가 10 ** 9 in range(10 ** 10)을 빠르게 계산하는 것도 같은 원리입니다.

객체에 값(item) 있으면 True를 반환하며, 존재하지 않다면 False를 반환합니다.

True나 False가 아닌 다른 값을 반환하더라도, in 연산의 결과는 bool()로 변환되어 참으로 간주되는 값은 True, 거짓으로 간주되는 값은 False가 됩니다.


class Numbers:
    def __iter__(self):
        for n in [1, 2, 3, 4]:
            print(f"{n} 비교")
            yield n


print(2 in Numbers())
print(5 in Numbers())
결과
1 비교
2 비교
True
1 비교
2 비교
3 비교
4 비교
False

__contains__()가 정의되어 있지 않다면, in 연산자는 __iter__()로 요소를 순회하며 값을 비교합니다.

2 in Numbers()는 1, 2를 차례로 비교하다가 같은 값을 찾는 즉시 멈추고 True를 반환합니다. 5 in Numbers()는 끝까지 비교한 뒤 False를 반환합니다.

즉, 순회 방식은 요소의 개수에 비례하는 시간이 걸립니다. 사전이나 집합처럼 빠르게 찾을 수 있는 구조를 내부에 가지고 있다면 __contains__()를 정의해 그 구조를 활용하는 것이 좋습니다.

__iter__()도 없다면 __getitem__()으로 0부터 색인하며 비교합니다.



컨테이너 메서드 종합 예제

앞에서 살펴본 메서드들을 하나의 클래스에 모두 정의한 예제입니다. 각 메서드가 어떤 문법에서, 어떤 순서로 호출되는지 출력 결과와 함께 확인합니다.


import operator


class Daeheeyun(dict):

    def __init__(self):
        super().__init__()
        self._dict = {"A": 1, "B": 2, "C": 3}

    def __len__(self):
        print("length")
        return len(self._dict)

    def __length_hint__(self):
        print("length_hint")
        return operator.length_hint(self._dict)

    def __getitem__(self, key):
        try:
            return self._dict[key]
        except KeyError:
            return self.__missing__(key)

    def __setitem__(self, key, value):
        self._dict[key] = value

    def __delitem__(self, key):
        del self._dict[key]

    def __missing__(self, key):
        return self._dict

    def __iter__(self):
        return iter(self._dict)

    def __reversed__(self):
        return reversed(list(self._dict))

    def __contains__(self, item):
        if item in self._dict:
            print(f"{item} is contained in Key.")
            return True
        elif item in self._dict.values():
            print(f"{item} is contained in Value.")
            return True
        else:
            print(f"{item} is not contained in Key & Value.")
            return False


daehee = Daeheeyun()
print(operator.length_hint(daehee))
daehee["E"] = 5
print(daehee["D"])
print(list(reversed(daehee)))
print(operator.contains(daehee, 1))
del daehee["A"]
print(list(daehee))
print(bool(daehee))
결과
length
3
{‘A’: 1, ‘B’: 2, ‘C’: 3, ‘E’: 5}
[‘E’, ‘C’, ‘B’, ‘A’]
1 is contained in Value.
True
length
[‘B’, ‘C’, ‘E’]
length
True

예제는 내부의 _dict 사전에 데이터를 저장하고, 컨테이너 메서드를 통해 _dict에 접근하는 클래스입니다.

  • Tip : _dict를 클래스 변수(_dict = {...})로 선언하면 모든 인스턴스가 같은 사전을 공유하게 됩니다. 인스턴스마다 별도의 데이터를 가지도록 __init__() 메서드에서 정의합니다.


결과를 한 줄씩 따라가면 다음과 같습니다.

  1. operator.length_hint(daehee) : __len__()과 __length_hint__()가 모두 정의되어 있으므로 __len__()이 우선 실행되어 length를 출력하고 3을 반환합니다. 그러므로 length_hint는 출력되지 않습니다.
  2. daehee["E"] = 5 : __setitem__()이 내부 사전에 E를 추가합니다. 출력은 없습니다.
  3. daehee["D"] : __getitem__()에서 KeyError가 발생하자 직접 __missing__("D")를 호출합니다. 예제의 __missing__()은 내부 사전 전체를 반환하도록 작성했으므로 {'A': 1, 'B': 2, 'C': 3, 'E': 5}가 출력됩니다.
  4. reversed(daehee) : __reversed__()가 키를 역순으로 반환합니다.
  5. operator.contains(daehee, 1) : 1 in daehee와 같으며, __contains__()가 키에서 1을 찾지 못하고 값에서 찾았으므로 1 is contained in Value.를 출력하고 True를 반환합니다.
  6. del daehee["A"] : __delitem__()이 A를 삭제합니다.
  7. list(daehee) : 리스트를 만들기 전에 공간을 확보하기 위해 길이를 조회하므로 length가 출력되고, __iter__()로 남은 키 ['B', 'C', 'E']를 얻습니다.
  8. bool(daehee) : __bool__()이 없으므로 __len__()을 호출해 length가 출력되고, 길이가 3이므로 True입니다.


Daeheeyun은 dict를 상속했지만 __getitem__()을 재정의했으므로, dict의 __getitem__()이 하던 __missing__() 자동 호출이 사라집니다. 그래서 except KeyError:에서 __missing__()을 직접 연결했습니다.

또한 데이터를 부모 dict가 아닌 _dict에 저장하므로, 재정의하지 않은 get(), keys(), items() 같은 dict의 메서드는 비어 있는 부모 사전을 사용합니다. 내장 자료형을 상속할 때는 이렇게 일부 메서드만 재정의하면 동작이 어긋날 수 있으므로 주의합니다.

  • Tip : collections.abc 모듈의 Mapping, MutableMapping, Sequence 등의 추상 클래스를 상속하면, 필수 메서드만 구현해도 나머지 컨테이너 메서드가 자동으로 제공됩니다.



collections.abc 활용하기

컨테이너 메서드를 모두 직접 작성하는 대신, collections.abc 모듈의 추상 클래스(Abstract Base Class)를 상속하면 필수 메서드 몇 개만 구현해도 나머지 기능을 자동으로 얻을 수 있습니다.


from collections.abc import MutableMapping


class LowerDict(MutableMapping):
    def __init__(self):
        self._data = {}

    def __getitem__(self, key):
        return self._data[key.lower()]

    def __setitem__(self, key, value):
        self._data[key.lower()] = value

    def __delitem__(self, key):
        del self._data[key.lower()]

    def __iter__(self):
        return iter(self._data)

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


d = LowerDict()
d["Name"] = "Python"
d.update(VERSION="3.14")

print(d["NAME"], "version" in d, len(d))
print(d.get("missing", "없음"), list(d.items()))
print(d.pop("Version"), list(d.keys()))
결과
Python True 2
없음 [(‘name’, ‘Python’), (‘version’, ‘3.14’)]
3.14 [‘name’]

키의 대소문자를 구분하지 않는 사전을 MutableMapping으로 구현한 예제입니다.

직접 작성한 메서드는 __getitem__(), __setitem__(), __delitem__(), __iter__(), __len__() 다섯 개뿐이지만, update(), get(), items(), keys(), pop(), in 연산자 등을 모두 사용할 수 있습니다.

MutableMapping이 제공하는 메서드들은 내부적으로 우리가 작성한 다섯 개의 메서드를 호출하도록 만들어져 있습니다. 예를 들어 "version" in d는 __getitem__()을 호출해 KeyError가 발생하는지로 판단하므로, 키가 소문자로 변환되어 True가 됩니다.

앞의 종합 예제처럼 dict를 상속했을 때 생기는 메서드 사이의 불일치가 없다는 것이 장점입니다.


from collections.abc import MutableMapping


class Half(MutableMapping):
    def __getitem__(self, key):
        return 0


try:
    Half()
except TypeError as e:
    print(type(e).__name__, ":", e)
결과
TypeError : Can’t instantiate abstract class Half without an implementation for abstract methods ‘__delitem__’, ‘__iter__’, ‘__len__’, ‘__setitem__‘

필수 메서드를 하나라도 구현하지 않으면 인스턴스를 만드는 시점에 TypeError가 발생하며, 오류 메시지에 빠진 메서드의 이름이 표시됩니다.

그러므로 구현을 빠뜨린 메서드를 실행 도중이 아니라 객체를 만들 때 바로 알 수 있습니다.

추상 클래스 필수 메서드 자동으로 제공되는 기능
Sequence __getitem__, __len__ in, iter, reversed, index, count
MutableSequence Sequence + __setitem__, __delitem__, insert append, extend, pop, remove, += 등
Mapping __getitem__, __iter__, __len__ in, keys, items, values, get, ==, !=
MutableMapping Mapping + __setitem__, __delitem__ pop, popitem, clear, update, setdefault



정리

이름 설명 반환값/특징
__len__(self) len(obj), bool(obj) 0 이상의 정수
__length_hint__(self) operator.length_hint(obj) 예상 길이(정수), __len__()이 우선
__getitem__(self, key) obj[key], obj[a:b] 값, 없으면 IndexError/KeyError
__setitem__(self, key, value) obj[key] = value 반환값 없음
__delitem__(self, key) del obj[key] 반환값 없음
__missing__(self, key) dict 하위 클래스에서 없는 키 조회 대신 사용할 값, 키를 추가하지 않음
__iter__(self) for, iter(), list(), 언패킹 반복자
__reversed__(self) reversed(obj) 역순 반복자
__contains__(self, item) item in obj, item not in obj 참/거짓


  • 컨테이너 문법은 모두 매직 메서드 호출로 바뀌며, 메서드가 없으면 Python이 다른 메서드로 대신 처리하거나 TypeError를 발생시킵니다.
  • 예외 규칙(IndexError, KeyError, TypeError)을 지켜야 for, in 같은 내장 기능이 올바르게 동작합니다.
  • 여러 메서드를 일관되게 구현해야 한다면 collections.abc의 추상 클래스를 상속하는 것이 안전합니다.

댓글 남기기