부동소수점(Floats)

부동소수점(Floats)

프로그램을 만들다 보면 정수만으로는 표현할 수 없는 값들, 예를 들어 실수나 아주 크거나 아주 작은 수를 다뤄야 할 때가 있어요. 그럴 때 Zig가 제시하는 해법이 바로 이번에 다룰 부동소수점 타입입니다. 어떤 타입이 있는지, 리터럴은 어떻게 쓰는지, 그리고 계산 시 부동소수점 연산이 어떻게 동작하는지까지 차근차근 살펴볼게요.

출처: Zig Documentation

본문

Zig는 다음과 같은 부동소수점 타입들을 제공해요.

  • f16 - IEEE-754-2008 binary16
  • f32 - IEEE-754-2008 binary32
  • f64 - IEEE-754-2008 binary64
  • f80 - IEEE-754-2008 80-bit extended precision
  • f128 - IEEE-754-2008 binary128
  • c_longdouble - matches long double for the target C ABI

숫자가 커질수록 더 많은 비트를 사용해서 더 넓은 범위와 정밀도를 담아요. 보통은 f32f64를 주로 쓰고, 필요에 따라 더 큰 타입을 고르면 됩니다.

Float Literals

부동소수점 리터럴은 comptime_float 타입을 가져요. 이 타입은 가장 큰 다른 부동소수점 타입인 f128과 동일한 정밀도와 연산을 갖도록 보장되어 있어요. 즉 리터럴은 어느 부동소수점 타입으로든 자연스럽게 변환될 수 있죠.

부동소수점 리터럴은 어떤 부동소수점 타입으로든 coerce할 수 있고, 소수 부분이 없는 경우에는 어떤 정수 타입으로도 변환할 수 있어요.

다양한 리터럴 표기법을 실제로 볼까요.

float_literals.zig
const floating_point = 123.0E+77;
const another_float = 123.0;
const yet_another = 123.0e+77;

const hex_floating_point = 0x103.70p-5; const another_hex_float = 0x103.70; const yet_another_hex_float = 0x103.70P-5;

// underscores may be placed between two digits as a visual separator const lightspeed = 299_792_458.000_000; const nanosecond = 0.000_000_001; const more_hex = 0x1234_5678.9ABC_CDEFp-10;

눈여겨볼 점이 몇 가지 있어요. 일반적인 십진 표기(123.0)에 더해 지수 표기(123.0e+77)와 16진수 부동소수점(0x103.70p-5)도 지원하죠. 그리고 마지막 세 줄처럼 두 숫자 사이에 밑줄(_)을 넣어 가독성을 높일 수도 있어요.

다만 NaN, 무한대, 음의 무한대를 위한 문법은 없어요. 이런 특수한 값들은 표준 라이브러리를 통해 만들어야 합니다.

float_special_values.zig
const std = @import("std");

const inf = std.math.inf(f32); const negative_inf = -std.math.inf(f64); const nan = std.math.nan(f128);

std.mathinfnan 함수에 타입을 넘겨주는 방식이죠. 이렇게 해서 f32의 무한대, f64의 음의 무한대, f128의 NaN을 각각 얻을 수 있어요.

Floating Point Operations

기본적으로 부동소수점 연산은 .strict 모드를 사용해요. 하지만 블록 단위로 .optimized 모드로 전환할 수도 있습니다. 두 모드의 차이를 보여주는 예제를 봐볼게요.

float_mode_obj.zig
const std = @import("std");
const big = @as(f64, 1 << 40);

export fn foo_strict(x: f64) f64 { return x + big - big; }

export fn foo_optimized(x: f64) f64 { @setFloatMode(.optimized); return x + big - big; }

Shell
$ zig build-obj float_mode_obj.zig -O fast

두 함수 모두 x + big - big를 계산해요. big1을 40비트 왼쪽으로 시프트한 값이라 x에 비해 훨씬 커요. foo_strict는 그냥 기본 모드로 돌고, foo_optimized@setFloatMode(.optimized)로 최적화 모드를 켠 거죠.

여기서 코드를 두 개의 오브젝트 파일로 나눠야 하는 이유가 있어요. 이 테스트에서는 그렇지 않으면 최적화기가 모든 값을 컴파일 타임에 계산해 버리는데, 컴파일 타임 연산은 strict 모드로 동작하기 때문입니다.

float_mode_exe.zig
const print = @import("std").debug.print;

extern fn foo_strict(x: f64) f64; extern fn foo_optimized(x: f64) f64;

pub fn main() void { const x = 0.001; print("optimized = {}\n", .{foo_optimized(x)}); print("strict = {}\n", .{foo_strict(x)}); }

Shell
$ zig build-exe float_mode_exe.zig float_mode_obj.o -O fast
$ ./float_mode_exe
optimized = 0.001
strict = 0.0009765625

foo_strictfoo_optimizedextern으로 선언해 별도 오브젝트 파일의 함수를 가져와요. 실행 결과를 보면 x = 0.001일 때 optimized는 그대로 0.001을 반환하지만, strict는 0.0009765625가 나와요. xbig보다 훨씬 작다 보니 x + big 결과에서 x의 정보가 사라져버리고, strict 모드는 그 연산 순서를 그대로 지키기 때문이죠. 반면 optimized 모드는 수식이 x로 정리된다는 걸 알아내고 더 정확한 결과를 냅니다.

더 알아보기

부동소수점 연산 모드와 관련된 내용은 아래 문서에서 더 자세히 다뤄요.