|
| 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