숫자 표현

숫자 표현 (Number representation)

Dart 앱은 종종 여러 플랫폼을 대상으로 해요. 예를 들어 Flutter 앱이 iOS, Android, 웹을 대상으로 할 수 있죠. 앱이 플랫폼 특화 라이브러리에 의존하지 않거나, 플랫폼 의존적인 방식으로 숫자를 쓰지만 않으면 코드는 같을 수 있어요. 이 페이지는 네이티브와 웹 숫자 구현의 차이점, 그리고 그 차이를 무시해도 되도록 코드를 쓰는 방법의 세부 내용을 다뤄요.

출처: Dart 공식 문서

본문

Dart와 다른 언어의 숫자 구현을 보면, Dart는 성능, 코드 크기, 플랫폼 상호운용성을 이유로 항상 플랫폼 특화 숫자 표현과 의미론을 허용했어요. C/C++에서 정수 값을 위해 흔히 쓰는 int 타입은 네이티브 머신 아키텍처(16, 32, 64비트)에 가장 잘 매핑되도록 플랫폼 특화인 것과 비슷하죠. Java에서 분수 값을 위한 floatdouble 타입은 원래 모든 플랫폼에서 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는 intdouble을 모두 이 단일 표현에 매핑해요. 보이는 타입 계층은 같지만, 기본의 숨겨진 구현 타입은 다르고 서로 얽혀 있어요.

Dart는 효율성을 위해 intdouble을 몇 가지 다른 방식으로 표현하지만, 이 구현 클래스들은 숨겨져 있어요. 일반적으로 플랫폼 특화 타입을 무시하고 intdouble을 구체 타입으로 생각해도 돼요.

웹의 int는 분수 부분이 없는 배정밀도 부동소수점 값으로 표현돼요. 실제로 꽤 잘 동작해요. 배정밀도 부동소수점은 53비트의 정수 정밀도를 제공하니까요. 하지만 int 값은 항상 double 값이기도 해서, 몇 가지 놀라운 일이 생길 수 있어요.

동작의 차이점 (Differences in behavior)

대부분의 정수와 double 산술은 본질적으로 같은 동작을 해요. 하지만 특히 코드가 정밀도, 문자열 포맷, 기본 런타임 타입에 대해 엄격한 기대를 가질 때 중요한 차이가 있어요.

이 절에서 설명하는 것처럼 산술 결과가 다를 때, 그 동작은 플랫폼 특화이고 변경될 수 있어요. 이 페이지가 설명하는 어떤 플랫폼 특화 동작이든 덜 놀랍고, 더 일관적이고, 더 성능이 좋도록 바뀔 수 있어요.

정밀도 (Precision)

다음 표는 일부 숫자 식이 정밀도 때문에 어떻게 달라지는지 보여줘요. 여기서 mathdart: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)

네이티브 플랫폼에서 doubleint는 구별되는 타입이에요. 어떤 값도 동시에 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.infinityint로 해석돼요. 플랫폼 특화 동작이므로 앞으로 바뀔 수 있어요.

네이티브
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비트 부호 있는 숫자를 제공하죠. 다만 이 타입들을 신중하게 사용하세요. 종종 상당히 더 크고 느린 코드를 만들기 때문이에요.

더 알아보기