본 기록에서 언급하는 책은 Fluent Python 2nd Edition입니다.
PEP 484에서 소개된 거다. PEP 484에서는 함수의 인자, 리턴 값, 변수에 타입을 정의할 수 있는 문법을 소개했다. 이렇듯 이제 파이썬은 프로젝트 컨벤션에 따라 강타입 언어로도 쓸 수 있게됐다. 우하하.
한번 파이썬의 타입 힌트에 대해서 알아보자.
정적 타입 힌트에는 한계가 있다고 한다. 책에서는 자세히 설명하지 않는데 찾아보니 유연성 부족, 추상화의 제약 등의 한계가 있다고 한다.
왜? 고차 함수나 제네릭 프로그래밍에서 매우 복잡한 타입을 다루는 경우 타입 시스템이 그 복잡성을 모두 처리하지 못할 수 있기 때문이다. 사실 정확하게 공감은 안 간다. 대충 뉘앙스정도는 파악되니 일단 넘어가지만 나중에 더 자세히 알아보자.
아무튼 이러한 정적 타입 힌트의 한계를 타파하기 위한 PEP 484의 제안은 gradual type system, 점진적 타입 시스템이었다. 이에 대해 먼저 이해하고 넘어가보자.
점진적 타입 시스템이란 정적 타이핑, 동적 타이핑 사이에 있는 무언가다. 내가 타이핑을 할 수도 있고 안 할 수도 있는 시스템을 의미한다. 대표적인걸로 타입스크립트가 있다. 요 점진적 타입 시스템이란 것의 특징을 알아보자.
옵셔널함.
Any 타입으로 간주한다.런타임에 타입 에러를 캐치하지 않음.
성능 향상을 시키는 건 아님.
점진적 타입 시스템의 가장 큰 장점은 이 모든 것들이 옵셔널하다는 것이다. 정적으로 했을 때에 대한 문제와 동적으로 했을 때에 대한 문제를 적절히 절충했다. 이게 좋다는 것이다.
너무 복잡한 데이터 같으면 이를 적절히 Any 정도로 처리할 수 있겠지. 그런 것들이 복잡도를 낮춘다고 보는 것 같다. 별도의 커스텀 타입을 만들어주지 않아도 괜찮으니까.
너무 많이 타입을 할당하는 것은 파이썬의 유연함을 제대로 활용하지 못하게하는 근본적인 이유가 되므로 조심하라고 책에서 얘기하니 참고해보자.
예제로 타입힌트가 어케 돌아가는지 함 보자. mypy를 이용해 심플한 파이썬 함수에 타입힌트를 달아보자.
💡 타입 체커가 mypy밖에 없음?
그건 아님. pytype, Pyright, Pyre 등등 타입 체커는 굉장히 많다.
예제에서 마이파이 쓰는건 이 당시 마이파이가 제일 유명해서 그런 것이라고 얘기하니 참고하자.
pip install mypy 하면 됨. 끝.
이제 이렇게 하고 mypy --some-command-line file.py하면 된다.
--disallow-untyped-defs 커맨드 라인은 마이파이가 모든 매개변수와 값에 대해서 타입힌트가 없는 함수 정의를 플래그로 지정한다. 얘를 사용하면 타입이 없는 경우 마이파이가 에러문으로 혼내준다.
@mark.parametrize('qty, expected', [
(1, '1 part'),
(2, '2 parts'),
])
def test_show_count(qty, expected):
got = show_count(qty, 'part')
assert got == expected
def test_show_count_zero():
got = show_count(0, 'part')
assert got == 'no parts'
적당히 파이테스트 코드를 써보자. 얘를 mypy --disallow-untyped-defs .. 로 실행시키면 에러가 터진다.
.../no_hints/ $ mypy --disallow-untyped-defs messages_test.py
messages.py:14: error: Function is missing a type annotation
messages_test.py:10: error: Function is missing a type annotation
messages_test.py:15: error: Function is missing a return type annotation
messages_test.py:15: note: Use "-> None" if function does not return a value
Found 3 errors in 2 files (checked 1 source file)
이렇게. 신기하죠잉. 파이썬 런타임 언어인데 뭔가 컴파일 에러처럼 작동하니까.
점진적 타이핑의 첫 스텝에선 일단 --disallow-incomplete-defs 를 쓰는 것을 선호한다. 이거 쓰면 처음에는 아무 것도 알려주지 않는다.
.../no_hints/ $ mypy --disallow-incomplete-defs messages_test.py
Success: no issues found in 1 source file
대충 이렇게.. 근데 여기서 타입을 추가한다면?
def test_show_count(qty, expected) -> str:
...
이러고 마이파이로 실행한다면 다음과 같이 나온다.
/no_hints/ $ mypy --disallow-incomplete-defs messages_test.py
messages.py:14: error: Function is missing a type annotation
for one or more arguments
Found 1 error in 1 file (checked 1 source file)
인자에 타입 힌팅이 빠졌다고 혼내준다. 타입 힌팅을 쓴 부분에 대해선 다른 부분에 빠지는게 없는지 확인해주는 것이다. 그야말로 점진적 타입힌팅이라 할 수 있다.
def test_show_count(qty: str, expected: str) -> str:
...
이렇게 하면 에러 안남.
위에서 말했듯이 과도한 타입 강제는 파이썬의 유연함을 제대로 활용 못하게 만드니까 이렇게 하는 걸 추천하는 것이다. 왠만하면 이렇게 하도록 하자.
def show_count(count, word):
if count == 1:
return f'1 {word}'
count_str = str(count) if count else 'no'
return f'{count_str} {word}s'
이런 함수가 있다고 하자.
def test_irregular() -> None:
got = show_count(2, 'child', 'children')
assert got == '2 children'
이렇게 하고 마이파이로 돌리면 테스트 부터가 안 돌아간다.
.../hints_2/ $ mypy messages_test.py
messages_test.py:22: error: Too many arguments for "show_count" Found 1 error in 1 file (checked 1 source file)
이렇게 고쳐보자:
def show_count(count: int, singular: str, plural: str = '') -> str:
if count == 1:
return f'1 {singular}'
count_str = str(count) if count else 'no'
if not plural:
plural = singular + 's'
return f'{count_str} {plural}'
이러면 잘 된다. 이런식으로 타입 변수에 디폴트 값을 줄 수도 있다.
def show_count(count: int, singular: str, plural: str = '') -> str:
우리가 이렇게 기존에 디폴트 값을 줬었다. 충분히 멋쟁이 방법이지만 맥락에 따라 None을 주는 것이 더 좋은 디폴트 값이 될 때가 있다.
from typing import Optional
def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:
이렇게 만들면 plural에 None도 들어갈 수 있고 str도 들어갈 수 있다. 이게 좋을 땐 언제일까? 값이 주입되면 None이 아니라 str 값일 것이다. plural 값이 None이라는 것은 값을 안넣어줬다라는 의미이다.
이에 대한 적절한 분기가 하고 싶을 때 None이 효과적일 수 있다. 물론 plural == '' 도 가능하겠지만 가독성이 좋지는 않다.
다음은 코틀린 코드인데 내가 비슷한 상황에 nullable 변수를 사용한 것이다. nullable 변수는 Optional과 매우 유사하다고 생각하면 된다.
// 이러한 함수 시그니처가 있다고 하자.
fun findBySocialId(
socialId: String
): User?
val user = userReader.findBySocialId(profile.id!!)
?: return userWriter.register(
socialId = profile.id,
socialType = SocialInfoEnum.NAVER,
email = profile.email!!,
nickname = profile.nickname!!
).toResult()
?:로 정의되는 코드 블록은 해당 함수의 값이 null이라면 동작하게 할 함수의 블록이다.
만약 해당 socialId를 가진 유저가 있으면 가져오고, 만약 없다면 유저를 회원가입 시키는 로직이다.
이런 식의 분기처리가 쉬우니 매우 용이하다.
뭔 멍소리냐 싶은데 한번 천천히 보자.
from collections import abc
def double(x: abc.Sequence):
return x * 2
이 코드를 보자. 반환형에 대한 타입 정의는 안 됐는데 암튼 이건 대충 무시하고, 인자의 타입과 함수의 내용에 좀 집중을 해보자.
x는 abc.Sequence 타입이다. 함수의 내용에서는 x * 2 를 쓰고 있다. 이거 마이파이로 돌리면 어케될 것 같나?
터진다. 왜? Sequence는 __mul__() 매직 메서드가 없다. 이 연산을 할 수 있는 타입만 x의 타입이 될 수 있는 것이다.
마이파이는 곱하기 소스를 분석하는 동안 x * 2 를 잘못된 것으로 판단한다. 이는 선언된 타입에 대해 지원되지 않는 연산이기 때문이다.
이런 모든 연산에 대해서 잘 돌아가면 타입으로 지정한 것이 통과되므로 연산에 의해 정의된다라는 표현을 쓴 것이다.
그러나 좀 알아둬야할 게 있다.
연산에 의해서 정의되기는 하는데, 반드시 연산을 통해야지만 타입을 체크할 수 있는 건 아니다.
a: int = "hey"
이 코드를 돌려봐라. 당연히 터진다. 타입을 체크하는 기준은 연산도 있지만 위처럼 변수의 타입 정보를 수집한 뒤, 검사하는 식으로도 이뤄진다. 코드의 맥락을 파악했을 때 일관적이지 못한 점을 잡아서도 체크를 하니 참고를 하자.
그럼 여기서 더 궁금한점. "hey"라는 녀석이 str인걸 타입 체커는 어떻게 알 수 있을까? 일단 헤이가 str라는 걸 알아봤으니까 퍽 터지는거 아니야.
진짜 초 간단하다. 리터럴의 형태를 보고 파악한다. 대충 쌍따옴표 있으면 str 이렇게 알아본다는 것이다. 타입 체커들은 리터럴의 형태를 다 알고 있다. 신기하죠?
점진적 타입 시스템에서의 핵심은 요 Any 타입이다. 흔히 다이나믹 타입, 즉 동적 타입으로 많이 알려져있다.
타입 힌트 없이 작업할 때랑 똑같은 취급이다. 다이나믹하게 쓸 수 있슴.
얘는 내부적으로도 좀 특별한 놈인데, 모든 타입의 상위 타입인 object로 예를 들어보자.
def some_func(val: object) -> object:
return val * 2
object는 이게 안 된다. 왜냐면 얘는 __mul__() 매직 메서드가 구현되어있지 않기 때문이다. 최상단 타입이니까 그럴 수 있겠지.
근데 Any로 했다면 이게 된다. 왜 되냐, Any는 가장 최상단과 가장 최하단의 모든 계층에 있는 마법의 타입이기 때문이다. 양자역학의 타입인 것이다.
💀 뭔 멍소리일까. 그딴게 세상에 어딨어.
그래서 좀 더 알아봤습니다.
Any는 모든 타입을 대표할 수 있는 일종의 "슈퍼 타입"처럼 취급됩니다.얘는 타입 체커들에게 있어서 추상적으로 취급되는 거에요.
위에서 연산을 만나면 이 타입이 이 연산을 구현할 수 있는지로 타입을 체크한다고 얘기했습니다.
타입체커는 Any를 만났을 경우 매직 메서드 구현 여부에 대해서 전혀 따지지 않도록 별도의 예외처리를 합니다.
검사 결과를 무조건 가능. True로 만들어버리는 예외처리가 되어있다는 것. 사실상 검사 자체를 무력화하는 것이라고 보시면 될 것 같습니다.
당연히 남발하면 개발 용이성을 떨어뜨리게 될테니 적절히 필요한 곳에 쓰도록 하자.
심플 타입은 int, float, str, bytes 등 타입 힌트에 직접 쓸 수 있는 녀석들(클래스 같이 따로 정의해줄 필요 없이)을 말한다.
클래스에 대한 설명은 그냥 그 개념을 잘 아는 사람이라면 다 아는 내용들로만 있다. 추상 클래스는 유용한 타입 힌팅이 될 수 있다느니,.. 하위 클래스는 모든 상위 클래스와의 일관성을 가진다니(다형성) ... 등등 더 설명은 않겠다.
Optional은 아까 봤다. None과 Optional로 감싼 타입의 형태로 받을 수 있도록하는 녀석이다.
Union은 Optional 이랑 비슷한데 좀 더 넓게 줄 수 있는 놈이다. 예를 들면 Union[int, float] 같이 말이다. Union[int, None] Optional[int]는 같은 의미이다.
그외에 딱히 특별한건 없음
파이썬 컬렉션의 징그러운 특징을 아십니까. 컬렉션에 여러 유형의 값들을 담을 수 있다는 점을 말하는 것이다. 이게 실제로는 그다지 유용하지 않다. 컬렉션에 객체를 넣으면 나중에 해당 객체를 조작하고 싶을 가능성이 높으며, 일반적으로 이는 최소한 하나의 공통 메서드를 공유해야 함을 의미한다.
말이 안 되잖아 솔직히.
a = ["1", 112, 19212390870]
for i in a:
i.replace("1", "4")
이런식으로 짜면 어떻게 되는데. 당연 박살나겠지. 징그러운 문화다 이건. 책 쓴 아저씨도 이건 별로 안좋다고 했어. 컬렉션으로 데이터를 묶는 건 그 이유가 있어서 그렇다.(공통 처리) 이게 유용할 땐 알고리즘 풀 때나 아주 가끔 유용할 것 같다.(아닐 수도)
그래서 타입힌팅으로 컬렉션 내부 객체들의 타입을 강제할 수 있다.
a: list[int] = [...]
이런 느낌으로 말이다.
점진적 타입 시스템에서는 서로 다른 두 가지 관점이 상호작용한다.
덕 타이핑: 스몰톡, 파이썬, js, 루비 등등이 이 관점을 채택했다.
Nominal Typing: C++, Java, C#에서 채택한 관점으로 타입힌팅 걸어놓은 Python에서 채택했다.
아래는 명목적 타이핑, 정적 타입 체킹, 런타임 동작을 대조하는 바보 같은 예시다.(책에서 그랫음)
# birds.py
class Bird: pass
class Duck(Bird):
def quack(self):
print('Quack!')
def alert(birdie):
birdie.quack()
def alert_duck(birdie: Duck) -> None:
birdie.quack()
def alert_bird(birdie: Bird) -> None:
birdie.quack()
# daffy.py
from birds import *
daffy = Duck()
alert(daffy)
alert_duck(daffy)
alert_bird(daffy)
이런 함수들이 있다고 해보자. 마이파이로 birds.py를 돌리면 뻑난다. 버드는 꽉 메서드가 없으니까.
daffy.py를 돌려도 당연히 같은 이유로 뻑난다.
근데 파이마이는 함수 자체의 동작에는 문제가 없다고 볼 것이다. 일단 돌아는 가잖아. 실제로 그냥 python3로 실행시켜보면 잘 된다.
.../birds/ $ python3 daffy.py
Quack!
Quack!
Quack!
우하하.
런타임에서는 파이썬이 진짜 1도 타입을 신경 안쓰는 걸 알 수 있다.