본문 바로가기

파이썬 코딩의 기술

파이썬코딩의기술 - BW15 딕셔너리 삽입 순서에 의존할 때는 조심하라

파이썬 3.5 이전에는...

딕셔너리에 대해 이터레이션을 수행하면 키를 임의의 순서로 돌려줬으며, 이터레이션 순서는 원소가 삽입된 순서와 일치하지 않았습니다. 

(예) 동물 이름과 새끼 동물 이름을 연결하는 딕셔너리를 정의하고 이를 출력해봅니다.

# Python 3.5

baby_names = {
    'cat' : 'kitten',
    'dog' : 'puppy',
}
print(baby_names)

>>> 
{'dog' : 'puppy', 'cat': 'kitten'}

-> 딕셔너리를 만들 때는 키를 'cat', 'dog' 순서로 삽입했지만, 이 딕셔너리를 출력하면 역순인 'dog', 'cat' 순서로 출력됨

-> 혼란 가중시킴 ..

< 이유 > 

- 이전의 딕셔너리 구현이 내장 hash 함수와 파이썬 인터프리터가 시작될 때 초기화되는 난수 씨앗값(seed)을 사용하는 해시 테이블 알고리즘으로 만들어졌기 때문입니다.

- 인터프리터 실행 시마다 달라지는 난수 씨앗값과 hash가 어우러지면서 딕셔너리의 순서가 삽입 순서와 일치하지 않고 프로그램을 실행할 때마다 달라졌습니다.

 

파이썬 3.6부터는 딕셔너리가 삽입 순서를 보존하도록 동작이 개선됐고, 파이썬 3.7부터는 아예 파이썬 언어 명세에 이 내용이 포함됐습니다. 

# Python 3.6

baby_names = {
    'cat': 'kitten',
    'dog': 'puppy',
}
print(baby_names)

>>> 
{'cat': 'kitten', 'dog': 'puppy'}

파이썬 3.5 및 그 이전 버전에서의 keys, values, items, popitem 등 딕셔너리가 제공하는 메서드

# Python 3.5 

print(list(baby_names.keys()))
print(list(baby_names.values()))
print(list(baby_names.items()))
print(baby_names.popitem()) # 임의로 원소를 하나 선택

>>> 
['dog', 'cat']
['puppy', 'kitten']
[('dog', 'puppy'), ('cat', 'kitten')]
('dog', 'puppy')

파이썬 3.6 이상 버전에서의 keys, values, items, popitem 등 딕셔너리가 제공하는 메서드

- 메소드가 삽입 순서와 같은 순서를 제공

# Python 3.6

print(list(baby_names.keys()))
print(list(baby_names.values()))
print(list(baby_names.items()))
print(baby_names.popitem()) # 마지막에 삽입된 원소

>>> 
['cat', 'dog']
['kitten', 'puppy']
[('cat', 'kitten'), ('dog', 'puppy')]
('dog', 'puppy')

-> 이런 변경으로 dict 타입과 이 타입의 특정 구현에 의존하는 여러 다른 파이썬 기능에 수많은 영향을 미쳤습니다.

 

파이썬 3.5 및 그 이전 버전에서의 함수에 대한 키워드 인자 (**kwargs로 모든 인자를 얻는 경우)

- 예전에는 순서가 뒤죽박죽인 것처럼 보였고, 그로 인해 함수 호출을 디버깅하기 어려웠습니다.

# Python 3.5

def my_func(**kwargs):
    for key, value in kwargs.items():
        print('%s = %s' % (key, value))
        
my_func(goose='gosling', kangaroo='joey')

>>> 
kangaroo = joey
goose = gosling

파이썬 3.6 이상 버전에서의 함수에 대한 키워드 인자 (**kwargs로 모든 인자를 얻는 경우)

- 키워드 인자의 순서는 프로그래머가 함수를 호출할 때 사용한 인자 순서와 항상 일치합니다.

# Python 3.6

def my_func(**kwargs):
    for key, value in kwargs.items():
        print(f'{key} = {value}')
        
my_func(goose='gosling', kangaroo='joey')

>>> 
goose = gosling
kangaroo = joey

파이썬 3.5 및 그 이전 버전에서의 클래스도 인스턴스 딕셔너리에 dict 타입을 사용합니다. 

- 예전 파이썬 버전에서는 Object 필드가 난수 같은 동작을 보였습니다.

# Python 3.5

class MyClass:
    def __init__(self):
        self.alligator = 'hatchling'
        self.elephant = 'calf'
 

a = MyClass()
for key, value in a.__dict__.items():
    print('%s = %s' % (key, value))
    

>>> 
elephant = calf
alligator = hatchling

파이썬 3.6 이상 버전에서의 각 인스턴스 필드를 대입한 순서가 __dict__에 반영될 것이라고 예상할 수 있습니다.

Class MyClass:
    def __init__(self):
        self.alligator = 'hatchling'
        self.elephant = 'calf'

a = MyClass()
for key, value in a.__dict__.items():
    print(f'{key} = {value}')
    
>>> 
alligator = hatchling
elephant = calf

 

(예1) 가장 귀여운 아기 동물을 뽑는 콘테스트의 결과를 보여주는 프로그램을 작성한다고 할 떄 각 동물의 득표수를 저장하는 딕셔너리가 있음

votes = {
    'otter': 1281,
    'polar bear': 587,
    'fox': 863,
}

(예2) 득표 데이터를 처리하고 각 동물의 이름과 순위를 빈 딕셔너리에 저장하는 함수를 정의합니다. 

def populate_ranks(votes, ranks):
    names = list(votes.keys())
    names.sort(key=votes.get, reverse=True)
    for i, name in enumerate(names, 1):
        ranks[name] = i

(예3) 콘테스트에서 어떤 동물이 우승했는지 보여줄 함수가 필요합니다.

- 이 함수는 populate_ranks가 딕셔녀리에 내용을 등수 오름차순으로 등록하나독 가정하고 동작함 

- 따라서 첫 번째 키가 우승자 입니다.

def get_winner(ranks):
    return next(iter(ranks))

(예4) 각 함수가 설계한 대로 작동해서 예상한 결과를 표시하는지 확인해봅니다.

ranks = {}
populate_ranks(votes, ranks)
print(ranks)
winner = get_winner(ranks)
print(winner)

>>> 
{'otter': 1, 'fox': 2, 'polar bear' : 3}
otter

(예5) 요구사항이 변경되어 등수가 아니라 알파벳순으로 표시해야하는 경우 

- collections.abc 모듈을 사용해 딕셔너리와 비슷하지만 내용을 알파벳 순서대로 이터레이션 해주는 클래스를 정의할 수 있습니다.

 

from collections.abc import MutableMapping

class SortedDict(MutableMapping):
    def __init__(self):
        self.data = {}
        
    def __getitem__(self, key):
        return self.data[key]
    
    def __setitem__(self, key, value):
        self.data[key] = value
        
    def __delitem__(self, key):
        del self.data[key]
    
    def __iter__(self):
        keys = list(self.data.keys())
        keys.sort()
        for key in keys:
            yield key
        
    def __len__(self):
        return len(self.data)

SortedDict는 표준 딕셔너리의 프로토콜을 지킴

- SortedDict 인스턴스를 표준 dict 위치에 사용해도 아무런 오류가 발생하지 않음

- 하지만 실행 결과는 요구 사항에 맞지 않음(?)

sorted_ranks = SortedDict()
populate_ranks(votes, sorted_ranks)
print(sorted_ranks.data)
winner = get_winner(sorted_ranks)
print(winner)

>>> 
{'otter': 1, 'fox': 2, 'polar bear': 3}
fox

코드는 dict 대신 SortedDict를 사용하므로 get_winner의 구현이 populate_ranks의 삽입 순서에 맞게 딕셔너리를 

이터레이션한다는 가정이 성립하지 않습니다.

- 따라서 우승 동물로는 득표수가 1등인 동물이 아니라 알파벳 순서로 맨 앞에 오는 동물인 fox가 반환됩니다.

 

이러한 문제를 해결하는 세 가지 방법이 있습니다.

첫 번째 방법은 ranks 딕셔너리가 어떤 특정 순서로 이터레이션된다고 가정하지 않고 

get_winner 함수를 구현하는 것 입니다. < 가장 보수적이고 가장 튼튼한 해법 >

def get_winner(ranks):
    for name, rank in ranks.items():
        if rank == 1:
            return name
            
winner = get_winner(sorted_ranks)
print(winner)

>>> 
otter

두번째 방법은 함수 맨 앞에 ranks의 타입이 우리가 원하는 타입인지 검사하는 코드를 추가하는 것입니다.

- ranks가 우리가 원하는 타입이 아니면 예외를 던집니다.

< 보수적인 접근 방법보다 실행 성능이 더 좋음 >

def get_winner(ranks):
    if not isinstance(ranks, dict):
        raise TypeError('dict 인스턴스가 필요합니다')
    return next(iter(ranks))
get_winner(sorted_ranks)

>>> 
Traceback ...
TypeError: dickt 인스턴스가 필요합니다.

세번째 방법은 타입 애너테이션(annotation)을 사용해서 get_winner에 전달되는 값이

딕셔너리와 비슷한 동작을 하는 MutableMapping 인스턴스가 아니라 

dict 인스턴스가 되도록 강제하는 것 입니다.

(예) 앞의 코드에 타입 애너테이션을 붙이고 mypy 도구를 엄격한 모드로 사용한 예이다. 

from typing import Dict, MutableMapping

def populate_ranks(votes: Dict[str, int], ranks: Dict[str, int]) -> None:
    names = list(votes.keys())
    names.sort(key=votes.get, reverse=True)
    for i, name in enumerate(names, 1):
        ranks[name] = i
        
def get_winner(ranks: Dict[str, int]) -> str:
    return next(iter(ranks))
    
    
class SortedDict(MutableMapping[str, int]):
    ....
    
votes = {
    'other': 1281,
    'polar bear': 587,
    'fox': 863,
}

sorted_ranks = SortedDict()
populate_ranks(votes, sorted_ranks)
print(sorted_ranks.data)
winner = get_winner(sorted_ranks)
print(winner)

$ python3 -m mypy --strict example.py
.../example.py:48: error: Argument 2 to "populate_ranks" has incompatible type
"SortedDict"; expected "Dict[str, int]"
.../example.py:50: error: Argument 1 to "get_winner" has incompatible type 
"SortedDict"; expected "Dict[str, int]"

-> dict 와 MutableMapping 타입의 차이를 올바로 감지해서 적절한 타입의 객체를 사용하지 않았을때 오류를 발생시킵니다.

이 해법은 정적 타입 안정성과 런타임 성능을 가장 잘 조합해줍니다.

 

# 기억해야할 내용

- 파이썬 3.7부터는 dict 인스턴스에 들어있는 내용을 이터레이션할 때 키를 삽입한 순서대로 돌려받는다는 사실에 의존할 수 있습니다.

- 파이썬은 dict는 아니지만 딕셔너리와 비슷한 객체를 쉽게 만들 수 있게 해줍니다. 이런 타입의 경우 키 삽입 순서가 그대로 보존된다고

가정할 수 없습니다. SortedDict 

- 딕셔너리와 비슷한 클래스를 조심스럽게 다루는 방법으로는 dict 인스턴스의 삽입 순서 보존에 의존하지 않고 코드를 작성하는 방법,

실행 시점에 명시적으로 dict 타입을 검사하는 방법, 타입 애너테이션과 정적 분석(static analysis)을 사용해 dict 값을

요구하는 방법이 있습니다.