파이썬의 관용구와 효율성 1/28/07

롭 나이트(Rob Knight)가 코전트(Cogent) 프로젝트를 위하여 작성함

한글판 johnsonj 2008.06.12 목

내용

코드를 읽기 쉽게 만들려면 어떤 관용구를 사용해야 하는가?

코드를 더 빠르게 만들려면 어떤 테크닉을 사용해야 하는가?

코딩 지도 규범으로 돌아가자

코드를 읽기 쉽게 만들려면 어떤 관용구를 사용해야 하는가?

"파이썬 요리책(The Python Cookbook)", 특히 앞의 몇 장을 읽어보자. 작성이 잘된 파이썬 코드의 예가 모인 훌륭한 소스이다.

문자열은 리스트로 구축하고 마지막에 ''.join을 사용하자. join 메쏘드는 리스트가 아니라 가름자(separator)에서 호출된다. 빈 문자열에서 호출되면 가름자 없이 조각들을 결합해 주는데, 이는 파이썬의 버릇으로서 처음 보면 약간 놀랍다. 중요한 것은 이것이다: +로 문자열을 구축하면 선형 시간이 아니라 제곱 시간이 걸린다! 관용구 하나를 배워야 한다면, 이 관용구를 익히자.

나쁨: for s in strings: result += s
옳음: result = ''.join(strings)
		

언제나 객체의 유형이 아니라 그의 능력을 사용하자. 파이썬은 동적으로 형이 정의되는 언어이다: 기본적으로 한 객체가 특정 유형인지 걱정하지 않아도 된다. 특정 인터페이스를 지원하기만 하면 된다. 이 때문에 인상적인 다형성을 무료로 사용할 수 있다. 예를 들어, 문자열이 알파벳인지 여부를 점검하는 코드는 다음과 같다:

for char in string:
    if char not in alphabet:
        raise ValueError, "Char %s not in alphabet %a" % (char, alphabet)
		

alphabet이 문자열인지, 사전인지, 리스트인지, 아니면 내가 정의한 객체인지 문제가 되지 않는다. __contains__ 특수 메쏘드를 지원하기만 하면 된다.

가능하면 in을 사용하자 (자신의 클래스에서 __contains__를 오버라이드하면 if x in y 구문을 지원할 수 있으며 __iter__를 오버라이드하여 for x in y 구문을 지원할 수 있다). 이 덕분에 서술문을 보편적이고 다형성 있게 유지할 수 있다.

개선: for key in d: print key     # 임의의 문자열에도 작동한다
나쁨:  for key in d.keys(): print key # 객체가 keys()를 가져야 하는 제한이 있다
Better: if key not in d: d[key] = []
Worse:  if not dict.has_key(key): d[key] = []
		

주의: 사전을 수정하고 싶으면 여전히 d.keys()를 사용할 필요가 있다. for key in d: del d[key] 는 다음 RuntimeError: dictionary changed size during iteration 에러를 야기한다. 대신에 for key in d.keys(): del d[key] 를 사용하자.

객체가 특정 유형이어야 한다면 강제형변환을 사용하자. x 문자열이어야만 코드가 작동한다면, isinstance(str, x) 같은 것을 사용하는 대신에 str(x)을 사용하는 것이 어떨까? 에러를 잡고 싶다면, try/except에 싸 넣으면 되고, 모든 가능성을 예측하고 싶은 경우라면, 이것이 훨씬 더 일반적인 해결책이 될 것이다.

if x == 0if x == "" 또는 if x == Noneif x == False 대신에 if not x을 사용하자; 마찬가지로, if x != 0if x != None 등등 대신에 if x을 사용하자. 예외의 경우가 있다면: 숫자에 대하여, 0은 거짓 값이므로, False를 돌려주는 것들과 0을 구별할 필요가 있다. 0으로 추정되는 부동 소수점 수와 int(0) 또는 float(0.0)를 비교할 때 주의하자: 반올림 에러 때문에 버그가 급격히 늘어날 수 있다.

문자열 모듈 말고 문자열 메쏘드를 사용하자. 예를 들어, startswith(s, 'abc') 대신에 s.startswith('abc')를 사용하자. 이렇게 하면 문자열 인터페이스의 일부만을 지원하는 다른 객체들을 사용할 수 있다 (예를 들어, 직접 작성한 것들): string 모듈 함수는 일반적으로 진짜 문자열을 예상한다. 몇몇 경우에는 import string을 사용할 필요가 있다: 예를 들어, maketrans는 여전히 문자열 모듈에서만 사용이 가능하다. 그렇지만, 일반적으로 저런 반입 서술문을 본다면 경고가 표시된다.

for line in infile.readlines() 대신에, for line in infile을 사용하자. readlinesxreadlines는 어쨌거나 파이썬 2.3에서부터 비추천이다. 새로운 반복자 프로토콜을 권장한다. for line in infile 버전을 사용하면 infile이 무엇이든 연속열 같이 행위하기만 하면 된다. 이는 검증에 아주 큰 도움이 될 수 있다. 실제로, 그냥 for line in lines을 사용하자: 기본적으로 줄이 파일에서 왔는지, 문자열 리스트에서 왔는지, 아니면 기타 다른 반복자인지, 사전의 키인지, 기타 무엇으로부터 왔든지 신경쓸 필요가 없다.

리스트를 역정렬하려면, 다음과 같이 하자:

list.sort()
list.reverse()
		

훨씬 더 읽기 쉽고, 게다가 꼼수적인 1-줄 대안책보다 더 빠르다. 제-자리 메쏘드인 sort()reverse()는 값을 돌려주지 않으니 주의하자. 이 때문에 당황할 수 있는데, 왜냐하면 sorted_list = orig_list.sort()와 같이 해 보면 sorted_listNone이고 이제 orig_list가 정렬되어 있기 때문이다. 그냥 역정렬된 리스트를 순회하고 싶을 뿐이라면 주의하자. (파이썬 2.5에서 부터) for i in reversed(sorted(orig_list))을 사용하면 된다.

무한 회돌이에는 'while 1:'을 사용하자. 즉, 회돌이 몸체를 적어도 한 번 실행하자. 이는 파이썬의 관용구이지만, 다른 사람들도 한 번쯤은 자신이 사용했던 언어에서 보았을 것이다. 예를 들어:

while 1:
    curr_line = reader.next()
    if not curr_line:
        break
    curr_line.process()
		

코드를 특수 사례로 어지럽히지 않기 위해 에러를 피하기 보다는 잡자. 이 관용구는 LBYL ('look before you leap(뛰기 전에 살펴보자)')에 대조하여 EAFP('easier to ask forgiveness than permission(허락보다 용서를 구하는 편이 더 쉽다)')라고 부른다. 이 덕분에 코드의 가독성이 더 좋아지는 경우가 많다. 예를 들어:

개악:
# int 형변환이 에러를 일으킬지 점검한다
if not isinstance(s, str) or not s.isdigit:
    return None
elif len(s) > 10:    # int 형변환에 너무 자리수가 많음
    return None
else:
    return int(str)

개선:
try:
    return int(str)
except (TypeError, ValueError, OverflowError): # int 형변환 실패함
    return None
		

(이 경우 주의할 것은 두 번째 버전이 더욱 더 좋은데 , 선두의 +와 - 뿐만 아니라 (32-비트 머신의) 2와 10조 사이의 값도 올바르게 처리하기 때문이다. 가능한 모든 실패 사례를 예측하려고 코드를 어지럽히지 말자: 그냥 시도해 보고 적절하게 예외 처리를 하자.)

적절한 에러만 잡자. 어느 에러를 잡고 싶은지 지정하지 않고서 catch를 사용하는 것은 대단히 위험하다. 왜냐하면 아무거나 다 잡아 버리기 때문이다. ZeroDivisionErrorValueError 같이, 특정한 종류의 에러가 예상된다면 그 에러만 일어날 거라는 가정하에 다른 어떤 것도 잡지 말자. 그렇지 않으면 메모리가 고갈될 수도 있으며, 또는 올바르지 못한 속성을 가진 또는 그 연산이 구현되지 않은 객체를 건넬 가능성이 있다. 이런 예측불가의 에러를 감춰버리면 디버깅이 아주 어려워진다. 특히 엉터리 에러 메시지를 인쇄한다면 말이다.

임시 변수를 사용하지 않고 값을 교환하기. 대신에, 묵시적인 터플 풀기를 사용하자. a, b = b, a를 사용하면 a와 b를 교환할 수 있다. 사실, 얼마든지 원하는 갯수만큼 이렇게 할 수 있다: ad, 등등에 짝지으려면 a, b, c, d = d, b, c, a.

리스트의 (또는 어떤 연속열이든지) 요소를 그의 인덱스로 얻으려면 zip을 사용하자:

			indices = xrange(maxint)    # 여기에서 한 번만 필요하다; 본인은 Utils.py에 정의해 두었다.
			for d, index in zip(data, indices):
			# 여기에서 d와 인덱스를 가지고 일을 한다
		

(파이썬 2.3에서는 enumerate(data)를 지원하는데, 이 함수는 연속열을 게으르게 평가하며, 이 덕분에 이 관용구가 대체로 불필요하게 되었다. 그렇지만, 여러 리스트와 함께 인덱스를 포함시키고 싶을 경우에는 여전히 쓸모가 있다. 예를 들어, zip(list_1, list_2, indices)와 같이 말이다. 64-비트 시스템에서는 실패할 수도 있다.)

인덱스가 필요없다면, 그냥 다음과 같이 하자:

for i in items:
    something(i)

...다음은 추천하지 않는다:

for index in range(len(items)):
    something(items[index])
		

(타자수가 더 많이 들고, 더 못생겼고, 더 느리다.)

어떤 테크닉을 사용해야 코드를 더 빠르게 만들까?

언제나 윤곽잡기를 먼저 하고 나서 속도를 위한 최적화를 하자. 제일 먼저 가독성을 위한 최적화를 하자: '최적화된' 코드를 읽는 것보다 읽기 쉬운 코드를 조율하는게 더 쉽다. 특히 그 최적화가 효과적이지 못하다면 말이다. 코드를 읽기 힘들게 만드는 테크닉을 사용하기 전에, 내장 profile.py 스크립트로 어플리케이션을 실행하여 정말로 병목인지 점검해야 한다. 프로그램이 특정 메쏘드를 실행하는 데 실행시간의 10%를 소모한다면, 그의 속도를 10배나 증가시키더라도 총 실행시간의 9%를 절약할 뿐이다.

가능하면 항상 좋은 알고리즘을 사용하자. 위의 규칙이 적용되지 않는 경우는 대안 알고리즘들의 시간 복잡도에 큰 차이가 있을 경우이다. 제곱시간에서 선형시간으로, 또는 지수시간에서 폴리노미얼 시간으로 실행시간이 감소한다면 데이터 세트가 언제나 작을 것이라고 (20개 요소 이하라고) 확신하지 않는 한 언제나 시도해 볼 가치가 있다.

작동 할 만큼만 가장 단순하게 선택하자. 한 문자열이 특정한 하부문자열로 시작하는지 그저 알고 싶을 뿐이라면 정규 표현식을 사용하지 말자: 대신 .startswith을 사용하자. 그저 한 문자열에 특정 문자가 포함되어 있는지 알고 싶을 뿐이라면 .index를 사용하지 말자: 대신 in을 사용하자. 그냥 문자열 리스트를 사용해도 된다면 StringIO를 사용하지 말자. 일반적으로, 단순할 수록 버그가 줄어들고 코드는 더욱 읽기 쉬워진다. .index 호출을 복잡하게 조합할지라도 정규 표현식보다 훨씬 더 빠르며, 그 결과를 나포하는 것보다 그냥 일치시키는 편이 훨씬 이해하기 쉬울 것이다.

리스트로 문자열을 구축하고 끝에 ''.join을 사용하자. 물론, 이미 이를 위의 "파이썬 관용구"에서 보셨지만, 너무나 중요한 것이라서 다시 한 번 언급해야겠다고 생각했다. join은 문자열 메쏘드로서 리스트가 아니라 가름자(separator)에 요청된다. 이를 빈 문자열에서 호출하면 가름자 없이 조각들을 결합해주는데, 이는 파이썬의 버릇으로서 처음 보면 약간 놀랍다. 이것이 중요하다: +로 문자열을 구축하면 선형시간이 아니라 제곱 시간이 걸린다!

잘못: 
for s in strings: result += s
옳음: 
result = ''.join(strings)
		

가능하면 객체 신분에 대하여 테스트하자: 다시 말해 if x != None보다는 if x is not None이 더 좋다. 동등성보다 신분으로 객체를 테스트하는 것이 좀 더 효율적이다. 왜냐하면 신분은 실제 데이터가 아니라 메모리상의 주소만 점검하기 때문이다 (물리적 위치가 같은 객체이기만 하면 두 객체는 동일하다).

검색에는 리스트가 아니라 사전(또는 집합)을 사용하자. 두 리스트 사이에 공통 요소가 있는지 알아보려면, 첫 리스트를 사전에 넣고 그 안에서 두 번째 리스트 안의 원소들을 찾아 보자. 리스트에서 요소를 찾는 것은 선형-시간이지만, 반면에 사전이나 집합에서 요소를 찾는 것은 상수 시간이다. 이것으로 종종 검색 시간을 제곱 시간에서 선형 시간으로 줄일 수있다.

가능하면 내장 sort를 사용하자. sort는 맞춤 비교 함수를 매개변수로 취할 수 있지만, 이 때문에 아주 느릴 수 있는데 함수가 내부 회돌이에서 적어도 O(n log n) 시간 호출되어야 하기 때문이다. 시간을 절약하려면, 항목 리스트를 터플 리스트로 바꾸자. 여기에서 각 터플의 첫 요소는 각 항목에 대하여 그 함수로 미리 계산된 값이며 (예를 들어, 필드를 추출), 마지막 요소는 그 항목 자체이다.

이 관용구를 DSU('decorate-sort-undecorate')라고 부른다. 'decorate' 단계에서, (transformed_value, second_key, ... , original value)가 담긴 터플 리스트를 만든다. 'sort' 단계에서, 그 터플에 내장 sort를 사용하자. 'undecorate' 단계에서, 각 터플로부터 마지막 요소를 추출하여 원래 리스트를 정렬된 순서로 열람하자. 예를 들어:

aux_list = [i.Count, i.Name, ... i) for i in items]
aux_list.sort()    # Count로 정렬한 다음, Name으로 정렬, ... , 다음 요소 그 자체로 정렬한다
sorted_list = [i[-1] for i in items] # 마지막 요소를 추출한다
		

최신 버전의 파이썬에서는 종종 DSU가 불필요하다. 다음 페이지에 파이썬에서의 다양한 정렬 테크닉에 관하여 연구가 잘 되어 있다.

함수를 리스트에 적용하려면 mapfilter를 사용하자. map은 함수를 리스트의 각 요소에 (기술적으로, 연속열에) 적용해서 그 결과를 리스트로 돌려준다. filter는 함수를 연속열의 각 요소에 적용해서, (__nonzero__ 내장 메쏘드를 사용하여) True로 평가된 요소들만을 모아서 리스트로 돌려준다. 이 함수들로 코드를 많이 줄일 수 있다. 또한 더 빠르게 만들 수 있는데, 회돌이가 전적으로 C API 안에서 일어나며 회돌이 변수를 파이썬 객체에 절대로 엮지 않기 때문이다.

개악:
strings = []
for d in data:
    strings.append(str(d))

개선:
strings = map(str, data)
		

조건이 부가되어 있거나 함수가 메쏘드일 경우 리스트통합을 사용하자. 아니면 하나 이상의 매개변수를 취하자. 이런 사례에서는 mapfilter가 제대로 일을 하지 못하는데, 원하는 연산을 해주는 인자-한개짜리 함수를 새로 만들어야 하기 때문이다. 이 때문에 더욱 더 느려지는데, 파이썬 수준에서 더 많은 일이 수행되어야 하기 때문이다. 리스트 통합은 놀랍도록 읽기가 좋은 때가 많다.

개악:
result = []
for d in data:
    if d.Count > 4:
        result.append[3*d.Count]

개선:
result = [3*d.Count for d in data if d.Count > 4]
		

똑같은 리스트 통합을 반복적으로 하고 있다면, 유틸리티 함수들을 만들어서 map이나 filter를 사용하자:

def triple(x):
    """3 * x.Count를 돌려준다: .Count가 없으면 AttributeError를 일으킨다."""
    return 3 * x.Count

def check_count(x):
    """x.Count가 존재하고 3보다 크면 1을 돌려준다. 그렇지 않으면 0을 돌려준다."""
    try:
        return x.Count > 3
    except:
        return 0

result = map(triple, filter(check_count, data))
		

유틸리티 함수를 만들려면 함수 공장을 사용하자. 가끔, 특히 mapfilter를 아주 많이 사용하고 있다면, 다른 함수나 메쏘드가 단 한개의 매개변수를 취하도록 변환해주는 유틸리티 함수가 필요할 것이다. 특히나, 데이터를 그 함수에 한 번 묶고 난 다음, 다양한 객체들에다 반복적으로 적용하고 싶은 경우가 있을 것이다. 위의 예제에서는 한 객체의 특정 변수를 3배로 만들어주는 함수가 필요했다. 그러나 정말 필요한 것은 어떤 변수 이름이든 값을 돌려주고 그 가족에 곱셈 함수를 붙여주는 함수 공장이다:

def multiply_by_field(fieldname, multiplier):
    """ 필드 "fieldname"에 multiplier를 곱하는 함수를 돌려준다."""
    def multiplier(x):
        return getattr(x, fieldname) * multiplier
    return multiplier

triple = multiply_by_field('Count', 3)
quadruple = multiply_by_field('Count', 4)
halve_sum = multiply_by_field('Sum', 0.5)
		

이는 함수를 만들어내는 아주 강력하고 일반적인 테크닉이다. 예를 들어 지정된 필드에서 단어 리스트를 검색하거나, 한 특정 객체의 여러 필드에 여러 조치를 수행하는 등등의 테크닉에 사용된다. 매우 비슷한 일을 하는 자잘한 함수들을 수 없이 작성하는 일은 고통스럽다. 그러나 함수 공장으로 만들어내면 아주 쉬운 일이다.

합이나 곱 등등을 얻으려면 operator 모듈과 reduce를 사용하자. reduce는 함수 하나와 연속열 하나를 취한다. 먼저 그 함수를 첫 두 요소에 적용한 다음, 그 결과를 취하고 그 결과와 다음 요소에 그 함수를 적용하고, 또 그 결과를 취해서 그 결과와 다음 요소에 그 함수를 또 적용하고, 등등 리스트의 끝에 도달할 때까지 계속된다. 이 덕분에 리스트를 (또는 실제로 어떤 연속열도) 따라서 요소들을 축적하기가 아주 쉬워진다. 파이썬 2.3에는 (숫자 전용의) 내장 sum() 함수가 있음을 주의하자. 이 때문에 그 필요성이 예전만 못하다.

개악:
sum = 0
for d in data:
    sum += d
product = 1
for d in data:
    product *= d

개선:
from operator import add, mul
sum = reduce(add, data)
product = reduce(mul, data)
		

필드를 이름에 짝지으려면 zipdict를 사용하자. zip은 한 쌍의 연속열을 터플 리스트로 바꿔준다. 안에는 각 연속열의 첫째값, 둘째값 등등의 값이 담긴다. 예를 들어, zip('abc', [1,2,3]) == [('a',1),('b',2),('c',3)]. 이를 이용하면 이름에 짝짓고 싶은 필드가 순서대로 되어 있을 때 타자수를 절감할 수 있다:

나쁨:
fields = '|'.split(line)
gi = fields[0]
accession = fields[1]
description = fields[2]
#etc.
lookup = {}
lookup['GI'] = gi
lookup['Accession'] = accession
lookup['Description'] = description
#etc.

좋음:
fieldnames = ['GI', 'Accession', 'Description'] #etc.
fields = '|'.split(line)
lookup = dict(zip(fieldnames, fields))

최상:
def FieldWrapper(fieldnames, delimiter, constructor=dict):
    """한 줄을 갈라서 그것을 객체로 싸 넣어주는 함수를 돌려준다.

    필드 이름은 키워드 인자로 건네지므로,
    구성자는 그대로 건네질 것으로 예측한다.
    """
    def FieldsToObject(line):
        fields = [field.strip() for field in line.split(delimiter)]
        result = constructor(**dict(zip(fieldnames, fields)))
    return FieldsToObject

FastaFactory = FieldWrapper(['GI','Accession','Description'], '|', Fasta)
TaxonFactory = FieldWrapper(['TaxonID', 'ParentID', ...], '|', Taxon)
CodonFreqFactory = FieldWrapper(['UUU', 'UUC', 'UUA',...], ' ', CodonFreq)
#등등 싸 넣고 싶은 데이터베이스 테이블을 포함하여, 비슷한 데이터에 적용.