NLP Blog

이펙티브 파이썬 - 46. 내장 알고리즘과 자료 구조를 사용하자

|

BetterWay 46. 내장 알고리즘과 자료 구조를 사용하자

  • 알고리즘을 니들 맘대로 짜면 느려지는데, 그건 파이썬 문제가 아님
  • 이런 문제는 최적의 알고리즘과 자료 구조를 사용하지 않아서 일어났을 가능성이 큼
  • 파이썬에 이런 최적 알고리즘과 자료구조를 갖추고 있으니 그걸 사용하자 다시 구현하면 머리 터짐

더블 엔디드 큐

  • collection 모듈의 deque클래스는 더블 엔디드 큐 (double ended queue)이다.
  • deque는 큐의 처음과 끝에서 아이템을 삽입하거나 삭제할 때 항상 일정한 시간이 걸리느 연산을 제공
  • FIFO 큐를 만들 때 이상적
from collections import deque
fifo = deque()
fifo.append(1)
x = fifo.popleft()
print (x)
1
  • 이렇게만 보면 list와 같지 않느냐? 할 수 있는데!
  • list의 경우 리스트의 시작 부분에서 아이템을 삽입하거나 삭제하는 연산에서 linear time이 걸린다 deque보다 훨씬 느림

정렬된 딕셔너리

  • 표준 딕셔너리는 정렬되어 있지 않음
  • 즉, 같은 키와 값을 담은 dict를 순회해도 다른 순서가 나올 수 있다는 것
  • 이런 동작은 딕셔너리의 빠른 해시 테이블을 구현하는 방식이 만들어낸 뜻밖의 부작용임
from random import randint
a={}
a['foo'] = 1
a['bar'] = 2

# 무작위로 'b'에 데이터를 추가해서 해시 충돌을 일으킴
while True:
    z = randint(99, 1013)
    print(z)
    b = {}
    for i in range(z):
        b[i] = i
    b['foo'] = 1
    b['bar'] = 2
    for i in range(z):
        del b[i]
    if str(b) != str(a):
        break

print(a)
print(b)
print('Equal?', a == b)

152
{'foo': 1, 'bar': 2}
{'bar': 2, 'foo': 1}
Equal? True
  • collection모듈의 OrderedDict클래스는 키가 삽입된 순서를 유지하는 특별한 딕셔너리 타입
  • OrderedDict의 키를 순회하는 것은 예상 가능한 동작이므로 테스팅과 디버깅이 쉬워짐
from collections import OrderedDict

a = OrderedDict()
a['foo'] = 1
a['bar'] = 2

b = OrderedDict()
b['foo'] = 'red'
b['bar'] = 'blue'

print(a)
print(b)
for value1, value2 in zip(a.values(), b.values()):
    print(value1, value2)
OrderedDict([('foo', 1), ('bar', 2)])
OrderedDict([('foo', 'red'), ('bar', 'blue')])
1 red
2 blue

기본 딕셔너리

  • 딕셔너리는 통계를 관리하고 추적하는 작업에 유용
  • 딕셔너리를 사용할 때 한 가지 문제는 어떤 키가 이미 존재한다고 가정할 수 없다는 것
  • 이 문제 때문에 딕셔너리에 저장된 카운터를 증가시키는 것처럼 간단한 작업도 까다로움
stats = {}
key = 'my_counter'
if key not in stats:
    stats[key] = 0
stats[key] += 1
  • collections 모듈의 defaultdict클래스는 키가 존재하지 않으면 자동으로 기본값을 저장하도록 하여 이런 작업을 간소화
  • 할 일은 그저 키가 없을 때마다 기본값을 반환할 함수를 제공하는 것 뿐
  • 다음 예제에서 내장 함수 int는 0을 반환 (카운터를 증가시키는것은 간단데스)
from collections import defaultdict
stats = defaultdict(int)
stats['my_counter'] += 1

힙 큐

  • 힙(heap)은 우선순위 큐(priority queue)를 유지하는 유용한 자료구조다
  • heapq모듈은 표준 list타입으로 힙을 생성하는 heappush, heappop, nsmallest같은 함수를 제공한다
  • 임의우 우선순위를 가지는 아이템을 어떤 순서로도 힙에 삽입할 수 있다.
from heapq import *
a = []
heappush(a, 5)
heappush(a, 3)
heappush(a, 7)
heappush(a, 4)

# 우선 순위가 높은 것 (가장 낮은 수)부터 제거됨
print(heappop(a), heappop(a), heappop(a), heappop(a))
3 4 5 7
  • 결과로 만들어지는 listheapq외부에서도 쉽게 사용할 수 있다.
  • 힙의 0 인덱스에 접근하면 항상 가장 작은 아이템이 반환됨
a=[]
heappush(a, 5)
heappush(a, 3)
heappush(a, 7)
heappush(a, 4)
assert a[0] == nsmallest(1, a)[0] == 3
print(nsmallest(2, a))
[3, 4]
  • listsort메서드를 호출하더라도 힙의 불변성이 유지
print('Before', a)
a.sort()
print('After', a)
Before [3, 4, 7, 5]
After [3, 4, 5, 7]
  • 이러한 각 heapq연산에 걸리는 시간은 리스트의 길이에 비례하여 로그 형태로 증가
  • 표준 파이썬 리스트로 같은 동작을 수행하면 시간이 선형적으로 증가

바이섹션

  • list에서 아이템을 검색하는 작업은 index메서드를 호출할 때 리스트의 길이에 비례한 선형적 시간이 걸린다
import time
x = list(range(10**8))
start_time = time.time()
i = x.index(991234)
print(time.time() - start_time)
0.01512289047241211
from bisect import *
start_time = time.time()
i = bisect_left(x, 991234)
print(time.time() - start_time)
9.894371032714844e-05
  • 바이너리 검색의 복잡도는 로그 형태로 증가한다.
  • 아이템 백만 개를 담은 리스트를 bisect로 검색할 때 걸리는 시간은 아이템 14개를 담은 리스트를 index로 순차 검색할 때 걸리는 시간과 거의 같다

이터레이터 도구

  • 내장 모듈 itertools는 이터레이터를 구성하거나 이터레이터와 상호작용하는데 유용한 함수를 다수 포함(이미 bw16, 17에서 본 바 있음)
  • itertools에 있는 함수는 세가지 주요 카테고리로 나뉜다.

  • 이터레이터 연결
    • chain: 여러 이터레이터를 순차적인 이터레이터 하나로 결합
    • cycle: 이터레이터의 아이템을 영원히 반복한다.
    • tee : 이터레이터 하나를 병렬 이터레이터 여러개로 나눈다.
    • zip_longest : 길이가 서로 다른 이터레이터들에도 잘 동작하는 내장 함수 zip의 변형

  • 이터레이터에서 아이템 필터링
    • islice : 복사없이 이터레이터를 숫자로된 인덱스로 슬라이스한다.
    • takewhile : 서술 함수(predicate function)가 True를 반환하는 동안 이터레이터의 아이템을 반환
    • dropwhile : 서술함수가 처음으로 False를 반환하고 나면 이터레이터의 아이템을 반환
    • filterfalse : 서술함수가 False를 반환하는 이터레이터의 모든 아이템을 반환. 내장함수 filter의 반대 기능

  • 이터레이터에 있는 아이템들의 조함
    • product: 이터레이터에 있는 아이템들의 카테시안 곱을 반환한다. 깊게 중첩된 리스트 컴프리헨션에 대한 훌륭한 대안
    • permutations : 이터레이터에 있는 아이템을 담은 길이 N의 순서 있는 순열(permutations)을 반환
    • combinations : 이터레이터에 있는 아이쳄이 중복되지 않게 담은 길이 N의 순서없는 조합(combinations)을 반환

핵심정리

  • 알고리즘과 자료 구조를 표현하는 데는 파이썬의 내장 모듈을 사용하자
  • 이 기능들을 직접 재구현하지는 말자. 올바르게 만들기가 어렵기 때문이다.

Comment  Read more

이펙티브 파이썬 - 45. 지역 시간은 time이 아닌 datetime으로 표현하자

|

BetterWay 45. 지역 시간은 time이 아닌 datetime으로 표현하자

  • 협정 세계시(UTC, Coordinated Universal Time)는 시간대에 의존하지 않는 표준시간 표현
  • UTC는 유닉스 기원 이후로 지나간 초로 시간을 표현하는 컴퓨터에서 잘 작동 (사람한테는 안맞음)
  • 사람이 사용하는 시간은 현재 있는 위치는 기준
    • 사람들 : 정오 or 오전 8시
    • 컴퓨터 : UTC 15:00 - 7시
  • UTC와 지역 시간 사이의 변환이 필요

  • 파이썬은 두가지 시간대 변환 방법을 제공 (time, datetime)
  • time은 치명적인 오류가 일어날 가능성이 큼

time 모듈

  • 내장 모듈 time의 localtime함수는 유닉스 타임스탬프(유닉스 기원 이후 지난 초 UTC기준)를 호스트 컴퓨터의 시간대와 일치하는 지역 시간으로 변환
from time import localtime, strftime

now = 1407694710
local_tuple = localtime(now)
time_format = "%Y-%m-%d %H:%M:%S"
time_str = strftime(time_format, local_tuple)
print(time_str)
  • 때로는 지역시간을 받아서 유닉스 타임스탬프로 바꿔야하는 경우도 있음
  • strptime함수로 시간 문자열을 파싱 > mktime으로 지역 시간을 유닉스 타임스탬프로 변환
from time import mktime, strptime

time_tuple = strptime(time_str, time_format)
utc_now = mktime(time_tuple)
print(utc_now)
1407694710.0
  • 한 시간대의 지역 시간을 다른 시간대의 지역 시간으로 변환하려면? (샌프란시스코 -> 뉴욕)
  • time, localtime, strptime 함수의 반환 값을 직접 조작해서 시간대 반환은 ㄴㄴ 너무 복잡함
  • 많은 운영체제에서 시간대 변경을 자동으로 관리하는 설정 파일을 갖추고 있음, 파이썬에서는 time 모듈을 이용해 이러한 시간대를 사용 가능
# 태평양 연안 표준시의 샌프란시스코 시간대에서의 출발 시각을 파싱하는 코드
parse_format = '%Y-%m-%d %H:%M:%S %Z'
depart_sfo = '2014-05-01 15:45:16 PDT'
time_tuple = strptime(depart_sfo, parse_format)
time_str = strftime(time_format, time_tuple)
---------------------------------------------------------------------------

ValueError                                Traceback (most recent call last)

<ipython-input-3-44d00c3f5106> in <module>()
      2 parse_format = '%Y-%m-%d %H:%M:%S %Z'
      3 depart_sfo = '2014-05-01 15:45:16 PDT'
----> 4 time_tuple = strptime(depart_sfo, parse_format)
      5 time_str = strftime(time_format, time_tuple)


/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/_strptime.py in _strptime_time(data_string, format)
    502     """Return a time struct based on the input string and the
    503     format string."""
--> 504     tt = _strptime(data_string, format)[0]
    505     return time.struct_time(tt[:time._STRUCT_TM_ITEMS])
    506


/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/_strptime.py in _strptime(data_string, format)
    341     if not found:
    342         raise ValueError("time data %r does not match format %r" %
--> 343                          (data_string, format))
    344     if len(data_string) != found.end():
    345         raise ValueError("unconverted data remains: %s" %


ValueError: time data '2014-05-01 15:45:16 PDT' does not match format '%Y-%m-%d %H:%M:%S %Z'
  • 에러가 뜨는데…. 플랫폼에 의존적인 time 모듈의 특성 (본 코드가 돌아간 플랫폼은 OS X)
  • 실제 동작은 내부의 C함수가 호스트 운영체제와 어떻게 동작하느냐에 따라 결정됨
  • 이와 같은 동작 때문에 파이썬 time은 신뢰하기 어려움

datetime 모듈

  • 파이썬에서 시간을 표현하는 두 번째 방법은 내장 모듈 datetimedatetime 클래스를 사용하는 것
  • time모듈과 마찬가지로 datetime은 UTC에서의 현재 시각을 지역 시간으로 변경하는 데 사용할 수 있다.
# 현재 시각을 UTC로 얻어와서 한국 로컬 시간으로 변경하는 코드
from datetime import datetime, timezone

now = datetime(2014, 8, 10, 18, 18, 30)
now_utc = now.replace(tzinfo=timezone.utc)
now_local = now_utc.astimezone()
print(now_local)
print(now)
print(now_utc)
2014-08-11 03:18:30+09:00
2014-08-10 18:18:30
2014-08-10 18:18:30+00:00
  • datetime 모듈로도 지역 시간을 다시 UTC의 유닉스 타임스탬프로 쉽게 변경 가능
time_str = '2014-08-11 03:18:30'
now = datetime.strptime(time_str, time_format)
time_tuple = now.timetuple()
utc_now = mktime(time_tuple)
print(utc_now)
1407694710.0
  • datetime모듈은 time모듈과 달리 한 지역 시간을 다른 지역 시간으로 신뢰성 있게 변경
  • 하지만 tzinfo 클래스와 관련 메서드를 이용한 시간대 변환 기능만 제공….
  • pytz를 사용하면 모든 시간대 사용가능, 모든 시간대에 대한 정의를 담은 전체 데이터베이스를 포함함
  • pytz를 효과적으로 사용하려면 항상 지역 시간을 UTC로 먼저 변경해야함.
  • 이후 UTC값에 필요한 datatime연산 (오프셋 지정 등)을 수행
  • 마지막으로 지역 시간으로 변환
# NYC 도착시간은 UTC datetime으로 변환하는 코드
import pytz
arrival_nyc = '2014-05-01 23:33:24'
nyc_dt_naive = datetime.strptime(arrival_nyc, time_format)
eastern = pytz.timezone('US/Eastern')
nyc_dt = eastern.localize(nyc_dt_naive)
utc_dt = pytz.utc.normalize(nyc_dt.astimezone(pytz.utc))
print(utc_dt)
2014-05-02 03:33:24+00:00
# UTC datetime을 얻었으니 샌프란시스코 지역 시간으로 변환해보자
pacific = pytz.timezone('US/Pacific')
sf_dt = pacific.normalize(utc_dt.astimezone(pacific))
print(sf_dt)
2014-05-01 20:33:24-07:00
# 마찬가지로 한국의 지역시간으로 변환 가능
seoul = pytz.timezone('Asia/Seoul')
seoul_dt = seoul.normalize(utc_dt.astimezone(seoul))
print(seoul_dt)
2014-05-02 12:33:24+09:00
  • datetimepytz를 사용하면 이런 변환이 호스트 컴퓨터에서 구동하는 운영체제와 상관없이 모든 환경에서 동일하게 동작

핵심 정리

  • 서로 다른 시간대를 변환하는 데는 time모듈을 사용하지 말자
  • pytz모듈과 내장 모듈 datetime으로 서로 다른 시간대 사이에서 시간을 신뢰성 있게 변환하자.
  • 항상 UTC로 시간을 표현하고, 시간을 표시하기 전에 마지막 단게로 UTC 시간을 지역 시간으로 변환하자

Comment  Read more

이펙티브 파이썬 - 24. 객체를 범용으로 생성하려면 @classmethod 다형성을 이용하자

|

BetterWay24. 객체를 범용으로 생성하려면 @classmethod 다형성을 이용하자

  • 파이썬에서 객체가 다형성을 지원할 뿐만 아니라 클래스도 다형성을 잘 지원한다.

  • 다형성은 계층 구조에 속한 여러 클래스가 자체의 메서드를 독립적인 버전으로 구현하는 방식
  • 다형성을 이용하면 여러 클래스가 같은 인터페이스나 추상 기반 클래스를 충족하면서도 다른 기능을 제공할 수 있다.

  • ex) 맵리듀스 구현을 작정할 때 입력 데이터를 표현할 공통 클래스가 필요하다고 하자.
    • 다음은 서브클래스에서 정의해야하는 read메서드가 있는 입력 데이터 클래스다.
class InputData(object):

    def read(self):
        raise NotImplementedError
# 디스크에 있는 파일에서 데이터를 읽어오도록 구현한 InputData의 서브클래스
class PathInputData(InputData):

    def __init__(self, path):
        #super.__init__()
        self.path = path

    def read(self):
        return open(self.path).read()
  • PathInputData 같은 InputData 서브클래스가 몇 개든 있을 수 있고, 각 서브 클래스에서는 처리할 바이트 데이터를 반환하는 표준 인터페이스인 read를 구현할 것이다.
  • 다른 InputData 서브클래스는 네트워크에서 데이터를 읽어오거나 데이터의 압축을 해제하는 기능 등을 할 수 있다.
  • 표준 방식으로 입력 데이터를 처리하는 맵리듀스 작업 클래스에도 비슷한 추상 인터페이스가 필요하다.
class Worker(object):
    def __init__(self, input_data):
        self.input_data = input_data
        self.result = None

    def map(self):
        raise NotImplementedError

    def reduce(self, other):
        raise NotImplementedError
# 적용하려는 특정 맵 리듀스 함수를 구현한 Worker의 구체 서브클래스다(간단한 줄바꿈 카운터)
class LineCountWorker(Worker):
    def map(self):
        data = self.input_data.read()
        self.result = data.count('\n')

    def reduce(self, other):
        self.result += other.result
  • 이렇게 구현하면 잘 동작할 것처럼 보이지만 결국 커다란 문제에 직면
  • 이 모든 코드 조각을 무엇으로 연결할 것인가? 적절히 인터페이스를 설계하고 추상화한 클래스들이지만 일단 객체를 생성한 후에나 유용. 무엇으로 객체를 만들고 맵리듀스를 조율할까?

  • 가장 간단한 방법은 헬퍼 함수로 직접 객체를 만들고 연결하는 것
    • 다음은 디렉터리의 내용을 나열하고 그 안에 있는 각 파일로 PathInputData 인스턴스를 생성하는 코드
import os

def generate_inputs(data_dir):
    for name in os.listdir(data_dir):
        yield PathInputData(os.path.join(data_dir, name))
# 다음으로 generate_inputs 함수에서 반환한 InputData 인스턴스를 사용하는 LineCountWorker 인스턴스를 생성
def create_workers(input_list):
    workers = []
    for input_data in input_list:
        workers.append(LineCountWorker(input_data))
    return workers
from threading import Thread
# map 단계를 여러 스레드로 나눠서 이 Worker 인스턴스를 실행. 그런다음 reduce를 반복적으로 호출해서 결과를 최종값 하나로 합친다
def excute(workers):
    threads = [Thread(target=w.map) for w in workers]
    for thread in threads: thread.start()
    for thread in threads: thread.join()

    first, rest = workers[0], workers[1:]
    for worker in rest:
        first.reduce(worker)
    return first.result

# 마지막으로 단계별로 실행하려고 mapreduce 함수에서 모든 조각을 연결
def mapreduce(data_dir):
    inputs = generate_inputs(data_dir)
    workers = create_workers(inputs)
    return excute(workers)
# 테스트용 입력 파일로 이 함수를 실행해보면 잘 동작
from tempfile import TemporaryDirectory

def write_test_files(tmpdir):

    for i in range(1000):
        with open(os.path.join(tmpdir, str(i) + '.txt'), 'w') as f:
            f.write('*\n' * i)

with TemporaryDirectory() as tmpdir:
    write_test_files(tmpdir)
    result = mapreduce(tmpdir)

print('There are', result, 'lines')

There are 499500 lines
  • 무엇이 문제인가? 큰 문제는 mapreduce 함수가 전혀 범용적이지 않다는 점
    • 다른 InputDataWorker 서브클래스를 작성한다면 generate_inputs, create_workers, mapreduce함수를 알맞게 다시 작성해야 함

  • 이 문제는 결국 객체를 생성하는 범용적인 방법의 필요성으로 귀결
  • 다른 언어에서는 이 문제를 생성자 다형성으로 해결함
    • 이 방식을 따르려면 각 InputData 서브클래스에서 맵리듀스를 조율하는 헬퍼 메서드가 범용적으로 사용할 수 있는 특별한 생성자를 제공해야함
    • 문제는 파이썬이 단일 생성자 메서드 __init__ 만을 허용한다는 점
    • 격국 모든 InputData 서브클래스가 호환되는 생성자를 갖춰야 한다는 터무니 없는 요구사항 임
  • 이 문제를 해결하는 가장 좋은 방법은 @classmethod 다형성을 이용하는 것
    • @classmethod 다형성은 생성된 객체가 아니라 전체 클래스에 적용된다는 점만 빼면 InputData.read에 사용한 인스턴스 메서드의 다형성과 똑같다
  • 이 발상을 맵리듀스 관련 클래스에 적용해보자. 여기서는 공통 인터페이스를 사용해 새 InputData 인스턴스를 생성하는 범용 클래스 메서드로 InputData 클래스를 확장한다
class GenericInputData(object):
    def read(self):
        raise NotImplementedError

    @classmethod
    def genrate_inputs(cls, config):
        raise NotImplementedError
  • generate_inputs 메서드는 GenericInputData를 구현하는 서브클래스가 해석할 설정 파라미터들을 담은 딕셔너리를 받는다.
  • 다음 코드에서는 입력 파일들을 얻어올 디렉터리를 config로 알아낸다.
class PathInputData(GenericInputData):
    def __init__(self, path):
        self.path = path

    def read(self):
        return open(self.path).read()

    @classmethod
    def generate_inputs(cls, config):
        data_dir = config['data_dir']
        for name in os.listdir(data_dir):
            yield cls(os.path.join(data_dir, name))

  • 비슷하게 GenericWorker 클래스에 create_workers 헬퍼를 작성한다.
  • 여기서는 input_class 파라미터(GenericInputData의 서브클래스여야함)로 필요한 입력을 만들어냄
  • cls()를 범용 생성자로 사용해서 GenericWorker를 구현한 서브클래스의 인스턴스를 생성
class GenericWorker(object):
    def __init__(self, input_data):
        self.input_data = input_data
        self.result = None

    def map(self):
        raise NotImplementedError

    def reduce(self, other):
        raise NotImplementedError

    @classmethod
    def create_workers(cls, input_class, config):
        workers = []
        for input_data in input_class.generate_inputs(config):
            workers.append(cls(input_data))
        return workers
  • 위의 input_class.generate_inputs 호출이 바로 여기서 보여주려는 클래스 다형성이다.
  • 또한 create_workers__init__ 메서드를 직접 사용하지 않고 GenericWorker를 생성하는 또 다른 방법으로 cls를 호출함을 알 수 있다

  • GenericWorker를 구현할 서브클래스는 부모 클래스만 변경하면 된다.
class LineCountWorker(GenericWorker):
    def map(self):
        data = self.input_data.read()
        self.result = data.count('\n')

    def reduce(self, other):
        self.result += other.result
# mapreduce 함수를 완전히 범용적으로 재작성
def mapreduce(worker_class, input_class, config):
    workers = worker_class.create_workers(input_class, config)
    return excute(workers)
  • 테스트용 파일로 새로운 작업 클래스 객체를 실행하면 이전에 구현한 것과 같은 결과가 나옴
  • 차이는 mapreduce 함수가 범용적으로 동작하려고 더 많은 파라미터를 요구한다는 점
with TemporaryDirectory() as tmpdir:
    write_test_files(tmpdir)
    config = {'data_dir': tmpdir}
    result = mapreduce(LineCountWorker, PathInputData, config)
    print(result)
499500
  • 이제 GenericInputDataGenericWorker의 다른 서브클래스를 원하는 대로 만들어도 glue code를 작성할 필요가 없다

핵심 정리

  • 파이썬에서는 클래스별로 생성자를 한개(__init__ 메서드) 만 만들수 있다.
  • 클래스에 필요한 다른 생성자를 저으이하려면 @classmethod를 사용하자.
  • 구체 서브클래스들을 만들고 연결하는 범용적인 방법을 제공하려면 클래스 메서드 다형성을 이용하자

Comment  Read more

이펙티브 파이썬 - 23. 인터페이스가 간단하면 클래스 대신 함수를 받자

|

BetterWay 23. 인터페이스가 간단하면 클래스 대신 함수를 받자

  • 파이썬 내장 API의 상당수에는 함수를 넘겨서 동작을 사용자화하는 기능이 있음
  • API는 이런 hook를 이용해서 여러분이 작성한 코드를 실행 중에 호출한다
  • ex) list 타입의 sort 메서드는 정렬에 필요한 각 인덱스의 값을 결정하는 선택적인 key인수를 받는다.
    • 다음 코드에서는 lambda 표현식을 key 후크로 넘겨서 이름 리스트를 길이로 정렬한다
names = ['Socrates', 'Archimedes', 'Plato', 'Aristotle']
names.sort(key=lambda x: len(x))
print(names)
['Plato', 'Socrates', 'Aristotle', 'Archimedes']
  • 다른 언어에서라면 후크를 추상 클래스로 정의할 것이라고 예상할 수도 있다.
  • 하지만 파이썬의 후크 중 상당수는 인수와 반환 값을 잘 정의해놓은 단순히 상태가 없는 함수다.
  • 함수는 클래스보다 설명하기 쉽고 정의하기도 간단해서 후크로 쓰기에 이상적임
  • 함수가 후크로 동작하는 이유는 파이썬이 일급 함수(first-class function)를 갖췄지 때문
    • 언어에서 함수와 메서드를 다른 값처럼 전달하고 참조할 수 있음

  • 예를들어 defaultdict 클래스의 동작을 사용자화한다고 해보자
  • 이 자료 구조는 찾을 수 없는 키에 접근할 때마다 호출될 함수를 받는다.
  • defaultdict에 넘길 함수는 딕셔너리에서 찾을 수 없는 키에 대응할 기본값을 반환해야 한다.
  • 다음은 키를 찾을 수 없을 때마다 로그를 남기고 기본값으로 0을 반환하는 후크를 정의한 코드다
from collections import defaultdict
def log_missing():
    print('Key added')
    return 0
# 초깃값을 담은 딕셔너리와 원하는 증가 값 리스트로 `log_missing` 함수를 두번(각각 'red'와 'orange'일 때) 실행하여 로그를 출력하게 해보자.
current = {'green': 12, 'blue': 3}
increments = [
    ('red', 5),
    ('blue', 17),
    ('orange', 9),
]
result = defaultdict(log_missing, current)
print('Before:', dict(result))
for key, amount in increments:
    result[key] += amount
print('After: ', dict(result))

Before: {'blue': 3, 'green': 12}
Key added
Key added
After:  {'blue': 20, 'orange': 9, 'red': 5, 'green': 12}
  • log_missing같은 함수를 넘기면 결정 동작과 부작용을 분리하므로 API를 쉽게 구축하고 테스트 할 수 있음
  • 예를 들어 기본값 후크를 defualtdict에 넘겨서 찾을 수 없는 키의 총 개수를 센다고 해보자.
    • 이렇게 만드는 한 가지 방법은 상태 보존 클로저를 사용
    • 다음은 상태 보존 클로저를 기본값 후크로 사용하는 헬퍼 함수다
def increment_with_report(current, increments):
    added_count = 0

    def missing():
        nonlocal added_count  # 상태 보존 클로저
        added_count += 1
        return 0

    result = defaultdict(missing, current)
    for key, amount in increments:
        result[key] += amount

    return result, added_count
  • defaultdictmissing 후크가 상태를 유지한다는 사실을 모르지만, increment_with_report 함수를 실행하면 튜플의 요소로 기대한 개수인 2를 얻는다
  • 이는 간단한 함수를 인터페이스용으로 사용할 때 얻을 수 있는 또 다른 이점
  • 클로저 안에 상태를 숨기면 나중에 기능을 추가하기도 쉬움
result, count = increment_with_report(current, increments)
assert count == 2
  • 상태 보존 후크용으로 클로저를 정의할 때 생기는 문제는 상태가 없는 함수의 예제보다 이해하기 어렵다는 점
  • 또 다른 방법은 보존할 상태를 캡슐화하는 작은 클래스를 정의 하는 것
class CountMissing(object):
    def __init__(self):
        self.added = 0

    def missing(self):
        self.added += 1
        return 0
  • 다른 언어에서라면 이제 CountMissing 인터페이스를 수용하도록 defaultdict를 수정해야 한다고 생각할 것이다.
  • 하지만 파이썬에서는 일급 함수 덕분에 객체로 CountMissing.missing 메서드를 직접 참조해서 defaultdict의 기본값 후크로 넘길 수 있다.
  • 메서드가 함수 인터페이스를 충족하는 건 자명하다
counter = CountMissing()
result = defaultdict(counter.missing, current)   # 메서드 참조

for key, amount in increments:
    result[key] += amount
assert counter.added == 2
  • 헬퍼 클래스로 상태 보존 클로저의 동작을 제공하는 방법이 앞에서 increment_with_report 함수를 사용한 방법보다 명확
  • 그러나 CounterMissing 클래스 자체만으로는 용도가 무엇인지 바로 이해하기 어렵다. 누가 CountMissing객체를 생성하는가? 누가 missing 메서드를 호출하는가? 나중에 다른 공개 메서드를 클래스에 추가할 일이 있을까? defaultdict와 연계해서 사용한 예를 보기 전까지는 이 클래스가 수수께끼로 남는다.

  • 파이썬에서는 클래스에 __call__이라는 특별한 메서드를 정의해서 이런상황을 명확하게 할 수 있다.
  • __call__ 메서드는 객체를 함수처럼 호출할 수 있게 해준다.
  • 또한 내장 함수 callable이 이런 인스턴스에 대해서는 True를 반환하게 만든다.
class BetterCountMissing(object):
    def __init__(self):
        self.added = 0

    def __call__(self):
        self.added += 1
        return 0

counter = BetterCountMissing()
counter()
assert callable(counter)
# 다음은 BetterCountMissing 인스턴스를 defaultdict의 기본값 후크로 사용하여 딕셔너리에 없어서 새로 추가된 키의 개수를 알아내는 코드
counter = BetterCountMissing()
result = defaultdict(counter, current)   # __call__이 필요함
for key, amount in increments:
    result[key] += amount
assert counter.added == 2
  • 이 예제가 CountMissing.missing 예제보다 명확하다.
  • __call__ 메서드는 (API 후크처럼) 함수 인수를 사용하기 적합한 위치에 클래스의 인스턴스를 사용할 수 있다는 사실을 드러낸다.
  • 이 코드를 처음 보는 사람을 클래스의 주요 동작을 책임지는 진입점(entry point)으로 안내하는 역할도 한다.
  • 클래스의 목적이 상태 보존 클로저로 동작하는 것이라는 강력한 힌트를 제공

  • 무엇보다도 __call__ 을 사용할 때 defaultdict는 여전히 무슨 일이 일어나는지 모른다
  • defaultdict에 필요한 건 기본값 후크용 함수뿐이다. 파이썬은 하고자 하는 작업에 따라 간단한 함수 인터페이스를 충족하는 다양한 방법을 제공

핵심 정리

  • 파이썬에서 컴포넌트 사이의 간단한 인터페이스용으로 클래스를 저으이하고 인스턴스를 생성하는 대신에 함수만 써도 종종 충분
  • 파이썬에서 함수와 메서드에 대한 참조는 일급이다. 즉, 다른 타입처럼 표현식에서 사용할 수 있다.
  • __call__이라는 특별한 메서드는 클래스의 인스턴스를 일반 파이썬 함수처럼 호출할 수 있게 해준다.
  • 상태를 보존하는 함수가 필요할 때 상태 보존 클로저를 정의하는 대신 __call__ 메서드를 제공하는 클래스를 정의하는 방안을 고려하자.

Comment  Read more

이펙티브 파이썬 - 22. 딕셔너리와 튜플보다는 헬퍼 클래스로 관리하자

|

BetterWay 22. 딕셔너리와 튜플보다는 헬퍼 클래스로 관리하자

  • 딕셔너리 타입은 객체의 수명이 지속되는 동안 동적인 내부 상태를 관리하는 용도로 아주 좋다
    • ‘동적’이란? : 예상하지 못한 식별자들을 관리해야 하는 상황
  • ex) 이름을 모르는 학생 집단의 성적을 기록하고 싶다, 학생별로 미리 정의된 속성을 사용하지 않고 딕셔너리에 이름을 저장하는 클래스를 정의 가능
class SimpleGradebook(object):
    def __init__(self):
        self._grades = {}

    def add_student(self, name):
        self._grades[name] = []

    def report_grade(self, name, score):
        self._grades[name].append(score)

    def average_grade(self, name):
        grades = self._grades[name]
        return sum(grades) / len(grades)
# 클래스 사용
book = SimpleGradebook()
book.add_student('Isaac Newton')
book.report_grade('Isaac Newton', 90)

print(book._grades)
print(book.average_grade('Isaac Newton'))
{'Isaac Newton': [90]}
90.0
  • 딕셔너리는 정말 사용하기 쉬워서 과도하게 쓰다가 코드를 취약하게 작성할 위험이 있음
  • 예를들어 SimpleGradebook 클래스를 확장해서 모든 성적을 한 곳에 저장하지 않고 과목별로 저장한다고 하자
  • 이런 경우 _grades 딕셔너리를 변경해서 학생이름(키)를 또 다른 딕셔너리(값)에 매핑하면 된다.
  • 가장 안쪽에 있는 딕셔너리는 과목(키)를 성적(값)에 매핑한다.
class BySubjectGradebook(object):
    def __init__(self):
        self._grades = {}

    def add_student(self, name):
        self._grades[name] = {}

# report_grade와 average_grade 메서드는 여러단계의 딕셔너리를 처리하느라 약간 복잡해지지만 아직은 다룰만

    def report_grade(self, name, subject, grade):
        by_subject = self._grades[name]
        grade_list = by_subject.setdefault(subject, [])
        grade_list.append(grade)

    def average_grade(self, name):
        by_subject = self._grades[name]
        total, count = 0, 0
        for grades in by_subject.values():
            total += sum(grades)
            count += len(grades)
        return total / count
book = BySubjectGradebook()
book.add_student('Albert Einstein')
book.report_grade('Albert Einstein', 'Math', 75)
print(book._grades)
{'Albert Einstein': {'Math': [75]}}
book.report_grade("Albert Einstein", 'Math', 65)
book.report_grade('Albert Einstein', 'Gym', 90)
book.report_grade('Albert Einstein', 'Gym', 95)
print(book._grades)
{'Albert Einstein': {'Gym': [90, 95], 'Math': [75, 65]}}
  • 아직 까지는 괜찮았는데…. 요구사항이 더 생겼다고 해보자
  • 수업의 최종 성적에서 weight 가 추가가 되서, 중간고사와 기말고사를 쪽지시험보다 중요하게 하려고 한다고 해보자
  • 이 기능을 구현하는 방법은 가장 안쪽 딕셔너리를 변경해서 과목(키)을 성적(값)에 매핑하지 않고, 성적과 비중을 담은 튜플 (score, weight)에 매핑하는것이다
class WeightedGradebook(object):
    def __init__(self):
        self._grades = {}

    def add_student(self, name):
        self._grades[name] = {}

    def report_grade(self, name, subject, score, weight):
        by_subject = self._grades[name]
        grade_list = by_subject.setdefault(subject, [])
        grade_list.append((score, weight)) # append에 들어가는 값을 tuple로 변경

    #report_grade 수정은 간단하지만, average_grade 메서드는 2중 루프분이 생긴다
    def average_grade(self, name):
        by_subject = self._grades[name]
        score_sum, score_count = 0, 0
        for subject, scores in by_subject.items():
            subject_avg, total_weight = 0, 0
            for score, weight in scores:
                subject_avg += score * weight
                total_weight += weight
            score_sum += subject_avg
            score_count += total_weight

        return score_sum / score_count
# 클래스를 사용하는 방법도 더 어려워짐, 숫자들이 무엇을 의미하는지도 명확하지 않음
book = WeightedGradebook()
book.add_student('Albert Einstein')
book.report_grade('Albert Einstein', 'Math', 80, 0.10)
book.report_grade('Albert Einstein', 'Math', 90, 0.40)
book.report_grade('Albert Einstein', 'Math', 85, 0.50)
book.report_grade('Albert Einstein', 'Gym', 80, 0.10)
book.report_grade('Albert Einstein', 'Gym', 80, 0.40)
book.report_grade('Albert Einstein', 'Gym', 100, 0.50)
print(book._grades)
{'Albert Einstein': {'Gym': [(80, 0.1), (80, 0.4), (100, 0.5)], 'Math': [(80, 0.1), (90, 0.4), (85, 0.5)]}}
print(book.average_grade('Albert Einstein'))
88.25
  • 이렇게 복잡해지면 딕셔너리와 튜플 대신 클래스의 계층 구조를 사용할 때가 된 것이다.

  • 파이썬 내장 딕셔너리와 튜플을 사용하면 계층이 한 단계가 넘는 중첩은 피해야 한다
    • 딕셔너리를 담은 딕셔너리는 피해야 한다.
  • 여러 계층으로 중첩하면 다른 프로그래머들이 코드를 이해하기 어려워지고 유지보수의 악몽에 빠지게 된다.
  • 관리하기가 복잡하다고 느끼는 즉시 클래스로 옮겨가야한다.
  • 데이터를 더 잘 캡슐화한 잘 정의된 인터페이스 제공 가능
  • 인터페이스와 실제 구현 사이에 추상화 계층을 만들 수 있음

클래스 리팩토링

  • 의존 관계에서 가장 아래에 있는 성적부터 클래스로 옮겨보자
  • 이렇게 간단한 정보를 담기에 클래스는 너무 무거워 보인다. 성적은 변하지 않으니 튜플을 사용하는 게 적절해 보임
  • 다음 코드에서는 리스트 안에 성적을 기록하려고 (score, weight) 튜플을 사용
grades = []
grades.append((95, 0.45))
#...
total = sum(score * weight for score, weight in grades)
total_weight = sum(weight for _, weight in grades)
average_grade = total /total_weight
  • 문제는 일반 튜플은 위치에 의존한다는 점
  • 성적에 선생님의 의견 같은 더 많은 정보를 연관지으려면 이제 튜플을 사용하는 곳을 모두 찾아서 아이템 두 개가 아니라 세 개를 쓰도록 수정해야 한다.
  • 다음 코드에서는 세번째 값을 _로 받아 그냥 무시하도록 함
grades = []
grades.append((95, 0.45, 'Great job'))
#...
total = sum(score * weight for score, weight, _ in grades)    # _ 사용
total_weight = sum(weight for _, weight, _ in grades)    # _ 사용
average_grade = total /total_weight
  • 튜플을 점점 더 길게 확장하는 패턴은 딕셔너리의 계층을 깊게 두는 방식과 비슷
  • 튜플의 아이템이 두 개를 넘어가면 다른 방법을 고려해야 한다

  • collection 모듈의 namedtuple 타입이 정확히 이런 요구에 부합한다.
  • namedtuple을 이용하면 작은 불면 데이터 클래스(immutable data class)를 쉽게 정의할 수 있다.
import collections
Grade = collections.namedtuple('Grade', ('score', 'weight'))
  • 불변 데이터 클래스는 위치 인수나 키워드 인수로 생성할 수 있다.
  • 필드는 이름이 붙은 속성으로 접근할 수 있다. 이름이 붙은 속성이 있으면 나중에 요구 사항이 또 변해서 단순 데이터 컨테이너에 동작을 추가해야 할 때, namedtuple에서 직접 작성한 클래스로 쉽게 바꿀 수 있다

namedtuple의 제약

namedtuple이 여러 상황에서 유용하긴 하지만 장점보다 단점을 만들어낼 수 있는 상황도 이해해야 함


  • namedtuple로 만들 클래스에 기본 인수 값을 설정할 수 없다.
    • 그래서 데이터에 선택적인 속성이 많으면 다루기 힘들어진다.
    • 속성을 사용할 때는 클래스를 직접 정의하는게 나을 수 있다.
  • namedtuple 인스턴스의 속성 값을 여전히 숫자로 된 인덱스와 순회 방법으로 접근할 수 있다.
    • 특히 외부 API로 노출한 경우에는 의도와 다르게 사용되어 나중에 실제 클래스로 바꾸기 더 어려울 수도 있다.
    • namedtuple 인스턴스를 사용하는 방식을 모두 제어할 수 없다면 클래스를 직접 정의하는게 낫다
# 성적들을 담은 단일 과목을 표현하는 클래스를 작성해보자
class Subject(object):
    def __init__(self):
        self._grades = []

    def report_grade(self, score, weight):
        self._grades.append(Grade(score, weight))

    def average_grade(self):
        total, total_weight = 0, 0
        for grade in self._grades:
            total += grade.score * grade.weight
            total_weight += grade.weight
        return total / total_weight

# 이제 한 학생이 공부한 과목들을 표현하는 클래스를 작성해보자
class Student(object):
    def __init__(self):
        self._subjects = {}

    def subject(self, name):
        if name not in self._subjects:
            self._subjects[name] = Subject()
        return self._subjects[name]

    def average_grade(self):
        total, count = 0, 0
        for subject in self._subjects.values():
            total += subject.average_grade()
            count += 1
        return total / count
# 마지막으로 학생의 이름을 키로 사용해 동적으로 모든 학생을 담을 컨테이너를 작성
class Gradebook(object):
    def __init__(self):
        self._students = {}

    def student(self, name):
        if name not in self._students:
            self._students[name] = Student()
        return self._students[name]
# 이 세 클래스의 코드 줄 수는 이전에 구현한 코드의 두 배에 가깝다.
# 하지만 이해하기 훨씬 쉬움
# 사용 예제도 명확하고 확장하시 쉽다.
book = Gradebook()
albert = book.student('Albert Einstein')
math = albert.subject('Math')
math.report_grade(80, 0.10)
# ...
print(albert.average_grade())
80.0
  • 필요하면 이전 형태의 API 스타일로 작성한 코드를 새로 만든 객체 계층 스타일로 바꿔주는 하위 호환용 메서드를 작성해도 됨

핵심 정리

  • 다른 딕셔너리나 긴 튜플을 담은 딕셔너리를 생성하지 말자.
  • 정식 클래스의 유연성이 필요 없다면 가벼운 불변 데이터 컨테이너에는 namedtuple을 사용하자.
  • 내부 상태를 관리하는 딕셔너리가 복잡해지면 여러 헬퍼 클래스를 사용하는 방식으로 관리 코드를 바꾸자

Comment  Read more