(C#) 1. 데이터 다루기
정수 타입의 크기와 부호에 따라 캐스팅에서 값이 어떻게 잘리는지 직접 확인했다. C#이 정수 오버플로를 조용히 넘긴다는 것과, float을 double에 넣어도 정밀도가 늘지 않는다는 것까지 정리했다.
왜 타입 크기부터 보나
프로그램은 결국 데이터와 로직이다. 그중 데이터를 담는 그릇을 먼저 정해야 한다.
int만 써도 대부분 돌아가는데 굳이 byte나 short를 구분하는 이유는, 같은 데이터가 수천 수만 개 생기는 순간부터 크기가 의미를 갖기 때문이다. 캐릭터 하나의 레벨을 int(4바이트)로 잡느냐 byte(1바이트)로 잡느냐가, 캐릭터 10만 개면 400 KB와 100 KB의 차이가 된다. 서버로 보내는 패킷이면 그대로 트래픽이 된다.
문제는 그릇을 바꿔 담을 때 생긴다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
using System;
namespace DataControl
{
class Program
{
static void Main(string[] args)
{
// 데이터 + 로직
// 체력 0
// 1. 바구니 크기가 다른 경우!
int a = 0x0FFFFFFF;
short b = (short)a; // 0xFFFF 만 저장됨 (상위 짤림)
// 2. 바구니 크기는 같긴 한데, 부호가 다를 경우
byte c = 255;
sbyte sb = (sbyte)c;
// Underflow(언더플로우), Overflow(오버플로우)
// 0xFF = 0b11111111 = -1
// 3. 소수
float f = 3.1415f;
double d = f;
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// byte(1바이트, 0~255), short(2바이트, -3만~3만), int(4바이트, -21억~21억), long(8바이트)
// sbyte(1바이트, -128~127), ushort(2바이트, 0~6만), uint(4바이트, 0~43억), ulong(8바이트)
//int hp;
//short level = 100;
//long id;
//hp = 100;
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// 10진수
// 0 1 2 3 4 5 6 7 8 9
//byte attack = 0;
//attack--; // -1 이 255(최댓값)가 됨.
//Console.WriteLine("Hello Number ! {0}", hp);
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
}
}
}
큰 그릇에서 작은 그릇으로
int a = 0x0FFFFFFF;를 short에 담으면 b는 0x0FFF가 아니라 하위 2바이트인 0xFFFF 만 남고, 그 값은 부호 있는 short에서 -1이다.
찍어보면 확인된다.
1
2
3
int a = 0x0FFFFFFF; // 268435455
short b = (short)a;
Console.WriteLine(b); // -1
상위 바이트가 그냥 버려진다. 값이 작아지는 게 아니라 완전히 다른 값이 된다. 268435455가 −1이 되는 것이 대표적이다.
이게 위험한 이유는 컴파일러가 아무 말도 안 하기 때문이다. (short)라고 명시적으로 캐스팅을 썼으니 “알고 하는 것”으로 간주한다. 명시적 캐스팅을 요구하는 것 자체가 경고인 셈이다.
반대로 작은 그릇에서 큰 그릇으로는 캐스팅 없이 들어간다. 값이 잘릴 일이 없으니 컴파일러가 알아서 넣어준다.
1
2
short s = 100;
int i = s; // 캐스팅 필요 없다
부호가 다르면 같은 비트가 다른 값이 된다
1
2
3
byte c = 255;
sbyte sb = (sbyte)c;
Console.WriteLine(sb); // -1
크기는 똑같이 1바이트인데 값이 255에서 −1로 바뀐다. 비트는 하나도 안 바뀌었다. 0b11111111이 그대로 있다.
달라진 건 읽는 규칙이다. byte는 8비트를 전부 크기로 읽어서 255고, sbyte는 최상위 비트를 부호로 읽는 2의 보수 방식이라 −1이다.
2의 보수에서 −1이 전부 1인 이유는 이렇게 보면 납득이 된다. 0 - 1을 계산할 때 0b00000000에서 1을 빼면 위에서 빌려와 0b11111111이 된다. 십진수에서 1000 - 1 = 999가 되는 것과 같은 원리다.
주석에 적어둔 것도 같은 이야기다.
1
2
byte attack = 0;
attack--; // 255 가 된다
체력이나 공격력을 byte로 잡아두고 감소시키다가 0 아래로 내려가면, 죽는 게 아니라 최대치가 된다. 게임에서 이런 버그는 “체력이 0이 되는 순간 무적이 되는” 형태로 나타난다.
C#은 정수 오버플로를 조용히 넘긴다
여기가 직접 돌려보기 전에는 몰랐던 부분이다. C#은 기본적으로 unchecked 모드라, 오버플로가 나도 예외를 안 던지고 값만 잘린다.
1
2
int max = int.MaxValue; // 2147483647
Console.WriteLine(max + 1); // -2147483648
checked 블록으로 감싸면 예외가 난다.
1
2
3
4
5
checked
{
int max = int.MaxValue;
Console.WriteLine(max + 1); // OverflowException
}
프로젝트 전체에 적용하려면 .csproj에 넣는다.
1
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
성능이 아주 중요한 루프가 아니면 켜두는 편이 낫다는 게 지금 생각이다. 오버플로가 조용히 지나가면 잘못된 값이 계속 흘러가서 한참 뒤 엉뚱한 곳에서 증상이 나타난다.
다만 상수끼리의 연산은 컴파일 타임에 검사된다. int x = int.MaxValue + 1;처럼 리터럴로 쓰면 checked가 없어도 컴파일 에러다. 변수를 거쳐야 런타임 문제가 된다.
float을 double에 넣어도 정밀도는 안 늘어난다
1
2
3
float f = 3.1415f;
double d = f;
Console.WriteLine(d); // 3.1414999961853027
처음 봤을 때 이상했다. 분명 3.1415를 넣었는데 왜 3.1414999...가 나오나.
이유는 float이 이미 3.1415를 정확히 담고 있지 않아서다. float은 32비트에 유효숫자 7자리 정도만 표현하고, 3.1415에 가장 가까운 값이 3.14149999618530273437500이다. 그 값을 double로 옮기면 그릇만 커질 뿐 없던 정밀도가 생기지 않는다. 오히려 double은 자릿수를 더 보여줄 수 있어서 원래 있던 오차가 드러난다.
그러니 double로 계산할 거면 처음부터 double 리터럴로 써야 한다.
1
double d2 = 3.1415; // 3.1415 로 나온다
C#에는 decimal도 있다. 10진 기반이라 0.1 + 0.2가 정확히 0.3이 된다. 대신 느리고 범위가 좁다. 돈처럼 10진 자릿수가 정확해야 하는 값에 쓴다.
1
2
Console.WriteLine(0.1 + 0.2 == 0.3); // False
Console.WriteLine(0.1m + 0.2m == 0.3m); // True
float 리터럴에 f를 안 붙이면 double로 해석되어 컴파일 에러가 나는 것도 같은 맥락이다. double을 float에 넣는 건 정밀도가 줄어드는 방향이라 명시적 캐스팅을 요구한다.
타입 범위를 외울 필요는 없다
주석에 범위를 적어뒀는데, 코드에서 바로 물어볼 수 있다.
1
2
3
4
Console.WriteLine(byte.MinValue + " ~ " + byte.MaxValue); // 0 ~ 255
Console.WriteLine(short.MinValue + " ~ " + short.MaxValue); // -32768 ~ 32767
Console.WriteLine(int.MaxValue); // 2147483647
Console.WriteLine(sizeof(int)); // 4
int가 약 21억까지라는 건 감으로 기억해두면 유용했다. 누적 카운터나 밀리초 단위 시간처럼 계속 늘어나는 값에서 21억이 생각보다 빨리 온다. 밀리초로 세면 약 24일이다.
정리하면
- 큰 타입에서 작은 타입으로 캐스팅하면 상위 비트가 버려진다. 값이 작아지는 게 아니라 다른 값이 된다
- 크기가 같아도 부호가 다르면 같은 비트를 다르게 읽는다.
byte255와sbyte−1은 같은 비트다 - C#의 정수 오버플로는 기본적으로 조용하다.
checked나CheckForOverflowUnderflow로 예외로 바꿀 수 있다 float을double에 넣어도 정밀도는 안 늘어난다. 원래 있던 오차가 드러날 뿐이다- 10진 자릿수가 정확해야 하면
decimal