반응형

계약에 의한 디자인

관계자가 기대하는 바를 암묵적으로 코드에 삽입 X

양측이 동의하는 계약을 먼저 한 다음, 계약을 어겼을 경우는 명시적으로 왜 계속할 수 없는지 예외를 발생시키라는 것

책에서 말하는 계약은 소프트웨어 컴포넌트 간의 통신 중에 반드시 지켜져야 할 몇 가지 규칙을 강제하는 것

  • 사전조건: 코드가 실행되기 전 체크해야하는 것들(ex) 파라미터에 제공된 데이터의 유효성 검사)
  • 사후조건: 함수 반환값의 유효성 검사로 호출자가 이 컴포넌트에서 기대한 것을 제대로 받았는지 확인하기 위해 수행
  • 불변식: 함수가 실행되는 동안 일정하게 유지되는 것으로 로직에 문제가 없는지 확인하기 위한 것(docstring 문서화하는 것이 좋다)
  • 부작용: 선택적으로 코드의 부작용을 docstring에 언급하기도 한다

사전조건(precondition)

  • 함수나 메소드가 제대로 동작하기 위해 보장해야 하는 모든 것들
  • 함수는 처리할 정보에 대한 적절한 유효성 검사를 해야 하는데 어디서 할지에 대해 2가지로 나뉨
    • 관대한(tolerant) 접근법: 클라이언트가 함수를 호출하기 전에 모든 유효성 검사를 진행
    • 까다로운(demanding) 접근법: 함수가 자체적으로 로직을 실행하기 전에 검사를 진행

⇒ 어디에서 유효성 검사를 진행하든 어느 한쪽에서만 진행해야 함

사후조건(postcondition)

  • 함수나 메소드가 반환된 후의 상태를 강제하는 것

파이썬스러운 계약

  • 메소드, 함수, 클래스에 제어 메커니즘을 추구하고 검사에 실패할 경우 RuntimeError나 ValueError를 발생시키는 것
  • 사전조건, 사후조건 검사, 핵심 기능 구현은 가능한 한 격리된 상태로 유지하는 것이 좋음

계약에 의한 디자인(DbC) - 결론

  • 문제가 있는 부분을 효과적으로 식별하는데 가치가 있음
  • 명시적으로 함수나 메소드가 정상적으로 동작하기 위해 필요한 것이 무엇인지, 무엇을 반환하는지를 정의해 프로그램의 구조를 명확히 할 수 있음
  • 원칙에 따라 추가적인 작업이 발생하지만 이방법으로 얻은 품질은 장기적으로 보상됨

방어적(defensive) 프로그래밍

  • 계약에 의한 디자인과는 다른 접근 방식
  • 계약에서 예외를 발생시키고 실패하게 되는 모든 조건을 기술하는 대신 코드의 모든 부분을 유효하지 않은 것으로부터 스스로 보호할 수 있게 하는 것
    • 예상할 수 있는 시나리오의 오류를 처리 - 에러 핸들링 프로시져
    • 발생하지 않아야 하는 오류를 처리하는 방법 - assertion error

에러 핸들링

  • 일반적으로 데이터 입력확인 시 자주 사용
  • 목적은 예상되는 에러에 대해서 실행을 계속할지/ 프로그램을 중단할지 결정하는 것

에러처리방법

  • 값 대체(value substitution)
  • 에러 로깅
  • 예외 처리

값 대체

  • 일부 시나리오에서 오류가 있어 소프트웨어가 잘못된 값을 생성하거나 전체가 종료될 위험이 있을 경우 결과 값을 안전한 다른 값으로 대체하는 것
  • 항상 가능하지는 않고 신중하게 선택해야 함 (견고성과 정확성 간의 trade-off)
  • 정보가 제공되지 않을 경우 기본 값을 제공할 수도 있음
import os

configuration = {"dbport": 5432}
print(configuration.get("dbhost", "localhost"))  # localhost
print(configuration.get("dbport"))  # 5432

print(os.getenv("DBHOST"))  # None

print(os.getenv("DPORT", 5432))  # 5432
  • 두번째 파라미터 값을 제공하지 않으면 None을 반환

사용자 정의함수에서도 파라미터의 기본 값을 직접 정의할 수 있음

def connect_database(host="localhost", port=5432):
    pass
  • 일반적으로 누락된 파라미터를 기본 값으로 바꾸어도 큰 문제가 없지만 오류가 있는 데이터를 유사한 값으로 대체하는 것을 더 위험하여 일부 오류를 숨겨버릴 수 있음

예외처리

어떤 경우에는 잘못된 데이터를 사용하여 계속 실행하는 것보다는 차라리 실행을 멈추는 것이 더 좋을 수 있음

  • 입력이 잘못되었을 때만 함수에 문제가 생기는 것이 아님 (외부 컴포넌트에 연결되어 있는 경우)
  • 이런 경우에는 함수 자체의 문제가 아니기 때문에 적절하게 인터페이스를 설계하면 쉽게 디버깅 할 수 있음

⇒ 예외적인 상황을 명확하게 알려주고 원래의 비즈니스 로직에 따라 흐름을 유지하는 것이 중요

정상적인 시나리오나 비즈니스 로직을 예외처리하려고 하면 프로그램의 흐름을 읽기가 어려워짐

→ 예외를 go-to문처럼 사용하는 것과 같다. 올바른 위치에서 추상화를 하지 못하게 되고 로직을 캡슐화하지도 못하게 됨.

마지막으로 예외를 대게 호출자에게 잘못을 알려주는 것으로 캡슐화를 약화시키기 때문에 신중하게 사용해야 함→이는 함수가 너무 많은 책임을 가지고 있다는 것을 의미할 수도 있음. 함수에서 너무 많은 예외를 발생시켜야 한다면 여러개의 작은 기능으로 나눌 수 있는지 검토해야 함

올바른 수준의 추상화 단계에서 예외 처리

  • 예외는 오직 한가지 일을 하는 함수의 한 부분이어야 함
  • 서로 다른 수준의 추상화를 혼합하는 예제. deliver_event 메소드를 중점적으로 살펴보면
import logging
import time

logger = logging.getLogger(__name__)

class DataTransport:
    """다른 레벨에서 예외를 처리하는 객체의 예"""

    _RETRY_BACKOFF: int = 5
    _RETRY_TIMES: int = 3

    def __init__(self, connector):
        self._connector = connector
        self.connection = None

    def deliver_event(self, event):
        try:
            self.connect()
            data = event.decode()
            self.send(data)
        except ConnectionError as e:
            logger.info("커넥션 오류 발견: %s", e)
            raise
        except ValueError as e:
            logger.error("%r 이벤트에 잘못된 데이터 포함: %s", event, e)
            raise

    def connect(self):
        for _ in range(self._RETRY_TIMES):
            try:
                self.connection = self._connector.connect()
            except ConnectionError as e:
                logger.info("%s: 새로운 커넥션 시도 %is", e, self._RETRY_BACKOFF)
                time.sleep(self._RETRY_BACKOFF)
            else:
                return self.connection
        raise ConnectionError(f"연결실패 재시도 횟수 {self._RETRY_TIMES} times")

    def send(self, data):
        return self.connection.send(data)
    def deliver_event(self, event):
        try:
            self.connect()
            data = event.decode()
            self.send(data)
        except ConnectionError as e:
            logger.info("커넥션 오류 발견: %s", e)
            raise
        except ValueError as e:
            logger.error("%r 이벤트에 잘못된 데이터 포함: %s", event, e)
            raise
  • ConnectionError와 ValueError는 별로 관계가 없음
  • 매우 다른 유형의 오류를 살펴봄으로써 책임을 어떻게 분산해야 하는지에 대한 아이디어를 얻을 수 있음
    • ConnectionError는 connect 메소드 내에서 처리되어야 함. 이렇게 하면 행동을 명확하게 분리할 수 있다. 메소드가 재시도를 지원하는 경우 메소드 내에서 예외처리를 할 수 있음
    • ValueError는 event의 decode 메소드에 속한 에러로 event를 send 메소드에 파라미터로 전달 후 send 메소드 내에서 예외처리를 할 수 있음
  • 위 내용처럼 구현을 수정하면 deliver_event 메소드에서 예외를 catch할 필요가 없음
def connect_with_retry(connector, retry_n_times: int, retry_backoff: int = 5):
    """<connector>를 사용해 연결을 시도함.
    연결에 실패할 경우 <retry_n_times>회 만큼 재시도
    재시도 사이에는 <retry_backoff>초 만큼 대기

    연결에 성공하면 connection 객체를 반환
    재시도 횟수를 초과하여 연결에 실패하면 ConnectionError 오류 발생

    :param connector: connect() 메소드를 가진 객체
    :param retry_n_times: 연결 재시도 횟수
    :param retry_backoff: 재시도 사이의 대기 시간(초)

    """
    for _ in range(retry_n_times):
        try:
            return connector.connect()
        except ConnectionError as e:
            logger.info("%s: 새로운 커넥션 시도 %is", e, retry_backoff)
            time.sleep(retry_backoff)

    exc = ConnectionError(f"연결 실패 ({retry_n_times}회 재시도)")
    logger.exception(exc)
    raise exc
class DataTransport:
    """추상화 수준에 따른 예외 분리를 한 객체"""

    _RETRY_BACKOFF: int = 5
    _RETRY_TIMES: int = 3

    def __init__(self, connector: Connector) -> None:
        self._connector = connector
        self.connection = None

    def deliver_event(self, event: Event):
        self.connection = connect_with_retry(
            self._connector, self._RETRY_TIMES, self._RETRY_BACKOFF
        )
        self.send(event)

    def send(self, event: Event):
        try:
            return self.connection.send(event.decode())
        except ValueError as e:
            logger.error("%r contains incorrect data: %s", event, e)
            raise
  • deliver_event 메소드 내에서 예외 catch 하는 부분 없어짐

엔드 유저에게 Traceback 노출 금지

  • 보안을 위한 고려사항으로 예외가 전파되도록하는 경우는 중요한 정보를 공개하지 않고 “알 수 없는 문제가 발생했습니다” 또는 “페이지를 찾을 수 없습니다”와 같은 일반적인 메세지를 사용해야 함

비어있는 except 블록 지양

  • 파이썬의 안티패턴 중 가장 악마같은 패턴(REAL 01)으로 어떠한 예외도 발견할 수 업슨 문제점이 있음
try:
    process_data()
except: 
    pass
  • 아무것도 하지 않는 예외 블록을 자동으로 탐지할 수 있도록 CI 환경을 구축하면 좋음
더보기

flake8
pylint

https://pylint.pycqa.org/en/latest/user_guide/messages/warning/bare-except.html

name: Lint Code

on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v2

    - name: Set up Python
      uses: actions/setup-python@v2
      with:
        python-version: '3.x'

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt

    - name: Run flake8
      run: |
        flake8 . --select=E722
      
    - name: Run pylint
      run: |
        find . -name "*.py" | xargs pylint --disable=all --enable=W0702

대안으로 아래 두 항목 동시에 적용하는 것이 좋다

  1. 보다 구체적인 예외처리 (AttributeError 또는 KeyError)
  2. except 블록에서 실제 오류 처리
  • pass를 사용하는 것은 그것이 의미하는 바를 알 수 없기 때문에 나쁜 코드이다
  • 명시적으로 해당 오류를 무시하려면 contextlib.suppress 함수를 사용하는 것이 올바른 방법
import contextlib

with contextlib.suppress(KeyError):
    process_data()

원본 예외 포함

  • raise <e> from <original_exception> 구문을 사용하면 여러 예외를 연결할 수 있음
  • 원본 오류의 traceback 정보가 새로운 exception에 포함되고 원본 오류는 새로운 오류의 원인으로 분류되어 cause 속성에 할당 됨
class InternalDataError(Exception):
    """업무 도메인 데이터의 예외"""

def process(data_dictionary, record_id):
    try:
        return data_dictionary[record_id]
    except KeyError as e:
        raise InternalDataError("데이터가 존재하지 않음") from e

test_dict = {"a": 1}

process(test_dict, "b")

Traceback (most recent call last):
File "/Users/woo-seongchoi/Desktop/CleanCode/ch3/main.py", line 7, in process
return data_dictionary[record_id]
~~~~~~~~~~~~~~~^^^^^^^^^^^
KeyError: 'b'*

The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/Users/woo-seongchoi/Desktop/CleanCode/ch3/main.py", line 14, in <module>
process(test_dict, "b")
File "/Users/woo-seongchoi/Desktop/CleanCode/ch3/main.py", line 9, in process
raise InternalDataError("데이터가 존재하지 않음") from e
InternalDataError: 데이터가 존재하지 않음*

 

파이썬에서 assertion 사용하기

  • 절대로 일어나지 않아야 하는 상황에 사용되므로 assert 문에 사용된 표현식을 불가능한 조건을 의미로 프로그램을 중단시키는 것이 좋다
try: 
    assert condition.holds(), "조건에 맞지 않음"
except AssertionError:
    alternative_procedure() # catch 후에도 계속 프로그램을 실행하면 안됨

위 코드가 나쁜 또 다른 이유는 AssertionError를 처리하는 것 이외에 assertion 문장이 함수라는 것

assert condition.holds(), "조건에 맞지 않음"
  • 함수 호출은 부작용을 가질 수 있으며 항상 반복가능하지 않음. 또한 디버거를 사용해 해당 라인에서 중지하여 오류 결과를 편리하게 볼 수 없으며 다시 함수를 호출한다 하더라도 잘못된 값이었는지 알 수 없음
result = condition.holds()
assert result > 0, f"Error with {result}"

예외처리와 assertion의 차이

  • 예외처리는 예상하지 못한 상황을 처리하기 위한 것 ⇒ 더 일반적
  • assertion은 정확성을 보장하기 위해 스스로 체크하는 것
반응형
반응형

이글은 책 "파이썬 클린 코드" ch2의 내용을 읽고 요약 및 추가한 내용입니다. 

 

 

pythonic 코드란?

  • 일종의 python 언어에서 사용되는 관용구

 

Pythonic 코드를 작성하는 이유

  • 일반적으로 더 나은 성능을 보임
  • 코드도 더 작고 이해하기 쉬움

 

인덱스와 슬라이스

  • 파이썬은 음수 인덱스를 사용하여 끝에서부터 접근이 가능
my_numbers = (4, 5, 3, 9)
print(my_numbers[-1]) # 9
print(my_numbers[-3]) # 5
  • slice를 이용하여 특정 구간의 요소를 얻을 수 있음
    • 끝 인덱스는 제외
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
print(my_numbers[2:5])  # (2, 3, 5)
print(my_numbers[::]) # (1, 1, 2, 3, 5, 8, 13, 21)

간격 값 조절

  • index를 2칸씩 점프
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
print(my_numbers[1:7:2])  # 1, 3, 8
  • slice 함수를 직접 호출할 수도 있음
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)

interval = slice(1, 7, 2)
print(my_numbers[interval]) # (1, 3, 8)

 

자체 시퀀스 생성

  • indexing 및 slice는 __getitem__ 이라는 매직 메서드 덕분에 동작
  • 클래스가 시퀀스임을 선언하기 위해 collections.abc모듈의 Sequence 인터페이스를 구현해야 함
class C(Sequence):                      # Direct inheritance
    def __init__(self): ...             # Extra method not required by the ABC
    def __getitem__(self, index):  ...  # Required abstract method
    def __len__(self):  ...             # Required abstract method
    def count(self, value): ...         # Optionally override a mixin method
from collections.abc import Sequence

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)

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

컨텍스트 관리자(context manager)

  • 사전 조건과 사후 조건이 있는 일부 코드를 실행해야 하는 상황에 유용
    • 리소스 관리와 관련된 컨텍스트 관리자 자주 볼 수 있음
def process_file(fd):
    line = fd.readline()
    print(line)

fd = open("test.txt")
try:
    process_file(fd)
finally:
		print("file closed")
    fd.close()

123 file closed

똑같은 기능을 매우 우아하게 파이썬 스럽게 구현

def process_file(fd):
    line = fd.readline()
    print(line)

with open("test.txt") as fd:
    process_file(fd)

 

context manager는 2개의 매직 메소드로 구성

  • __enter__ : with 문이 호출
  • __exit__ : with 블록의 마지막 문장이 끄나면 컨텍스트가 종료되고 __exit__가 호출됨

context manager 블록 내에 예외 또는 오류가 있어도 __exit__ 메소드는 여전히 호출되므로 정리 조건을 안정하게 실행하는데 편함

예시: 데이터베이스 백업

  • 백업은 오프라인 상태에서 해야함 (데이터베이스가 실행되고 있지 않는 동안) → 서비스 중지 필요

방법 1

  • 서비스를 중지 → 백업 → 예외 및 특이사항 처리 → 서비스 다시 처리 과정을 단일 함수로 만드는 것
def stop_database():
    run("systemctl stop postgresql.service")

def start_database():
    run("systemctl start postgresql.service")

class DBHandler:
    def __enter__(self):
        stop_database()
        return self

    def __exit__(self, exc_type, ex_value, ex_traceback):
        start_database()

    def db_backup():
        run("pg_dump database")

    def main():
        with DBHandler():
            db_backup()
  • DBHandler 를 사용한 블록 내부에서 context manager 결과를 사용하지 않음
    • __enter__에서 무언가를 반환하는 것이 좋은 습관
  • main() 에서 유지보수 작업과 상관없이 백업을 실행. 백업에 오류가 있어도 여전히 __exit__을 호출
  • __exit__의 반환 값을 잘 생각해야 함. True를 반환하면 잠재적으로 발생한 예외를 호출자에게 전파하지 않고 멈춘다는 뜻으로 예외를 삼키는 것은 좋지 않은 습관

Context manager 구현

  1. contextlib.contextmanager 데코레이터 사용
import contextlib

@contextlib.contextmanager
def db_handler():
    try:
        stop_database()  (1)
        yield            (2)
    finally:
        start_database() (4)

with db_handler():
    db_backup()          (3)

@contextlib.contextmanager

  • 해당 함수의 코드를 context manager로 변환
  • 함수는 generator라는 특수한 함수의 형태여야 하는데 이 함수는 코드의 문장을 __enter__와 __exit__매직 메소드로 분리한다.
    • yield 키워드 이전이 __enter__ 메소드의 일부처럼 취급
    • yield 키워드 다음에 오는 모든 것들을 __exit__로직으로 볼 수 있음

 

2. contextlib.ContextDecorator 클래스 사용

import contextlib

def stop_database():
    print("stop database")

def start_database():
    print("start database")

def run(text):
    print(text)

class dbhandler_decorator(contextlib.ContextDecorator):
    def __enter__(self):
        stop_database()
        return self

    def __exit__(self, ext_type, ex_value, ex_traceback):
        start_database()

@dbhandler_decorator()
def offline_backup():
    run("pg_dump database")

offline_backup()

stop database
pg_dump database
start database

  • with 문이 없고 함수를 호출하면 offline_backup 함수가 context manager 안에서 자동으로 실행됨
  • 원본 함수를 래핑하는 데코레이터 형태로 사용
    • 단점은 완전히 독립적이라 데코레이터는 함수에 대해 아무것도 모름 (사실 좋은 특성)

contextlib 의 추가적인 기능

import contextlib

with contextlib.suppress(DataConversionException):
    parse_data(nput_json_or_dict)
  • 안전하다고 확신되는 경우 해당 예외를 무시하는 기능
  • DataConversionException이라고 표현된 예외가 발생하는 경우 parse_data 함수를 실행

컴프리헨션과 할당 표현식

  • 코드를 간결하게 작성할 수 있고 가독성이 높아짐
def run_calculation(i):
    return i

numbers = []

for i in range(10):
    numbers.append(run_calculation(i))

print(numbers) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

위의 코드를 아래와 같이 바로 리스트 컴프리헨션으로 만들 수 있음

numbers = [run_calculation(i) for i in range(10)]
  • list.append를 반복적으로 호출하는 대신 단일 파이썬 명령어를 호출하므로 일반적으로 더 나은 성능을 보임

dis 패키지를 이용한 어셈블리코드 비교각 assembly 코드 (list comprehension)

import dis

def run_calculation(i):
    return i

def list_comprehension():
    numbers = [run_calculation(i) for i in range(10)]
    return numbers

# Disassemble the list comprehension function
dis.dis(list_comprehension)

def for_loop():
    numbers = []
    for i in range(10):
        numbers.append(run_calculation(i))
    return numbers

# Disassemble the for loop function
dis.dis(for_loop)

 

각 assembly 코드 (list comprehension)

  6           0 LOAD_CONST               1 (<code object <listcomp> at 0x7f8e5a78f710, file "example.py", line 6>)
              2 LOAD_CONST               2 ('list_comprehension.<locals>.<listcomp>')
              4 **MAKE_FUNCTION**            0
              6 LOAD_GLOBAL              0 (range)
              8 LOAD_CONST               3 (10)
             10 **CALL_FUNCTION**            1
             12 GET_ITER
             14 CALL_FUNCTION            1
             16 RETURN_VALUE

 # for loop 
 10           0 BUILD_LIST               0
              2 STORE_FAST               0 (numbers)
 11           4 SETUP_LOOP              28 (to 34)
              6 LOAD_GLOBAL              0 (range)
              8 LOAD_CONST               1 (10)
             10 CALL_FUNCTION            1
             12 GET_ITER
        >>   14 FOR_ITER                16 (to 32)
             16 STORE_FAST               1 (i)
 12          18 LOAD_FAST                0 (numbers)
             20 LOAD_ATTR                1 (append)
             22 LOAD_GLOBAL              2 (run_calculation)
             24 LOAD_FAST                1 (i)
             26 CALL_FUNCTION            1
             28 CALL_METHOD              1
             30 POP_TOP
             32 JUMP_ABSOLUTE           14
        >>   34 POP_BLOCK
 13     >>   36 LOAD_FAST                0 (numbers)
             38 RETURN_VALUE

 

리스트 컴프리헨션 예시

import re
from typing import Iterable, Set

# Define the regex pattern for matching the ARN format
ARN_REGEX = r"arn:(?P<partition>[^:]+):(?P<service>[^:]+):(?P<region>[^:]*):(?P<account_id>[^:]+):(?P<resource_id>[^:]+)"

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """
    collected_account_ids = set()
    for arn in arns:
        matched = re.match(ARN_REGEX, arn)
        if matched is not None:
            account_id = matched.groupdict()["account_id"]
            collected_account_ids.add(account_id)
    return collected_account_ids

# Example usage
arns = [
    "arn:aws:iam::123456789012:user/David",
    "arn:aws:iam::987654321098:role/Admin",
    "arn:aws:iam::123456789012:group/Developers",
]

unique_account_ids = collect_account_ids_from_arns(arns)
print(unique_account_ids)
# {'123456789012', '987654321098'}

위 코드 중 collect_account_ids_from_arns 함수를 집중해서 보면,

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """
    collected_account_ids = set()
    for arn in arns:
        matched = re.match(ARN_REGEX, arn)
        if matched is not None:
            account_id = matched.groupdict()["account_id"]
            collected_account_ids.add(account_id)
    return collected_account_ids

위 코드를 컴프리헨션을 이용해 간단히 작성 가능

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """

    matched_arns = filter(None, (re.match(ARN_REGEX, arn) for arn in arns))
    return {m.groupdict()["account_id"] for m in matched_arns}

python 3.8이후에는 할당표현식을 이용해 한문장으로 다시 작성 가능

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """

    return {
        matched.groupdict()["account_id"]
        for arn in arns
        if (matched := re.match(ARN_REGEX, arn)) is not None
    }
  • 정규식 이용한 match 결과들 중 None이 아닌 것들만 matched 변수에 저장되고 이를 다시 사용

더 간결한 코드가 항상 더 나은 코드를 의미하는 것은 아니지만 분명 두번째나 세번째 코드가 첫번째 코드보다는 낫다는 점에서는 의심의 여지가 없음

 

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

파이썬에서의 밑줄

class Connector:
    def __init__(self, source):
        self.source = source
        self._timeout = 60

conn = Connector("postgresql://localhost")
print(conn.source)  # postgresql://localhost
print(conn._timeout)  # 60

print(conn.__dict__)  # {'source': 'postgresql://localhost', '_timeout': 60}
  • source와 timeout이라는 2개의 속성을 가짐
    • source는 public, timeout은 private
    • 하지만 실제로는 두 개의 속성에 모두 접근 가능
  • _timeout는 connector 자체에서만 사용되고 바깥에서는 호출하지 않을 것이므로 외부 인터페이스를 고려하지 않고 리팩토링 가능

2개의 밑줄은? (__timeout) → name mangling 으로 실제로 다른 이름을 만듦

  • _<classname>__<attribute-name>
class Connector:
    def __init__(self, source):
        self.source = source
        self.__timeout = 60

conn = Connector("postgresql://localhost")
print(conn.source)  # postgresql://localhost

print(conn.__dict__)  
# {'source': 'postgresql://localhost', '_Connector__timeout': 60}
  • __timeout → 실제 이름은_Connector__timeout 이 됨
  • 이는 여러번 확장되는 클래스의 메소드 이름을 충돌없이 오버라이드 하기 위해 만들어진거로 pythonic code의 예가 아님

결론

⇒ 속성을 private으로 정의하는 경우 하나의 밑줄 사용

 

프로퍼티(Property)

class Coordinate:
    def __init__(self, lat: float, long: float) -> None:
        self._latitude = self._longitude = None
        self.latitude = lat
        self.longitude = long

    @property
    def latitude(self) -> float:
        return self._latitude
    
    @latitude.setter
    def latitude(self, lat_value: float) -> None:
        print("here")
        if lat_value not in range(-90, 90+1):
            raise ValueError(f"유호하지 않은 위도 값: {lat_value}")
        self._latitude = lat_value

    @property
    def longitude(self) -> float:
        return self._longitude
    
    @longitude.setter
    def longitude(self, long_value: float) -> None:
        if long_value not in range(-180, 180+1):
            raise ValueError(f"유효하지 않은 경도 값: {long_value}")
        self._longitude = long_value

coord = Coordinate(10, 10)
print(coord.latitude)

coord.latitude = 190 # ValueError: 유호하지 않은 위도 값: 190
  • property 데코레이터는 무언가에 응답하기 위한 쿼리
  • setter는 무언가를 하기 위한 커맨드

둘을 분리하는 것이 명령-쿼리 분리 원칙을 따르는 좋은 방법

보다 간결한 구문으로 클래스 만들기

객체의 값을 초기화하는 일반적인 보일러플레이트

  • 보일러 플레이트: 모든 프로젝트에서 반복해서 사용하는 코드
def __init__(self, x, y, ...):
    self.x = x
    self.y = y
  • 파이썬 3.7부터는 dataclasses 모듈을 사용하여 위 코드를 훨씬 단순화할 수 있다 (PEP-557)
    • @dataclass 데코레이터를 제공
  • 클래스에 적용하면 모든 클래스의 속성에 대해서 마치 __init__ 메소드에서 정의한 것처럼 인스턴스 속성으로 처리
  • @dataclass 데코레이터가 __init__ 메소드를 자동 생성
  • field라는 객체 제공해서 해당 속성에 특별한 특징이 있음을 표시
    • 속성 중 하나가 list처럼 변경가능한 mutable 데이터 타입인 경우 __init__에서 비어 있는 리스트를 할당할 수 없고 대신에 None으로 초기화한 다음에 인스턴스마다 적절한 값으로 다시 초기화 해야함

 

from dataclasses import dataclass

@dataclass
class Foo:
    bar: list = []

# ValueError: mutable default <class 'list'> for field a is not allowed: use default_factory
  • 안되는 이유는 위의 bar 변수가 class variable이라 모든 Foo 객체들 사이에서 공유되기 때문
class C:
  x = [] # class variable

  def add(self, element):
    self.x.append(element)

c1 = C()
c2 = C()
c1.add(1)
c2.add(2)
print(c1.x)  # [1, 2]
print(c2.x)  # [1, 2]

 

아래처럼 default_factory 파라미터에 list 를 전달하여 초기값을 지정할 수 있도록 하면 됨

from dataclasses import dataclass, field

@dataclass
class Foo:
    bar = field(default_factory=list)

__init__ 메소드가 없는데 초기화 직후 유효성 검사를 하고 싶다면?

⇒ __post_init__에서 처리 가능

반응형
반응형

이글은 책 "파이썬 클린 코드" ch1의 내용을 읽고 요약 및 추가한 내용입니다. 

클린 코드의 의미


  • 기계나 스크립트가 판단할 수 없고 전문가가 판단할 수 있는 것
  • 프로그래밍 언어란 아이디어를 다른 개발에게 전달하는 것이고 여기에 클린 코드의 진정한 본질이 있다

⇒ 클린 코드를 정의하기 보다는 좋은 코드 나쁜 코드의 차이점을 확인하고 훌륭한 코드와 좋은 아키텍쳐를 식별하여 자신만의 정의를 하는 것이 좋음

클린코드의 중요성


  1. 민첩한 개발과 지속적인 배포가 가능
  • 유지보수가 가능한 상태로 가독성이 높아야 기획자가 새롭게 기능을 요구할 때마다 리팩토링을 하고 기술 부채를 해결하느라 시간이 오래 걸리지 않음
  1. 기술부채 발생
  • 잠재적인 문제로 언젠가는 돌발 변수가 될 수 있음

클린 코드에서 코드 포매팅의 역할


PEP (Python Enhancement Proposal)

  • PEP 8: Style Guide for Python Code 로 가장 잘 알려진 표준이며 띄어쓰기, 네이밍 컨벤션, 줄 길이 제한 등의 가이드라인을 제공

클린 코드를 위한 보조적인 역할, PEP8을 100% 준수한다고 하더라도 여전히 클린 코드의 요건을 충족하지 못할 수 있음

PEP8 특징

  1. 검색 효율성
  • PEP8: 변수에 값을 할당하는 경우와 함수의 키워드 파라미터에 값을 할당하는 경우를 구분
# core.py
#
#
#

def get_location(location: str = ""):
    pass

#변수에 값을 할당할 때 띄어쓰기 사용 O
current_location = get_location() 

#키워드 인자에 값을 할당할 때 띄어쓰기사용 X
get_location(location=current_location) 

  • location이라는 키워드 인자에 값이 할당되는 경우를 찾는 경우

$ grep -nr “location=” ./core.py:13:get_location(location=current_location)

  • nr 옵션
    • n : line number 표시
    • r : 해당 디렉토리에서 recursive 하게 subdirectory도 검색
  • location이라는 변수에 값이 할당되는 경우를 찾는 경우

$ grep -nr “location =” ./core.py:10:current_location = get_location()

  1. 일관성
  2. 더 나은 오류 처리
  3. 코드 품질

문서화(Documentation)


코드주석(Code comments)

  • 가능한 한 적은 주석을 갖는 것을 목표로 해야 함
    • 주석 처리된 코드는 절대 없어야 함

Docstring

  • 소스 코드에 포함된 문서 (리터럴 문자열)
  • 내가 작성한 컴포넌트를 다른 엔지니어가 사용하려고 할 때 docstring을 보고 동작방식과 입출력 정보등을 확인 할 수 있어야 함
  • Python은 동적인 데이터 타입을 갖기 때문에 docstring이 큰 도움이 됨

docstring은 코드에서 분리되거나 독립된 것이 아니라 일부

단점은 지속적으로 수작업을 해야 한다는 것 (코드가 변경되면 업데이트를 해야함)

⇒ 가치 있는 문서를 만들기 위해 모든 팀원이 문서화에 노력해야 함

어노테이션

  • PEP-3107에서 어노테이션을 소개
    • 코드 사용자에게 함수 인자로 어떤 값이 와야 하는지 힌트를 주자는 것
from dataclasses import dataclass

@dataclass
class Point:
    lat: float
    long: float

def locate(latitude: float, longitude: float) -> Point:
    """맵에서 좌표에 해당하는 객체를 검색"""
    pass

  • 함수 사용자에게 힌트를 주지만 파이썬이 타입을 검사하거나 강제하지는 않음

어노테이션으로 타입만 지정할 수 있는 것은 아니고, 인터프리터에서 유효한 어떤 것(변수의 의도를 설명하는 문자열, 콜백이나 유효성 검사 함수로 사용할 수 있는 callable)도 가능

EX) 몇 초 후에 어떤 작업을 실행하는 함수

def launch_task(delay_in_seconds):
    pass
  • delay_in_seconds 파라미터는 긴 이름을 가지고 있어 많은 정보를 담고 있는 것 같아 보이지만 사실 충분한 정보를 제공하지 못함
    • 허용 가능한 지연시간은 몇초?
    • 분수를 입력해도 되나?
Seconds = float
def launch_task(delay: Seconds):
    pass
  • Seconds 어노테이션을 사용하여 시간을 어떻게 해석할지에 대해 작은 추상화 진행
  • 나중에 입력 값의 형태를 변경하기로 했다면 이제 한 곳에서만 관련 내용을 변경하면 됨
    • Seconds = float

어노테이션을 사용하면 __annotations__ 이라는 특수한 속성이 생김

  • 어노테이션의 이름과 값을 매핑한 dictionary

아래 locate함수에 대해 __annotations__ 을 출력해보면,

from dataclasses import dataclass

@dataclass
class Point:
    lat: float
    long: float

def locate(latitude: float, longitude: float) -> Point:
    """맵에서 좌표에 해당하는 객체를 검색"""
    pass

print(locate.__annotations__)

{'latitude': <class 'float'>, 'longitude': <class 'float'>, 'return': <class 'main.Point'>}

타입 힌트는 단순히 데이터 타입을 확인하는 것이 아니라 유의미한 이름을 사용하거나 적절한 데이터 타입 추상화를 하도록 도와줄 수 있음

def process_clients(clients: list):

def process_clients(clients: list[tuple[int, str]]):

...

from typing import Tuple
Client = Tuple[int, str]
def process_clients(clients: list[Client]):

어노테이션을 도입하면 클래스를 보다 간결하게 작성하고 작은 컨테이너 객체를 쉽게 정의 가능

  • @dataclass 데코레이터를 사용하면 별도의 __init__ 메소드에서 변수를 선언하고 할당하는 작업을 하지 않아도 바로 인스턴스 속성으로 인식
# Before
class Point:
    def __init__(self, lat, long):
        self.lat = lat
        self.long = long
# After
from dataclasses import dataclass
@dataclass
class Point:
    lat: float
    long: float
# After
from dataclasses import dataclass

@dataclass
class Point:
    lat: float
    long: float

print(Point.__annotations__) 
# {'lat': <class 'float'>, 'long': <class 'float'>}
print(Point(1, 2))
# Point(lat=1, long=2)

Q. 어노테이션은 docstring을 대체하는 것일까?

  • 둘은 상호보완적인 개념
def data_from_response(response: dict) -> dict:
    if response["status"] != 200:
        raise ValueError
    return {"data": response["payload"]}
  • input, output 형식에 대해서는 알 수 있지만 상세한 내용은 알 수 없음
    • ex) respone 객체의 올바른 instance 형태

상세한 내용에 대해서 docstring으로 보완할 수 있음

def data_from_response(response: dict) -> dict:
    """response의 HTTP status가 200이라면 response의 payload를 반환
    
    
    - response의 예제::
    {
        "status": 200, # <int>
        "timestamp": "....", # 현재 시간의 ISO 포맷 문자열
        "payload": {...} # 반환하려는 dictionary 데이터
    }
    
    
    """
    if response["status"] != 200:
        raise ValueError
    return {"data": response["payload"]}
  • input, output의 예상 형태를 더 잘 이해할 수 있고 단위 테스트에서도 유용한 정보로 사용됨

도구설정

  • 반복적인 확인 작업을 줄이기 위해 코드 검사를 자동으로 실행하는 기본도구설정

데이터 타입 일관성 검사

  • mypy, pytype 등의 도구를 CI build에 포함시킬 수 있음
$ pip install mypy
from typing import Iterable
import logging

logger = logging.getLogger()

def broadcast_notification(message: str, relevant_user_emails: Iterable[str]):
    for email in relevant_user_emails:
        logger.warning(f"{message} 메세지를 {email}에게 전달")

broadcast_notification("welcome", "user1@domain.com")
# mypy가 오류 내뱉지 않음
$ mypy core.py  
Success: no issues found in 1 source file

welcome 메세지를 u에게 전달
welcome 메세지를 s에게 전달
welcome 메세지를 e에게 전달
welcome 메세지를 r에게 전달
welcome 메세지를 1에게 전달
welcome 메세지를 @에게 전달
welcome 메세지를 d에게 전달
welcome 메세지를 o에게 전달
welcome 메세지를 m에게 전달
welcome 메세지를 a에게 전달
welcome 메세지를 i에게 전달
welcome 메세지를 n에게 전달
welcome 메세지를 .에게 전달
welcome 메세지를 c에게 전달
welcome 메세지를 o에게 전달
welcome 메세지를 m에게 전달

  • 잘못된 호출. 문자열 또한 iterable 객체이므로 for 문이 정상 동작하지만 유효한 이메일 형식이 아님

리스트나 튜플만 허용하도록 더 강력한 타입 제한을 주면,

from typing import List, Tuple, Union
import logging

logger = logging.getLogger()

def broadcast_notification(
    message: str, relevant_user_emails: Union[List[str], Tuple[str]]
):
    for email in relevant_user_emails:
        logger.warning(f"{message} 메세지를 {email}에게 전달")

broadcast_notification("welcome", "user1@domain.com")
> mypy core.py
core.py:14: error: Argument 2 to "broadcast_notification" has incompatible type "str"; expected "list[str] | tuple[str]"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

일반적인 코드 검증

  • 데이터 타입 이외에도 일반적인 유형의 품질 검사도 가능
  • pycodestyle(pep8), flake8
  • 더 엄격한 pylint

자동 포매팅

  • black formatter
  • PEP-8보다 엄격하게 포매팅하여 문제의 핵심에 보다 집중
  • —check 옵션을 사용해 코드를 포맷하지않고 표준을 준수하는지 검사만 하는 것도 가능
    • CI 프로세스에 통합하여 유용하게 사용될 수 있음

자동 검사 설정

  • 리눅스 개발환경에서 빌드를 자동화하는 가장 일반적인 방법은 Makefile을 사용하는 것
 
반응형

+ Recent posts