요약

상자에 “깨지는 물건”, “냉장 보관” 같은 꼬리표를 붙여 두면 택배 기사가 내용물을 열어 보지 않고도 어떻게 다룰지 안다. 프로그램의 값에 붙이는 이런 꼬리표가 타입이고, 어떤 식에 어떤 꼬리표가 붙는지 정해 둔 규칙 묶음이 타입 시스템이다. 컴파일러는 이 규칙으로 “참·거짓 자리에 숫자를 넣었다” 같은 실수를 프로그램을 돌리기 전에 잡는다. 다만 기계어로 내려가면 꼬리표가 사라지므로 그 전 단계에서 검사해야 하고, 무엇을 실수로 볼지는 언어마다 다르다.

예시로 보기

타입을 보지 않으면 무엇이 곤란한지는 기계어 쪽에서 보면 드러난다. 어셈블리 명령 add $r1, $r2, $r3은 레지스터 두 개에 든 비트를 더해 세 번째 레지스터에 넣는다.

  • int a = 0; int b = 1; int c = a + b;에서 나온 덧셈이면 세 레지스터가 모두 정수라 맞는 연산이다.
  • int* a = malloc(…); float b = 3.5; int c = a + b;에서 나온 덧셈이면 한쪽은 메모리 주소, 한쪽은 실수다. 이 덧셈은 허용되지 않는다.

그런데 두 경우의 어셈블리 명령은 똑같다. 레지스터에는 비트만 있고 “이 비트는 주소다”, “이 비트는 실수다”라는 표시가 없다. 그래서 어셈블리 단계에서는 둘을 가릴 수 없고, 변수의 타입이 아직 남아 있는 추상 구문 트리 단계에서 검사한다.

실수 3.5를 32비트로 적으면 0x40600000이다. 이 비트를 정수로 읽으면 1,080,033,280이다. 여기에 정수 덧셈으로 1을 더하면 0x40600001이 되고, 다시 실수로 읽으면 4.5가 아니라 3.5보다 아주 조금 큰 수다1. 덧셈 명령은 시킨 대로 비트를 더했을 뿐이다. 잘못은 그 덧셈을 시킨 쪽에 있고, 그것을 미리 막는 것이 타입 검사다.

검증: 3.5의 비트 0x40600000, 정수 덧셈 뒤 0x40600001이 4.5가 아님, int a[32]가 int 32칸 — 33_type-system_verify.py

세 가지 말

타입(type, data type). 자료에 붙은 속성이다. 프로그래머가 그 자료를 어떻게 쓰려는지 컴파일러에 알려 준다. C의 선언으로 보면 이렇다.

선언 알려 주는 것
int a; a는 정수다
char a; a는 글자 하나다
int a[32]; a는 정수 32칸짜리 배열이다
char* a; a는 글자가 있는 곳의 주소다
bool a; a는 참 또는 거짓이다

타입 시스템(type system). 프로그램의 여러 구성 요소에 타입을 붙여 주는 규칙의 모음이다. 규칙의 예를 셋 들면 이렇다.

  • a && b: a와 b는 둘 다 참·거짓(불) 값이어야 하고, 결과도 불 값이다.
  • char a = b;: b는 글자여야 한다.
  • a = 3.0f;: a는 float이나 double이어야 한다.

이 세 규칙은 실제 C의 규칙이 아니라, 컴파일러의 원리를 보이려고 쓰는 단순한 언어의 규칙이다. 실제 C는 더 느슨하다. C에서 a && b의 a와 b는 정수·실수·포인터 중 무엇이어도 되고, 결과는 0 또는 1이 든 int다2. 이 문서 모음의 문제는 단순한 언어의 규칙(둘 다 bool이면 결과도 bool)을 따른다.

타입 검사(type checking). 연산자가 바라는 타입과 실제 피연산자의 타입이 맞는지 확인하는 일이다. 피연산자는 연산자가 계산에 쓰는 값이다. a + b에서는 a와 b다.

타입 오류가 나는 코드

다음 코드에는 타입 오류가 여러 군데 숨어 있다.

while (foo(x + 5) <= 10) {
    if (1.0 + 2.0) {

    } else if (1 == null) {

    }
}

타입 추론 규칙으로 읽으면 이렇다.

  • if (1.0 + 2.0): 조건 자리에는 bool이 와야 하는데 1.0 + 2.0은 double이다.
  • 1 == null: 비교하는 두 쪽의 타입이 같아야 하는데 한쪽은 int, 한쪽은 null이다.
  • foo(x + 5) <= 10: foo가 int를 받아 int를 돌려주는 함수로 선언되어 있고 x가 int일 때만 맞다. 선언을 봐야 알 수 있다.

자동 타입 변환은 이 규칙들에 없다

컴파일러 전체의 흐름을 그린 그림에서는 의미 분석기가 A = B + C의 C 위에 inttofloat 노드를 끼워 넣기도 한다. 정수 C를 실수로 바꿔서 더하라는 뜻이다. 실제 컴파일러는 이렇게 타입이 다른 값을 알아서 맞춰 주기도 한다.

타입 추론 규칙에는 이런 변환이 없다. 덧셈 규칙은 양쪽 타입이 같을 때만 쓸 수 있어서, int + double은 맞는 규칙이 없어 타입 오류다. 그래서 타입이 섞인 식은 변환 규칙이 따로 주어지지 않는 한 오류로 판정한다.

활용

  • 타입 검사는 의미 분석이 확인하는 다섯 규칙 중 넷째(변수를 맞는 타입의 값과 함께 쓴다)와 다섯째(함수를 맞는 개수와 타입의 인자로 부른다)를 맡는다. 앞의 셋은 스코프 검사가 맡는다.
  • 검사 방법: 타입 추론 규칙으로 식의 타입을 정하고, 타입 검사 절차대로 트리를 돈다.
  • 흔한 실수: “타입이 맞다”를 “값이 맞다”와 섞는다. 타입 검사는 int c = a / b;의 타입만 보고, b가 0인지는 보지 않는다.

연결

확인 문제

C1 타입, 타입 시스템, 타입 검사를 각각 한 문장으로 쓰라.

답: 타입은 프로그래머가 자료를 어떻게 쓰려는지 컴파일러에 알려 주는 자료의 속성이다. 타입 시스템은 프로그램의 구성 요소에 타입을 붙여 주는 규칙의 모음이다. 타입 검사는 피연산자의 타입이 연산자가 바라는 타입과 맞는지 확인하는 일이다.

C2 타입 검사를 어셈블리 코드가 아니라 추상 구문 트리에서 하는 이유를 add $r1, $r2, $r3으로 설명하라.

답: 정수끼리 더하는 코드와 포인터에 실수를 더하는 코드가 똑같은 add 명령이 된다. 레지스터에는 비트만 있고 그 비트가 정수인지 주소인지 실수인지 적혀 있지 않다. 그래서 어셈블리 단계에서는 맞는 덧셈과 틀린 덧셈을 가릴 수 없다. 변수의 타입이 남아 있는 더 높은 단계의 표현에서 검사해야 한다.
이유: 흔한 오답은 “어셈블리가 읽기 어려워서”다. 읽기 어려운 것이 아니라 가릴 정보가 사라진 것이다.

C3 자동 변환이 없는 타입 추론 규칙으로 볼 때 타입 오류인 것을 모두 고르라. (가) if (1.0 + 2.0) { } (나) int c = a + b; (a, b는 int) (다) double s = d + n; (d는 double, n은 int) (라) int q = a / b; (a는 int, b는 값이 0인 int)

답: (가)와 (다). (가)는 조건 자리에 double이 왔다. (다)는 덧셈의 양쪽 타입이 달라 맞는 규칙이 없다. int를 double로 바꿔 주는 자동 변환 규칙은 없다.
이유: (라)는 타입으로는 맞다. 0으로 나누는 것은 값의 문제라 타입 검사가 잡지 못한다.

  1. 실제 타입은 꼬리표와 달리 실행 파일 안에 따로 남지 않는다. ↩

  2. 실제 C의 && 규칙은 C11 표준 6.5.13절(논리 AND 연산자)을 따랐다. 피연산자는 스칼라 타입이면 되고, 결과의 타입은 int다. ↩