숫자 표현
숫자 표현 (Number representation)
Dart 앱은 종종 여러 플랫폼을 대상으로 해요. 예를 들어 Flutter 앱이 iOS, Android, 웹을 대상으로 할 수 있죠. 앱이 플랫폼 특화 라이브러리에 의존하지 않거나, 플랫폼 의존적인 방식으로 숫자를 쓰지만 않으면 코드는 같을 수 있어요. 이 페이지는 네이티브와 웹 숫자 구현의 차이점, 그리고 그 차이를 무시해도 되도록 코드를 쓰는 방법의 세부 내용을 다뤄요.
출처: Dart 공식 문서
본문
Dart와 다른 언어의 숫자 구현을 보면, Dart는 성능, 코드 크기, 플랫폼 상호운용성을 이유로 항상 플랫폼 특화 숫자 표현과 의미론을 허용했어요. C/C++에서 정수 값을 위해 흔히 쓰는 int 타입은 네이티브 머신 아키텍처(16, 32, 64비트)에 가장 잘 매핑되도록 플랫폼 특화인 것과 비슷하죠. Java에서 분수 값을 위한 float와 double 타입은 원래 모든 플랫폼에서 IEEE 754를 엄격히 따르도록 설계됐지만, 이 제약은 효율성 때문에 거의 즉시 완화됐어요(strictfp가 정확한 일관성에 필요해요).
Dart 숫자 표현 (Dart number representation)
Dart에서 모든 숫자는 공통 Object 타입 계층의 일부이고, 두 개의 구체적이고 사용자에게 보이는 숫자 타입이 있어요: 정수 값을 나타내는 int, 분수 값을 나타내는 double.
플랫폼에 따라 이 숫자 타입들은 다른, 숨겨진 구현을 가져요. 특히 Dart는 컴파일하는 두 가지 아주 다른 타입의 대상이 있어요.
- 네이티브: 대부분 64비트 모바일 또는 데스크톱 프로세서.
- 웹: JavaScript를 주 실행 엔진으로.
다음 표는 Dart 숫자가 보통 어떻게 구현되는지 보여줘요.
| 표현 | |
|---|---|
네이티브 int |
64비트 부호 있는 2의 보수 ✅ |
네이티브 double |
64비트 부동소수점 ✅ |
웹 int |
64비트 부동소수점 ✅ |
웹 double |
64비트 부동소수점 ✅ |
네이티브 대상에서는 int가 부호 있는 64비트 정수 표현에, double이 기본 프로세서와 일치하는 64비트 IEEE 부동소수점 표현에 매핑된다고 가정할 수 있어요.
하지만 Dart가 JavaScript로 컴파일되고 상호운용하는 웹에는 단일 숫자 표현이 있어요: 64비트 배정밀도 부동소수점 값이에요. 효율성을 위해 Dart는 int와 double을 모두 이 단일 표현에 매핑해요. 보이는 타입 계층은 같지만, 기본의 숨겨진 구현 타입은 다르고 서로 얽혀 있어요.
Dart는 효율성을 위해
int와double을 몇 가지 다른 방식으로 표현하지만, 이 구현 클래스들은 숨겨져 있어요. 일반적으로 플랫폼 특화 타입을 무시하고int와double을 구체 타입으로 생각해도 돼요.
웹의 int는 분수 부분이 없는 배정밀도 부동소수점 값으로 표현돼요. 실제로 꽤 잘 동작해요. 배정밀도 부동소수점은 53비트의 정수 정밀도를 제공하니까요. 하지만 int 값은 항상 double 값이기도 해서, 몇 가지 놀라운 일이 생길 수 있어요.
동작의 차이점 (Differences in behavior)
대부분의 정수와 double 산술은 본질적으로 같은 동작을 해요. 하지만 특히 코드가 정밀도, 문자열 포맷, 기본 런타임 타입에 대해 엄격한 기대를 가질 때 중요한 차이가 있어요.
이 절에서 설명하는 것처럼 산술 결과가 다를 때, 그 동작은 플랫폼 특화이고 변경될 수 있어요. 이 페이지가 설명하는 어떤 플랫폼 특화 동작이든 덜 놀랍고, 더 일관적이고, 더 성능이 좋도록 바뀔 수 있어요.
정밀도 (Precision)
다음 표는 일부 숫자 식이 정밀도 때문에 어떻게 달라지는지 보여줘요. 여기서 math는 dart:math 라이브러리를 나타내고, math.pow(2, 53)은 2⁵³이에요.
웹에서는 정수가 53비트를 넘으면 정밀도를 잃어요. 특히 2⁵³과 2⁵³+1은 잘림(truncation) 때문에 같은 값에 매핑돼요. 네이티브에서는 이 값들을 여전히 구별할 수 있어요. 네이티브 숫자는 64비트를 가지니까요 — 값에 63비트, 부호에 1비트.
오버플로의 효과는 2⁶³-1과 2⁶³을 비교할 때 보여요. 네이티브에서는 후자가 2의 보수 산술에서 예상대로 -2⁶³으로 오버플로해요. 웹에서는 이 값들이 다르게 표현되므로 오버플로하지 않아요. 정밀도 손실 때문에 근사치일 뿐이에요.
| 식 | 네이티브 | 웹 |
|---|---|---|
math.pow(2, 53) - 1 |
9007199254740991 |
9007199254740991 |
math.pow(2, 53) |
9007199254740992 |
9007199254740992 |
math.pow(2, 53) + 1 |
9007199254740993 |
9007199254740992 |
math.pow(2, 62) |
4611686018427387904 |
4611686018427388000 |
math.pow(2, 63) - 1 |
9223372036854775807 |
9223372036854776000 |
math.pow(2, 63) |
-9223372036854775808 |
9223372036854776000 |
math.pow(2, 64) |
0 |
18446744073709552000 |
동일성 (Identity)
네이티브 플랫폼에서 double과 int는 구별되는 타입이에요. 어떤 값도 동시에 double이자 int일 수 없죠. 웹에서는 그렇지 않아요. 이 차이 때문에 동일성(identity)은 플랫폼 간에 달라질 수 있지만, 동등성(==)은 달라지지 않아요.
다음 표는 동등성과 동일성을 사용하는 몇 가지 식을 보여줘요. 동등성 식은 네이티브와 웹에서 같아요. 동일성 식은 보통 달라요.
| 식 | 네이티브 | 웹 |
|---|---|---|
1.0 == 1 |
true |
true |
identical(1.0, 1) |
false |
true |
0.0 == -0.0 |
true |
true |
identical(0.0, -0.0) |
false |
true |
double.nan == double.nan |
false |
false |
identical(double.nan, double.nan) |
true |
false |
double.infinity == double.infinity |
true |
true |
identical(double.infinity, double.infinity) |
true |
true |
타입과 타입 검사 (Types and type checking)
웹에서 기본 int 타입은 double의 하위 타입과 같아요. 분수 부분이 없는 배정밀도 값이니까요. 실제로 웹에서 x is int 형태의 타입 검사는, x가 0인 분수 부분을 가진 숫자(double)라면 true를 반환해요.
그 결과 웹에서는 다음이 참이에요.
- 모든 Dart 숫자(
num타입 값)는double이에요. - Dart 숫자는 동시에
double이자int일 수 있어요.
이 사실들은 is 검사와 runtimeType 프로퍼티에 영향을 줘요. 부수 효과로 double.infinity가 int로 해석돼요. 플랫폼 특화 동작이므로 앞으로 바뀔 수 있어요.
| 식 | 네이티브 | 웹 |
|---|---|---|
1 is int |
true |
true |
1 is double |
false |
true |
1.0 is int |
false |
true |
1.0 is double |
true |
true |
(0.5 + 0.5) is int |
false |
true |
(0.5 + 0.5) is double |
true |
true |
3.14 is int |
false |
false |
3.14 is double |
true |
true |
double.infinity is int |
false |
true |
double.nan is int |
false |
false |
1.0.runtimeType |
double |
int |
1.runtimeType |
int |
int |
1.5.runtimeType |
double |
double |
비트 연산 (Bitwise operations)
웹에서 성능상의 이유로, int에 대한 비트(&, |, ^, ~) 및 시프트(<<, >>, >>>) 연산자는 네이티브 JavaScript에 해당하는 것을 사용해요. JavaScript에서 피연산자는 부호 없는 것으로 취급되는 32비트 정수로 잘려요. 이 취급은 더 큰 수에서 놀라운 결과를 낳을 수 있어요. 특히 피연산자가 음수이거나 32비트에 안 맞으면 네이티브와 웹 사이에 다른 결과를 낼 가능성이 커요.
다음 표는 피연산자가 음수이거나 32비트에 가까울 때 네이티브와 웹 플랫폼이 비트 및 시프트 연산자를 어떻게 취급하는지 보여줘요.
| 식 | 네이티브 | 웹 |
|---|---|---|
-1 >> 0 |
-1 |
4294967295 |
-1 ^ 2 |
-3 |
4294967293 |
math.pow(2, 32).toInt() |
4294967296 |
4294967296 |
math.pow(2, 32).toInt() >> 1 |
2147483648 |
0 |
(math.pow(2, 32).toInt()-1) >> 1 |
2147483647 |
2147483647 |
문자열 표현 (String representation)
웹에서 Dart는 일반적으로 숫자를 문자열로 변환할 때 JavaScript에 위임해요(예를 들어 print의 경우). 다음 표는 첫 번째 열의 식을 변환하면 어떻게 다른 결과가 나올 수 있는지 보여줘요.
| 식 | 네이티브 toString() |
웹 toString() |
|---|---|---|
1 |
"1" |
"1" |
1.0 |
"1.0" |
"1" |
(0.5 + 0.5) |
"1.0" |
"1" |
1.5 |
"1.5" |
"1.5" |
-0 |
"0" |
"-0.0" |
math.pow(2, 0) |
"1" |
"1" |
math.pow(2, 80) |
"0" |
"1.2089258196146292e+24" |
무엇을 해야 하나요? (What should you do?)
보통은 숫자 코드를 바꿀 필요가 없어요. Dart 코드는 수년간 네이티브와 웹 플랫폼 양쪽에서 실행돼 왔고, 숫자 구현 차이는 드물게 문제가 돼요. 작은 정수 범위를 반복하고 리스트를 인덱싱하는 것 같은 흔하고 전형적인 코드는 같게 동작해요.
문자열 결과를 비교하는 테스트나 주장(assertion)이 있다면 플랫폼에 강한(platform-resilient) 방식으로 작성하세요. 예를 들어 숫자가 포함된 문자열 식의 값을 테스트한다고 해볼게요.
void main() {
var count = 10.0 * 2;
var message = "$count cows";
if (message != "20.0 cows") throw Exception("Unexpected: $message");
}
이 코드는 네이티브 플랫폼에서는 성공하지만 웹에서는 예외를 던져요. 웹에서 message가 "20 cows"(소수점 없음)이니까요. 대안으로 조건을 다음과 같이 써서 네이티브와 웹 플랫폼 양쪽에서 통과하게 할 수 있어요.
if (message != "${20.0} cows") throw ...
비트 조작의 경우 명시적으로 32비트 청크(chunk)로 작업하는 걸 고려하세요. 모든 플랫폼에서 일관적이에요. 32비트 청크의 부호 있는 해석을 강제하려면 int.toSigned(32)를 쓰세요.
정밀도가 중요한 다른 경우에는 다른 숫자 타입을 고려하세요. BigInt 타입은 네이티브와 웹 양쪽에서 임의 정밀도 정수를 제공해요. fixnum 패키지는 웹에서도 엄격한 64비트 부호 있는 숫자를 제공하죠. 다만 이 타입들을 신중하게 사용하세요. 종종 상당히 더 크고 느린 코드를 만들기 때문이에요.