파이썬다운 함수 만들기

Heebeom·2023년 4월 23일
0

앞선 포스팅에서, 함수(funtion) 사용으로 코드의 중복을 막는 예를 보면서, 함수의 중요성을 살펴보았다. 이처럼 함수는 우리가 코드를 더 작은 조각으로 나누도록 도와주는 "프로그램 속 프로그램" 이라고 할 수 있다.

그렇다면 "좋은 함수"란 무엇일까? 일반적으로 "좋은 함수"란 함수의 이름, 크기, 파라미터, 복잡도가 잘 조화된 함수를 말하는데, 사실 이런 함수를 만드는 것은 쉽지 않다. 예를 들어, 함수의 이름은 너무 길지도, 짧지도 않아야 하는데 이 기준이 대체 뭘까?

오늘 포스팅에서, 각각의 요소에 대해 Trade-Off를 제시함으로써, 이러한 결정을 도와주고자 한다.

함수 이름

함수 이름에 수행 작업과 행위 대상을 명확하게 담자

함수는 동작을 수행하는 존재이기 때문에, 이름으로 동사를 사용한다. 하지만 통상적으로 함수는 동작의 행위 대상이 존재하기 때문에, 대상 또한 함수명에 명시해 함수의 작업과, 수행 대상을 명확히 해야 한다.

예를 들어, refresh_connection()이란 함수는 '수행 작업'과 '행위 대상'이 명확하게 들어나는 반면에, refresh()라는 함수는 수행 작업은 알지만 대체 무엇을 갱신한다는 것인지 모른다.

메소드는 행위 대상이 필요없다.

하지만, 클래스나 모듈의 메소드는 예외 대상이다. 메소드는 속해 있는 클래스나 모듈을 대상으로 동작한다는 것을 일반적으로 알고 있기 때문이다.

예를 들면, WebBrowser 모듈의 open()이라는 메소드는 누구나 'WebBrowser'를 대상으로 동작한다는 것을 알 수 있다.

너무 짧은 이름보다는, 길고 설명적인 이름이 좋다.

당신은 gcd()라는 함수의 의미를 알겠는가? 수학적 지식이 있다면 단번에 두 수의 최대 공약수를 반환하는 사실을 알겠지만, 대부분의 경우는 getGreatestCommonDenominator()라고 써야 이해하기 쉬울 것이다.

또한 이름이 너무 짧으면 파이썬의 내장 함수를 덧씌울 가능성이 있다. 만약 2개의 수에서 최댓값을 찾는 함수의 이름을 max()라고 짓는다면, 파이썬의 max() 내장함수를 덮어씌울 가능성이 있다.


함수 크기의 트레이드오프

일반적으로 긴 함수보다 짧은 함수가 낫다는 믿음이 있고, 최대한 작게 코드를 분할하는 것을 미덕으로 삼는 개발자들도 많다. 하지만 '짧은 함수'는 이해하기 쉽고 유지보수가 쉽다는 장점도 있지만, 함수 갯수가 너무 많아진다는 단점도 생긴다.

실제로 함수의 갯수가 많아지면 아래와 같은 여러 부수적인 단점이 생긴다.

  1. 각 함수별로 설명적이고, 정확한 이름을 붙이기 힘들다.
  2. 함수를 많이 사용할수록 작성할 Document의 양이 많아진다.
  3. 함수 간의 관계가 복잡해지고, 오히려 전체 코드 길이가 늘어날 수 있다.

1 ~ 2번은 알겠는데, 3번의 '코드 길이가 늘어날 수 있다'는 뭘까? 한번 예를 들어 알아보자.

def move_player(curt_loc): 
    while True:
    	print("명령을 입력하세요.")
    	command = input()
        
        if command == "QUIT":
        	sys.exit()
        
        if command not in ("L", "R"):
        	print("L, R 중 하나를 입력하세요.")
            continue
        
        if command == "L":
        	if curt_loc == 0:
            	return 0
        	return curt_loc - 1
        return curt_loc + 1

이 함수는 총 16행이다. 플레이어에게 이동 명령을 입력받고, 명령이 유효한지 확인 후 실제로 이동하는 작업을 하고 있다. 만약 이 함수를 더 작게 쪼갠다면 어떨까?

def move_player(curt_loc):
	while True:
    	command = ask_for_player_move()
        exit_if_quit(command)
        
        if is_vaild_command(command):
        	return exec_move_command(command)
        else:
        	continue

def ask_for_player_move():
	print("명령을 입력하세요.")
	return input()

def exit_if_quit(command):
	if command == "QUIT":
    	sys.exit()

def is_valid_command(command):
	if command not in ("L", "R"):
        print("L, R 중 하나를 입력하세요.")
        return False
    return True
    
def exec_move_command(command):
    if command == "L":
   		if curt_loc == 0:
        	return 0
        return curt_loc - 1
    return curt_loc + 1

분할한 코드를 보면, 총 30줄로 오히려 길이가 늘어났다. 또한 command 매개변수가 move_player() 안에서 함수에 인자로 전달되고, 반환되면서 함수를 나눴음에도 구조 또한 더욱 복잡해졌다.

이처럼, 적당히 짧은 함수는 이해하기 편하지만 너무 과하면 오히려 가독성이 떨어질 수 있다. 팀원과 상의하거나, 개인이 경험 상으로 만들어진 기준을 세워 함수의 크기를 관리하자. (책에서는 30줄 이하로 정의하였다.)


함수 파라미터와 인수

여러분들도 아시겠지만 함수의 파라미터가 많을수록 함수의 복잡도가 커진다. 함수의 복잡도가 커진다는 것은 가독성과 유지 보수성이 낮아지므로, 우리는 파라미터를 어느 정도로(대체로 0 ~ 3개) 제한할 필요가 있다.

기본 인수를 사용하면 파라미터를 줄일 수 있다.

기본 인수는 함수를 호출할 때 파라미터 값을 지정하지 않을 때 쓰는 기본값(default value)이다. 함수 호출의 대다수가 특정 파라미터 값을 사용한다면, 이 기본값을 써서 파라미터를 최적화할 수 있다.

예를 들어, 특정 name에게 인삿말을 하는 프로그램을 작성해 보자.

def gretting(name, gretting_word='Hello'):
	print(f'{gretting_word}, {name}')

gretting() 함수를 두 번째 인수 없이 호출하면 이 함수는 기본적으로 'Hello'라는 문자열을 사용한다. 이처럼 인수의 입력 자체를 없앨 수 있는데, 이 특성 때문에 기본 인수가 있는 파라미터는 맨 뒤에 위치해야 한다.

함수는 인수의 위치로 어떤 파라미터인지 판단한다.

사용자가 특별히 명시하지 않는다면, 대부분의 함수와 메소드는 사용자가 입력한 인수를 위치로 판단한다.

예를 들어, 당신이 'def some_func(a, b):' 로 함수를 정의했다면, 사용자가 첫 번째로 입력한 인수를 a, 두 번째를 b로 판단하는 것이다. 이것을 위치기반 인수(positional argument)라고 한다.

*와 **를 사용해 함수의 인수 전달하기

만약 리스트 원소 전부를 문자열 형태로 출력하고 싶을 때는 어떻게 할까? *와 **를 사용해 리스트, 튜플 등의 아이템을 전달할 수 있다.

일반적으로는 print()에 리스트를 매개변수로 전달하려고 하겠지만, print() 함수는 아래와 같이 리스트를 하나의 값으로 출력한다.

args = ['cat', 'dog', 'moose']
print(args)
# >> ['cat', 'dog', 'moose']

이 문제를 해결하려면, 리스트 앞에 * 구문을 붙이면 된다. 그러면 print()는 배열의 원소를 개별 위치기반 인수로 해석해 출력한다.

args = ['cat', 'dog', 'moose']
print(*args) # print('cat', 'dog', 'moose')와 같다. 
# >> cat dog moose

또한 ** 구문으로 딕셔너리 등을 'key, value' 쌍으로 전달할 수 있다. 만약에 각각 출력할 원소를 공백 말고 '-'으로 구분하기 위해 print()의 'sep' 파라미터를 사용한다면, 아래와 같이 구문을 사용할 수 있다.

kwargs_for_print = {'sep': '-'}
args = ['cat', 'dog', 'moose']

# print('cat', 'dog', 'moose', sep='-')와 같다. 
print(*args, **kwargs_for_print)
# >> cat-dog-moose

위 예시는 파라미터가 한 개 뿐이라 오히려 코드가 복잡해졌지만, 수많은 파라미터를 받는 함수와 메소드의 경우는 유용할 것이다.

*를 이용해 가변인수 함수 만들기

우리가 함수를 정의할 때, 파라미터에 [*] 구문을 사용해 가변적인 위치기반 인수를 받는 가변인수(variadic) 함수를 만들 수 있다. 예를 들어 print()는 사용자가 입력한 임의의 개수의 인수를 구분자를 붙여 출력한다.

예를 들어 임의 개수를 인수를 받아 곱하는 product() 함수를 정의해 보자.

def product(*args):
	result = 1
    for num in args:
    	result *= num
    return result

함수 내부에서 args 파라미터에 [*] 구문을 사용해 가변인수 함수를 정의하였다. 여기서 간과할 점은, args는 파이썬 인터프리터 입장에서 임의의 개수의 원소를 가진 파이썬 튜플일 뿐이다. 그래서 꼭 이름이 args 필요도 없다. (관례상 사용할 뿐이다.)

이 가변인수 함수를 언제 사용할까? 정답은 '함수를 단순하게 할 수 있으면' 사용한다. 따로 명확한 기준이 있는 게 아니며, 실제로 파이썬의 경우도 sum()은 단일 리스트 입력만 받지만, min()과 max()는 리스트와 가변인수 모두 혀용한다.

**를 이용해 가변인수 함수 만들기

[*]구문과 더불어, [**] 구문도 가변인수 함수 만들기에 사용할 수 있다. [*] 구문과 다른 점은 파이썬 인터프리터가 가변인수를 튜플이 아닌, 딕셔너리로 본다는 점이다. 이는 수없이 많은 키워드 파라미터를 가진 함수를 최적호하는데 용이하다.

만약 118개의 원소들(수소, 산소 등)을 모두 가진 함수가 있다고 해보자. 아 함수를 [**] 구문을 통해 최적화 하는 예는 다음과 같다.

def formMolecules(**kwargs):
	if len(kwargs) == 2 and kwargs['hydrogen'] == 2 and kwargs['oxygen'] == 1:
   	return 'waters'

이처럼 가변 파라미터를 ** 구문으로 구현한다면, 사용하는 파라미터가 바뀔 시 파라미터 정의를 다시 할 필요가 없다는 장점이 생긴다. 그래서 함수 호출과 정의가 간단해지므로, 가독성이 높은 코드를 만들 수 있다.


함수형 프로그래밍

함수형 프로그래밍은 전역 변수 등의 함수 외부를 수정하지 않고 계산 목적의 함수 작성을 강조하는 프로그래밍 패러다임이다. 파이썬에서도 프로그래머의 의도에 따라 함수형 프로그래밍을 할 수 있다.

부수효과

부수효과는 함수의 코드와 지역 변수 바깥에 가하는 모든 변화를 의미한다. 예를 들어 함수 A가 지역 변수만을 사용한다면 부수효과가 없는 함수이고, 전역 변수의 값을 조정한다면 부수효과가 있는 함수이다.

관련된 개념으로 결정론적(deterministic) 함수가 있는데, 이는 항상 같은 인수에 대해 같은 결과값을 반환하는 경우이다. 서로 다른 인수를 빼는 substract(123, 987)의 호출은 항상 -864를 반환한다. 결정론적 함수는 특성 상, 캐싱(Caching)을 활용해 성능 개선을 이룰 수 있다.

반대의 비결정론적(nondeterministic) 함수는 random 같이 항상 같은 인수여도 매번 다른 결과값을 반환하는 함수이다.

결정론적이고 부수효과가 없는 함수를 순수 함수(pure function)라고 한다. 순수 함수는 아래와 같은 이점이 있다.

  • 단위 테스트와 버그 재현이 쉽다.
  • 공유 자원이 없어 멀티쓰레드 프로그래밍을 적용해도 안전하다.
  • 순서가 없어 병렬 작업에 효율적이다.

고차원 함수

고차원 함수는 다른 함수를 인수로 넘기거나 반환할 수 있다. 파이썬에서의 함수는 일급 객체이기 때문에 문자열과 같은 다른 객체와 성질이 같기 때문에, 함수 종류와 상관없이 전달하거나 반환할 수 있다.

람다 함수

람다(lambda) 함수는 익명 함수 또는 이름 없는 함수라고도 부르며, 이름이 없고 하나의 return 문으로만 구성된 단순한 함수이다.

고차원 함수에 함수를 인수로 전달할 때는 함수의 이름을 정할 필요가 없어, 람다 함수를 인수에 사용하는 것이 적합하다.

람다 함수는 아래과 같이 lambda 키워드와 파라미터, 표현식으로 사용한다.

# 사각형의 집합, [10, 2]에서 10은 가로를, 2는 세로를 나타냄
rects = [[10, 2], [3, 6], [2, 4], [3, 9], [10, 7], [9, 9]]
sorted(rects, key=lambda rect: (rect[0] * 2) + (rect[1] + 2))

sorted() 함수는 key라는 함수를 받는 고차원 함수인데, 만약 rect의 원소의 둘레를 기준으로 정렬하고 싶다면, key 키워드에 lambda 키워드로 람다 함수를 정의한다면 코드의 길이도 짧아지고, 가독성도 향상된다.

리스트 컴프리헨션을 이용한 매핑과 필터링

초기의 파이썬 프로그래밍은 람다 함수와 map(), filter()를 이용해 리스트를 변환하고 필터링하였다. 자세히 말하면 map()은 각 원소에 어떠한 작업을 수행하고, filter()는 조건에 따라 리스트를 필터링하는 고차원 함수이다.

하지만 이제 파이썬은 '리스트 컴프리헨션'이라는 강력한 기능을 가지고 있다. 리스트 컴프리헨션이 훨씬 간결하고 강력할 뿐더러, 속도 또한 빠르니 map()과 filter()를 이용할 필요가 없다.

리스트 컴프리헨션은 이전 포스터에서 자세히 다뤘으니 참고 바란다.

결괏값은 항상 동일한 데이터 타입이어야 한다

결괏값은 항상 예측 가능해야 한다.

파이썬은 동적 타입을 지원하기 떄문에, 파이썬 함수와 메소드는 어떤 타입도 자유롭게 반환할 수 있다. 하지만 가독성 측면에서 이는 좋지 않으니, 사용자는 하나의 데이터 타입만 반환해야 한다.

만약에 숫자와 문자를 같이 반환하는 A라는 함수가 있다고 가정해보자. A의 반환값을 정수라 생각해서 16진수로 바꾸는 hex() 함수를 이용한다면, 문자가 반환될 시 예외가 발생할 것이다.

특히 None 값은 반환에 특히 조심해야 한다. 항상 None을 반환하는 경우가 아니라면, 예외를 역추적하기 매우 어렵다.

profile
이도저도 아닌 개발자

0개의 댓글