[번역] Mistakes You Make When Declaring Functions in Python

woodonggyu·2021년 7월 12일
0

원문 : Top 5 Mistakes You Make When Declaring Functions in Python

함수는 모든 프로그래밍 프로젝트에서 중요한 구성 요소이다.

올바르게 사용했다면, 읽기 쉽고 유지 관리가 가능한 코드를 작성하는 실용적인 방법이다.
그러나 함수가 올바르게 선언되지 않은 경우 가독성이 떨어지고 유지 보수 비용이 증가한다.

"Readability counts. (가독성이 중요하다.)" — The Zen of Python


Improper Function Names (부적절한 함수 명)

함수명은 유일성(unique), 명시성(informative), 일관성(consistent) 세 가지 규칙을 지켜야 한다.


Unique (유일성)

매우 간단한 요구 사항이다. 파이썬의 모든 객체와 마찬가지로, 함수 명도 이름을 통해 구별한다.

같은 이름의 함수를 선언하면 IDE(PyCharm, Visual Studio Code 등)가 알려주거나, 혹은 먼저 정의된 함수가 무시된다.

>>> # define a function
>>> def say_hello():
...     print('say hello, first function')
...
>>> # define a function having the same name
>>> def say_hello():
...     print('say hello, second function')
... 
>>> say_hello()
say hello, second function

Informative (명시성)

함수의 역할은 특정 작업을 수행하도록 작성되었으므로, 함수 명에 수행할 작업이 드러나야 한다.

그렇지 않을 경우, 다른 사람의 프로그램이나 지난 달에 작성한 내 코드를 이해하는 데 어려움을 겪을 수 있다.

정보가 된다는 것은 기능의 의도된 목적에 대해 구체적이고 정확하다는 것을 의미한다.

>>> # too generic, unspecific 
>>> def do_something():
...     print("do something")
... 
>>> # vs. a more specific name
>>> def say_hi():
...     print("say hi")
... 
>>> # not accurately describe its function
>>> def process_numbers(number1, number2):
...     result = number1 * number2
...     return result
... 
>>> # vs. a more accurate description
>>> def multiply_numbers(number1, number2):
...     result = number1 * number2
...     return result

Consistent (일관성)

파이썬 프로그래밍은 모듈화를 권장한다.

즉, 관련된 클래스와 함수는 특정 모듈에서 그룹화하는 것이 좋다는 의미이다. 모듈 내에서 또는 모듈 간에 함수의 이름들은 일관성을 가져야 한다.

여기서 말하는 일관성이란, 객체들과 함수들의 이름을 동일한 규칙을 두고 따르는 것을 의미한다.

>>> # functions performing similar operations
>>> def multiply_numbers(number1, number2):
...     return number1 * number2
... 
>>> def add_numbers(number1, number2):
...     return number1 + number2
... 
>>> def divide_numbers(number1, number2):
...     return number1 / number2
... 
>>> # define a custom class
>>> class Mask:
...     def __init__(self, price):
...         self.price = price
...
...     # two functions returning two kinds of prices
...     def promotion_price(self):
...         return self.price * 0.9
...
...     def sales_price(self):
...         return self.price * 0.75
...

Mixed Duties and Excessive Length (여러 작업 및 함수 코드 길이 초과)

특정 작업을 수행하는 함수 내에 너무 많은 작업이 포함되어 있으면 안된다.

이는 일부 상급 프로그래머도 프로그램을 지속적으로 리팩토링하지 않으면 때때로 저지를 수 있는 실수이다.

잘 만들어진 함수는 잘 정의된 하나의 동작만을 수행하는 함수이다.

"각 함수는 정확한 한 가지 동작만을 수행하는 것이 좋다. 리팩토링을 통해 코드 전체의 가독성을 높일 수 있다."

여러 작업을 수행하는 함수의 또 다른 문제는 코드의 길이가 지나치게 길어진다. 그렇기에 버그가 발생할 경우, 기능에 대해 이해하기도 어려울 뿐더러 디버깅도 어렵다.


아래 예제 코드는 pandas 모듈을 이용해 생리학 실험 데이터를 처리하는 코드이다.

문제점은 process_physio_data() 함수에서 3개의 데이터 처리를 모두 수행하고 있어, 함수의 길이가 100줄이 넘어갈 것이다.

>>> import pandas as pd
>>> 
>>> def process_physio_data(subject_id):
...     # first step, read the related files
...     df0 = pd.read_csv(f'{subject_id}_v1.csv')
...     df1 = pd.read_csv(f'{subject_id}_v2.csv')
...     df2 = pd.read_csv(f'{subject_id}_v3.csv')
...     df3 = pd.read_csv(f'{subject_id}_v4.csv')
...     # the end of first step
...
...     # second, some clean up procedures
...     # 
...     # process these four DataFrames
...     # 50 lines of code here
...     # generate a big DataFrame
...     #
...     # the end of the second step
...     big_df = pd.DataFrame()
...
...     # third, some complex calculations
...     #
...     # process the big DataFrames
...     # 50 lines of code here
...     # generate a small DataFrame
...     #
...     # the end of the third step
...     small_df = pd.DataFrame()
...
...     return small_df
...

위와 같은 경우 각 처리를 맡은 함수를 따로 작성하여 데이터 처리과정을 돋보이게 할 수 있다.

>>> import pandas as pd
>>> 
>>> # the helper function that reads the data
>>> def read_physio_data(subject_id):
...     df0 = pd.read_csv(f'{subject_id}_v1.csv')
...     df1 = pd.read_csv(f'{subject_id}_v2.csv')
...     df2 = pd.read_csv(f'{subject_id}_v3.csv')
...     df3 = pd.read_csv(f'{subject_id}_v4.csv')
...     return [df0, df1, df2, df3]
... 
>>> # the helper function that cleans up the data
>>> def clean_physio_data(dfs):
...     # all the 50 lines of code for data clean up
...     big_df = pd.DataFrame()
...     return big_df
... 
>>> # the helper function that calculates the data
>>> def calculate_physio_data(df):
...     # all the 50 lines of code for data calculation
...     small_df = pd.DataFrame()
...     return small_df
...
>>> # updated function
>>> def process_physio_data(subject_id):
...     # first step, reading
...     dfs = read_physio_data(subject_id)
...     # second step, cleaning
...     big_df = clean_physio_data(dfs)
...     # third step, calculation
...     small_df = calculate_physio_data(big_df)
...     
...     return small_df
...

No Documentation (문서가 없는 함수)

문서화는 프로그래머가 긴 경험을 통해 배워야하는 작업이다. 겉으로 보기에는 아무런 설명서가 없어도 코드가 원하는 동작을 수행하기에 문제가 없어보인다.

예를 들어, 우리가 몇 주 동안 계속해서 단일 프로젝트 작업할 경우에는 각 기능별 작업에 대해 정확히 알고 있다. 그러나 시간이 지나 코드를 수정해야할 경우, 코드를 다시 이해하기 위해서 얼마나 많은 시간을 소요할 것인가?

API 를 공유하는 팀 업무 환경이나 오픈소스 라이브러리를 만드는 경우라면, 문서화는 더욱 더 중요해진다.

문서화가 잘 되어있다면 다른 사람이 만든 복잡한 함수에 대해서도 "어떻게 호출하며, 반환 값은 무엇인지"에 대해 빠르게 파악할 수 있다.

함수에 대해 장황하게 Docstring을 달아야 한다는 뜻이 아니다. 앞서 말한 함수의 세 가지 규칙(유일성, 명시성, 일관성)을 잘 따르고, 하나의 작업만을 수행하는 함수를 작성했다면 문서는 간단히 작성해도 된다.


Incorrect Use of Default Values (기본 값을 잘못 사용한 함수)

파이썬에서는 함수의 인자에 기본 값을 설정할 수 있다. 많은 내장함수도 이 기능을 사용한다.


예시를 먼저 살펴보자.

range() 함수를 이용해 리스트 객체를 만들 수 있으며, 기본 문법은 range(start, end, step)이다. step은 생략하면 기본 값은 1을 가지게 되지만, 아래와 같이 명시할 수 있다.

>>> # range function using the default step 1
>>> list(range(5, 15))
[5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
>>> # explicitly set the step to be 2
>>> list(range(5, 15, 2))
[5, 7, 9, 11, 13]

만약 함수 인자의 기본 값에 뮤터블 자료형을 넣으면 복잡해진다.

※ 뮤터블(mutable) 자료형
자료형이 값에 대해 추가, 수정, 삭제가 불가능한 특징을 이뮤터블(immutable)이라 하고, 반대로 값에 대해 추가, 수정, 삭제가 가능할 경우 뮤터블(mutable) 하다고 한다.**


예제 코드를 살펴보자.

첫 번째와 두 번째 동작을 살펴보면 예상한대로 결과가 출력된다. 하지만 세 번째 케이스는 이상하다.

빈 리스트에 94 를 추가했기에 [94] 가 나와야 정상인데, 결과 값은 [98, 94] 가 출력되었다.

>>> # define a function invovling the default value for a list
>>> def append_score(score, scores=[]):
...     scores.append(score)
...     print(scores)
... 
>>> append_score(98)
[98]
>>> append_score(92, [100, 95])
[100, 95, 92]
>>> append_score(94)
[98, 94]

이러한 이유는 파이썬에서 함수는 일급 객체(first-class citizen)이자 일반 객체이기 때문이다.

즉, 함수 객체는 함수가 정의될 때 생성되는데 함수의 기본 인자 값도 이 때 같이 생성된다.


기본 값이 뮤터블인 함수 인자를 추적하는 예제코드를 살펴보자.

__default__ 속성을 통해 함수의 기본 인자 값과 주소를 알 수 있다. 이후 함수를 두 번 호출했는데 같은 메모리 주소를 갖는 리스트(scores)가 사용되는 것을 볼 수 있다.

>>> # updated function to show the id for the scores
>>> def append_score(score, scores=[]):
...     scores.append(score)
...     print(f'scores: {scores} & id: {id(scores)}')
... 
>>> append_score.__defaults__
([],)
>>> id(append_score.__defaults__[0])
4650019968
>>> append_score(95)
scores: [95] & id: 4650019968
>>> append_score(98)
scores: [95, 98] & id: 4650019968

이러한 실수를 피하기 위해서는 뮤터블 자료형의 기본 값으로 None 을 사용하면 된다. 기본 값이 None인 인자에 대해서는 미리 객체를 만들어두지 않기 때문이다.

대신 함수 호출 시, scores 값을 생략했다면 함수 내부에서 만들어주어야 한다.

>>> # use None as the default value
>>> def append_score(score, scores=None):
...     if not scores:
...         scores = []
...     scores.append(score)
...     print(scores)
... 
>>> append_score(98)
[98]
>>> append_score(92, [100, 95])
[100, 95, 92]
>>> append_score(94)
[94]

Abuse of *args & **kwargs (가변 인자 남용)

파이썬에서는 함수의 입력 인자의 개수를 가변적으로 선언할 수 있다. 사용하는 라이브러리의 문서를 한번이라도 봤다면 *args, **kwargs 라는 표현을 본 적이 있을 것이다.

*args 는 위치 인자(positional argument)의 개수가 정해지지 않았음을 뜻하고, **kwargs 는 키워드 인자(keyword argument)의 개수가 정해지지 않았음을 뜻한다.

위치 인자는 인자의 위치(순서)를 통해 인식되는 인자이며, 키워드 인자는 키워드를 통해 인식되는 인자이다.


예제 코드는 아래와 같다.

add_numbers() 함수에서 num0, num1 은 위치 인자이고, num2, num3 는 키워드 인자이다.
키워드 인자 간에는 순서를 바꿔도 무관하지만, 키워드 인자와 위치 인자 간에는 순서를 바꿀 수 없다.

>>> # a function involving both positional and keyword arguments
>>> def add_numbers(num0, num1, num2=2, num3=3):
...     outcome = num0 + num1 + num2 + num3
...     print(f"num0={num0}, num1={num1}, num2={num2}, num3={num3}")
...     return outcome
... 
>>> add_numbers(0, 1)
num0=0, num1=1, num2=2, num3=3
6
>>> add_numbers(0, 1, num3=4, num2=5)
num0=0, num1=1, num2=5, num3=4
10

*args, **kwargs 는 다음과 같은 자료형으로 변환된다.

  • 가변 개수의 위치 인자(*args)는 튜플로 변환되어 전달된다.
  • 가변 개수의 키워드 인자(**kwargs)는 딕셔너리로 변환되어 전달된다.
>>> # function with *args
>>> def show_numbers(*numbers):
...     print(f'type: {type(numbers)}')
...     print(f'list from *args: {numbers}')
... 
>>> show_numbers(1, 2, 3)
type: <class 'tuple'>
list from *args: (1, 2, 3)
>>> 
>>> # function with **kwargs
>>> def show_scores(**scores):
...     print(f'type: {type(scores)}')
...     print(f'list from **kwargs: {scores}')
... 
>>> show_scores(a=1, b=2, c=3)
type: <class 'dict'>
list from **kwargs: {'a': 1, 'b': 2, 'c': 3}

*args, **kwargs 키워드가 함수 작성을 유연하게 만들어주는 것은 맞지만, 이를 너무 남용하면 함수 사용이 어려워진다.

아래 예시에서는 pandas 모듈의 read_csv 함수에서는 총 49개의 인자를 받는다.

pandas.read_csv(filepath_or_buffer: Union[str, pathlib.Path, IO[~AnyStr]], sep=',', delimiter=None, header='infer', names=None, index_col=None, usecols=None, squeeze=False, prefix=None, mangle_dupe_cols=True, dtype=None, engine=None, converters=None, true_values=None, false_values=None, skipinitialspace=False, skiprows=None, skipfooter=0, nrows=None, na_values=None, keep_default_na=True, na_filter=True, verbose=False, skip_blank_lines=True, parse_dates=False, infer_datetime_format=False, keep_date_col=False, date_parser=None, dayfirst=False, cache_dates=True, iterator=False, chunksize=None, compression='infer', thousands=None, decimal: str = '.', lineterminator=None, quotechar='"', quoting=0, doublequote=True, escapechar=None, comment=None, encoding=None, dialect=None, error_bad_lines=True, warn_bad_lines=True, delim_whitespace=False, low_memory=True, memory_map=False, float_precision=None)

첫 번째가 위치 인자이고, 나머지 48개는 키워드 인자이기에 다음과 같이 선언할 수 있다.

pandas.read_csv(filepath_or_buffer: Union[str, pathlib.Path, IO[~AnyStr]], **kwargs)

하지만 위와 같이 만든 함수는 함수 내부에서 **kwargs 언패킹 뿐만 아니라, 호출할 때 어떤 키워드를 써야 하는지 미리 알기 어렵다.

소위 전문가라는 사람이 왜 무시무시한 길이의 함수를 만드는 걸까? 이유는 바로 파이썬의 철학 때문이다.

"명시적인(explict) 것이 암묵적인(implict) 것보다 낫다" — The Zen of Python

*args, **kwargs 를 쓰면 함수 선언은 간결해질 수 있으나, 대신 코드의 명시성 저하를 감수해야 한다.

팀으로 진행하는 업무라면 코드는 명시적이어야 이해하기 쉽다. 따라서, *args, **kwargs 는 가급적 지양하자.

0개의 댓글