컴퓨터공학 300 주제 시리즈의 021번째 글이다. 전체 지도는 여기.

한 줄 요약

변수는 값에 붙인 이름이고, 타입은 그 값의 비트를 어떻게 해석하고 어떤 연산을 허락할지 정하는 규칙이며, 표현식은 값과 연산자를 엮어 새 값을 계산하는 식이다.

왜 필요한가

프로그래밍의 거의 모든 버그는 결국 “이 값이 내가 생각한 그 값이 아니었다”로 요약된다. 0.1 + 0.2 가 0.3 이 아니고, 음수 나눗셈 결과가 언어마다 다르고, 리스트를 복사했다고 생각했는데 같은 객체를 두 이름이 가리키고 있었다. 이런 일은 변수·타입·표현식의 정확한 의미를 모르면 반복된다.

이 글은 파트 2 의 출발점이다. 이후 다룰 스코프, 값 전달, 타입 시스템, 메모리 모델이 모두 여기서 정한 용어 위에 올라간다.

핵심 개념

변수: 상자인가, 이름표인가

변수를 설명하는 비유는 두 가지가 있다.

  • 상자 모델: 변수는 메모리의 한 칸이고, 대입은 그 칸에 값을 복사해 넣는 일이다. C 의 int x = 10; 이 이렇다. x 는 4바이트(대개) 공간 자체다.
  • 이름표 모델: 변수는 어딘가 있는 객체에 붙은 이름이다. 대입은 이름표를 다른 객체로 옮겨 다는 일이다. Python, JavaScript 의 객체, Java 의 참조 타입 변수가 이렇다.

Python 공식 언어 레퍼런스는 이를 명시적으로 정의한다. 모든 데이터는 객체이고, 객체는 정체성(identity), 타입, 값을 가진다. 정체성은 생성 후 바뀌지 않으며 is 연산자로 비교한다(Data model).

상자 모델 (C)            이름표 모델 (Python)
 x: [ 10 ]                x ──┐
 y: [ 10 ]  (복사본)       y ──┴──> [1, 2, 3]  (객체 하나)

타입: 비트의 해석 규칙

메모리에 있는 것은 비트뿐이다. 같은 32비트 패턴도 정수로 읽으면 한 값, IEEE 754 부동소수점으로 읽으면 전혀 다른 값이다. 타입은 이 해석 규칙과 허용 연산을 묶은 것이다.

범주 예 주의할 점
정수 C int, Java int, Python int C·Java 는 고정 폭이라 넘치면 감싸거나(wrap) 정의되지 않은 동작. Python int 는 임의 정밀도
부동소수점 double, Python float 이진 분수라 0.1 을 정확히 못 담는다
불리언 bool Python 에서 bool 은 int 의 하위 타입이다
문자열 str, String 대부분의 현대 언어에서 불변
복합 리스트, 배열, 구조체, 딕셔너리 가변인지 불변인지가 핵심

Python 은 float 가 대개 C 의 double, 즉 IEEE 754 배정밀도로 구현된다고 문서화한다. 그래서 0.1 + 0.2 는 0.30000000000000004 가 된다. 10진수 0.1 은 2진수로 무한 소수이기 때문이다. 공식 튜토리얼에 이 현상을 따로 설명한 장이 있다(Floating-Point Arithmetic). 돈 계산처럼 10진수 정확성이 필요하면 decimal 모듈을 쓴다.

가변과 불변

타입을 볼 때 “이 값을 제자리에서 바꿀 수 있는가”를 꼭 확인해야 한다. Python 의 int, float, str, tuple 은 불변이고 list, dict, set 은 가변이다. 불변 객체에 x += 1 을 하면 기존 객체가 바뀌는 게 아니라 새 객체가 만들어지고 이름표가 옮겨간다. 가변 객체는 두 이름이 같은 객체를 가리킬 때 한쪽 수정이 다른 쪽에서 보인다. 이것을 별칭(aliasing) 이라 한다.

표현식과 평가

표현식(expression)은 평가하면 값이 나오는 식이다. 문장(statement)은 실행하면 효과가 생기는 단위다. x = 1 + 2 에서 1 + 2 는 표현식이고 전체는 대입문이다.

표현식을 평가할 때 세 가지 규칙이 결과를 정한다.

  1. 우선순위: * 가 + 보다 먼저 묶인다. 1 + 2 * 3 은 7.
  2. 결합 방향: 대부분 왼쪽부터 묶지만 거듭제곱은 오른쪽부터다. Python 에서 2 ** 3 ** 2 는 2 ** 9 = 512 다.
  3. 평가 순서: 피연산자를 어느 쪽부터 계산하는가. Python 은 왼쪽에서 오른쪽으로 평가한다고 레퍼런스에 명시한다(Expressions). C 는 많은 경우 순서를 정하지 않는다.

단락 평가

and, or 는 결과가 확정되면 나머지를 평가하지 않는다. Python 의 x or y 는 x 가 참이면 x 를, 아니면 y 를 돌려준다. 불리언으로 바꾸지 않고 피연산자 자체를 돌려준다는 점이 중요하다. 그래서 name or "기본값" 같은 관용구가 가능하다. "" and 1/0 은 앞이 거짓이라 나눗셈을 아예 하지 않는다.

정수 나눗셈은 언어마다 다르다

-7 / 2 의 정수 몫을 두고 언어는 두 갈래로 나뉜다.

언어 몫 나머지 규칙
Python //, % -4 1 내림(floor)
C /, % -3 -1 0 쪽으로 버림(truncate)
JavaScript Math.trunc, % -3 -1 버림

Python 레퍼런스는 x == (x//y)*y + (x%y) 가 성립하고 나머지의 부호가 제수와 같다고 정의한다. 해시 버킷 번호나 원형 버퍼 인덱스를 음수로 계산할 때 이 차이가 버그로 이어진다.

암묵적 형변환

JavaScript 는 "1" + 1 을 "11" 로, "3" * "4" 를 12 로 바꾼다. 연산자마다 형변환 규칙이 다르다(JavaScript data types). Python 은 "1" + 1 에서 TypeError 를 낸다. 이 차이는 #026 “정적 타입과 동적 타입”에서 강한 타입·약한 타입으로 다시 다룬다.

직접 해 보기

아래를 e21.py 로 저장해 실행한다(Python 3.12 에서 확인).

a = [1, 2, 3]
b = a
b.append(4)
print(a, a is b)
print(0.1 + 0.2, 0.1 + 0.2 == 0.3)
print(2 ** 100)
print(-7 // 2, -7 % 2, int(-7 / 2))
print(1 + 2 * 3, (1 + 2) * 3, 2 ** 3 ** 2)
print(True + True, isinstance(True, int))
print(0 or "기본값", "" and 1/0, 3 < 5 < 4)
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2"))

출력:

[1, 2, 3, 4] True
0.30000000000000004 False
1267650600228229401496703205376
-4 1 -3
7 9 512
2 True
기본값  False
0.3

읽을 거리가 많다. b.append 가 a 에서도 보인다(별칭). 2 ** 100 이 넘치지 않는다(임의 정밀도 정수). 3 < 5 < 4 는 3 < 5 and 5 < 4 로 해석되는 비교 연쇄라 False 다. "" and 1/0 은 빈 문자열을 그대로 돌려주어 출력에 빈칸이 생겼다.

C 로 고정 폭 정수의 감쌈을 보자.

#include <stdio.h>
int main(void) {
    printf("%d %d\n", -7 / 2, -7 % 2);   /* -3 -1 */
    unsigned char c = 255;
    c = c + 1;
    printf("%d\n", c);                   /* 0 */
    return 0;
}

부호 없는 정수는 2^n 으로 나눈 나머지로 감싸도록 C 표준이 정해 두었다. 부호 있는 정수의 오버플로는 정의되지 않은 동작이라 결과를 기대하면 안 된다.

현업에서는

  • 금액 계산: 결제·정산 코드에서 float 로 원 단위 이하를 다루면 합계가 1원씩 어긋난다. 정수(최소 화폐 단위)나 Decimal, DB 의 NUMERIC 을 쓴다.
  • JSON 경계: JSON 숫자는 언어 구현에 따라 배정밀도로 읽히는 경우가 많아서, 2^53 을 넘는 ID 를 숫자로 내보내면 JavaScript 쪽에서 값이 바뀐다. 큰 ID 는 문자열로 내보내는 것이 안전하다(RFC 8259 도 상호운용성 문제로 이 범위를 언급한다, RFC 8259).
  • 설정 값의 타입: 쿠버네티스 매니페스트 YAML 에서 "8080" 과 8080 은 다른 타입이다. 환경 변수 값은 문자열이어야 해서 숫자를 따옴표 없이 쓰면 적용이 거부된다. 홈랩 클러스터에서 처음 매니페스트를 짤 때 흔히 만나는 오류다.
  • 별칭 버그: 기본값 객체를 여러 요청이 공유해 한 요청의 수정이 다른 요청에 새는 사고. #025 에서 자세히 본다.

확인 문제

  1. Python 에서 x = 5; y = x; x += 1 후 y 는 얼마인가? 리스트였다면 어떻게 다른가?
  2. 2 ** 3 ** 2 와 (2 ** 3) ** 2 의 값을 각각 구하라.
  3. Python 의 -7 % 3 과 C 의 -7 % 3 을 구하라.
  4. config.get("timeout") or 30 이 의도와 다르게 동작하는 입력을 하나 들어라.
  5. 0.1 이 이진 부동소수점으로 정확히 표현되지 않는 이유를 한 문장으로 설명하라.

풀이

  1. y 는 5 다. 정수는 불변이라 x += 1 은 새 객체 6 을 만들고 x 이름표만 옮긴다. 리스트에 x += [1] 을 하면 제자리 수정이라 y 에서도 보인다.
  2. 512 와 64. 거듭제곱은 오른쪽 결합이다.
  3. Python 은 2(내림, 제수 부호), C 는 -1(버림, 피제수 부호).
  4. timeout 이 0 으로 설정된 경우. 0 은 거짓이라 30 으로 바뀐다. None 여부를 명시적으로 검사해야 한다.
  5. 1/10 은 분모에 5 가 있어 유한한 이진 소수로 끝나지 않기 때문이다.

더 읽을거리 (References)