[CS300 #021] 변수·타입·표현식 — 컴퓨터가 값을 다루는 방식
컴퓨터공학 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 + 2 * 3은 7. - 결합 방향: 대부분 왼쪽부터 묶지만 거듭제곱은 오른쪽부터다. Python 에서
2 ** 3 ** 2는2 ** 9 = 512다. - 평가 순서: 피연산자를 어느 쪽부터 계산하는가. 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 에서 자세히 본다.
확인 문제
- Python 에서
x = 5; y = x; x += 1후y는 얼마인가? 리스트였다면 어떻게 다른가? 2 ** 3 ** 2와(2 ** 3) ** 2의 값을 각각 구하라.- Python 의
-7 % 3과 C 의-7 % 3을 구하라. config.get("timeout") or 30이 의도와 다르게 동작하는 입력을 하나 들어라.- 0.1 이 이진 부동소수점으로 정확히 표현되지 않는 이유를 한 문장으로 설명하라.
풀이
y는 5 다. 정수는 불변이라x += 1은 새 객체 6 을 만들고x이름표만 옮긴다. 리스트에x += [1]을 하면 제자리 수정이라y에서도 보인다.- 512 와 64. 거듭제곱은 오른쪽 결합이다.
- Python 은 2(내림, 제수 부호), C 는 -1(버림, 피제수 부호).
timeout이0으로 설정된 경우. 0 은 거짓이라 30 으로 바뀐다.None여부를 명시적으로 검사해야 한다.- 1/10 은 분모에 5 가 있어 유한한 이진 소수로 끝나지 않기 때문이다.
더 읽을거리 (References)
- Python Language Reference, Data model — 객체의 정체성·타입·값 정의
- Python Language Reference, Expressions — 평가 순서, 우선순위, 나눗셈 정의
- Python Tutorial, Floating-Point Arithmetic: Issues and Limitations
- MDN, JavaScript data types and data structures