Ruby 2.4에서는 Fixnum과 Bignum이 하나의 클래스(Integer)로 통합되었습니다. 그래서 지금이야말로 Ruby의 다양한 숫자 타입을 다시 한번 짚어볼 좋은 기회라고 생각합니다!
이번 포스트에서 바로 그 내용을 다뤄보겠습니다 🙂
Ruby 숫자 타입 개요
먼저 Ruby의 모든 숫자 관련 클래스들이 어떤 계층 구조를 이루고 있는지 살펴보겠습니다:
Numeric
Integer
Fixnum
Bignum
Float
Complex
Rational
BigDecimal (Standard Library)
위에서 볼 수 있듯이 Numeric 클래스가 모든 숫자 클래스의 부모 역할을 합니다. 참고로 ancestors 메서드를 사용하면 어떤 클래스든 상위 클래스들을 쉽게 확인할 수 있습니다.
예시:
Fixnum.ancestors - Fixnum.included_modules [Fixnum, Integer, Numeric, Object, BasicObject]
이제 각 클래스를 표로 정리해 보겠습니다:
| 클래스 | 설명 | 예시 |
|---|---|---|
| Integer | Fixnum과 Bignum의 부모 클래스 |
1 |
| Fixnum | OS 정수 타입(32비트 또는 64비트)에 담길 수 있는 정수 | 1 |
| Bignum | 더 큰 숫자를 다룰 때 사용 | 111111111111 |
| Float | 부정확할 수 있는 소수 | 5.0 |
| Complex | 허수를 포함한 수학 연산에 사용 | (1+0i) |
| Rational | 분수를 표현하는 데 사용 | (2/3) |
| BigDecimal | 완벽한 정밀도를 가진 소수 | 3.0 |
Float의 부정확성 문제
Ruby 공식 문서에서는 Float 클래스를 "부정확하다(imprecise)"고 설명합니다.
왜 그럴까요?
예제로 직접 확인해 보겠습니다:
0.2 + 0.1 == 0.3 # false
왜 false가 나올까요?
0.2 + 0.1의 실제 결과를 살펴보겠습니다:
0.30000000000000004
바로 이것이 '부정확성'의 의미입니다.
이런 현상은 float가 메모리에 저장되는 방식 때문에 발생합니다. 항상 정확한 소수 값이 필요하다면 BigDecimal 클래스를 사용하면 됩니다.
Float vs BigDecimal
BigDecimal은 임의 정밀도(arbitrary-precision)의 소수를 제공하는 클래스입니다.
예시:
require 'bigdecimal'
BigDecimal("0.2") + BigDecimal("0.1") == 0.3
# true
그렇다면 왜 항상 BigDecimal을 사용하지 않을까요? 바로 속도가 훨씬 느리기 때문입니다!
벤치마크 결과:
Calculating -------------------------------------
bigdecimal 21.559k i/100ms
float 79.336k i/100ms
-------------------------------------------------
bigdecimal 311.721k (± 7.4%) i/s - 1.552M
float 3.817M (±11.7%) i/s - 18.803M
Comparison:
float: 3817207.2 i/s
bigdecimal: 311721.2 i/s - 12.25x slower
BigDecimal은 Float보다 약 12배 느립니다. 그래서 기본 숫자 타입으로 채택되지 않은 것이죠 🙂
Fixnum vs Bignum
이 섹션에서는 Ruby 2.4 이전 버전에서 Fixnum과 Bignum이 어떻게 달랐는지 살펴보겠습니다.
코드부터 확인해 보겠습니다:
1.class # Fixnum 100000000000.class # Bignum
Ruby는 개발자 대신 알아서 적절한 클래스를 선택해 주며, 필요할 때 자동으로 Fixnum을 Bignum으로 승격시킵니다.
참고: 64비트 Ruby 인터프리터를 사용하는 경우
Bignum객체를 얻으려면 더 큰 숫자가 필요할 수 있습니다.
왜 서로 다른 클래스가 필요했을까요? 큰 숫자를 다루려면 별도의 구현 방식이 필요하고, 큰 숫자 연산은 상대적으로 느리기 때문입니다. 결국 Float와 BigDecimal의 관계와 비슷한 구조가 되는 것입니다.
Fixnum의 특별한 속성
Fixnum 클래스에는 몇 가지 특별한 성질이 있습니다. 예를 들어 객체 ID(object id)가 일정한 공식을 통해 계산된다는 점입니다.
1.object_id # 3 20.object_id # 41
그 공식은 다음과 같습니다: (숫자 * 2) + 1.
여기서 더 흥미로운 사실은, Fixnum을 사용할 때 실제로는 객체가 전혀 생성되지 않는다는 점입니다. 값 자체가 객체 ID에서 파생되기 때문에 Fixnum에는 따로 저장할 데이터가 없습니다.
이것은 어디까지나 구현 디테일이지만, 알아두면 꽤 재미있는 내용이라고 생각합니다 🙂
MRI(Matz's Ruby Interpreter)는 값과 객체 ID 사이의 변환을 위해 다음 두 매크로를 사용합니다:
INT2FIX(i) ((VALUE)(((SIGNED_VALUE)(i))<<1 | FIXNUM_FLAG)) FIX2LONG(x) ((long)RSHIFT((SIGNED_VALUE)(x),1))
여기서 일어나는 연산을 "비트 시프트(bit shifting)"라고 하며, 모든 비트를 왼쪽 또는 오른쪽으로 밀어내는 작업입니다.
왼쪽으로 한 칸 시프트하는 것은 2를 곱하는 것과 같습니다. 그래서 공식이 (숫자 * 2) + 1이 되는 것이죠. 여기서 +1은 FIXNUM_FLAG에서 비롯됩니다.
반면 Bignum은 일반적인 클래스처럼 동작하며 평범한 객체 ID를 사용합니다:
111111111111111.object_id # 23885808
결국 인터프리터 수준에서 Fixnum 객체는 심볼(symbol)과 비슷한 방식으로 동작하고, Bignum 객체는 문자열에 더 가깝다고 할 수 있습니다.
Ruby 2.4의 Integer
Ruby 2.4부터 Fixnum과 Bignum은 폐기(deprecated)되었지만, 내부 동작 방식은 여전히 동일합니다.
Ruby는 자동으로 한 타입에서 다른 타입으로 전환해 줍니다.
단, 클래스 자체는 변경되지 않습니다.
즉, 작은 Integer 숫자는 여전히 예전의 Fixnum과 같은 방식으로 동작합니다.
마무리
이번 포스트에서는 Ruby에 존재하는 다양한 숫자 관련 클래스들에 대해 알아보았습니다.
Float는 부정확할 수 있으며, 정확성이 성능보다 중요하다면 BigDecimal을 사용하면 된다는 점을 배웠습니다. 또한 Fixnum 객체는 인터프리터 수준에서 특별하게 취급되지만, Bignum은 일반적인 객체라는 사실도 확인했습니다.
이 글이 유익했다면 아래 양식을 통해 뉴스레터 구독을 잊지 마세요 🙂