파이썬 클린 코드 – 3
좋은 코드의 일반적인 특징
계약에 의한 디자인
- 컴포넌트는 기능을 숨겨 캡슐화하고 함수를 사용할 고객에게는 API를 노출해야 한다.
- 컴포넌트의 함수, 클래스,. 메서드는 유의사항에 따라 동작해야 하며 그렇지 않을 경우 코드가 깨지게 된다.
- 반대로 클라이언트는 특정 응답을 기대하며 이것과 다른 경우 함수 호출에 실패하게 된다.
- 계약에 의한 디자인이란, 관계자가 기대하는 바를 암묵적으로 코드에 삽입하는 대신, 양측이 동의하는 계약을 먼저 한 다음, 계약을 어겼을 경우 명시적으로 왜 계속할 수 없는지 예외를 발생시키라는 것이다.
- 여기서 계약은 소프트웨어 컴포넌트 간의 통신 중에 반드시 지켜져야 할 규칙을 강제하는 것이다.
- 사전 조건: 코드가 실행되기 전에 체크해야하는 것들이다. 함수가 진행되기 전에 처리되어야 하는 모든 조건을 의미한다.
- 문제는 유효성 검사를 어디할지인데, 클라이언트가 함수를 호출할 때 할 것인지, 함수가 자체적으로 검사할 것인지에 대한 문제가 있다.
- 사후 조건: 함수 반환 값의 유효성 검사가 수행된다. 호출자가 기대된 것을 제대로 받았는지 확인한다.
- 불변식: 함수가 실행되는 동안에 일정하게 유지되는 것을 나타낸다.
- 부작용: 코드의 부작용을 docstring에 언급하기도 한다.
- 이상적으로는 이런 부분들을 컴포넌트 계약서에 문서화하는것이 좋지만 사전 조건, 사후 조건은 코드 레벨에서 강제한다.
- 사전 조건은 클라이언트와 관련이 있고, 사후 조건은 컴포넌트와 연관이 있다. 즉 책임 소재를 신속하게 파악할 수 있다.
방어적(defensive) 프로그래밍
- 계약에 의한 디자인과는 달리 방어적 프로그래밍은 객체, 함수, 메서드 등 코드의 모든 부분을 유효하지 않은 것들로부터 스스로 보호할 수 있게 하는 것이다.
에러 핸들링
- 에러 핸들링은 예상되는 에러에 대해 실행을 계속할 수 있을지 아니면 프로그램을 중단할지를 결정하는 것이다.
값 대체
- 잘못된 값을 생성한 경우, 결과 값을 안전한 다른 값으로 대체할 수 있다.
-
항상 값 대체가 가능하지는 않기 때문에 견고성과 정확성 간의 트레이드오프에서 결정을 하여야 한다.
예외 처리
- 어떤 경우에는 호출자에게 실패했음을 알리는 것이 좋은 선택이다.
- 단 예외 상황을 명확히 알려주고 원래의 로직을 따라 흐름을 유지하여야 한다. 예외를 사용하여 로직을 처리하려고 하면 프로그램의 흐름을 읽기 어려워진다.
-
예외는 캡슐화를 약화시킨다. 함수에 예외가 많을 수록 호출자가 호출하는 함수에 대해 더 많은 것을 알아야 한다.
-
에러는 발생한 곳에서 처리하도록 하자.
- Traceback은 노출하는 것에 유의하자.
- 비어있는 except 블록을 사용하지 말자
1 2 3 4 5 |
try: process_data() except: pass |
- 위 코드는 절대 실패하지 않는다.
- 보다 구체적인 예외를 사용하고,
except
블록에서도 실제 오류 처리를 하자.
- 보다 구체적인 예외를 사용하고,
- 기본 예외를 새로운 예외로 래핑할 경우, 원본 예외를 포함하도록 할 수 있다.
1 2 3 4 5 6 7 8 9 |
class InternalDataError(Exception): """This is new exception.""" def process(data_dictionary, record_id): try: return data_dictionary[record_id] except KeyError as e: raise InternalDataError("Record not present") from e |
- 이렇게 하면 원본 예외는
__cause__
속성에 할당된다.
파이썬에서 어설션 사용하기
- 절대로 일어나지 않아야 할 상황에 사용한다. 결함이 있다는 것을 의미한다.
- Assertion을 로직과 섞거나 제어 흐름으로 사용하지 않도록 하자.
1 2 3 4 5 |
try: assert condition.holds(), "Condition not mathced." except AssertionError: alternative-procedure() |
- 위 예제는 좋지 않은 생각이다.
- 또한 조건으로 함수가 사용되고 있는데, 이 또한 수정되어야 한다.
1 2 3 |
result = condition.holds() assert result > 0, "Error {0}".format(result) |
관심사의 분리
- 프로그램의 각 부분은 책임이 다르면 컴포넌트, 계층, 모듈로 분리되어야 한다. 각 요소들은 기능의 일부분에 대해서만 책임을 지며 나머지 부분에 대해서는 알 필요가 없다.
- 파급 효과를 최소화하기 위함이다.
응집력(cohesion)과 결합력(coupling)
- 응집력이란 객체가 잘 정의된 목적을 가져야 하고 가능하면 작아야 한다는 것이다.
- 응집력이 높을 수록 더 유용하고 재사용이 가능하다.
- 결합력이란 두개 이상의 객체가 서로 어떻게 의존하는지를 나타낸다. 너무 의존적이면 다음과 같은 단점이 있다.
- 낮은 재사용성: 함수가 특정 객체에 의존성이 큰 경우, 해당 객체와 결합된 상태가 되어서 다른 상황에서는 함수를 사용하기가 어려워진다.
- 파급 효과: 둘중 하나를 변경하면 다른 부분에도 영향을 미친다.
- 낮은 수준의 추상화: 서로 다른 추상화 레벨에서 문제를 해결하기 어렵기 때문에 관심사가 분리되어 있다고 보기 어렵다.
개발 지침 약어
DRY/OAOO
- DRY(Do not Repeat Yourself), OAOO(Once and Only Once)
- 중복을 반드시 피해야 한다. 코드에 있는 지식은 단 한번, 단 한 곳에 정의도어야 한다. 코드를 변경하려고 할 때 수정이 필요한 곳은 단 한군데만 있어야 한다.
- 어떤 로직이든 코드 전체에 반복이 있다면, 수정을 할 때 하나라도 빠트리면 버그가 발생할 것이다.
- 여러 곳에서 정의한 경우 변경하는데 더 많은 시간이 소요된다.
- 여러 코드를 변경해야 하는 경우 사람이 모든 인스턴스의 위치를 기억해야 한다.
YAGNI
- YAGNI(You Ain't Gonna Need It)
- 미래의 모든 요구사항을 고려하여 매우 과잉 엔지니어링을 하지만, 요구사항이 동작하지 않거나 나타나더라도 예상과는 다르게 동작한다.
- 하지만 이미 과잉 엔지니어링된 코드는 리팩토링이 어렵고 확장하는 것이 어려워진다.
- 현재의 요구사항만을 잘 해결하기 위한 코드를 작성하자.
KIS
- KIS(Keep It Simple)
- 필요이상 솔루션을 복잡하게 만들지 않도록 하자. 단순할 수록 유지관리가 쉽다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class COmplicatedNamespace: ACCEPTED_VALUES = ("id_", "user", "location") @classmethod def init_with_data(cls, **data) instance = cls() for key, value in data.items() if key in cls.ACCEPTED_VALUES: setattr(instance, key, value) return instance cn = ComplicatedNameSpace.init_with_data( id_=42, user="root", location="127.0.0.1", extra="excluded") |
- 이러한 코드는 사용자가
ini_with_data
라는 일반적이지 않은 메서드 이름을 알아야 한다.__init__
메서드를 사용하는 것이 간편하다.
EAFP/LBYL
- EAFP(Easier to Ask Forgiveness than Permission)
- 일단 코드를 실행하고 예외가 발생하면 cath해서 except 블록에서 바로 잡자.
- LBYL(Look Before You Leap)
- 실행하기 전에 먼저 무엇을 사용하려는지 확인하라는 것이다.
- 파이썬은 LBYL에 가깝다. 암묵적인 것보다 명시적인 것이 좋다.
1 2 3 4 |
if os.path.exists(filename): with open(filename) as f: ... |
보다는
1 2 3 4 5 6 |
try: with open(filename) as f: ... except FileNotFoundError as e: logger.error(e) |
쪽에 낫다.
컴포지션과 상속
- 상속은 강력한 개념이지만 위험도 있다. 부모와 강력하게 결합된 새로운 클래스가 생긴다는 점이다.
- 가장 좋은 이득은 코드 재사용이지만, 단지 부모 클래스에 있는 메서드를 이용하기 위해서 상속을 하는 것은 좋지 않은 생각이다. 재사용을 위해서 응집력 높은 객체를 사용하는 방법도 있다.
상속이 좋은 선택인 경우
- 만약 부모의 대부분의 메서드를 필요로 하지 않고 재정의하거나 대체해야 한다면 설계상의 실수라고 할 수 있다.
- 상위 클래스는 너무 막연한 정의와 너무 많은 책임을 가졌다.
- 하위 클래스는 상위 클래스의 적절한 세분화가 아니다.
-
잘 사용한 좋은 예는, 기존의 컴포넌트의 기능을 그대로 물려 받으면서 추가 기능을 더하려는 경우, 또는 특정 기능을 수정하려는 경우
- 인터페이스 정의 또한 상속의 좋은 예이다. 어떤 객체에 인터페이스 방식을 강제하고자 할 때 사용한다.
- 새로운 예외를 만드는 것 또한 상속의 좋은 예이다.
상속 안티패턴
- 부모 클래스는 파생 클래스의 일부를 공통적인 정의로 갖는다. 따라서 public 메서드는 부모 클래스가 정의하는 것과 일치해야한다.
- 아래는 예이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class TransactionalPolicy(collections.UserDict): def change_in_policy(self, customer_id, **new_policy_data): self[customer_id].update(**new_policy_data) policy = TransactionalPolicy({ "client001": { "fee": 1000.0, "expiration_date": datetime(2020, 1, 3), } }) print(policy["client001"]) # {'fee': 1000.0, 'expiration_date': datetime.datetime(2020, 1, 3, 0, 0)} print(dir(policy)) # ['_MutableMapping__marker', '__abstractmethods__', '__class__', '__contains__', '__copy__', '__delattr__', '__delitem__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__setattr__', '__setitem__', '__sizeof__', '__slots__', '__str__', '__subclasshook__', '__weakref__', '_abc_impl', 'change_in_policy', 'clear', 'copy', 'data', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values'] |
- 적절히 구현된 것 같지만 불필요한 수많은 메서드가 정의되어있다.
- 문제점
- 확장을 한다면 개념적으로 확장되고 세부적인 것이라는 의미해야하는데, 그렇지 않으며 이름만 보고 사전 타입의 확장이라는 것을 알기가 힘들다.
- 새로운 클래스
TransactionalPolicy
는 사전의 모든 메서드를 갖는데, 이런 메서드는 사용되지 않으며 오히려 부작용을 불러올 수 있다.
- 따라서 기본 클래스에 추가되는, 그리고 보다 특화된 것을 구현할 때에만 확장을 해야 한다.
- 올바른 해결책은 컴포지션을 사용하는 것이다.
TransactionalPolicy
자체가 사전이 되는 것이 아니라 사전을 활용하는 것이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class TransactionalPolicy: 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) |
- 이렇게 하면 추후 변경하려고 해도 인터페이스만 유지하면 사용자는 영향을 받지 않는다.
다중 상속
- 다중 상속은 잘못 사용하면 더 큰 문제가 생길 수 있다. 따라서 9장에서 볼 새로운 패턴과 믹스인을 사용하도록 하고 있다.
- 그 전에 복잡한 계층구조에서 메서드는 어떻게 결정되는지 살펴본다.
메서드 결정 순서
- 최상위 클래스에서 두개 이상의 클래스를 확장하고, 확장된 클래스를 모두 상속받아 하나의 클래스를 확장할 경우, 어느 메서드가 호출되는지가 모호하다.
- 다음과 같이 확인해보자
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
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): """확장""" class ConcreteModuleA23(BaseModule2, BaseModule3): """확장""" print(str(ConcreteModuleA12("test"))) # module-1:test print([cls.__name__ for cls in ConcreteModuleA12.mro()]) # ['ConcreteModuleA12', 'BaseModule1', 'BaseModule2', 'BaseModule', 'object'] |
- 파이썬에서는 C3 linearization 또는 MRO라는 알고리즘에 따라 메서드 호출 순서를 결정한다.
- 혹은 예제 코드처럼 순서를 직접 출력해볼 수도 있다.
믹스인(mixin)
- 믹스인은 코드를 재사용하기 위해 일반적인 행동을 캡슐화해놓은 기본 클래스이다.
- 믹스인 클래스는 그 자체를 확장해서는 동작하지 않고, 다른 클래스와 함께 믹스인 클래스를 다중 상속하여 믹스인에 있는 메서드나 속성을 사용한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class BaseTokenizer: def __init__(self, str_token): self.str_token = str_token def __iter__(self): yield from self.str_token.split("-") tk = BaseTokenizer("abc-de-efg123-ABC") print(list(tk)) # ['abc', 'de', 'efg123', 'ABC'] class UpperIterableMixin: def __iter__(self): return map(str.upper, super().__iter__()) class Tokenizer(UpperIterableMixin, BaseTokenizer): pass tk2 = Tokenizer("abc-de-efg123-ABC") print(list(tk2)) # ['ABC', 'DE', 'EFG123', 'ABC'] |
- 믹스인에서
__iter__
를 호출하고 다시super()
호출을 통해BaseTokenizer
에 위윔하게 된다.
함수와 메서드와 인자
인자는 함수에 어떻게 복사되는가
- 모든 인자는 값에 의해 전달 (passed by a value) 된다.
- 인자에 변경을 가하는 경우, 객체 타입에 따라 다르다. mutable 객체인 경우 반환 시 실제 값이 변형된다.
- 따라서 예상치 못한 부작용을 막기 위해 가급적 함수 내에서 변형은 피하는게 낫다.
가변 인자
- 가변 인자를 사용하려면 인자를 패킹할 변수에 이름 앞에 별표를 사용한다.
1 2 3 4 5 6 7 8 9 10 11 |
def f(first, second, third): print(first) print(second) print(third) l = [1, 2, 3] f(*l) # 1 # 2 # 3 |
- 부분적인 언패킹도 가능하다.
1 2 3 4 5 6 7 |
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] |
- 언패킹의 좋은 예는 다음과 같다.
1 2 3 4 5 6 |
def bad_users_from_rows(dbrows) -> list: return [User(row[0], row[1], row[2]) for row in dbrows] ## 위의 함수 보다는 아래가 낫다. def users_from_rows(dbrows) -> list: return [User(user_id, first_name, last_name) for (user_id, first_name, last_name) in dbrows] |
- 사전에 이중 별표를 사용하여 인자로 전달하면 파라미터 이름으로 키를 사용하고 파라미터의 값으로 사전의 값을 사용한다 아래 두 코드는 같은 의미를 지닌다.
1 2 3 |
function(**{"key": "value"}) function(key="value") |
- 반대로 함수 정의에 이중 별표를 사용하면 반대의 동작을 한다. 키워드로 제공된 인자들이 사전으로 패킹된다.
함수 인자의 개수
- 너무 많은 인자를 사용하는 함수나 메서드는 나쁜 디자인의 징후이다.
- 첫번째 대안은 여러 인자를 포함하는 새로운 객체를 만드는 것이다.
- 두번째는 가변 인자나 키워드 인자를 사용하는 것인데, 이는 매우 동적이어서 유지보수하기가 어렵기 때문이다.
- 만약 파라미터의 값에 대응하여 너무 많은 것들을 함수에서 처리하고 있다면 여러 작은 함수로 분리를 하라는 신호이다.
함수 인자와 결합력
- 함수의 파라미터가 많을 수록 호출자 함수와 밀접하게 결합되어있을 가능성이 높다.
- f1, f2가 있다고 하고 f2는 5개의 파라미터를 가진다고 하자.
- 더 많은 파라미터를 사용하는 f2는 정상 동작을 위한 정보를 수집하는 것이 어려울 것이다.
- f1가 f2를 호출하기 위한 정보를 다 가지고 있다고 한다면, f1는 이미 f2가 어떤 동작을 하는지 알고 있어 자체적으로 수행할 수도 있는 상태일 것이다. 즉 추상화가 부족한 상황이다.
- 또한 f2는 다른 환경에서 사용하기가 어려워 f1에서만 사용되고, 재사용성이 떨어진다.
많은 인자를 취하는 작은 함수의 서명
- 너무 많은 파라미터를 사용하는 함수를 찾았을 때 어떻게 리팩토링 할 것인가?
- 만약 공통 객체에 파라미터 대부분이 포함되어있다면 그냥 그 객체를 전달하는 것이 나을 수도 있다.
1 2 3 4 |
track_request(request.header, request.ip_addr, request.request_id) # -> track_request(request) |
- 주의할 점은 전달 받은 객체를 변경해서는 안된다. 객체를 바꾸고 싶다면 값을 복사하여 수정본을 반환하는 것이 낫다.
- 한 객체에 담겨 있지 않은 경우 파라미터 그룹핑을 하는 것도 한 대안이다.
- 함수의 서명을 변경하는 것은 최후의 수단이지만,
*args
,**kwargs
등으로 사용하는 것은 이해하기 어렵게 만드니 조심해야 한다.
소프트웨어 디자인 우수 사례 결론
소프트웨어 독립성
- 모듈, 클래스, 함수를 변경하면 수정한 컴포넌트가 외부 세게에 영향을 미치지 않아야만 하다.
- 런타임 관점에서 독립성은 변경을 내부의 문제로 만드는 것이다.
1 2 3 4 5 6 7 8 9 |
def calculate_price(base_price: float, tax: float, discount: float) -> float: return (base_price * (1 + tax)) * (1 - discount) def show_price(price: float) -> str: return "{0:,.2f}".format(price) def str_final_price(base_price: float, tax: float, discount: float, fmt_function=str) -> str: return fmt_function(calculate_price(base_price, tax, discount)) |
- 위의 두개 함수는 서로 독립성을 가져 하나를 변경해도 다른 하나는 변경되지 않는다.
- 아래 함수 또한
show_price
함수에 영향이 없다. - 하나를 변경해도 나머지 함수가 그대로라는 것을 알면 변경하기가 편하다.
- 코드의 두 부분이 독립적이라는 것은 다른 부분에 영향을 주지 않고 변경할 수 있다는 뜻이다.
- 이는 변경된 부분의 단위 테스트가 나머지 부분의 테스트와 독립적이라는 것을 의미한다.
코드 구조
- 여러 정의가 들어있는 큰 파일을 만드는 것은 좋지 않다.
- 코드 여러 부분이 어떤 한 파일의 정의에 종속되어 있다면 패키지를와 패키지 내의
__init__.py
파일을 이용해보자.