계약에 의한 디자인
컴포넌트는 기능을 숨겨 캡슐화하고 API만을 노출해야 한다.
API 디자인시 예상되는 입력, 출력, 부작용을 문서화 해야한다. 그러나 문서화가 런타임시의 소프트웨어 동작까지 강제할 수는 없다. 코드가 정상적으로 동작하기 위해 기대하는 것과 호출자가 반환받기를 기대하는 것은 디자인의 하나가 되어야 한다.
여기서 계약 이라는 개념이 생긴다. 계약에 의한 디자인은 관리자가 기대하는 바를 암묵적으로 코드에 삽입하는 대신 양측이 동의하는 계약을 먼저 한 다음, 계약을 어겼을 경우는 명시적 예외를 발생시키는 것이다.
여기서 계약은 소프트웨어 컴포넌트 간의 통신 중에 반드시 지켜져야 할 몇 가지 규칙을 강제하는 것이다. 계약은 주로 사전 조건, 사후 조건을 명시하지만 때로는 불변식과 부작용을 기술한다.
- 사전 조건 : 코드가 실행되기 전에 체크해야 하는 것. 파라미터에 제공된 데이터의 유효성을 검사한다.
- 사후조건 : 함수 반환 값의 유효성 검사가 수행된다. 호출자가 이 컴포넌트에서 기대한 것을 제대로 받았는지 확인하기 위해 수행한다.
- 불변식 : 때로는 함수의 docstring에 불변식에 대해 문서화하는 것이 좋다. 불변식은 함수가 실행되는 동안에 일정하게 유지되는 것으로 함수의 로직에 문제가 없는지 확인하기 위한 것이다.
- 부작용 : 선택적으로 코드의 부작용을 docstring에 언급하기도 한다.
계약에 의해 디자인을 하게 되면 오류가 발생할 때 쉽게 찾아낼 수 있다. 또한 에러를 발생시키는 데서 그치는 것이 아니라 책임의 한계를 명확히 하는데 도움이 된다.
사전 조건은 클라이언트와 연관되어 있다. 클라이언트는 코드를 실행하기 위해 사전에 약속한 조건을 준수해야만 한다. 사후 조건은 컴포넌트와 연관되어 있다. 컴포넌트는 클라이언트가 확인하고 강제할 수 있는 값을 보장을 해야 한다.
사전조건
함수나 메서드가 제대로 동작하기 위해 보장해야 하는 모든 것들을 말한다. 특히 파이썬은 동적으로 타입이 결정되므로 전달된 데이터가 적절한 타입인지, 정확한지 확인하는 경우도 있다.
유효성 검사는 클라이언트가 함수를 호출하기 전에 모든 유효성 검사를 하는 관용적인 접근법과 함수가 자체적으로 로직을 실행하기 전에 검사하는 까다로운 접근법이 존재한다.
어떤 방식을 택하든 중복 제거 원칙을 항상 지켜야 한다.
사후 조건
사후 조건은 메서드 또는 함수가 반환된 후의 상태를 강제하는 계약의 일부이다.
함수 또는 메서드가 사전 조건에 맞는다면 사후조건은 특정 속성이 보존되도록 보장해야 한다.
메서드가 적절히 실행되었다면 계약이 이루어졌으므로 사후조건 검증에 통과하고 클라이언트는 반환 객체를 아무 문제없이 사용할 수 있어야 한다.
계약에 의한 디자인 - 결론
계약을 정의함으로써 런타임시 오류가 발생했을 때 코드의 어떤 부분이 손상되었는지 계약이 파손되었는지가 명확해진다.
이 원칙을 따르게 되면 코드가 더욱 견고해진다. 각 컴포넌트들은 자체적으로 제약 조건과 불변식을 관리하며 이러한 불변식이 유지되는한 프로그램이 정상 동작하는 것으로 볼 수 있다.
방어적 프로그래밍
방어적 프로그래밍은 객체, 함수 또는 메서드와 같은 코드의 모든 부분을 유효하지 않은 것으로부터 스스로 보호할 수 있게 하는 것이다.
방어적 프로그래밍의 주요 주제는 예상할 수 있는 시나리오의 오류를 처리하는 방법과 발생하지 않아야 하는 오류를 처리하는 방법에 대한 것이다.
에러 핸들링
에러 핸들링의 주요 목적은 예상되는 에러에 대해서 실행을 계속할 수 있을지 아니면 극복할 수 없는 오류여서 프로그램을 중단할지를 결정하는 것이다.
프로그램에서 에러를 처리하는 방법은 다음과 같다.
- 값 대체
- 에러 로깅
- 예외 처리
값 대체
오류가 있어 소프트웨어가 잘못된 값을 생성하거나 전체가 종료될 위험이 있는 경우 결과 값을 안전한 다른 값으로 대체할 수 있다.
값 대체가 항상 가능하지는 않다. 대체 값이 실제로 안전한 옵션인 경우에 한해 신중하게 선택해야 한다. 이 결정을 내리는 것은 견고성과 정확성간의 트레이드오프이다.
약간 다른 방향으로 보다 안전한 방법을 택하려면 제공되지 않은 데이터에 기본 값을 사용하는 것이다. 설정되지 않은 변수의 기본 값, 설정 파일의 누락된 항목 또는 함수의 파라미터와 같은 것들은 기본 값으로 동작이 가능하다.
일반적으로 누락된 파라미터를 기본 값으로 바꾸어도 큰 문제가 없지만 오류가 있는 데이터를 유사한 값으로 대체하는 것은 더 위험하며 일부 오류를 숨길 수 도 있다.
예외 처리
잘못되거나 누락된 입력 데이터가 있는 경우 복구 처리가 가능한 경우가 있다. 그러나 어떤 경우에는 에러가 발생하기 쉽다는 가정으로 계속 실행하는 것보다 차라리 실행을 멈추고 호출자에게 실패했음을 알리는 것이 좋은 선택이다. 이 경우는 사전 조건 검증에 실패한 경우이다.
함수 호출 실패는 함수 자체의 문제가 아니라 외부 컴포넌트 중 하나의 문제로 인한 것일 수 있다. 함수는 심각한 오류에 대해 명확하고 분명하게 알려주어 쉽게 해결할 수 있도록 해야 한다.
예외적인 상황을 명확하게 알려주고 원래의 비즈니스 로직에 따라 흐름을 유지하는 것이 중요하다.
호출자가 알아야만 하는 실질적인 문제가 있을 경우에는 예외를 발생시켜야 한다.
예외는 대개 호출자에게 잘못을 알려주는 것이다. 예외는 캡슐화를 약화시키기 때문에 신중하게 사용해야 한다. 함수에 예외가 많을수록 호출자가 호출하는 함수에 대해 더 많은 것을 알아야 한다.
다음은 파이썬의 예외와 관련된 몇 가지 권장 사항이다.‘
올바른 수준의 추상화 단계에서의 예외 처리
예외는 오직 한 가지 일을 하는 함수의 한 부분이어야 하며, 함수가 처리하는 예외는 캡슐화된 로직과 일치해야 한다.
class DataTransport:
"""다른 레벨에서 예외를 처리하는 객체의 예"""
retry_threshold: int = 5
retry_n_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_n_times):
try:
self.connection = self._connector.connect()
except ConnectionError as e:
logger.info("%s: 새로운 연결 시도 %is", e,self.retry_threshold)
time.sleep(self.retry_threshold)
else:
return self.connection
raise ConnectionError(f"{self.retry_n_times} 번째 재시도 연결 실패")
def send(self, data):
return self.connection.send(data)위 예제에서 deliver_event 메서드 내부에서 매우 다른 유형의 에러인 ValueError와 ConnectionError가 동시에 발생하고 있다. 이렇게 매우 다른 유형의 오류를 살펴봄으로써 책임을 어떻게 분산해야 하는지에 대한 아이디어를 얻을 수 있다.
ConnectionError는 connect 메서드 내에서 처리되어야 한다. 이렇게 하면 행동을 명확하게 분리할 수 있게 된다.
def connect_with_retry(connector, retry_n_times, retry_threshold=5):
"""connector의 연결을 맺는다. <retry_n_times>회 재시도
연결에 성공하면 connection 객체 반환
재시도까지 모두 실패하면 ConnectionError 발생
:param connector: '.connect()' 메서드를 가진 객체
:param retry_n_times int: "connector.connect()"를 호출 시도하는 횟수
:param retry_threshold int: 재시도 사이의 간격
"""
for _ in range(retry_n_times):
try:
return connector.connect()
except ConnectionError as e:
logger.info("%s: 새로운 연결 시도 %is", e, retry_threshold)
time.sleep(retry_threshold)
exc = ConnectionError(f"{retry_n_times} 번째 재시도 연결 실패")
logger.exception(exc)
raise exc이 함수는 연결을 맺고, 발생 가능한 예외를 처리하고 로깅을 담당한다.
이렇게 구현을 수정하면 deliver_event에서는 예외를 catch할 필요가 없다.
class DataTransport:
"""추상화 수준에 따른 예외 분리를 한 객체의 예제"""
retry_threshold: int = 5
retry_n_times: int = 3
def __init__(self, connector):
self._connector = connector
self.connection = None
def deliver_event(self, event):
self.connection = connect_with_retry(self._connector, self.retry_n_times, self.retry_threshold)
self.send(event)
def send(self, event):
try:
return self.connection.send(event.decode())
except ValueError as e:
logger.error("%r contains incorrect data: %s", event, e)
raise두 가지 예외를 분리한 새로운 메서드는 더 작고 읽기 쉽다.
Trackback 노출 금지
특정 문제를 나타내는 예외가 있는 경우 문제를 효율적으로 해결할 수 있도록 trackback 정보, 메시지 및 기타 수집 가능한 정보를 로그로 남기는 것이 중요하다. 그러나 이러한 세부사항은 절대 사용자에게 보여서는 안된다.
파이썬에서 trackback 정보는 매우 유용하고 많은 디버깅 정보를 포함한다. 이 정보가 악의적인 사용자에게 노출될 경우 중요 정보의 유출이 발생할 위험이 있다.
비어있는 except 블록 지양
try:
process_data()
except:
pass위와 같이 코드를 작성할 경우 실패해야만 할 때조차 실패하지 않는다. 이것은 “에러는 결코 조용히 전달되어서는 안된다.” 라는 파이썬 원칙에 위배된다. 진짜 예외가 발생해도 이 코드는 실패하지 않으며 이러한 코드는 문제를 숨기고 유지보수를 더 어렵게 만든다.
여기에는 두 가지 대안이 있다.
- 보다 구체적인 예외를 사용한다 (
Exception같은 광범위한 예외를 사용해서는 안된다) except블록에서 실제 오류 처리를 한다.
가장 좋은 방법은 두 항목을 동시에 적용하는 것이다.
보다 구체적인 예외를 사용하면 사용자는 무엇을 기대하는지 알게 되기 때문에 프로그램을 더 쉽게 유지 보수할 수 있다.
원본 예외 포함
오류 처리 과정에서 다른 오류를 발생시키고 메세지를 변경할 수도 있다. 이 경우 원본 예외를 포함하는 것이 좋다.
raise <e> from <original_exception> 구문을 사용하면 원본의 trackback이 새로운 exception에 포함되고 원본 예외는 __cause__속성으로 설정된다.
class InternalDataError(Exception):
"""업무 도메인 데이터의 예외"""
def process(data_dictionary, record_id):
try:
return data_dictionary[record_id]
except KeyError as e:
raise InternalDataError("Record not present") from e예외의 타입을 변경할 때는 항상
raise <e> from <o>구문을 사용한다.
파이썬에서 Assertion 사용하기
assertion은 절대로 일어나지 않아야 하는 상황에 사용되므로 assert 문에 사용된 표현식은 불가능한 조건을 의미한다. 이 상태가 된다는 것은 소프트웨어에 결함이 있음을 의미한다.
assertion은 잘못된 시나리오에 도달할 경우 프로그램이 더 큰 피해를 입지 않도록 하는 것이다. 이러한 이유로 assertion을 비즈니스 로직과 섞거나 소프트웨어 흐름 제어 메커니즘으로 사용해서는 안된다.
try:
assert condition.holds(), "조건에 맞지 않음"
except: AssertionError:
alternative_procedure()위 예제는 좋지 않은 코드이다.
Assertion이 실패하면 반드시 프로그램을 종료시켜야 한다. 또한 Assertion 문장에 설명이 포함된 오류 메시지를 작성하여 나중에 디버깅하고 수정할 수 있도록 한다.
result = condition.holds()
assert result > 0, "에러 {}".format(result)코드를 줄이고 유용한 정보를 포함하도록 수정한 코드
관심사의 분리
책임이 다르면 컴포넌트, 계층, 모듈로 분리되어야 한다. 프로그램의 각 부분은 기능의 일부분에 대해서만 책임을 지며 나머지 부분에 대해서는 알 필요가 없다.
소프트웨어에서 관심사를 분리하는 목표는 파급 효과를 최소화하여 유지보수성을 향상시키는 것이다. 파급 효과는 어느 지점에서의 변화가 전체로 전파되는 것을 의미한다.
응집력과 결합력
응집력이란 객체가 작고 잘 정의된 목적을 가져야 하며 가능하면 작아야 한다는 것을 의미한다. 객체의 응집력이 높을수록 더 유용하고 재사용성이 높아지므로 더 좋은 디자인이다.
결합력이란 두 개 이상의 객체가 서로 어떻게 의존하는지 나타낸다. 이 종속성은 제한을 의미한다. 객체 또는 메서드의 두 부분이 서로 너무 의존적이라면 다음과 같은 바람직하지 않은 결과를 가져온다.
- 낮은 재사용성 : 어떤 함수가 특정 객체에 지나치게 의존하는 경우 이 함수는 해당 객체에 결합되게 된다.
- 파급 효과 : 너무 가깝게 붙어있게 되면 두 부분중 하나를 변경하면 다른 부분에도 영향을 미친다.
- 낮은 수준의 추상화: 두 함수가 너무 가깝게 관련되어 있으면 서로 다른 추상화 레벨에서 문제를 해결하기 어렵다.
개발 지침 약어
DRY/OAOO
DRY(Do not Repeat Yourself), OAOO(Once and Only Once)는 밀접한 관련이 있다.
코드에 있는 지식은 단 한번, 단 한 곳에 정의되어야 한다. 코드가 여러번 정의되었을 때의 문제는 다음과 같다.
- 오류가 발생하기 쉽다 : 코드 전체에 여러 번 반복되어 있는 로직을 수정할 때 인스턴스의 하나라도 빠뜨리면 버그가 발생한다.
- 비용이 비싸다 : 여러 곳에서 정의했을 경우 변경, 개발, 테스트 할 때 더 많은 시간이 소요된다.
- 신뢰성이 떨어진다 : 문맥상 여러 코드를 변경해야 하는 경우 사람이 모든 인스턴스의 위치를 기억해야 한다. 단일 데이터 소스가 아니므로 데이터의 완결성이 떨어진다.
def process_students_list(students):
# 중간 생략...
students_ranking = sorted(students, key=lambda s: s.passed * 11 - s.failed * 5 - s.years * 2)
# 학생별 순위 출력
for student in students_ranking:
print("이름: {0}, 점수: {1}".format(student.name, (student.passed * 11 - student.failed * 5 - student.years * 2)))위 함수 내에서 sorted 함수의 key로 사용되는 lambda가 특별한 도메인 지식을 나타내지만 아무런 정의가 없다. 코드에서 의미를 부여하지 않았기 때문에 순위를 출력하는 동안 중복이 발생한다.
도메인 문제에 대한 지식이 사용된 경우 의미를 부여해야 한다.
def score_for_student(student):
return student.passed * 11 - student.failed * 5 - student.years * 2
def process_students_list(students):
# 중간 생략...
students_ranking = sorted(students, key=score_for_student)
# 학생별 순위 출력
for student in students_ranking:
print("Name: {0}, Score: {1}".format(student.name, score_for_student(student)))YAGNI
YAGNI(You Ain’t Gonna Need it)는 과잉 엔지니어링을 하지 않기 위해 솔루션 작성 시 계속 염두에 두어야 하는 원칙이다. 오직 현재의 요구사항을 잘 해결하기 위한 소프트웨어를 작성하고 가능한 나중에 수정하기 쉽도록 작성하는 것이 좋다.
KIS
KIS(Keep It Simple) 원칙은 YAGNI 원칙과 매우 흡사하다. 소프트웨어 컴포넌트를 설계할 때 선택한 솔루션이 문제에 적합한 최소한의 솔루션인지 생각해야 한다.
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위 클래스는 제공된 키워드 파라미터 세트에서 네임 스페이스를 작성하지만 다소 복잡한 코드 인터페이스를 가지고 있다.
객체를 초기화 하기 위해 추가 클래스를 만드는 것은 꼭 필요하지 않으며, 반복을 통해 setattr을 호출하는 것은 상황을 더 악화시킨다. 사용자에게 노출된 인터페이스 또한 불분명하다.
>>> cn = ComplicatedNamespace.init_with_data(id_=42, user="root", location="127.0.0.1", extra="excluded")
>>> cn.id_, cn.user, cn.location
(42, 'root', '127.0.0.1')
>>> hasattr(cn, "extra")
False사용자는 초기화를 통해 init_with_data라는 일반적이지 않은 메서드의 이름을 알아야 한다. 파이썬에서 객체를 초기화 하는 경우 __init__ 메서드를 사용하는 것이 더 효과적이다.
class Namespace:
"""Create an object from keyword arguments."""
ACCEPTED_VALUES = ("id_", "user", "location")
def __init__(self, **data):
accepted_data = {k: v for k, v in data.items() if k in self.ACCEPTED_VALUES}
self.__dict__.update(accepted_data)EAFP/LBYL
EAFP(Easier to Ask Forgiveness than Permission)는 허락보다는 용서를 구하는 것이 쉽다는 뜻이다. 반면에 LBYL(Look Before You Leap)은 도약하기 전에 살피라는 뜻이다.
EAAP는 일단 코드를 실행하고 실제 동작하지 않을 경우에 대응한다는 뜻이다. 일반적으로는 코드를 실행하고 발생한 예외를 catch하고 except 블록에서 바로잡는 코드를 실행하게 된다.
LBYL는 그 반대이다 도약하기 전에 먼저 무엇을 사용하려고 하는지 확인하는 것이다. 예를들어 파일을 사용하기 전에 먼저 파일을 사용할 수 있는지 확인하는 것이다.
if os.path.exists(filename):
with open(filename) as f:
...파이썬은 EAFP 방식으로 만들어졌기 때문에 위 방식은 pythonic한 방식이 아니다.
try:
with open(filename) as f:
...
except FileNotFoundError as e:
logger.error(e)위와 같은 방식으로 다시 작성할 수 있다.
LBYL 원칙보다 EAFP 원칙이 더 바람직하다.
컴포지션과 상속
개발자는 종종 필요한 클래스들의 계층 구조를 만들고 각 클래스가 구현해야 하는 메서드를 결정하는 것으로부터 개발을 시작한다.
상속은 강력한 개념이지만 위험도 존재한다. 가장 주된 위험은 부모 클래스를 확장하여 새로운 클래스를 만들 때마다 부모와 강력하게 결합된 생긴다는 점이다. 소프트웨어를 설계할 때 결합력(coupling)을 최소한으로 줄이는 것이 중요하다.
상속이 좋은 선택인 경우
새로운 하위 클래스를 만들 때 클래스가 올바르게 정의되었는지 확인하기 위해 상속된 모든 메서드를 실제로 사용할 것인지 생각해보는 것이 좋다. 많은 메서드를 필요로 하지 않고 재정의하거나 대체해야 한다면 다름과 같은 이유로 설계상의 실수라고 할 수 있다.
- 상위 클래스는 잘 정의된 인터페이스 대신 막연한 정의와 너무 많은 책임을 가졌다.
- 하위 클래스는 확장하려고 하는 상위 클래스의 적절한 세분화가 아니다.
어떤 객체에 인터페이스 방식을 강제하고자 할 때 구현 하지 않은 기본 추상 클래스를 만들고, 실제 이 클래스를 상속하는 하위 클래스에서 적절한 구현을 하도록 하는 것이다.
다른 상속의 사용 예는 예외이다. 파이썬의 표준 예외는 Exception에서 파생된다.
상속 안티패턴
상속을 올바르게 사용하면 객체를 전문화하고 기본 객체에서 출발하여 세부적인 추상화를 할 수 있다.
부모 클래스는 새 파생 클래스의 공통 정의의 일부가 된다. 개발자가 코드 재사용만을 목적으로 상속을 사용하려고 할 때 매우 자주 발생한다.
class TransactionalPolicy(collections.UserDict):
"""잘못된 상속의 예"""
def change_in_policy(self, customer_id, **new_policy_data):
self[customer_id].update(**new_policy_data)딕셔너리 기능을 수행하는 인터페이스를 갖게 되었지만 클래스에 불필요한 수 많은 메서드가 포함되었다.
위 디자인에서는 계층 구조가 잘못되었다. 기본 클래스에서 새 클래스를 만드는 것은 말 그대로 그것이 개념적으로 확장되고 세부적인 것이라는 것을 의미한다. TransactionalPolicy는 이름만 보고 dict형태라는 것을 알 수 없다.
또한 결합력(coupling)에 대한 문제가 있다. TransactionalPolicy는 dict의 모든 메서드를 포함한다. TransactionalPolicy 에는 필요 없는 메서드가 포함되어 있다. 이것들은 public 메서드이므로 이 인터페이스의 사용자는 이 메서드를 호출할 수도 있다.
key 기능을 얻기 위해 dict를 확장하는 것은 충분한 확장의 근거가 되지 않는다.
올바른 해결법은 컴포지션을 사용하는 것이다. TransactionalPolicy 자체가 딕셔너리가 되는 것이 아니라 딕셔너리를 활용하는 것이다. 딕셔너리를 private 속성에 저장하고 __getitem__() 으로 사전의 프록시를 만들고 나머지 필요한 public 메서드를 추가적으로 구현하는 것이다.
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)파이썬의 다중상속
부적절하게 사용된 상속은 디자인 문제를 유발하며 특히 다중 상속을 잘못 사용하면 더 큰 문제를 초래할 수도 있다.
다중 상속은 올바르게 사용될 때에만 온전히 유효한 해결책이 될 수 있으므로 보통 어댑터 패턴과 믹스인을 사용한다.
메서드 결정 순서(MRO)
두 개 이상의 클래스를 확장하고 해당 클래스들이 모두 하나의 같은 기본 클래스를 확장한 경우 맨 아래 클래스가 어느 메서드가 사용될지 모호해진다.

클래스의 속성 값을 통해 명확하게 확인해볼 수 있다.
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):
"""1 & 2 계정 확장"""
class ConcreteModuleB23(BaseModule2, BaseModule3):
"""2 & 3 계정 확장""">>> str(ConcreteModuleA12('name'))
'module-1:name'파이썬은 C3 linearization 또는 MRO라는 알고리즘을 사용하여 이 문제를 해결한다. 이 알고리즘은 메서드가 호출되는 방식을 정의한다.
믹스인
믹스인은 코드를 재사용 하기 위해 일반적인 행동을 캡슐화 해놓은 기본 클래스이다. 일반적으로 믹스인 클래스는 클래스에 정의된 메서드나 속성에 의존하기 때문에 이 클래스만 확장해서는 확실히 동작하지 않는다. 보통은 믹스인 클래스를 다중 상속하여 믹스인에 있는 메서드나 속성을 사용한다.
다음은 문자열을 받아 하이픈(-) 으로 구분된 값을 반환하는 파서 클래스이다.
class BaseTokenizer:
def __init__(self, str_token):
self.str_token = str_token
def __iter__(self):
yield from self.str_token.split("-")>>> tk = BaseTokenizer("28a2320b-fd3f-4627-9792-a2b38e3c46b0")
>>> list(tk)
['28a2320b', 'fd3f', '4627', '9792', 'a2b38e3c46b0']여기에서 BaseTokenizer 클래스를 변경하지 않고 값을 대문자로 변경하고자 할 때 믹스인을 사용할 수 있다.
class UpperIterableMixin:
def __iter__(self):
return map(str.upper, super().__iter__())
# < 방향으로 오버라이딩된다. (BaseTokenizer > UpperIterableMixin)
class Tokenizer(UpperIterableMixin, BaseTokenizer):
passTokenizer는 믹스인에서 __iter__을 호출하고 다시 super() 호출을 통해 클래스 BaseTokenizer 에 위임한다.
함수와 메서드의 인자
파이썬의 함수 인자 동작방식
인자는 함수에 어떻게 복사되는가
파이썬에서는 모든 인자가 값에 의해 전달(passed by a value)된다. 함수에 값을 전달하면 함수의 로컬 변수에 할당하고 나중에 사용한다.
인자를 변경하는 함수는 인자의 타입에 따라 다른 결과를 낼 수 있다. 변형 가능한(mutable) 객체를 전달하고 함수에서 값을 변경하면 함수 반환 시 실제 값이 변경되는 부작용이 생길 수 있다.
>>> def function(arg):
arg += "in function"
print(arg)
>>> imutable = "hello"
>>> function(immutable)
hello in function
>>> mutable = list("hello")
>>> immutable
hello
>>> function(mutable)
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
>>> mutable
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']string객체는 불변형(immutable) 타입이므로 arg += <expression> 문장은 arg + <expression> 형태의 새로운 객체를 만들어 arg에 다시 할당한다.
변형 가능한(mutable) 객체인 리스트를 전달하면 해당 문장은 원래 리스트 객체에 대한 참조를 보유하고 있는 변수를 통해 값을 수정하므로 함수 외부에서도 실제 값을 수정할 수 있다.
이런 유형의 파라미터를 사용하면 예상치 못한 부작용을 유뱔할 수 있으므로 주의해야 한다.
함수 인자를 변경하지 않아야 한다. 최대한 함수에서 발생할 수 있는 부작용을 회피하라.
가변인자
가변 인자를 사용하려면 해당 인자를 패킹할 변수의 이름 앞에 별표(*)를 사용한다.
세 개의 인자를 갖는 함수에 패킹 기법을 사용하여 하나의 명령어로 값을 전달할 수 있다.
>>> def f(first, second, third):
print(first)
print(second)
print(third)
>>> l = [1, 2, 3]
>>> f(*l)
1
2
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]
>>> *rest, last = range(6)
>>> show(last, rest)
요소: 5 - 나머지: [0, 1, 2, 3, 4]
>>> first, *middle, last = range(6)
>>> first
0
>>> middle
[1, 2, 3, 4]
>>> last
5
>>> first, last, *empty = (1, 2)
>>> first
1
>>> last
2
>>> empty
[]변수 언패킹의 좋은 예는 반복이다. 일련의 요소를 반복해야 하고 각 요소가 차례로 있다면 각 요소를 반복할 때 언패킹 하는 것이 좋다.
USERS = [(i, f"first_name_{i}", f"last_name_{i}") for i in range(1_000)]
class User:
def __init__(self, user_id, first_name, last_name):
self.user_id = user_id
self.first_name = first_name
self.last_name = last_name
def __repr__(self):
return f"{self.__class__.__name__}({self.user_id!r}, {self.first_name!r}, {self.last_name!r})"
def bad_users_from_rows(dbrows) -> list:
"""DB 레코드에서 사용자를 생성하는 파이썬스럽지 않은 잘못된 사용 예"""
return [User(row[0], row[1], row[2]) for row in dbrows]
def users_from_rows(dbrows) -> list:
"""DB 레코드에서 사용자 생성"""
return [User(user_id, first_name, last_name) for (user_id, first_name, last_name) in dbrows]첫 번째 구현에서는 row[0], row[1], row[2] 가 무엇을 뜻하는지 전혀 알 수 없다. 반면에 언패킹을 사용한 쪽의 user_id, first_name, last_name는 자명한 이름이다.
비슷한 표기로 이중 별표(**)를 키워드 인자에 사용할 수 있다. 딕셔너리에 이중 별표를 사용하여 함수에 전달하면 파라미터의 이름으로 키를 사용하고, 파라미터의 값으로 사전의 값을 사용한다.
function(**{"key": "value"}) 이중 별표를 사용한 이 코드는 function(key="value") 이 코드와 동일하다.
반대로 이중 별표로 시작하는 파라미터를 함수에 사용하면 반대로 동작한다. 키워드 제공 인자들이 딕셔너리로 패킹된다.
>>> def function(**kwargs):
print(kwargs)
>>> function(key="value")
{'key': 'value'}함수 인자의 개수
함수의 인수가 많을수록 호출자 함수와 밀접하게 결합될 가능성이 커진다.
함수의 인수의 갯수를 줄일 대안 중 하나는 구체화가 있다. 다시말해 전달하는 모든 인자를 포함하는 새로운 객체를 만드는 것이다.
다른 방법은 파이썬의 특정기능 (가변 인자, 키워드) 를 사용해 동적 구조를 만드는 방법이다.
만약 파라미터 값에 대응하여 너무 많은 것들을 함수애서 처리하고 있다면 여러 작은 함수로 분리를 하라는 사인이다.
함수 인자와 결합력
함수의 인수가 많을수록 호출자 함수와 밀접하게 결합될 가능성이 커진다. 많은 파라미터를 사용할수록 호출자는 정상 동작을 위한 모든 정보를 수집하기 어려워진다.
함수가 보다 일반적인 인터페이스를 제공하고 더 높은 수준의 추상화로 작업할 수 있다면 코드 재사용성이 높아진다.
함수가 제대로 동작하기 위해 너무 많은 파라미터가 필요한 경우 코드에 문제가 있을 가능성이 높다.
많은 인자를 취하는 작은 함수
만약 공통 객체에 파라미터 대부분이 포함되어 있다면 가장 쉽게 수정할 수 있다.
track_request(request.headers, request.ip_addr, request_id) 이 예제에서 모든 파라미터가 request와 관련이 있으며 request를 파라미터로 전달하는 것이 좋은 방법이다. 올바르게 함수를 호출하려면 track_request(request)여야 한다.
변경 가능한 객체를 전당할 때에는 부작용에 주의해야 한다. 함수는 전달받은 객체를 변경해서는 안되며 변경이 필요할 경우 전달된 값을 복사한 다음 새로운 수정본을 반환하는 것이 나은 대안이다.
변경 불가능한 객체를 사용하여 부작용을 최소화 한다.
파라미터들을 그룹핑하여 디자인에서 누락되었던 추상화 작업을 할 수 있다.
위와 같은 방법들을 적용할 수 없다면 마지막 수단으로 함수의 구조를 변경하여 다양한 인자를 혀용할 수 있다. 인자가 많은 경우에 *args 또는 **kwargs를 사용하면 더 이해하기 어려운 상황을 만들 수 있다. 이런 경우 인터페이스에 대한 문서화를 하고 정확하게 사용했는지 확실히 확인해야 한다.
*args 와 **kwargs 로 정의된 함수가 융통성 있고 적응력이 좋지만, 함수는 구조를 잃어버리고 가독성을 거의 상실한다. 위치 인자 또는 가변 인자를 사용하면 매우 좋은 docstring을 제작하지 않는 한 나중에 해당 함수에 사용된 파라미터만 보고 정확한 동작을 알 수 없다.
소프트웨어 디자인 우수 사례 결론
소프트웨어의 독립성(orthogonality)
모듈, 클래스, 함수를 변경하면 수정한 컴포넌트가 다른 컴포넌트들에게 영향을 미치지 않아야 한다. 이것이 불가능하다고 해도 좋은 디자인은 가능한 한 영향을 최소화하려고 시도해야 한다.
파이썬에서 함수는 일반 객체일 뿐이므로 파라미터로 전달할 수 있다. 독립성을 얻기위해 이 기능을 활용할 수 있다.
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의 변경사항은 calculate_price에 영향을 미치지 않는다.
>>> 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'코드의 두 부분이 독립적이라는 것은 다른 부분에 영향을 주지 않고 변경할 수 있다는 것을 뜻한다. 이는 변경된 부분의 단위 테스트가 나머지 단위 테스트와도 독립적이라는 것을 뜻한다. 이러한 가정 하에 두 개의 테스트가 통과하면 전체 회귀 테스트를 하지 않고도 애플리케이션에 문제가 없다고 어느정도 확신할 수 있다.
독립성은 기능 면에서 생각해 볼 수 있다. 애플리케이션의 두 가지 기능이 완전히 독립적이라면 다른 코드를 손상시킬 것에 대한 염려가 없으므로 간단한 테스트 후에 배포할 수 있다.
코드 구조
코드를 구조화하는 방법은 팀의 작업 효율성과 유지보수성에 영향을 미친다.
여러 정의(클래스, 함수, 상수)가 들어있는 큰 파일을 만드는 것은 좋지 않으므로 피하는 것이 좋다. 좋은 코드는 유사한 컴포넌트끼리 정리하여 구조화해야 한다.
코드의 여러 부분이 해당 파일의 정의에 종속되어 있어도 전체적인 호환성을 유지하면서 패키지로 나눌 수 있다. 해결 방법은 __init__.py 파일을 가진 새 디렉터리를 만드는 것이다. 이렇게 하면 파이썬 패키지가 만들어 진다.
이 방법은 다음과 같은 이유 때문에 효율적이라고 할 수 있다.
- 모듈을 임포트할 때 구문을 분석하고 메모리에 로드할 객체가 줄어든다.
- 의존성이 줄었기 때문에 더 적은 모듈만 가져오면 된다.