python - python을 더 pythonic 하게 사용하기, 12가지 TIP

정현우·2023년 5월 8일
9
post-thumbnail

Pythonic python

개인적으로 python을 사용하면서 활용도 높게 pythonic하게 코딩이 가능한 문법이나 tip을 정리했다. if-else/for one line 이나 dunder나 함수형 method (filter, map...)은 제외 했다.

0. get attribute & walrus operator

  • 가장 기본적인, 짤에서 볼 수 있는 걸 살펴보자.

  • 사실 get__get__ dunder method를 호출하는 것이다. dict는 기본적으로 get 아주 잘 사용하는게 중요하다. python 초심자일때 생각보다 많이 놓치는 것 같다. 물론 나도 그랬다 :')

  • key를 include로 check를 하고 다른 값을 넣어야 한다면, dict["key"] 로 접근해서 attribute error를 맛보고 코드를 include (if attr in dict: ... else) 로 늘리지 말자!! get(attribute, 없으면 사용할 value) 를 사용하자!

  • 물론 key include를 check할때 if attr in dict 쓰는게 당연하다,, 하지만 특정 API 호출 request의 response.json() dict에서 Optional할 수 있는 key 접근할 땐 제발, 더욱이, get(attribute, 없으면 사용할 value) 를 사용하자!!

walrus operator

  • python 3.8 이상부터 사용가능하며 PEP 572에 Assignment Expressions이란 이름으로 정의되어 있다.

  • 바다 코끼리 연산자는 get을 쓸때 유용하다. 표현식의 결과를 변수에 할당하고, 동시에 반환 하는 연산자다. 예시를 보면 바로 캐치가 가능하다.

if map.get("won"): 
	result = a + b + map.get("won")
    unit = map.get("won")

# 바다코끼리 연산자 활용
if c := map.get("won"):
	result = a + b + c
    unit = c
    
# 반복문과 컨프리헨션에서도 유리하다.
while True:
    chunk = file.read(128)
    if not chunk:
        break
    process(chunk)

while chunk := file.read(128):
    process(chunk)

1. Comprehension & Generator

Comprehension

  • 이미 컴프리헨션에 대해서는 익히 알고 있을 것이다. 기본적인 목적은 "iterable한 object를 생성하기 위한 방법" 이다. 그래서 기본적으로 list, set, dict 등을 만들때 많이 사용한다.
arr = []
for i in range(0, 10):
	arr.append(i)
print(arr)  # Output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]


# 대신 아래와 같이 one-line으로 가능하다.
arr = [i for i in range(0, 10)]
print(arr)  # Output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]


# 역시 one-line if - else 와 묶어서 아래와 같이 사용가능하다.
arr = [i if i % 2 == 0 else 0 for i in range(0, 10)]
print(arr) # Output: [0, 0, 2, 0, 4, 0, 6, 0, 8, 0]


# 2차원 배열도 간단하게 가능하다.
arr = [[0 for _ in range(5)] for _ in range(5)]
print(arr)
# Output: [[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]
  • 컴프리헨션은 실제로 for-loop를 쓰는 것보다 생성시키는 size가 클수록 "빠르다". 사실 작은 수의 for-loop 생성은 큰 차이가 안나지만, 크기가 크면 초단위로 차이가 나기도 한다. 실제로 비교한 이 글을 확인해 보자!

Generator

  • 제네레이터는 "iterator를 생성하는 함수" 이다. 그리고 메모리 관점에서 효율이 높다. 쓸모없는 변수 사용이나 메모리 사용을 줄일 수 있기 때문이다. 이 얘기에 대한 자세한 사항은 해당 글로 대체하겠다.

  • 만드는 방법은 yeild 키워드를 사용하면 자연스럽게 generator가 된다. 처음 접하면 굉장히 생소하게 다가올 수 있다. 정확한 이해를 하려면, iteratoriter, next를 알고 있어야 한다.

def test():
    for i in range(7):
        yield i

arr = test()
print(arr)
for val in arr:
	print(val)

# Output
<generator object test at 0x0362EFB0>
0
1
2
3
4
5
6
  • 간단하게 yieldreturn 이라고 생각해보자. arr 자체는 test() function을 excute하고 있고, for - loop 에서 arr을 순회하면서 val을 출력할때 마다 내부 i 값의 진행 상태를 기억하고 있는 것 같다.

  • generator는 return을 해도 그때의 상태가 함수 내부변수에 기록된다. 첫번째 yield i 를 한 순간 0을 반환하고 멈춰있는 상태라고 볼 수 있다. 이렇게 generator는 완성된 리스트가 아닌 필요에 따라 값을 계산해서 반환하는 함수라고 볼 수 있다.

  • 그리고 이러한 특성때문에 끝이 없는, 무한한 순서가 있는 객체를 모델링할 때 많이 사용된다. 아래 2가지 예시를 살펴보자!

def number_sequence(n):
    i = 0
    while i < n:
        yield i
        i += 1

sequence = number_sequence(5)
for number in sequence:
    print(number)

# Output:
0
1
2
3
4
  • 그리고 가장 많은 예시로 "피보나치 수열" 를 만들 수 있다.
def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

# Generate the first 10 Fibonacci numbers
fib = fibonacci()
for i in range(10):
    print(next(fib))

# Output:
0
1
1
2
3
5
8
13
21
34
  • 얘를 밑에서 다시 살펴볼 filter (7번 filter) 와 같이 쓴다면? 아래와 같이 사용할 수 있다.
# Define a generator function to generate Fibonacci numbers
def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

# Use a lambda function with filter() to filter the Fibonacci sequence
filtered_fibonacci = filter(lambda x: x % 2 == 0, fibonacci())

# Print the first 10 even Fibonacci numbers
for i in range(10):
    print(next(filtered_fibonacci))

# Output:
0
2
8
34
144
610
2584
10946
46368
196418

2. swap

  • 보통 swap은 각 변수의 값을 바꾸는 역할을 하는 함수 또는 행위를 의미한다. 우리는 C에서 아래와 같이 매우 "불편" (사실 편함을 알기전엔 이게 너무 당연했던) 하게 했다.
int a = 1;
int b = 3;
int tmp = a;
a = b;
b = tmp;
  • 하지만 python은 파이써닉하게 아래와 같이 바로 swap이 된다.
a, b = b, a
  • 사실 생각해보면 multi return value를 one-line에 모두 받을 수 있게 한다면, 굉장히 당연하게 다가오는 개념이다.

3. 키워드 아규먼트 강제하기

  • 함수를 정의할때 파라미터값을 정의하는게 애매할때가 있다. 특히 범용성일 가지거나 library 성격을 가지는 function (or method) 일 경우 더욱 그렇다.

  • 특히 함수를 호출할때 전달하는 "인자"만 보고 해당 값이 왜 필요한지 추측하게, 사용할때 바로 의도를 알아차리게 하고 싶을때가 있다.

  • 그래서 positional argument와 keyword argument를 많이 혼용을 하는데, *args는 순서가 중요하다. 그래서 순서와 상관없이 fun(변수=값) 형태의 **kward 를 많이 쓰는데, 이때 필요한 key=value keyword argument를 강제할 수 있다.

def fun_print(*, a, b):
	print(a, b)
    
>>> fun_print(1, 2)
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
TypeError: fun_print() takes 0 positional arguments but 2 were given

>>> fun_print(a=1, b=2)
1 2
  • 실제로 많은 라이브러리에서도 활용하고 있고, 파라미터 변수를 잘 정의해두면, 사용할 때 인자값을 넣을때 keyword를 보고 의도를 파악하기 좋다.

4. 함수 언패킹을 위해 * 와 ** 사용하기

  • 특히 API를 설계하다보면 request의 url query string 또는 request payload(body) 값을 validate 또는 정형화 목적으로 당연하게 modeling을 하게 된다. 그게 python 진영에서는 주로 serializer, pydantic 또는 dataclass 가 그런 역할을 한다.

  • 하지만 해당 model(class) instance를 만들때 굉장히 많은, 다양한 값이 필요하게 된다.

from pydantic import BaseModel

class SalesSummary(BaseModel):
	total_tran_cnt: int
	total_tran_amt: int
	total_app_cnt: int
	total_app_amt: int
	total_cnl_cnt: int
	total_cnl_amt: int
    
    
my_sales = SalesSummary(
	total_tran_cnt=10,
    total_tran_cnt=1000,
    ...
)
  • 그러면 instance를 만들기 위한 값을 사전에 변수에 다 저장해 두고, dict 형태로 만들때가 많다. 게다가 보통 그런 값들은 step by step으로 만들어지는 경우도 많기 때문이다.
sumary_dict = {
	"total_tran_cnt": 10,
	"total_tran_amt": 2000
    ...
}
# 비즈니스 로직
res = request.get(...).json()
sumary_dict["total_cnl_amt"] = res.get("cancled")
...
  • 그리고 간단한 커스텀 validation이나 연산등을 하게 되는데, 그러면 또 SalesSummary instance를 만들때 sumary_dict 에서 하나씩 key 값을 가져와야 하는가? 이때 언패킹을 가장많이 사용한다. 아래 한 줄로 끝이다!
my_sales = SalesSummary(**sumary_dict)
  • 함수든, class instance를 만들든 실제로 언패킹은 굉장히 다양하고 빈번하게 사용된다. 위 예시는 키워드 아규먼트만 보였지만 포지셔널 아규먼트역시 동일하다!
def fun_print(a, b, c):
	print(a, b)

>>> l = [1, 2, 3]
>>> fun_print(*l)
1 2 3

packing & unpacking

  • 조금 더 단어에 대한 정확한 정의를 하자면, 언패킹"시퀀스 객체의 각 요소들을 개별 변수로 풀어서, 각각의 변수로 함수의 인자로 전달하는 기능" 이며, 패킹"고정되지 않은 여러개의 인자(parameter)를 묶은 하나의 시퀀스 인자" 이다.

  • 패킹의 가장 살펴보기 좋은 예시는 print 이다!

5. 클로저 & 데코레이터 활용하기

  • 클로저(Closure) 와 데코레이터(Decorator) 에 대한 고찰을 담은 글을 선행하고 오면 매우 좋다.

  • 위 글을 다 보면다면, 클로저의 정확한 의미와 데코레이터가 어떻게 액션되는지 캐치할 수 있을 것 이다. 그러면 우리는 아주 simple한 몇가지를 할 수 있다. info logging을 해주는 또는 print 해주는 데코레이터를 만들어 보자.

def print_argument(func):
    def wrapper(the_number):
        print("Argument for", func.__name__, "is", the_number)
        return func(the_number)
    return wrapper

@print_argument
def add_one(x):
    return x + 1

print(add_one(1))  

# Output:
"""
Argument for add_one is 1
2
"""
  • 위에서 잠깐 언급한 fibonacci를 활용한 excute time logging을 해주는 데코레이터를 만들어보자!
import logging
import time

logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s')

def log_execution_time(func):
    def wrapper(*args, **kwargs):
        start_time = time.monotonic()
        result = func(*args, **kwargs)
        end_time = time.monotonic()
        elapsed_time = end_time - start_time
        logging.debug(f'{func.__name__} executed in {elapsed_time:.4f} seconds')
        return result
    return wrapper

@log_execution_time
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

fibonacci(10)

6. 익명함수, lambda 함수 활용하기

  • "람다"란, 프로그래밍 언어에서 사용되는 개념으로 익명의 함수, 이름 없는 함수를 지칭하는 용어다. 보통 함수형 프로그래밍에서 "람다"라는 개념을 많이 차용한다.

  • 너무 depth있는 람다 함수 보다 pydantic하게 사용하기 좋은 형태의 lambda식을 살펴보자.

# 기본 표현 형식
lambda 인자 리스트 : 표현식(반환값)

# if을 사용한다면 콜론없이 표현한다.
lambda x : exp1 if 조건 else exp2
  • 단순한 식 비교, sorting에 가장 쉽게 사용할 수 있다.
s = ['banana', 'kiwi', 'apple', 'cucumber']
s = sorted(s, key=lambda x : len(x))
print(s)  # ['kiwi', 'apple', 'banana', 'cucumber']

max = lambda x, y : x if x > y else y
max(10, 200)  # 200
max(200, max(10, 20))  # 200
max(200, max(10, max(50, 1000)))  # 1000

7. filter

  • filter는 python 내장함수다. 이름에서 느낄 수 있듯이, 여러 개의 데이터로 부터 일부의 데이터만 추려낼 때 사용한다.
filter(조건 함수, 순회 가능한 데이터)
  • 기본 사용 형태는 아주 단순하며, "조건 함수" 부분을 lambda와 같이 섞어 쓰면 아주 편하게 one-line으로 다양한 것을 할 수 있다. 특히 데이터 전-후 처리에 많이 쓰인다.
# Define a list of numbers
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9]

# Use a lambda function with filter() to filter the list
filtered_numbers = list(filter(lambda x: x % 2 == 0, numbers))

# Print the filtered list
print(filtered_numbers)  # Output: [2, 4, 6, 8]
  • 또는 실제 List[Dict] 형태의 데이터 대상으로 특정 attribute값이 충족하는 값만 뽑아낼때 좋다.
>>> users = [{'mail': 'gregorythomas@gmail.com', 'name': 'Brett Holland', 'sex': 'M'},
...  {'mail': 'hintoncynthia@hotmail.com', 'name': 'Madison Martinez', 'sex': 'F'},
...  {'mail': 'wwagner@gmail.com', 'name': 'Michael Jenkins', 'sex': 'M'},
...  {'mail': 'daniel79@gmail.com', 'name': 'Karen Rodriguez', 'sex': 'F'},
...  {'mail': 'ujackson@gmail.com', 'name': 'Amber Rhodes', 'sex': 'F'}]

>>> def is_man(user):
...     return user["sex"] == "M"
...

>>> for man in filter(is_man, users):
...     print(man)
...
{'mail': 'gregorythomas@gmail.com', 'name': 'Brett Holland', 'sex': 'M'}
{'mail': 'wwagner@gmail.com', 'name': 'Michael Jenkins', 'sex': 'M'}

>>> for woman in filter(lambda u: u["sex"] != "M", users):
...     print(woman)
...
{'mail': 'hintoncynthia@hotmail.com', 'name': 'Madison Martinez', 'sex': 'F'}
{'mail': 'daniel79@gmail.com', 'name': 'Karen Rodriguez', 'sex': 'F'}
{'mail': 'ujackson@gmail.com', 'name': 'Amber Rhodes', 'sex': 'F'}

8. for - else 구문

  • for-else는 break가 있을 때 많이 사용한다. 기본 개념은 "for가 모두 루프하고 끝난다면" 이다.
# break 없는 for loop
for i in range(5):
    print(i, end=' ')
else:
    print("for문이 끝까지 실행됬습니다!")
# Output: 0 1 2 3 4 for문이 끝까지 실행됬습니다!
 
# if와 break가 있을 때, break를 만난다면
for i in range(5):
    if i == 2:
        break
    print(i, end=' ')
else:
    print("for문이 끝까지 실행됬습니다!")
# Output: 0 1

# if와 break가 있을 때, break를 만나지 않는다면
for i in range(3, 5):
    if i == 2:
        break
    print(i, end=' ')
else:
    print("for문이 끝까지 실행됬습니다!")
# Output: 3 4 for문이 끝까지 실행됬습니다!

9. heapq를 활용한 n 번째 큰수 또는 작은수

  • 기본적으로 heapq 의 성질을 아는데, 사용을 잘 안하는 경우가 많다.

  • n 번째를 빠르게, 용도에 따라 빈번하게 찾아야 할 때 가장 쉽고 빠르게 접근할 수 있는 방법은 "완전이진트리"를 떠올리게 되고, 자연스럽게 "우선순위큐"를 떠오르게 된다.

  • 기본적으로 파이썬 heapq 모듈은 priority queue 알고리즘을 제공한다. 모든 부모 노드는 그의 자식 노드보다 값이 작거나 큰 이진트리(binary tree) 구조인데, 내부적으로는 인덱스 0에서 시작해 k번째 원소가 항상 자식 원소들(2k+1, 2k+2) 보다 작거나 같은 최소 힙의 형태로 정렬된다.

  • 역시, 당연하게 list에서 메번 sorting을 해가면서 찾는 것 보다 훨씬 빠르다. 하지만 당연히, 삽입과 삭제에는 추가 연산이 따른다. 즉 이미 모두 메모리에 올리고 n 번째를 찾을 때 가장 유리하다는 것이다.

import heapq

scores = [51, 33, 64, 87, 91, 75, 15, 49, 33, 82]

print(heapq.nlargest(3, scores))  # [91, 87, 82]
print(heapq.nsmallest(5, scores))  # [15, 33, 33, 49, 51]
  • django에서 model 대상으로 아래와 같은 연산이 가능하다.
import heapq
from django.db.models import F, Sum
from myapp.models import Sales

N = 10  # get top 10 sales amounts

# get the top N sales amounts using heapq.nlargest
top_sales = heapq.nlargest(
	N, 
    Sales.objects.filter(...).annotate(
    	total_sales=Sum('amount')
	).values_list('total_sales', flat=True)
)
  • 참고로 부연 설명을 좀 붙이자면, order by 로 접근해서 slice하는게 더 좋고 빠를 수 있다. 사용할 수 있는 상황에 대한 예시로만 봐주시면 감사합니다!

10. Merge dictionaries in a single readable line

  • 3.9 version 이상에서 가능하다.

  • 두개의 dict를 하나의 key:value 쌍으로 합치되 중복되는 key의 value는 하나의 string 덩어리로 합칠 수 있다.

first_dictionary = {'name': 'Fatos', 'location': 'Munich'}
second_dictionary = {'name': 'Fatos', 'surname': 'Morina',
                     'location': 'Bavaria, Munich'}
result = first_dictionary | second_dictionary
print(result)  
# {'name': 'Fatos', 'location': 'Bavaria, Munich', 'surname': 'Morina'}

11. Merge 2 dictionaries quickly

  • 위와 비슷하지만, 언페킹을 활용한다면 2개의 dict를 하나의 dict로 빠르게 합칠 수 있다.
dictionary_one = {"a": 1, "b": 2}
dictionary_two = {"c": 3, "d": 4}

merged = {**dictionary_one, **dictionary_two}
print(merged)  # {'a': 1, 'b': 2, 'c': 3, 'd': 4}
  • 만약 중복되는 key가 있다면, merged에서 가장 나중에 언페킹이 된 dict key에 해당하는 value로 덮여씌워 진다.
dictionary_one = {"a": 1, "b": 2}
dictionary_two = {"a": 3, "d": 4}

merged = {**dictionary_one, **dictionary_two}
print(merged)  # {'a': 3, 'b': 2, 'd': 4}

마무리

  • 사실 python의 이런 축약형, pydantic한 technic은 100가지를 정리해 놓은 글도 있고, 창의적인 방법으로 접근한 글도 많다.

  • 우리가 "협업을 하는 개발자" 라면 항상 "과유불급"을 생각하게 된다. pydantic도 좋고, 퍼포먼스 및 메모리 향상을 위함을 추구하는 것도 좋지만 결국 "우리가 다 같이 쉽고 빠르게 이해하고, 규칙을 기반으로 두고 협업할 수 있는가" 가 핵심가치다.

  • 그런면에서 적어도 이 12가지는 현업에서도 큰 부담없이 자유롭게 사용이 가능할 것이라고 생각한다. 과한 잔기술은 나 혼자 작업하는 토이프로젝트에서 하자! python이 duck typing 언어에 자유도가 너무 높으니 항상 이런 부분이 가장 큰 장점이자 단점이라고 생각한다.

  • 그래서 type hint를 계속해서 신경쓰도록 하자! 적어도 function parameter의 type, return type 만큼은 말이다!


출처

profile
도메인 중심의 개발, 깊이의 가치를 이해하고 “문제 해결” 에 몰두하는 개발자가 되고싶습니다. 그러기 위해 항상 새로운 것에 도전하고 노력하는 개발자가 되고 싶습니다!

2개의 댓글

comment-user-thumbnail
2023년 5월 18일

좋은글 감사합니다.

1개의 답글