ChangHyeon Nam's Blog notes and thoughts

딕셔너리 삽입 순서에 의존할 때는 조심하라

Comments

[Effective Python] Better way 15: 딕셔너리 삽입 순서에 의존할 때는 조심하라

  • 파이썬 3.7부터는 dict 인스턴스에 들어 있는 내용을 이터레이션할 때 키를 삽입한 순서대로 돌려받는다는 사실에 의존 할 수 있다.
  • 파이썬 dict은 아니지만 dict과 비슷한 객체를 쉽게 만들 수 있기 때문에 키 삽입 순서가 유지된다고 보장할 수 없다.
  • dict과 비슷한 클래스를 조심스럽게 다루는 방법이 대표적으로 3가지가 있다.

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

      # python 3.5 버전
      baby_names={'cat':'kitten','dog':'puppy'}
      print(baby_names)
      # {'dog': 'puppy', 'cat': 'kitten'}
    

    이런 일이 발생하는 이유는 예전의 딕셔너리 구현이 내장 hash 함수와 파이썬 인터프리터가 시작할 때 초기화 된는 난수 seed을 사용하는 해시 테이블 알고리즘으로 만들어졌기 때문입니다. 인터프리터 실행마다 난수 seed값과 hash가 어우러 지면서 딕셔너리 순서가 삽입 순서와 일치 하지 않고, 프로그램 실행할 때마다 달라졌습니다.

  • python 3.6부터는 딕셔너리가 삽입순서를 보존하도록 동작이 개선되었고, python 3.7부터는 python 언어 명세에 내용이 포함되었습니다. 그래서 프로그래머가 생성한 순서대로 딕셔너리 내용을 표시합니다.

      # python 3.6 버전이후
      baby_names={'cat':'kitten','dog':'puppy'}
      print(baby_names)
      # {'cat': 'kitten', 'dog': 'puppy'}
    
  • python 3.6부터는 딕셔너리가 제공하는 모든 메서드에도 또한 삽입순서가 유지 됩니다.

      print(list(baby_names.keys()))
      print(list(baby_names.values()))
      print(list(baby_names.items()))
      print(list(baby_names.popitem())) #마지막에 삽입된 원소
      # ['cat', 'dog']
      # ['kitten', 'puppy']
      # [('cat', 'kitten'), ('dog', 'puppy')]
      # ['dog', 'puppy']
    
  • 이런 순서가 유지되는 변경은 dict 타입과 이 타입의 특정 구현에 의존하는 여러 다른 파이썬 기능에 수많은 영향을 끼쳤고, 대표적인 것이 함수에 대한 키워드 인자입니다. 현재는 키워드 인자의 순서가 프로그래머가 함수를 호출할 때 사용하는 인자 순서와 일치합니다.

      def func(**kwargs):
          for key,value in kwargs.items():
              print(f'{key}={value}')
      func(goose='gosling',kangaroo='joey')
      # goose=gosling
      # kangaroo=joey
    
  • 클래스의 인스턴스 딕셔너리에 대해서도 각 인스턴스 필드를 대입한 순서가 유지 됩니다.

      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
    
  • 딕셔너리가 삽입 순서를 유지하는 방식은 이제 파이썬 언어 명세의 일부가 됬습니다. 하지만 딕셔너리를 처리할 때 앞에서 설명한 삽입 순서 관련 동작이 항상 성립한다고 가정하며 안됩니다. 파이썬에서는 프로그래머가list, dict 등의 표준 프로토콜을 변경한 커스텀 컨테이너 타입을 쉽게 정의할 수 있습니다.

    파이썬은 정적 타입 지정 언어가 이니 때문에 대부부느이 경우 코드는 엄격한 계층 보다는 객체의 동작이 객체의 실질적인 타입을 결정하는 덕 타입핑에 의존합니다.

      votes = {
          'otter':1281,
          'polar bear': 587,
          'fox' : 863
      }
    
      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
      def get_winner(ranks):
          return next(iter(ranks))
    
      ranks={}
      populate_ranks(votes,ranks)
      print(ranks)
      winner = get_winner(ranks)
      winner = get_winner(ranks)
      print(winner)
    
      # {'otter': 1, 'fox': 2, 'polar bear': 3}
      # otter
    

    위 처럼 정상적으로 숫자가 가장 높은 동물이 출력됩니다. collections.abc 모듈을 사용하여 딕셔너리와 비슷하지만 내용을 알파벳 순서대로 iteration 해주는 클래스를 새로 정의 할 수 있습니다.

      from collections.abc import MutableMapping
    
      class SortedDict(MutableMapping):
          def __init__(self):
              self.data={}
          def __getitem__(self, item):
              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)
      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
    

    get_winner의 구현이 populate_ranks의 삽입 순서에 맞게 딕셔너리를 이터레이션 한다고 가정하였기 때문에, 원하는 결과가 나오지 않습니다.

  • 3가지 해결방법이 있습니다. 첫번째 방법은 ranks 딕셔너리가 어떤 특정 순서로 이터레이션 된다고 가정하지 않고 get_winner함수를 그냥 구현하는 것입니다.

      def get_winner(ranks):
          for name,rank in ranks.items():
              if rank ==1:
                  return name
      winner = get_winner(sorted_ranks.data)
      print(winner)
      # otter
    
  • 두번째 방법은 맨 함수앞에 ranks의 타입이 우리가 원하는 타입인지 검사하는 코드를 추가하는 것입니다.

      def get_winner(ranks):
          if not isinstance(ranks,dict):
              raise TypeError('dict 인스턴스가 필요합니다')
          return next(iter(ranks))
      get_winner(sorted_ranks)
      # TypeError: dict 인스턴스가 필요합니다
    
  • 세번째 방법은 타입 annotation을 사용해서 get_winner에 전달되는 값이 딕셔너리와 비슷한 동작을 하는 MutableMapping 인스턴스가 아니라 dict 인스턴스가 되도록 강제하는 것 입니다.

      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(name,i):
              ranks[name]=i
      def get_winner(ranks:Dict[str,int])->str:
          return next(iter(ranks))
      sorted_ranks=SortedDict()
      populate_ranks(votes,sorted_ranks)
      print(sorted_ranks.data)
      winner=get_winner(sorted_ranks.data)
      print(winner)
      # bw15.py:112: error: Call to untyped function "SortedDict" in typed context
      # bw15.py:113: error: Argument 2 to "populate_ranks" has incompatible type "SortedDict"; expected "Dict[str, int]"