[python cleancode] 2. 파이썬스러운 코드

햄도·2021년 4월 8일
0

Python Cleancode

목록 보기
2/9

출처

파이썬 클린코드를 읽으며 정리한 내용입니다.

인덱스와 슬라이스

  • 다른 언어와 색다른 접근방법 소개
  • 음수 인덱스를 사용하여 끝에서부터 접근 가능
my_numbers = (4, 5, 3, 9)
my_numbers[-1]
>> 9
my_numbers[-3]
>> 5
  • slice를 사용하여 특정 구간의 요소 접근 가능
  • 이 때 시작 인덱스는 포함, 끝 인덱스는 제외
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
my_numbers[2:5]
>>> (2, 3, 5)
  • 슬라이스가 범위를 넘어가도 오류가 나지 않는다.
my_numbers[:100]
>>> (1, 1, 2, 3, 5, 8, 13, 21)
my_numbers[-100:]
>>> (1, 1, 2, 3, 5, 8, 13, 21)
  • 시작 인덱스나 끝 인덱스를 제외하면 처음 또는 끝에서부터 조회한다.
my_numbers[:3]
>>> (1, 1, 2)
my_numbers[3:]
>>> (3, 5, 8, 13, 21)
  • 모든 인덱스를 사용하지 않는 경우 원래 튜플의 복사본을 생성한다.
my_numbers[:]
>>> (1, 1, 2, 3, 5, 8, 13, 21)
  • 간격 값을 설정할수도 있다.
# 1번째부터 7번째 원소 전까지 2칸 간격으로 조회
my_numbers[1:7:2]
>>> (1, 3, 8)
  • 시퀀스에 간격을 전달하는 것은 슬라이스를 전달하는 것과 같다. 슬라이스는 파이썬 내장 객체 중 하나이다.
interval = slice(1, 7, 2)
my_numbers[interval]
>>> (1, 3, 8)
# 시작, 중지, 간격 중 하나를 지정하지 않은 경우 None으로 간주
interval = slice(None, 3)
my_numbers[interval] == my_numbers[:3]
>>> True

자체 시퀀스 생성

  • 슬라이싱은 __getitem__이라는 매직 메서드 덕분에 동작한다.
  • __getitem__은 myobject[key]와 같은 형태를 사용할 때 호출되는 메서드로, key에 해당하는 대괄호 안의 값을 파라미터로 전달한다.
  • 특히 시퀀스는 __getitem____len__을 모두 구현하는 객체이므로 반복이 가능하다.
  • __getitem__을 사용자정의 클래스에 구현하려는 경우, 몇 가지를 고려해야 한다.

클래스가 표준 라이브러리 객체를 감싸는 래퍼인 경우

  • 기본 객체에 최대한 많은 동작을 위임한다.
class Items:
    def __init__(self, *values):
        self._values = list(values)
    
    def __len__(self):
        return len(self._values)
    
    def __getitem__(self, item):
        return self._values.__getitem__(item)
  • 위 케이스는 캡슐화 방식을 사용한 케이스이며, 상속을 사용하는 경우 collections.UserList 부모 클래스를 상속한다.

래퍼도 아니고 내장 객체를 사용하지도 않은 경우

  • 다음 사항에 유의해 시퀀스를 구현한다.
    • 범위로 인덱싱하는 결과는 해당 클래스와 같은 타입의 인스턴스여야 한다. -> 지키지 않는 경우 오류 발생 가능성
    • 슬라이스에 의해 제공된 범위는 마지막 요소를 제외해야 한다. -> 파이썬 언어와 일관성 유지

프로퍼티, 속성과 객체 메서드의 다른 타입들

  • 파이썬 객체의 모든 프로퍼티와 함수는 public이기 때문에 호출자가 객체의 속성을 호출하지 못하도록 할 방법이 없다.
  • 엄격한 강제사항은 아니지만 몇 가지 규칙이 있다. 밑줄로 시작하는 속성은 해당 객체에 대해 private을 의미하며, 외부에서 호출하지 않기를 기대하는 것이다.

파이썬에서의 밑줄

class Connector:
    def __init__(self, source):
        self.source = source
        self._timeout = 60
conn = Connector("postgresql://localhost")
conn.source
>>> 'postgresql://localhost'
conn._timeout
>>> 60
  • 위의 예시와 같이, 밑줄로 시작하는 속성에도 접근할 수는 있지만 관례적으로 timeout 속성은 내부에서만 사용되어야 한다. 이러한 규칙이 지켜져야 필요한 경우에 파급 효과 없이 안전하게 리팩토링할 수 있다. 메서드에도 동일한 원칙이 적용된다.
  • 객체의 인터페이스로 공개하는 용도가 아니라면 모든 멤버에는 접두사로 하나의 밑줄을 사용하는 것이 좋다.
  • 이중 밑줄을 붙이면 내부적으로 속성 이름이 바뀌는 특징으로 인해 private 속성과 메서드를 만들 수 있다는 오해가 있지만, 이것은 pythonic하지 못하다.
class Connector:
    def __init__(self, source):
        self.source = source
        self.__timeout = 60
    
    def connect(self):
        print(f"connecting with {self.__timeout}s")
conn = Connector('postgresql://localhost')
conn.connect()
>>> connecting with 60s
conn.__timeout
>>> 
---------------------------------------------------------------------------

AttributeError                            Traceback (most recent call last)

<ipython-input-6-d082cdc68a78> in <module>
----> 1 conn.__timeout
AttributeError: 'Connector' object has no attribute '__timeout'
  • 밑줄 두 개를 사용하면 파이썬은_<class-name>__<attribute-name> 형태의 다른 이름을 만든다. 이것을 이름 맹글링(name mangling)이라 한다.
  • 이는 여러번 확장되는 클래스의 메서드를 이름 충돌 없이 오버라이드하기 위해 만들어졌다.
  • 따라서 밑줄 두 개를 private 속성 및 메소드를 만드는 데에 사용하는 것은 파이썬스럽지 않다.

프로퍼티

  • 프로퍼티는 객체의 어떤 속성에 대한 접근을 제어하려는 경우 사용한다. 자바와 같은 다른 언어에서는 getter와 setter를 만들지만, 파이썬에서는 프로퍼티를 사용한다.
  • 프로퍼티는 명령-쿼리 분리 원칙(command and query separation)을 따르기 위해 좋은 방법이다.
    • 명령-쿼리 분리 원칙: 객체의 메서드가 무언가의 상태를 변경하거나 값을 반환하는 둘 중 하나만 수행해야 한다는 원칙
    • 프로퍼티를 사용하면 이 원칙을 지킬 수 있어 메서드의 역할에 대한 혼동을 피할 수 있다.
# 프로퍼티를 이용한 이메일 validation 예시
import re

EMAIL_FORMAT = re.compile(r"[^@]+@[^@]+[^@]+")

def is_valid_email(potentially_valid_email: str):
    return re.match(EMAIL_FORMAT, potentially_valid_email) is not None

class User:
    def __init__(self, username):
        self.username = username
        self._email = None
        
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, new_email):
        if not is_valid_email(new_email):
            raise ValueError(f"유효한 이메일이 아니므로 {new_email}값을 사용할 수 없음")
        self._email = new_email
  • 첫번째 @property 메서드는 private 속성인 email을 반환해, 외부에서 private 속성에 접근하는 것을 막는다.
  • @email.setter가 추가된 두번째 메서드는 email 속성을 변경하려고 할 때 호출되는 코드로, 명시적으로 유효성 검사를 할 수 있다.
  • 이 방식은 get, set 접두어를 사용해 사용자 메서드를 만드는 것보다 간단하다.
  • 모든 속성에 대해 프로퍼티를 작성할 필요는 없다. 속성 값을 가져오거나 수정할 때 특별한 로직이 필요한 경우에만 작성하자.

이터러블 객체

  • 파이썬의 반복은 이터러블 프로토콜이라는 자체 프로토콜을 사용해 동작한다.
  • for-in 문으로 객체를 반복할 수 있는지 확인하기 위해 파이썬은 다음 두 가지를 차례로 검사한다.
    • 객체가 __next____iter__ 이터레이터 메서드 중 하나를 포함하는지
    • 객체가 시퀀스이고 __len____getitem__를 모두 가졌는지
  • 즉 위 두 조건 중 하나를 만족하면 반복 가능한 객체라고 할 수 있다.

이터러블 객체 만들기

  • 아래 예시는 다음과 같은 과정으로 동작한다.
    1. 해당 객체의 iter()함수 호출
    2. iter()는 __iter__함수가 있는지 확인한다. 있으면 __iter__ 실행
    3. __iter__에서 반환된 이터레이터의 next()함수 호출
    4. next()함수는 이터레이터의 __next__함수 호출
    5. 더이상 생산할 것이 없으면 StopIteration 예외 발생
# 일정 기간의 날짜를 하루 간격으로 반복하는 객체 예시
from datetime import timedelta

class DateRangeIterable:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._present_day = start_date
        
    def __iter__(self):
        return self
    
    def __next__(self):
        if self._present_day >= self.end_date:
            raise StopIteration
        today = self._present_day
        self._present_day += timedelta(days=1)
        return today
from datetime import date
for day in DateRangeIterable(date(2020, 1, 1), date(2020, 1, 5)):
    print(day)
>>> 
2020-01-01
2020-01-02
2020-01-03
2020-01-04
  • 하지만 위와 같이 이터러블을 구현하는 경우, 한번 끝까지 실행하면 끝의 날짜에 도달한 상태이므로 재활용 할 수 없다. 즉, 두 개 이상의 for 루프에서 이 이터러블을 사용하면 첫 번째 루프만 작동하고, 두 번쨰 루프는 작동하지 않는다.
  • 이를 해결하기 위해 __iter__가 호출될 때마다 새로운 이터레이터를 만들 수 있지만, 새로운 DateRangeIterable 인스턴스가 필요하다.
  • 다른 방법으로는 __iter__에서 제너레이터를 사용할 수도 있다.
class DateRangeContainerIterable:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
    
    def __iter__(self):
        current_day = self.start_date
        while current_day < self.end_date:
            yield current_day
            current_day += timedelta(days=1)
r1 = DateRangeContainerIterable(date(2020, 1, 1), date(2020, 1, 5))
", ".join(map(str, r1))
>>> '2020-01-01, 2020-01-02, 2020-01-03, 2020-01-04'
max(r1)
>>> datetime.date(2020, 1, 4)
  • for 루프는 __iter__를 호출하고, __iter__는 제너레이터를 생성한다.
  • 이러한 형태의 객체를 컨테이너 이터러블이라고 한다.

시퀀스 만들기

  • iter()함수는 객체에 __iter__가 정의되어 있지 않으면 __getitem__을 찾고 없으면 TypeError를 발생시킨다.
  • 이러한 폴백 메커니즘으로 인해 시퀀스도 이터러블과 같이 순회할 수 있다.
  • 이터러블을 사용하면 다음 객체를 하나씩 가져오기 때문에 메모리를 적게 사용하지만, n번째 요소를 얻기 위한 시간복잡도가 O(n)이다.
  • 시퀀스로 구현하면 모든 객체를 한번에 가져오기 때문에 메모리가 많이 사용되지만 인덱싱의 시간복잡도가 O(1)이다.
class DateRangeSequence:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._range = self._create_range()
    
    def _create_range(self):
        days = []
        current_day = self.start_date
        while current_day < self.end_date:
            days.append(current_day)
            current_day += timedelta(days=1)
        return days
    
    def __getitem__(self, day_no):
        return self._range[day_no]
    
    def __len__(self):
        return len(self._range)
s1 = DateRangeSequence(date(2020, 1, 1), date(2020, 1, 5))
for day in s1:
    print(day)
>>> 
2020-01-01
2020-01-02
2020-01-03
2020-01-04
s1[0]
>>> datetime.date(2020, 1, 1)
  • 위 예시에서 DateRangeSequence 객체는 모든 작업을 래핑된 객체인 리스트에 위임하여 호환성과 일관성을 유지한다.
  • 일반적으로 이터레이션이 더 좋은 선택이지만 모든 요건을 염두에 두고 두 가지 구현 중 적절한 것을 선택해야 한다.

컨테이너 객체

  • __container__ 메서드를 구현한 객체
  • __container__ 메서드는 일반적으로 Boolean 값을 반환
  • 파이썬에서 in 키워드가 발견될 때 호출
  • element in containercontainer.__contains__(element)와 같이 해석된다.
  • 이 메서드를 잘 사용하면 코드의 가독성이 매우 높아진다!
# 2차원 게임 지도에서 특정 위치에 표시를 하는 예시
def mark_coordinate(grid, coord):
    if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height:
        grid[coord] = 'MARKED'
  • 위 예시에서 if문은 매우 난해해 보인다. 지도에서 자체적으로 grid라고 부르는 영역을 판단해주면 어떨까? 그리고 이 일을 더 작은 객체에 위임하면 어떨까?
class Boundaries:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        
    def __contains__(self, coord):
        x, y = coord
        return 0 <= x < self.width and 0 <= y < self.height

class Grid:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.limits = Boundaries(width, height)
    
    def __contains__(self, coord):
        return coord in self.limits
  • 위의 두 객체 모두 최소한의 논리를 사용하며, 메서드는 짧고 응집력이 있다.
  • 또한 코드가 의도를 직관적으로 표현하여 더 이상의 설명이 필요 없다.

객체의 동적인 속성

  • __getattr__ 매직 메서드를 사용해 객체에서 속성을 얻는 방법을 제어할 수 있다.
  • 파이썬에서 객체의 속성에 접근하는 과정은 다음과 같다.
    1. . 호출
    2. 객체의 사전에서 를 찾아 __getattribute__ 호출
    3. 객체에 가 없는 경우 를 파라미터로 전달하여 __getattr__이라는 추가 메서드 호출
  • __getattr__이라는 메서드를 이용해 반환 값을 제어하고, 심지어 새로운 속성을 만들수도 있다.
class DynamicAttributes:
    def __init__(self, attribute):
        self.attribute = attribute
        
    def __getattr__(self, attr):
        if attr.startswith("fallback_"):
            name = attr.replace("fallback_","")
            return f"[fallback resolved] {name}"
        raise AttributeError(f"{self.__class__.__name__}에는 {attr} 속성이 없음")
dyn = DynamicAttributes("value")
dyn.attribute
>>> 'value'
# __getattr__이 호출되어 값을 반환한다.
dyn.fallback_test
>>> '[fallback resolved] test'
# __dict__를 직접 조작하면 __getattr__이 호출되지 않는다.
dyn.__dict__["fallback_new"] = "new_value"
dyn.fallback_new
>>> 'new_value'
# 값을 검색할 수 없는 경우 AttributeError가 발생하며, 기본값을 반환한다.
getattr(dyn, "something", "default")
>>> 'default'

호출형 객체

  • 매직 메서드 __call__을 사용하면 객체를 일반 함수처럼 호출할 수 있다.
  • 파이썬은 object(*args, **kwargs)와 같은 구문을 object.__call__(*args, **kwargs)로 변환한다.
  • 이 메서드는 객체를 파라미터가 있는 함수처럼 사용하거나, 정보를 기억하는 함수처럼 사용할 경우 유용하다.
from collections import defaultdict

class CallCount:
    def __init__(self):
        self._counts = defaultdict(int)
        
    def __call__(self, argument):
        self._counts[argument] += 1
        return self._counts[argument]
cc = CallCount()
cc(1)
>>> 1

파이썬에서 유의할 점

  • 언어의 주요 기능을 이해하는 것 외에도 흔히 발생하는 잠재적인 문제를 피할 수 있는 코드를 작성하는 것도 중요하다.

변경 가능한 파라미터의 기본 값

  • 변경 가능한 객체를 함수의 기본 인자로 사용하면 안된다.
def wrong_user_display(user_metadata: dict = {"name": "John", "age": 30}):
    name = user_metadata.pop("name")
    age = user_metadata.pop("age")
    return f"{name} ({age})"
wrong_user_display()
>>> 'John (30)'
# 한번더 호출하면 작동하지 않는다.
wrong_user_display()
---------------------------------------------------------------------------
>>> 
  
KeyError                                  Traceback (most recent call last)

<ipython-input-38-468f5f73dd28> in <module>
----> 1 wrong_user_display()
<ipython-input-36-32436123dbd4> in wrong_user_display(user_metadata)
      1 def wrong_user_display(user_metadata: dict = {"name": "John", "age": 30}):
----> 2     name = user_metadata.pop("name")
      3     age = user_metadata.pop("age")
      4     return f"{name} ({age})"
KeyError: 'name'
  • 위 코드에는 두 가지 문제가 있다.
    • 변경 가능한 인자를 기본값으로 사용했다.
      • 기본 값을 사용해 함수를 호출하면 dict를 한 번만 생성하고, user_metadata는 이것을 가리킨다. 다시 함수가 호출되어도 처음 호출될 때 key를 없앤 dict는 여전히 메모리에 남아있고, 함수는 이 dict를 참조하게 된다.
    • 함수의 본문에서 가변 객체를 수정했다.
  • 기본값으로 None을 사용하고, 함수 본문에서 기본값을 할당하면 된다. 기본값으로 None을 사용할 때에는 docstring을 이용해 함수 본문에서 어떤 값이 할당되는지 명시하는 것이 좋다.

내장(built-in)타입 확장

  • 리스트, 문자열, 사전 등 내장 타입을 확장하는 올바른 방법은 collections 모듈을 사용하는 것이다.
  • 내장 타입을 직접 확장하는 클래스를 만들면, 기존에 존재하는 메서드들이 새로 생긴 자식 클래스의 오버라이드된 메서드를 호출하지 않기 때문에 문제가 생긴다.
    • 단, pypy에서는 문제가 생기지 않는다.
  • 이 경우 collections.UserDict, UserList, UserString 등을 사용하여 문제를 해결할 수 있다.
class BadList(list):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "짝수"
        else:
            prefix = "홀수"
        return f"[{prefix}] {value}"
bl = BadList((0, 1, 2, 3, 4, 5))
bl[0]
>>> '[짝수] 0'
"".join(bl)
---------------------------------------------------------------------------
>>> 
  
TypeError                                 Traceback (most recent call last)

<ipython-input-42-4256097aa2ac> in <module>
----> 1 "".join(bl)
TypeError: sequence item 0: expected str instance, int found
  • 문자열 리스트를 반복하는 함수 join은 새로 정의한 __getitem__을 호출하지 않는다.
  • 이 문제를 해결하기 위해 UserList에서 확장한 클래스를 작성해야 한다.
from collections import UserList

class GoodList(UserList):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "짝수"
        else:
            prefix = "홀수"
        return f"[{prefix}] {value}"
gl = GoodList((0, 1, 2, 3, 4, 5))
gl[0]
>>> '[짝수] 0'
"".join(gl)
>>> '[짝수] 0[홀수] 1[짝수] 2[홀수] 3[짝수] 4[홀수] 5'
profile
developer hamdoe

0개의 댓글