Skip to content

Commit e6977f1

Browse files
committed
better way 90 정리
1 parent e5f2c9a commit e6977f1

File tree

2 files changed

+344
-0
lines changed

2 files changed

+344
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,5 @@
131131
[87. 호출자를 API로부터 보호하기 위해 최상위 Exception 을 정의하라](./summary/BetterWay87.md)
132132
[88. 순환 의존성을 깨는 방법을 알아두라](./summary/BetterWay88.md)
133133
[89. 리팩터링과 마이그레이션 방법을 알려주기 위해 warning 을 사용하라](./summary/BetterWay89.md)
134+
[90. typing 과 정적 분석을 통해 버그를 없애라](./summary/BetterWay90.md)
134135

summary/BetterWay90.md

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
# 90. typing 과 정적 분석을 통해 버그를 없애라
2+
3+
## 1. 컴파일 시점 타입 안정성
4+
5+
- API를 올바르게 사용하고 하위 의존 관계를 올바른 방법으로 활용하는지 검사하는 매커니즘 필요
6+
- 문서는 API 를 제대로 사용하는 방법을 알려주는 훌륭한 방법이지만 충분하지 않고 잘못 사용하면 여전히 버그가 생김
7+
- 파이선은 역사적으로 동적인 기능이 초점을 맞춰 컴파일 시점 타입 안정성을 제공하지 않았음
8+
- 여러 프로그래밍 언어가 컴파일 시점 타입 검사 제공
9+
- 최근들어 특별한 구문과 `typing` 모듈이 도입되어 변수, 클래스 필드, 함수, 메소드에 타입 애너테이션을 붙일 수 있게 되었음
10+
- 타입 힌트를 사용하면 코드베이스를 점진적으로 변경하는 점진적 타입 지정 가능
11+
12+
## 2. 정적 분석 도구
13+
14+
- 타입 애너테이션을 추가하면 정적 분석 도구로 프로그램 소스 코드를 검사해서 버그가 생긴 가능성이 높은 부분을 식별할 수 있다는 장점 존재
15+
- `typing` 내장 모듈은 실제 그 자체로는 어떠한 타입 검사 기능도 제공하지 않음
16+
- 파이선 코드에 적용할 수 있고 별도의 도구가 소비하는 generics 를 포함한 타입 정의 시 공통 라이브러리 제공
17+
- generics
18+
- 여러 타입에 대해 작동가능한 일반적인 코드를 작성할 수 있게 해주는 기능
19+
- 파이선 정적 분석 도구
20+
- mypy
21+
- pytype
22+
- pyright
23+
- pyre
24+
25+
```bash
26+
$ python3 -m mypy --strict example.py
27+
```
28+
29+
- 프로그램을 실행하기 전 수많은 오류 감지 가능
30+
31+
- 다음 코드는 컴파일은 성공하지만 실행 시점에 예외가 발생하는 버그 예제
32+
33+
```python
34+
def subtract(a, b):
35+
return a - b
36+
37+
subtract(10, '5')
38+
39+
>>>
40+
Traceback ...
41+
TypeError: unsupported operand type(s) for -: 'int' and 'str'
42+
```
43+
44+
- 위 예제에 타입 애너테이션을 붙이면 아래와 같음
45+
- 파아미터와 변수 타입 애너테이션 사이는 콜론으로 구분
46+
- 반환값 타입은 함수 인자 목록 뒤에 `-> 타입` 형태
47+
- 타입 애너테이션과 mypy 를 사용하면 버그 탐지 가능
48+
49+
```python
50+
def subtract(a: int, b: int) -> int: # 함수에 타입 애너테이션을 붙임
51+
return a - b
52+
53+
subtract(10, '5') # 아이고! 문자열 값을 넘김
54+
55+
$ python3 -m mypy --strict example.py
56+
.../example.py:4: error: Argument 2 to "subtract" has incompatible type "str"; expected "int"
57+
```
58+
59+
- 다른 실수는 python3 로 넘어오면서 `bytes``str` 인스턴스를 섞어쓰는 점
60+
- 타입 힌트와 mypy 를 사용하면 정적으로 문제 탐지 가능
61+
62+
```python
63+
def concat(a, b):
64+
return a + b
65+
66+
concat('first', b'second')
67+
68+
>>>
69+
Traceback ...
70+
TypeError: can only concatenate str (not "bytes") to str
71+
```
72+
73+
```python
74+
def concat(a: str, b: str) -> str:
75+
return a + b
76+
77+
concat('첫째', b'second') # 아이고! bytes 값을 넘김
78+
$ python3 -m mypy --strict example.py
79+
.../example.py:4: error: Argument 2 to "concat" has incompatible type "bytes"; expected "str"
80+
```
81+
82+
- 타입 애너테이션을 클래스에도 적용 가능
83+
84+
```python
85+
class Counter:
86+
def __init__(self):
87+
self.value = 0
88+
89+
def add(self, offset):
90+
value += offset
91+
92+
def get(self) -> int:
93+
self.value
94+
```
95+
96+
```python
97+
counter = Counter()
98+
counter.add(5)
99+
100+
>>>
101+
Traceback ...
102+
UnboundLocalError: local variable 'value' referenced before assignment
103+
```
104+
105+
```python
106+
counter = Counter()
107+
found = counter.get()
108+
assert found == 0, found
109+
110+
>>>
111+
Traceback ...
112+
AssertionError: None
113+
```
114+
115+
- mypy 를 사용하면 위 2개 문제 탐지 가능
116+
117+
```python
118+
class Counter:
119+
def __init__(self) -> None:
120+
self.value: int = 0 # 필드/변수 애너테이션
121+
122+
def add(self, offset: int) -> None:
123+
value += offset # 아이고! 'self.'를 안 씀
124+
125+
def get(self) -> int:
126+
self.value # 아이고! 'return'을 안 씀
127+
128+
counter = Counter()
129+
counter.add(5)
130+
counter.add(3)
131+
assert counter.get() == 8
132+
133+
$ python3 -m mypy --strict example.py
134+
.../example.py:6: error: Name 'value' is not defined
135+
.../example.py:8: error: Missing return statement
136+
```
137+
138+
139+
- 동적으로 작동하는 파이선 강점은 덕 타입에 대해 작동하는 제너릭 가능을 작성하기 쉽다는 점
140+
- 덕 타입에 대한 제너릭 기능을 사용하면 한 구현으로 다양한 타입 처리 가능
141+
- 반복적인 수고를 줄일 수 있음
142+
- 테스트 단순화 가능
143+
- 리스트와 값을 모두 조합하는 덕 타입을 지원하는 제너릭 함수 정의 예제
144+
- 그러나 마지막 단언문 실패
145+
146+
```python
147+
def combine(func, values):
148+
assert len(values) > 0
149+
150+
result = values[0]
151+
for next_value in values[1:]:
152+
result = func(result, next_value)
153+
154+
return result
155+
156+
def add(x, y):
157+
return x + y
158+
159+
inputs = [1, 2, 3, 4j]
160+
result = combine(add, inputs)
161+
assert result == 10, result # 실패함
162+
163+
>>>
164+
Traceback ...
165+
AssertionError: (6+4j)
166+
```
167+
168+
- `typing` 모듈의 제너릭 지원을 사용하면 함수에 애너테이션 추가 가능, 문제 발견 가능
169+
170+
```python
171+
from typing import Callable, List, TypeVar
172+
173+
Value = TypeVar('Value')
174+
Func = Callable[[Value, Value], Value]
175+
176+
def combine(func: Func[Value], values: List[Value]) -> Value:
177+
assert len(values) > 0
178+
179+
result = values[0]
180+
for next_value in values[1:]:
181+
result = func(result, next_value)
182+
183+
return result
184+
185+
Real = TypeVar('Real', int, float)
186+
187+
def add(x: Real, y: Real) -> Real:
188+
return x + y
189+
190+
inputs = [1, 2, 3, 4j] # 아이고!: 복소수를 넣었다
191+
result = combine(add, inputs)
192+
assert result == 10
193+
194+
$ python3 -m mypy --strict example.py
195+
.../example.py:21: error: Argument 1 to "combine" has incompatible type "Callable[[Real, Real], Real]"; expected "Callable[[complex, complex], complex]"
196+
```
197+
198+
- 객체의 `None` 여부 문제
199+
200+
```python
201+
def get_or_default(value, default):
202+
if value is not None:
203+
return value
204+
return value
205+
206+
found = get_or_default(3, 5)
207+
assert found == 3
208+
209+
found = get_or_default(None, 5)
210+
assert found == 5, found # 실패함
211+
212+
>>>
213+
Traceback ...
214+
AssertionError: None
215+
```
216+
217+
- `typing` 모듈은 선택적인 타입 지원
218+
- 선택적인 타입은 프로그램이 널 검사를 제대로 수행한 경우에만 값을 다룰 수 있게 강제
219+
220+
```python
221+
from typing import Optional
222+
223+
def get_or_default(value: Optional[int],
224+
default: int) -> int:
225+
if value is not None:
226+
return value
227+
return value # 아이고!: 'default'를 반환해야 하는데 'value'를 반환했다
228+
229+
$ python3 -m mypy --strict example.py
230+
.../example.py:7: error: Incompatible return value type (got "None", expected "int")
231+
```
232+
233+
234+
- `typing` 모듈은 이외에도 다양한 기능 제공
235+
- [https://docs.python.org/3.8/library/typing](https://docs.python.org/3.8/library/typing)
236+
- 예외를 인터페이스 정의의 일부분으로 간주하지 않음
237+
- 예외를 제대로 발생시키고 잡아내는지 검증이 필요하면 테스트를 작성해야 함
238+
239+
## 3. `typing` 모듈 사용 시 빠지게 되는 함정
240+
241+
- 전방 참조를 처리할 때 생길 수 있는 문제
242+
243+
```python
244+
class FirstClass:
245+
def __init__(self, value):
246+
self.value = value
247+
248+
class SecondClass:
249+
def __init__(self, value):
250+
self.value = value
251+
252+
second = SecondClass(5)
253+
first = FirstClass(second)
254+
```
255+
256+
- 해당 프로그램에 타입 힌트를 추가하고 mypy 를 실행해도 아무 문제 없다고 보고
257+
258+
```python
259+
class FirstClass:
260+
def __init__(self, value: SecondClass) -> None:
261+
self.value = value
262+
263+
class SecondClass:
264+
def __init__(self, value: int) -> None:
265+
self.value = value
266+
267+
second = SecondClass(5)
268+
first = FirstClass(second)
269+
270+
$ python3 -m mypy --strict example.py
271+
```
272+
273+
- 그러나 실제로 이 코드를 실행하면 `SecondClass` 가 실제로 정의되기 전에 `FirstClass.__init__` 메소드의 파라미터에서 `SecondClass` 를 사용하므로 실패
274+
275+
```python
276+
class FirstClass:
277+
def __init__(self, value: SecondClass) -> None: # 깨짐
278+
self.value = value
279+
280+
class SecondClass:
281+
def __init__(self, value: int) -> None:
282+
self.value = value
283+
284+
second = SecondClass(5)
285+
first = FirstClass(second)
286+
287+
>>>
288+
Traceback ...
289+
NameError: name 'SecondClass' is not defined
290+
```
291+
292+
- 정적 분석 도구가 지원하는 방법 중 하나는 전방 참조가 포함된 타입 애너테이션을 표현할 때 문자열을 쓰는 것
293+
- 분석 도구는 이 문자열을 구문 분석해서 체크할 타입 정보 추출
294+
295+
```python
296+
class FirstClass:
297+
def __init__(self, value: 'SecondClass') -> None: # OK
298+
self.value = value
299+
300+
class SecondClass:
301+
def __init__(self, value: int) -> None:
302+
self.value = value
303+
304+
second = SecondClass(5)
305+
first = FirstClass(second)
306+
```
307+
308+
- `from __future__ import annotations` 사용
309+
- python 3.7 이상
310+
- 해당 임포트는 파이선 인터프리터가 프로그램을 실행할 때 타입 애너테이션에 지정된 값을 무시하라 지시
311+
- 전방 참조 문제 해결, 프로그램 시작할 때 성능 향상 가능
312+
313+
```python
314+
from __future__ import annotations
315+
316+
class FirstClass:
317+
def __init__(self, value: SecondClass) -> None: # OK
318+
self.value = value
319+
320+
class SecondClass:
321+
def __init__(self, value: int) -> None:
322+
self.value = value
323+
324+
second = SecondClass(5)
325+
first = FirstClass(second)
326+
```
327+
328+
329+
## 4. 정리
330+
331+
- 다음은 염두에 둘 만한 모범적인 사용법
332+
- 일반적인 전략은 아무 타입 애너테이션도 사용하지 않으면서 최초 버전을 작성하고, 이어서 테스트를 작성한 다음, 타입 정보가 가장 유용하게 쓰일 수 있는 곳에 타입 정보를 추가하는 것이다.
333+
- 새로운 코드를 작성하면서 처음부터 타입 애너테이션을 사용하려고 하면 개발 과정이 느려진다.
334+
- 타입 힌트는 여러분의 코드에 의존하는 많은 호출자(따라서 다른 사람들)에게 기능을 제공하는 API와 같이 코드베이스의 경계에서 가장 중요하다.
335+
- 타입 힌트는 API를 변경해도 API를 호출하는 사람들이 예기치 못한 오류를 보거나 코드가 깨지는 일이 없도록 하기 위해 통합 테스트([Better way 77](https://github.com/damho1104/Effective-Python/blob/master/summary/BetterWay77.md))나 경고([Better way 89](https://github.com/damho1104/Effective-Python/blob/master/summary/BetterWay89.md))를 보완한다.
336+
- API의 일부분이 아니지만 코드베이스에서 가장 복잡하고 오류가 발생하기 쉬운 부분에 타입 힌트를 적용해도 유용할 수 있다.
337+
- 타입 힌트를 코드의 모든 부분에 100% 적용하는 것은 바람직하지 않다.
338+
- 타입을 추가하다 보면, 타입을 추가해서 얻을 수 있는 이익(한계 이익)이 점점 줄어들기 마련이다(한계수확 체감 법칙(low of diminishing returns)).
339+
- 가능하면 여러분의 자동 빌드와 테스트 시스템의 일부분으로 정적 분석을 포함시켜서 코드베이스에 커밋할 때마다 오류가 없는지 검사해야 한다.
340+
- 추가로 타입 검사에 사용할 설정을 저장소에 유지해서 여러분이 협업하는 모든 사람이 똑같은 규칙을 사용하게 해야 한다.
341+
- 코드에 타입 정보를 추가해나갈 때는 타입을 추가하면서 타입 검사기를 실행하는 일이 중요하다.
342+
- 타입을 추가하면서 타입 검사기를 실행하지 않으면, 타입 힌트를 여기저기 흩뿌려 놓으면서 타입 검사 도구가 엄청나게 많은 오류를 표시하는 것을 보게 된다.
343+
- 이런 일이 발생하면, 낙담해서 결국 타입 힌트를 아예 사용하지 않게 될 수도 있다.

0 commit comments

Comments
 (0)