곰퓨타의 SW 이야기

CH 3. 좋은 코드의 일반적인 특징 본문

TIL/클린코드

CH 3. 좋은 코드의 일반적인 특징

곰퓨타 2023. 5. 7. 22:38

1. 계약에 의한 디자인

Contract

디자인할 때, 예상되는 입출력, 부작용에 대해서 명시하는 부분

DbC (Design by Contract) : 코드에 암묵적으로 기대하는 바를 추가하고, 잘못된 경우 명시적으로 예외 발생

 

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

  • 사전 조건 : 코드 실행 전 확인해야할 것 (ex. 데이터베이스, 파일, 이전에 호출된 다른 메서드 검사 등)
  • 사후 조건 : 함수 변환값의 유효성 검사
  • 불변식 (invariant) : docstring으로 불변식에 대해 문서화
  • 부작용 (side-effect) : docstring에 코드의 부작용 언급

 

계약하는 이유 : 오류를 쉽게 찾아낼 수 있음 + 잘못된 가정 하에 코드의 핵심 부분이 실행되는 것을 방지하기 위함

 

Pythonic Contract ? Function과 class에 예외를 발생시키는 것 (필요 시, 사용자 정의 예외)

 

2. 방어적 (defensive) 프로그래밍

예외 발생 및 실패 조건을 기술하는 것이 아닌,

객체, 함수 또는 메서드와 같은 코드의 모든 부분을 유효하지 않은 것으로부터 스스로 보호할 수 있게 하는 것

 

  • Error handling : 예상할 수 있는 시나리오의 오류 처리 방법
  • Assertion : 발생핮 않아야 하는 오류 처리 방법

 

Error handling

목적

  • 에러에 대해 실행을 계속할 수 있을지, 극복할 수 없어 프로그램을 중단할 지 결정하기 위함

에러 처리 방법

  • 값 대체 (value substitution) : 정합성을 깨지 않는 다른 값으로 대체 기본 값 혹은 잘 알려진 상수 (0), 초기값 설정해줌.
  • 에러 로깅
  • 에러 처리
    • 올바른 수준의 추상화 단계에서 예외 처리, 엔드 유저에게 Traceback 노출 금지, 비어 있는 except 블록 지양, 원본 예외 포함

애플리케이션에서 디코딩한 데이터를 -> 외부 컴포넌트에 전달하는 객체 DataTransport가 있다고 가정해보자.

(기존)

class DataTransport:
    """An example of an object handling exceptions of different levels."""

    _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):
        try:
            self.connect()
            data = event.decode()
            self.send(data)
        except ConnectionError as e:
            logger.info("connection error detected: %s", e)
            raise
        except ValueError as e:
            logger.error("%r contains incorrect data: %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: attempting new connection in %is",
                    e,
                    self._RETRY_BACKOFF,
                )
                time.sleep(self._RETRY_BACKOFF)
            else:
                return self.connection
        raise ConnectionError(
            f"Couldn't connect after {self._RETRY_TIMES} times"
        )

    def send(self, data: bytes):
        return self.connection.send(data)

def deliver_event(self, event)에서 ValueError와 ConnectionError는 연관이 없다. 이는 책임을 분리할 필요가 있다.

 

(new)

def connect_with_retry(
    connector: Connector, retry_n_times: int, retry_backoff: int = 5
):
    """Tries to establish the connection of <connector> retrying
    <retry_n_times>, and waiting <retry_backoff> seconds between attempts.
    If it can connect, returns the connection object.
    If it's not possible to connect after the retries have been exhausted, raises ``ConnectionError``.
    :param connector:         An object with a ``.connect()`` method.
    :param retry_n_times int: The number of times to try to call
                                ``connector.connect()``.
    :param retry_backoff int: The time lapse between retry calls.
    """
    for _ in range(retry_n_times):
        try:
            return connector.connect()
        except ConnectionError as e:
            logger.info(
                "%s: attempting new connection in %is", e, retry_backoff
            )
            time.sleep(retry_backoff)
    exc = ConnectionError(f"Couldn't connect after {retry_n_times} times")
    logger.exception(exc)
    raise exc


class DataTransport:
    """An example of an object that separates the exception handling by
    abstraction levels.
    """

    _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

Assertion

  • 절대로 일어나야 하지 않아야 하는 상황에서 사용된다.
    • 이는 더이상 처리가 불가능한 상황을 의미하므로 catch 후에 프로그램을 계속 실행하면 안된다.
  • 항상 변하지 않는 고정되 조건에 대해 검증할 때 사용된다.
  • Assertion Error은 예외를 처리하지 않는다.
  • 참이어야 하는 Boolean 조건
# unpythonic

try :
	assert condition.holds(), "Invalid condition"
except AssertError :
	alternatie_procedure()
    
    
# pythonic
	
result = condition.holds()
assert result > 0, f"Error on holds: {result}"

Unpythonic 예제에서,

AssertionError 처리하는 것 외에도 assert 문장이 함수여서 문제이다.

 

3. 관심사의 분리

  • 책임이 다르면 컴포넌트, 계층, 모듈로 분리되어야 한다.
  • 파급 (ripple) 효과 : 어느 지점에서의 변화가 전체로 전파되는 것
    •  파급 효과 ↓ -> 유지 보수성 ↑
  • 응집력(cohesion)과 결합력 (coupling)
    • 응집력 : 객체가 작고 잘 정의된 목적을 가져야 하며 가능한 작아야 한다!
      • 응집력 ↑ -> 재사용성 ↑
    • 결합력 : 두 개 이상의 객체가 서로 어떻게 의존하는지를 나타낸다!
      • 결합력  -> 재사용성 ↑

 

4. 개발 지침 약어

중복을 피할 것! : 코드에 있는 지식은 단 한번, 단 한 곳에 정의되어야 한다.

  • DRY / do not repeat yourself
  • OAOO / once and only once

 

단순할수록 좋다! : 과잉개발 멈춰!

  • YAGNI / you ain't gonna need it
  • KIS / Keep it simple

 

명시적인 것이 좋다!

  • EAFP / easier to ask forgiveness than permission : 일단 코드 실행 후, 동작하지 않으면 대응! -> EAFP가 좀 더 pythonic 하다. 예외 처리가 필욯나 부분으로 바로 이동하므로 가독성이 높다!
  • LBYL / look before you leap : 도약하기 전에 미리 살펴라!
# LBYL
if os.path.exists(filename) :
	with open(filename) as f :
    	...
        
 # EAFP
try :
 	with open(filename) as f:
    	...
except FileNotFoundError as e :
	logger.error(e)

 

5. 상속

나쁜 경우

대부분의 Method를 재정의하거나 대체하는 경우

* Method 의 전체를 사용하는지 생각해볼 필요가 있음.

 

  • Super Class (상위클래스)는 잘 정의된 인터페이스 대신 막연한 정의와 너무 많은 책임을 가졌다.
  • inherited Class (하위 클래스)는 확장하려고 하는 상위 클래스의 적절한 세분화가 아니다.

 

좋은 경우

Public method와 attribute를 interface로 잘 정의한 컴포넌트

Component class의 기능을 상속받으면서,

추가 기능을 더하는 경우 or 특정 기능만 수정하는 경우

ex ) BaseHttpRequestHandler

except Exception : 모든 예외가 Exception을 상속받았으므로, 모든 에러 캐치가 가능하다.

 

 

예시

(bad)

import collections
from datetime import datetime


class TransactionalPolicy(collections.UserDict):
    """Example of an incorrect use of inheritance."""

    def change_in_policy(self, customer_id, **new_policy_data):
        self[customer_id].update(**new_policy_data)
  • clear, copy, data, fromkeys, get, items, .., 매직 메서드 & 특수 메서드 등 불필요한 메서드가 많다!
  • TransactionPolicy는 특정 도메인의 정보를 나타내는 것이므로 일부분에 사용되는 엔티티여야 한다!

 

(good)

from datetime import datetime

class TransactionalPolicy:
    """Example refactored to use composition."""

    def __init__(self, policy_data, **extra_data):
        self._data = {**policy_data, **extra_data}

    def change_in_policy(self, customer_id, **new_policy_data):
        self._data[customer_id].update(**new_policy_data)

    def __getitem__(self, customer_id):
        return self._data[customer_id]

    def __len__(self):
        return len(self._data)
  • 컴포지션 사용 -> Trans.Policy 자체가 사전이 되는 것이 아닌 사전을 활용하기!
  • Class의 일부만 사용하고 싶은 (상속이 싫은 경우)
    • 상속의 경우, 부모가 바뀌면 자식도 업데이트가 필요하다.

 

파이썬의 다중 상속

  • 메서드 결정 순서 : MRO 알고리즘 사용
    • 호출된 자식 클래스 먼저 확인 -> 상속된 클래스를 나열한 순서대로 확인한다.
    • [cls.__name__ for cls in ConcreteModuleA12.mro()]와 같은 방식으로 호출 결정 순서 확인 가능
    • 예시는 "ConcreteModuleA12", "BaseModule1", "BaseModule2", "BaseModule", "object" 순서로 호출된다.

class BaseModule:
    module_name = "top"

    def __init__(self, module_name):
        self.name = module_name

    def __str__(self):
        return f"{self.module_name}:{self.name}"


class BaseModule1(BaseModule):
    module_name = "module-1"


class BaseModule2(BaseModule):
    module_name = "module-2"


class BaseModule3(BaseModule):
    module_name = "module-3"


class ConcreteModuleA12(BaseModule1, BaseModule2):
    """Extend 1 & 2
    >>> str(ConcreteModuleA12('name'))
    'module-1:name'
    """


class ConcreteModuleB23(BaseModule2, BaseModule3):
    """Extend 2 & 3
    >>> str(ConcreteModuleB23("test"))
    'module-2:test'
    """

 

  • 믹스인 (Mixin)
    • 코드 재사용을 위해 일반적인 행동을 캡슐화해놓은 부모 클래스
    • 다른 클래스 & 믹스인 클래스를 다중 상속
    • 믹스인 클래스의 메서드와 속성을 다른 클래스에서 활용한다.

 

class BaseTokenizer:	# 문자열을 받아서 하이픈으로 구분된 값을 반환하는 파서
    """
    >>> tk = BaseTokenizer("28a2320b-fd3f-4627-9792-a2b38e3c46b0")
    >>> list(tk)
    ['28a2320b', 'fd3f', '4627', '9792', 'a2b38e3c46b0']
    """

    def __init__(self, str_token):
        self.str_token = str_token

    def __iter__(self):
        yield from self.str_token.split("-")


class UpperIterableMixin:	# 특정 클래스에 대해 기본 클래스를 변경하지 않고 대문자로 변환
    def __iter__(self):
        return map(str.upper, super().__iter__())


# mixin을 통해 UpperIterableMixin + BaseTokenizer 모두 수행
class Tokenizer(UpperIterableMixin, BaseTokenizer):
    """
    >>> tk = Tokenizer("28a2320b-fd3f-4627-9792-a2b38e3c46b0")
    >>> list(tk)
    ['28A2320B', 'FD3F', '4627', '9792', 'A2B38E3C46B0']
    """


# >>> [cls.__name__ for cls in Toknizer.mro()]
['Tokenizer', 'UpperIterableMixin', 'BaseTokenizer', 'object']

 

6. 함수 메서드의 인자

파이썬의 함수 인자 동작 방식

  • Pass by value : 모든 인자가 값에 의해 전달된다.
  • 변경 가능한 객체를 전달하고 함수 내부에서 값을 변경하면 결과에서 실제 값이 변경될 수 있다.
>>> def function(arg) :
...	arg += "in function"
...	print(arg)
...

 

변경 불가능한 경우 (ex. String)

>>> immutable = "hello"
>>> function(immutable)
helloin function
>>> immutable
'hello'

 

변경 가능한 경우 (ex. List)

Pass by value 시 주소 값이 전달된다!

>>> mutable = list("hello")
>>> mutable
['h', 'e', 'l', 'l', 'o']
>>> function(mutable)
['h', 'e', 'l', 'l', 'o', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
>>> mutable
['h', 'e', 'l', 'l', 'o', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']

 

가변인자 

인자의 개수가 변하는 인자

  • 별표 (*, **) 를 통해 여러 인자를 packing 하여 함수에 전달하고 부분적으로 unpacking 할 수 있다.
>>> def f(first, second, third) :
...	print(first)
...	print(second)
...	print(third)
...
>>> l = [1,2,3]
>>> f(*l)
1
2
3

>>> a,b,c = [1,2,3]
>>> a
1
>>> b
2
>>> c
3
>>> def show(e, rest) :
...	print("요소 : {0} - 나머지 : {1}".format(e, rest))
...
>>> first, *rest = [1,2,3,4,5]
>>> show(first, rest)
요소 : 1 - 나머지 : [2, 3, 4, 5]
>>> first, *middle, last = range(6)
>>> first
0
>>> middle
[1,2,3,4]
>>> last
5

 

cf ) keyword argument (**) : 임의의 인자 허용 (dict.)

>>> def function(**kwargs) :
...	print(kwargs)
...
>>> function(key="value")
{'key' : 'value'}

 

  • 변수 unpacking을 사용하기에 "반복"은 좋다
"""Clean Code in Python - Chapter 3: General Traits of Good Code

> Packing / unpacking
"""
from dataclasses import dataclass


USERS = [(i, f"first_name_{i}", f"last_name_{i}") for i in range(1_000)]


@dataclass
class User:
    user_id: int
    first_name: str
    last_name: str

# DB 레코드로부터 User를 생성하는 파이썬스럽지 않은 나쁜 코드
def bad_users_from_rows(dbrows) -> list:
    """A bad case (non-pythonic) of creating ``User``s from DB rows."""
    return [User(row[0], row[1], row[2]) for row in dbrows]


# DB 레코드로부터 User 만들기
def users_from_rows(dbrows) -> list:
    """Create ``User``s from DB rows."""
    return [
        User(user_id, first_name, last_name)
        for (user_id, first_name, last_name) in dbrows
    ]

# DB 레코드로부터 User 만들기 : positional parameter 넘기는 경우
def users_from_rows2(dbrows) -> list:
    """Create ``User``s from DB rows."""
    return [User(*row) for row in dbrows]

 

기본함수

위치 전용, 키워드 전용 모두 가능하다!

>>> def my_function(x,y):
...	print(f"{x=}, {y=}")
...
>>> my_function(1,2)
x=1, y=2
>>> my_function(x=1, y=2)
x=1, y=2

 

위치 전용 (positional-only) 인자

>>> def my_function2(x,y,/):
...     print(f"{x=} {y=}")
...
>>> my_function2(1,2)
x=1 y=2
>>> my_function2(x=1, y=2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: my_function2() got some positional-only arguments passed as keyword arguments: 'x, y'

 

키워드 전용 (keyword-only) 인자

>>> def my_function3(x,y,*args, kw1, kw2=0) :   # *args 이후 키워드 인자만 받겠다.
...     print(f"{x=}, {y=}, {kw1=}, {kw2=}")
...
>>> my_function3(1,2,kw1=3, kw2=4)
x=1, y=2, kw1=3, kw2=4
>>> my_function3(1,2,kw1=3)
x=1, y=2, kw1=3, kw2=0

 

함수 인자의 개수

  • 함수가 너무 많은 인자를 가진다면 -> 나쁜 디자인일 가능성이 높다.
    • 1. 구체화 (reification)을 통해 전달하는 모든 인자를 포함하는 새로운 객체를 만든다.
    • 2. 가변인자나 키워드 인자를 사용하여 동적 서명을 가진 함수를 만든다.
      • 동적이므로 너무 남용하지 않도록 주의!
    • 3. 한 가지 기능으로 하는 함수를 분리해라!

 

  • 함수 인자와 결합력
    • 함수 인자가 많을수록 호출하기 위한 정보 수집하기 어렵다.
    • 함수의 추상화가 적으면 재사용성이 떨어지고 다른 함수에 의존적이게 된다.
    • Code Smell이 아닌지 검토 필요!

 

  • 많은 인자를 가진 함수의 서명 최소화
    • 공통 객체에 파라미터 대부분이 포함되어 있는 경우 리팩토링 하기

 

7. 소프트웨어 디자인 우수 사례 결론

소프트웨어 독립성 (orthogonality)

  • 모듈, 클래스 또는 함수를 변경하면 수정한 컴포넌트가 외부 세계에 영향을 미치지 않아야 한다.
  • 어떤 객체의 메서드를 호출하는 것은 다른 관련 없는 개체의 내부 상태를 변경해서는 안 된다.

 

예시)

"""Clean Code in Python - Chapter 3: General traits of good code

> Orthogonality

"""


def calculate_price(base_price: float, tax: float, discount: float) -> float:
    """
    >>> calculate_price(10, 0.2, 0.5)
    6.0

    >>> calculate_price(10, 0.2, 0)
    12.0
    """
    return (base_price * (1 + tax)) * (1 - discount)


def show_price(price: float) -> str:
    """
    >>> show_price(1000)
    '$ 1,000.00'

    >>> show_price(1_250.75)
    '$ 1,250.75'
    """
    return "$ {0:,.2f}".format(price)


def str_final_price(
    base_price: float, tax: float, discount: float, fmt_function=str
) -> str:
    """

    >>> str_final_price(10, 0.2, 0.5)
    '6.0'

    >>> str_final_price(1000, 0.2, 0)
    '1200.0'

    >>> str_final_price(1000, 0.2, 0.1, fmt_function=show_price)
    '$ 1,080.00'

    """
    return fmt_function(calculate_price(base_price, tax, discount))
  • calculate_price 함수와 show_price 함수는 독립이다.
  • 두 함수에 대한 단위 테스트를 성공하면 전체 테스트가 필요하지 않다!

 

코드 구조

  • __init__.py 파일을 가진 새 디렉토리를 만들면 파이썬 패키지가 만들어진다.
  • __init__.py 에 다른 파일에 있던 모든 정읠ㄹ 가져옴으로써 호환성도 보장할 수 있다.
  • 모듈의 __all__ 변수에 익스포트가 가능하도록 표시할 수도 있다.
    • 모듈을 임포트할 때 구문을 분석하고 메모리에 로드할 객체가 줄어든다.
    • 의존성이 줄었으므로 더 적은 모듈만 가져오면 된다.
# model structure
"""
model
|--- a.py
|--- b.py
┕--- c.py
"""

# model/__init__.py
from model.a import AModel
from model.b import BModel
from model.c import CModel

 

 

  • 프로젝트에서 사용할 상수 값을 저장할 특정 파일을 만들고 임포트 하는 것도 좋다!
from src.constants import OUTPUT_PATH

 

Reference

https://github.com/PacktPublishing/Clean-Code-in-Python-Second-Edition/tree/main/book/src/ch03/src

https://github.com/dream2globe/CleanCodeInPython/blob/master/Ch3_FeaturesOfGoodCodes.md

http://www.yes24.com/Product/Goods/114667254

Comments