Item 19: 3개보다 많은 return value를 unpack 하지 말 것
unpacking syntax의 효과 중 하나는 python의 함수가 여러 개의 값을 return 할 수 있게 해주는 것이다.
def get_stats(numbers):
minimum = min(numbers)
maximum = max(numbers)
return minimum, maximum
lengths = [63, 73, 72, 60, 67, 66, 71, 61, 72, 70]
minimum, maximum = get_stats(lengths) # Two return values
print(f'Min: {minimum}, Max: {maximum}')
>>>
Min: 60, Max: 73
위처럼 동작이 가능한 이유는, 튜플 형태로 여러 개의 값이 return 되기 때문이다.
마찬가지로 아래와 같이 starred expression에서도 잘 동작한다.
def get_avg_ratio(numbers):
average = sum(numbers) / len(numbers)
scaled = [x / average for x in numbers]
scaled.sort(reverse=True)
return scaled
longest, *middle, shortest = get_avg_ratio(lengths)
print(f'Longest: {longest:>4.0%}')
print(f'Shortest: {shortest:>4.0%}')
>>>
Longest: 108%
Shortest: 89%
만약 프로그램의 requirement가 변해서 추가적으로 악어의 average length, median length, and total population
size 가 필요하다고 하자. 이런 경우 get_stats 함수를 확장해서 다음과 같이 쓸 수 있다.
def get_stats(numbers):
minimum = min(numbers)
maximum = max(numbers)
count = len(numbers)
average = sum(numbers) / count
sorted_numbers = sorted(numbers)
middle = count // 2
if count % 2 == 0:
lower = sorted_numbers[middle - 1]
upper = sorted_numbers[middle]
median = (lower + upper) / 2
else:
median = sorted_numbers[middle]
return minimum, maximum, average, median, count
minimum, maximum, average, median, count = get_stats(lengths)
print(f'Min: {minimum}, Max: {maximum}')
print(f'Average: {average}, Median: {median}, Count {count}')
>>>
Min: 60, Max: 73
Average: 67.5, Median: 68.5, Count 10
하지만 위 코드의 get_stats 함수는 두 가지 문제점이 있다.
첫 번째, 모든 return 값이 numeric 이므로 실수로 변수의 순서를 잘못 정할수 있다.
아래의 예를 보자.
# Correct:
minimum, maximum, average, median, count = get_stats(lengths)
# Oops! Median and average swapped:
minimum, maximum, median, average, count = get_stats(lengths)
두 번째, 함수를 호출하고 값을 unpack하는 라인이 너무 길어서 가독성이 떨어진다.
따라서 이러한 문제들을 피하기 위해서, 함수에서 return하는 여러개의 값을 unpack할 때 3개보다 많은 value를 쓰지 않도록 한다.
Item 20: None을 return 하는 것보다 Exception을 일으키는 것을 선호하라
파이썬에서는 특별한 경우 None을 return 할 때가 많다.
def careful_divide(a, b):
try:
return a / b
except ZeroDivisionError:
return None
x, y = 1, 0
result = careful_divide(x, y)
if result is None:
print('Invalid inputs')
위의 코드처럼 분모에 0이 들어가면 Invalid inputs을 출력하도록 프로그램을 설계할 수 있다.
하지만 분자가 0인 경우, 아래와 같은 문제가 발생할 수 있다.
x, y = 0, 5
result = careful_divide(x, y)
if not result:
print('Invalid inputs') # This runs! But shouldn't
>>>
Invalid inputs
위의 경우, result는 0이 되고 Invalid inputs가 출력된다. 위의 if 문의 조건처럼 result를 evaluate하게 되면 이런 logical error 가 발생하게 된다. 이런 None을 특별한 의미로 사용했을 때 이처럼 false-equivalent한 return value를 잘못 해석하는 문제가 발생할 수 있다. 이런 error를 줄이기 위해서는 두 가지 방법이 사용된다.
첫 번째는, return value를 two-tuple(값이 두 개인 tuple)로 쪼개는 것이다. 그래서 tuple의 첫번째 값은 operation이 성공했는지 실패했는지를 의미하고, 두 번째는 실제 result를 가지게 된다.
def careful_divide(a, b):
try:
return True, a / b
except ZeroDivisionError:
return False, None
success, result = careful_divide(x, y)
if not success:
print('Invalid inputs')
하지만 아래와 같이 underscore (_) 를 이용해서 성공여부를 무시하는 등 프로그래머가 쉽게 무시할 수도 있으므로 그리 바람직한 방법은 아니다.
_, result = careful_divide(x, y)
if not result:
print('Invalid inputs')
두 번째, 절대로 특별한 case에 None을 return하지 않는 것이다.
대신, Exception을 발생시켜 caller가 이를 처리하도록 한다.
def careful_divide(a, b):
try:
return a / b
except ZeroDivisionError as e:
raise ValueError('Invalid inputs')
x, y = 5, 2
try:
result = careful_divide(x, y)
except ValueError:
print('Invalid inputs')
else:
print('Result is %.1f' % result)
>>>
Result is 2.5
더 나아가서, type annotation을 이용해 return value가 None이 아니라 항상 float이 되도록 할 수도 있다. 하지만 파이썬의 gradual typing에서는 함수의 인터페이스에 예외가 포함되는지를 표현할 수 없기 때문에, 해당 예외 케이스들을 Docstring을 이용해서 명시해야 한다.
def careful_divide(a: float, b: float) -> float:
"""Divides a by b.
Raises:
ValueError: When the inputs cannot be divided.
"""
try:
return a / b
except ZeroDivisionError as e:
raise ValueError('Invalid inputs')
Item 21: 변수의 scope와 Closures의 상호작용 방식을 이해할 것
만약 number list를 sorting 하고 싶은데, 몇 숫자들은 가장 앞쪽에 배치하고 싶을 때가 있다.
이럴 경우 보통 sort 메소드의 key에 helper function을 전달한다.
def sort_priority(values, group):
def helper(x):
if x in group:
return (0, x)
return (1, x)
values.sort(key=helper)
numbers = [8, 3, 1, 2, 5, 4, 7, 6]
group = {2, 3, 5, 7}
sort_priority(numbers, group)
print(numbers)
>>>
[2, 3, 5, 7, 1, 4, 6, 8]
위의 함수가 정상적으로 작동하는 3가지 이유가 있다.
- 파이썬이 closure를 지원함 : closure란 자신이 정의된 영역 밖의 변수를 참조하는 함수. closure 덕분에 helper function이 sort_priority 함수의 group 인자에 접근 가능
- 파이썬에서는 function이 first-class 객체(일급객체) 임 : first-class object는 직접 가리킬 수 있고, 변수에 대입할 수도 있고, 다른 함수에 parameter로 전달 할 수 있고, expression과 if문에서 함수를 비교하거나 function에서 return하는 것도 가능. 따라서 sort method가 closure function을 key로 받을 수 있음
- 파이썬에서 sequence를 비교할 때는 구체적인 규칙을 따름 : 먼저, index 0부터 비교, 같으면 index 1 비교, 같으면 다음 index로 넘어가는 식의 비교를 함. 따라서 helper closure가 return하는 값이 두 그룹을 sorting하는 기준 역할을 함.
위의 코드를 수정해서, 우선순위가 높은 원소가 있는지 확인하는 코드를 생각해보자.
def sort_priority2(numbers, group):
found = False
def helper(x):
if x in group:
found = True # Seems simple
return (0, x)
return (1, x)
numbers.sort(key=helper)
return found
found = sort_priority2(numbers, group)
print('Found:', found)
print(numbers)
>>>
Found: False
[2, 3, 5, 7, 1, 4, 6, 8]
정렬은 잘 됐으나, found의 결과가 False가 나왔다. 왜 그럴까?
변수를 reference 할 때 아래와 같은 순서로 traverse한다.
1. 현재 함수의 영역
2. 현재 함수를 둘러싸고 있는 영역
3. 모듈의 영역(global scope라고도 부름)
4. 빌트인 영역(len, str 과 같은 함수가 있는 영역)
그마저도 없다면 NameError가 발생한다.
다만, 변수에 값을 대입하는 건 다른 방식으로 작동한다. 변수가 현재 scope에 이미 정의돼 있으면 변수의 값만 새로운 값으로 바꾸고, 정의돼 있지 않다면 변수 assignment를 변수 definition처럼 취급한다. 따라서 새로 정의된 변수의 scope는 해당 assignment가 들어있던 함수가 된다.
따라서 위의 코드의 found 변수는 helper function 내에서 새로 정의되고 True가 대입되는 것이지, sort_priority2 의 found 변수에 값을 대입하는 것으로 취급되는 것이 아니다. 이와 같은 현상을 scoping bug라고 부르기도 한다.
하지만 어떻게 보면 이게 맞다고 봐야한다. 함수에서 사용한 local variable이 이 function을 감싸고 있는 module scope내에서 영향을 미치면 안되기 때문이다.
그래서 파이썬에서는 closure 밖으로 특정 데이터를 끌어내는 특별한 구문이 있다. nonlocal 이라는 키워드이다.
nonlocal문이 지정된 변수에 대해서는 앞에서 정한 영역 결정 규칙을 따르지만, module 수준까지는 올라가지 않는다.
def sort_priority3(numbers, group):
found = False
def helper(x):
nonlocal found # Added
if x in group:
found = True
return (0, x)
return (1, x)
numbers.sort(key=helper)
return found
위와 같이 found를 nonlocal로 선언하면 sort_priority3 의 found 변수를 helper안에서 사용하게 된다.
하지만 필자는 위처럼 간단한 함수 외에는 nonlocal을 사용하지 말라고 경고한다.
또한 만약 nonlocal을 사용하는 방식이 복잡해지면 아래와 같이 class를 이용하는 것이 오히려 가독성이 좋다.
class Sorter:
def __init__(self, group):
self.group = group
self.found = False
def __call__(self, x):
if x in self.group:
self.found = True
return (0, x)
return (1, x)
sorter = Sorter(group)
numbers.sort(key=sorter)
assert sorter.found is True
Item 22: Variable Positional Arguments를 통해 Visual Noise를 줄여라
*args (이를 varargs, star args 등으로 부름)를 이용하면 argument를 몇 개든지 받아서 처리할 수 있다.
def log(message, *values): # The only difference
if not values:
print(message)
else:
values_str = ', '.join(str(x) for x in values)
print(f'{message}: {values_str}')
log('My numbers are', 1, 2)
log('Hi there')
>>>
My numbers are: 1, 2
Hi there
만약 list의 원소들을 *args 자리에 전달하고 싶다면 *를 통해 unpacking을 해주면 된다.
favorites = [7, 33, 99]
log('Favorite colors', *favorites)
>>>
Favorite colors: 7, 33, 99
위를 좀 더 자세히 설명하면, *favorites를 통해 favorites리스트가 7, 33, 99라는 value로 각각 unpacking되고 이들이 parameter 로 전달되는 것이다.
하지만 가변 인자를 사용하면 두 가지 문제점이 있다.
1. optional positional argument들이 함수에 전달되기 전에 항상 tuple로 변환되는 것이다.
def my_generator():
for i in range(10):
yield i
def my_func(*args):
print(args)
it = my_generator()
my_func(*it)
>>>
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
따라서 메모리를 아주 많이 소비해 프로그램이 죽을 수도 있다.
따라서 *args에서 받는 argument의 개수가 처리할 수 있을만큼 적어야 사용하기에 적합하다.
2. 함수에 새로운 positional argument를 추가하면 해당 함수를 호출하는 모든 코드를 변경해야 한다.
따라서, 만약 *args를 인자로 받는 function을 확장할 때는 keyword기반의 argument만을 사용해야 한다.
더 방어적으로 프로그래밍하기를 원한다면 type annotation을 사용하는 것도 좋다.
Item 23: Keyword Arguments를 통해 Optional behavior을 제공하라
def remainder(number, divisor):
return number % divisor
위와 같이 나머지를 구하는 함수가 있다고 하자.
my_kwargs = {
'number': 20,
'divisor': 7,
}
assert remainder(**my_kwargs) == 6
keyword argument를 이용하면 dictionary 형태로 인자를 전달할 수 있다.
def print_parameters(**kwargs):
for key, value in kwargs.items():
print(f'{key} = {value}')
print_parameters(alpha=1.5, beta=9, gamma=4)
>>>
alpha = 1.5
beta = 9
gamma = 4
위와 같이 key와 value 를 전달할 수도 있다.
이처럼 keyword arguments가 제공하는 flexibility는 3가지 이점이 있다.
1. keyword argument를 이용하면 코드를 처음 보는 이들에게 function call 의 의미를 명확하게 알려준다. 예를 들어 위의 remainder(20, 7) 만 보고는 어떤 argument가 나누는 값이고 나눠지는 값인지 알기 어렵다. 이 때, number=20, divisor=7을 보면 이를 명확히 이해하게 해준다.
2. keyword arguments 의 경우 함수 정의에서 default 값을 지정할 수 있다. 다음의 코드는 어떤 탱크에 흘러 들어가는 유체의 시간당 유입량을 계산하는 코드이다.
def flow_rate(weight_diff, time_diff):
return weight_diff / time_diff
weight_diff = 0.5
time_diff = 3
flow = flow_rate(weight_diff, time_diff)
print(f'{flow:.3} kg per second')
>>>
0.167 kg per second
전형적인 경우, 시간당 유입량이 kg/s 이다. 하지만 다른 단위로 유입량을 추정하고 싶을 수 있다.
def flow_rate(weight_diff, time_diff, period):
return (weight_diff / time_diff) * period
flow_per_second = flow_rate(weight_diff, time_diff, 1)
문제는 위와 같은 경우 함수 호출시에 매번 period를 지정해야 하므로 초당 유입량을 계산하는 일반적인 경우에도 이 값을 꼭 지정해줘야 한다는 것이다.
따라서 아래와 같이 default를 지정해주면 된다.
def flow_rate(weight_diff, time_diff, period=1):
return (weight_diff / time_diff) * period
flow_per_second = flow_rate(weight_diff, time_diff)
flow_per_hour = flow_rate(weight_diff, time_diff, period=3600)
하지만 간단한 default의 경우 잘 동작하지만, 복잡한 default값을 사용하는 경우 코드가 복잡해질 수도 있다.
3. 어떤 함수를 사용하던 기존 caller에게는 backward compatibility(하위 호환성)을 제공하면서, 함수의 파라미터를 확장할 수 있는 방법을 제공한다. 따라서 기존 코드를 별도로 migration 하지 않아도 기능을 추가할 수 있고, 이는 bug 가능성을 낮춰준다.
예를 들어, 위의 flow_rate 함수를 확장해 kg이 아닌 무게 단위를 사용해 시간당 유입량 계산을 하고싶다고 하자.
측정 단위의 conversion rate를 하기 위한 optional parameter를 추가하면 된다.
def flow_rate(weight_diff, time_diff, period=1, units_per_kg=1):
return ((weight_diff * units_per_kg) / time_diff) * period
위의 경우 units_per_kg의 default는 1로 하고, 반환되는 무게 단위를 kg으로 유지한다. 즉, 확장을 했지만 기존 호출 코드는 동작이 바뀌지 않음을 의미한다.
위와 같은 방식의 문제점은, keyword argument가 아니라 positional argument로 사용을 할 수 있어 혼동을 야기할 수 있다는 것이다.
pounds_per_hour = flow_rate(weight_diff, time_diff, 3600, 2.2)
즉, optional parameter를 지정하는 최선의 방법은 positional argument를 절대 사용하지 않는 것이다.
Item 24: Dynamic Default Arguments를 지정할 때는 None과 Docstrings를 사용하라
from time import sleep
from datetime import datetime
def log(message, when=datetime.now()):
print(f'{when}: {message}')
log('Hi there!')
sleep(0.1)
log('Hello again!')
>>>
2019-07-06 14:06:15.120124: Hi there!
2019-07-06 14:06:15.120124: Hello again!
위는 함수를 호출할 때의 시간을 찍는 코드이다. 하지만 예상과 달리 함수를 호출한 두 번 모두 찍힌 시각이 같다. 그 이유는 datetime.now() 함수가 log function이 정의되는 그 한 번만 실행되기 때문이다.
def log(message, when=None):
"""Log a message with a timestamp.
Args:
message: Message to print.
when: datetime of when the message occurred.
Defaults to the present time.
"""
if when is None:
when = datetime.now()
print(f'{when}: {message}')
전통적인 방법으로는 위처럼 when에 default값을 None으로 하고 함수 내에서 시간을 주는 것이다.
이렇게 default값을 None으로 설정하는 것은 argument가 mutable할 경우 특히나 중요하다.
Item 25: Keyword-Only 와 Positional-Only Arguments를 명확히 구분하라
def safe_division_e(numerator, denominator, /,
ndigits=10, *, # Changed
ignore_overflow=False,
ignore_zero_division=False):
try:
fraction = numerator / denominator # Changed
return round(fraction, ndigits) # Changed
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return float('inf')
else:
raise
위처럼 인자에 /는 positional only argument의 끝을 의미하고 *는 key-word only argument의 시작을 의미한다.
이처럼 두 종류의 argument를 명확히 구분하면 에러를 방지할 수 있다.
Item 26: functools.wraps를 통해 Function Decorator를 정의하라
def trace(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f'{func.__name__}({args!r}, {kwargs!r}) '
f'-> {result!r}')
return result
return wrapper
@trace
def fibonacci(n):
"""Return the n-th Fibonacci number"""
if n in (0, 1):
return n
return (fibonacci(n - 2) + fibonacci(n - 1))
fibonacci = trace(fibonacci)
fibonacci 함수가 시작되기 전과 후에 decorated 함수는 wrapper code를 실행시킨다.
fibonacci(4)
>>>
fibonacci((0,), {}) -> 0
fibonacci((1,), {}) -> 1
fibonacci((2,), {}) -> 1
fibonacci((1,), {}) -> 1
fibonacci((0,), {}) -> 0
fibonacci((1,), {}) -> 1
fibonacci((2,), {}) -> 1
fibonacci((3,), {}) -> 2
fibonacci((4,), {}) -> 3
하지만 위의 경우 decorator에 의해 반환된 값이 fobonacci가 아닌 결과를 초래한다.
이를 해결하기 위해서는 functools라는 빌트인 모듈안에 wraps를 사용한다.
from functools import wraps
def trace(func):
@wraps(func)
def wrapper(*args, **kwargs):
...
return wrapper
@trace
def fibonacci(n):
...
help(fibonacci)
>>>
Help on function fibonacci in module __main__:
fibonacci(n)
Return the n-th Fibonacci number
'Computer Science > Effective Python' 카테고리의 다른 글
Chapter 5. Classes and Interfaces (0) | 2023.01.13 |
---|---|
Chapter 4. Comprehensions and Generators (1) | 2023.01.11 |
Chapter 2. Lists and Dictionaries (0) | 2023.01.06 |
Chapter 1. Pythonic Thinking (0) | 2023.01.03 |