Go

Go에서의 소수점 연산과 오차 처리

드리프트2 2024. 5. 17. 19:11

Better C - Go와 소수

시프트 연산

소수점(float)에서 시프트 연산을 하면 컴파일 에러가 발생합니다.

var a float64 = 100
b := a << 1
fmt.Printf("%f", b)

실행 결과:

main.go:9:9: invalid operation: a << 1 (shift of type float64)

올림, 내림

math 패키지의 Ceil(), Floor(), Trunc() 메서드.

var a float64 = 100.12345
var b float64 = -100.12345

fmt.Printf("a = %f\n", a)
fmt.Printf("b = %f\n\n", b)

fmt.Println("Ceil 인수 이상의 가장 작은 정수")
fmt.Printf("ceil a %f\n", math.Ceil(a))
fmt.Printf("ceil b %f\n\n", math.Ceil(b))

fmt.Println("Floor 인수 이하의 가장 큰 정수")
fmt.Printf("floor a %f\n", math.Floor(a))
fmt.Printf("floor b %f\n\n", math.Floor(b))

fmt.Println("Trunc 소수점 이하 절삭")
fmt.Printf("trunc a %f\n", math.Trunc(a))
fmt.Printf("trunc b %f\n\n", math.Trunc(b))

실행 결과:

a = 100.123450
b = -100.123450

Ceil 인수 이상의 가장 작은 정수
ceil a 101.000000
ceil b -100.000000

Floor 인수 이하의 가장 큰 정수
floor a 100.000000
floor b -101.000000

Trunc 소수점 이하 절삭
trunc a 100.000000
trunc b -100.000000

반올림

Round 함수는 Go1.10 이후에서 사용할 수 있습니다.

a := 100.123450
b := 100.523450
c := -100.123450
d := -100.523450

fmt.Println("Round 반올림")
fmt.Printf("round a %f\n", math.Round(a))
fmt.Printf("round b %f\n", math.Round(b))
fmt.Printf("round c %f\n", math.Round(c))
fmt.Printf("round d %f\n", math.Round(d))

실행 결과:

round a 100.000000
round b 101.000000
round c -100.000000
round d -101.000000

제로 나누기

int의 경우에는 panic이 발생합니다.

zero := 0
fmt.Printf("제로 나누기 %v\n", 1/zero)

실행 결과:

panic: runtime error: integer divide by zero

goroutine 1 [running]:
main.main()
    /tmp/sandbox853689335/main.go:9 +0x20

NaN(비수)

NaN은 Not a Number의 약자입니다.

-1의 제곱근과 같은 음수의 제곱근은 실수로 표현할 수 없기 때문에 NaN이 됩니다.

nan := math.Sqrt(-1)
fmt.Printf("%v\n", nan)
fmt.Printf("== 비교에서는 true가 되지 않음 %v\n", nan == math.NaN())
fmt.Printf("math.IsNaN()로 판정 %v\n", math.IsNaN(nan))

실행 결과:

NaN
== 비교에서는 true가 되지 않음 false
math.IsNaN()로 판정 true

Inf(무한)

Inf는 Infinity의 약자입니다.

float64로 표현할 수 있는 값의 범위를 초과하면 Inf가 됩니다.

inf_plus := math.Pow(0, -1)
fmt.Printf("%v\n", inf_plus)
fmt.Printf("== 비교 %v\n", inf_plus == math.Inf(1))
fmt.Printf("math.IsInf(inf_plus, 1)로 판정 %v\n", math.IsInf(inf_plus, 1))
fmt.Printf("math.IsInf(inf_plus, -1)로 판정 %v\n\n", math.IsInf(inf_plus, -1))

inf_minus := math.Log(0)
fmt.Printf("%v\n", inf_minus)
fmt.Printf("== 비교 %v\n", inf_minus == math.Inf(-1))
fmt.Printf("math.IsInf(inf_minus, 1)로 판정 %v\n", math.IsInf(inf_minus, 1))
fmt.Printf("math.IsInf(inf_minus, -1)로 판정 %v\n", math.IsInf(inf_minus, -1))

실행 결과:

+Inf
== 비교 true
math.IsInf(inf_plus, 1)로 판정 true
math.IsInf(inf_plus, -1)로 판정 false

-Inf
== 비교 true
math.IsInf(inf_minus, 1)로 판정 false
math.IsInf(inf_minus, -1)로 판정 true

제로 나누기에서 panic이 발생하지 않는 경우

제로를 제로로 나누면 panic이 아니라 NaN이 됩니다.

var zero float64 = 0
fmt.Printf("0/0= %v\n", zero/zero)

실행 결과:

0/0= NaN

오차

절단 오차

분수 등이 무한히 이어질 때 중간에서 자르는 오차입니다. 순환소수나 원주율 등이 이에 해당합니다.

loop := 1.0 / 3
fmt.Printf("%f\n", loop)

실행 결과:

// 원래는 무한히 계속되지만 중간에 잘립니다.
0.333333

유효 숫자 감소 오차

반올림 오차가 있는 값들끼리 뺄셈을 할 때 발생하는 유효 숫자의 감소로 인한 오차입니다.

a := math.Sqrt(1001)
b := math.Sqrt(999)
fmt.Printf("a = %f\n", a)
fmt.Printf("b = %f\n", b)
fmt.Printf("a - b = %f\n", a-b)

실행 결과:

a = 31.638584
b = 31.606961
a - b = 0.031623
// 실제로는 31.63858403911274914310629158480098308708005351898025493377 - 31.60696125855821654520421398569900243024310197917304499132

유효 숫자 8자리로 생각하면, 정확한 답은 0.031622780이고, 오차는 0.00000022입니다. 

계산 전의 값의 유효 숫자는 8자리였지만, 계산 결과는 유효 숫자가 5자리로 줄어들어 큰 차이가 발생합니다.

root(1001) - root(999) - Wolfram|Alpha
http://www.wolframalpha.com

정보 감소 오차

// 15자리
var a1 float64 = 1000000000000000
var b float64 = 1

fmt.Printf("a1 = %f\n", a1)
fmt.Printf("b = %f\n", b)
fmt.Printf("a1 + b = %f\n\n", a1+b)

// 16자리
var a2 float64 = 10000000000000000
fmt.Printf("a2 = %f\n", a2)
fmt.Printf("b = %f\n", b)
fmt.Printf("a2 + b = %f\n", a2+b)

실행 결과:

// a2에서는 더한 1이 사라집니다.

a1 = 1000000000000000.000000
b = 1.000000
a1 + b = 1000000000000001.000000

a2 = 10000000000000000.000000
b = 1.000000
a2 + b = 10000000000000000.000000

반올림 오차

반올림 오차는 10진수로는 정확하게 표현되지만, 2진수로 표현할 때 무한히 반복되는 값을 유한하게 잘라서 발생하는 오차입니다.

var a float64 = 0.1
var b float64 = 0.2
var c float64 = 0.3
var trueOrFalse = a + b == c
fmt.Printf("a(%f) + b(%f) == c(%f) -> %v\n", a, b, c, trueOrFalse)

실행 결과:

a(0.100000) + b(0.200000) == c(0.300000) -> false

오차가 발생하지 않는 특수 케이스

소수 계산에서도 오차가 발생하지 않는 경우는 상수를 선언했을 때입니다.

fmt.Printf("%v\n", 0.1 + 0.2 == 0.3)

실행 결과:

true

이러한 결과가 나오는 이유는, 상수는 임의의 정밀도로 정확한 값을 표현하기 때문입니다.

상수

숫자 상수는 임의의 정밀도로 정확한 값을 나타내며 오버플로우하지 않습니다.

https://golang.org/ref/spec#Constants

최대값과 최소값

max := math.MaxFloat64
min := -math.MaxFloat64

fmt.Printf("float64의 최소값(%v)과 최대값(%v)\n\n", min, max)
fmt.Printf("float64의 최소값에 대한 뺄셈 - 오차로 인해 최소값과 변함없음 = %v\n", min-1000000000000000000000000000000000000000000000000000000000000000000000)
fmt.Printf("float64의 최소값끼리 더하기 = %v\n\n", min+min)

fmt.Printf("float64의 최대값에 대한 덧셈 - 오차로 인해 최대값과 변함없음 = %v\n", max+1000000000000000000000000000000000000000000000000000000000000000000000)
fmt.Printf("float64의 최대값끼리 더하기 = %v\n", max+max)

실행 결과:

float64의 최소값(-1.7976931348623157e+308)과 최대값(1.7976931348623157e+308)

float64의 최소값에 대한 뺄셈 - 오차로 인해 최소값과 변함없음 = -1.7976931348623157e+308
float64의 최소값끼리 더하기 = -Inf

float64의 최대값에 대한 덧셈 - 오차로 인해 최대값과 변함없음 = 1.7976931348623157e+308
float64의 최대값끼리 더하기 = +Inf

캐스트

비상수 숫자의 변환에는 다음 규칙이 적용됩니다.

  • 부동 소수점 숫자를 정수로 변환할 때, 소수 부분은 버려집니다(0에 가까운 쪽으로 버림).
  • 정수 또는 부동 소수점 값을 다른 부동 소수점 형식으로 변환할 때, 또는 복소수 값을 다른 복소수 형식으로 변환할 때, 결과 값은 변환 대상 형식에 정의된 정밀도로 반올림됩니다. 예를 들어, float32 형식의 변수 x의 값은 저장될 때 IEEE-754의 32비트 숫자 이상의 정밀도가 사용되지만, float32(x)는 32비트로 반올림된 x의 값을 나타냅니다. 마찬가지로, x + 0.1은 32비트 이상의 정밀도가 사용되지만, float32(x + 0.1)은 그렇지 않습니다.
  • 부동 소수점 값 또는 복소수를 포함하는 모든 비상수 변환에서, 결과 형식이 값을 표현할 수 없는 경우, 변환 자체는 성공하지만 결과 값은 구현에 따라 달라질 수 있습니다.

예제

var a float64 = 1.1
var b int64 = int64(a)
fmt.Printf("float64에서 int64로 캐스트 %v\n", b)

실행 결과:

float64에서 int64로 캐스트 1
var a uint64 = 100
var b float64 = float64(a)

// 값의 범위 내에서는 문제가 없습니다.
fmt.Printf("uint64에서 float64로 캐스트 %v\n", b)

실행 결과:

uint64에서 float64로 캐스트 100

Go Playground - The Go Programming Language

다음의 위반 코드 예제에서는 return 문에서 식의 결과를 캐스트하지 않고, 반환 값의 범위나 정밀도가 예상 범위를 초과하지 않도록 보장하지 않습니다.

이 불확실성은 상수 0.1f의 사용으로 인해 발생합니다.

상수는 float보다 더 큰 범위나 정밀도를 가질 수 있습니다.

따라서 x * 0.1f의 결과도 float보다 더 큰 범위나 정밀도를 가질 수 있습니다.

앞서 언급했듯이, 이 범위나 정밀도는 float의 범위나 정밀도로 변환할 수 없을 수 있습니다.

따라서 calcPercentage()의 호출자는 예상보다 더 높은 정밀도의 값을 받을 수 있습니다.

이는 플랫폼에 따라 프로그램의 동작이 다를 수 있는 결과를 초래할 수 있습니다.

위반 코드(C 언어)

float calc_percentage(float value) {
  return value * 0.1f;
}

void float_routine(void) {
  float value = 99.0f;
  long double percentage;

  percentage = calc_percentage(value);
}

적합 코드(Go)

Go는 상수에 대해 타입을 명시하지 않을 때에도 적절한 타입이 되므로 문제가 없습니다.

func calc_percentage(value float32) float32 {
    return value * 0.1
}

func main() {
    var value float32 = 99.0
    var percentage float64 = float64(calc_percentage(value))
    fmt.Printf("%v\n", percentage)
}

비교

등가 연산자인 ==와 !=는 "비교 가능"한 오퍼랜드에 사용됩니다.

순서 연산자인 <, <=, >, >=는 "유순서"인 오퍼랜드에 사용됩니다.

이 용어들과 비교 결과는 다음과 같이 정의됩니다.

부동 소수점 값은 "비교 가능"하고 "유순서"이며, IEEE-754 규정에 따릅니다.

부동 소수점 변수를 루프 카운터로 사용하지 않기

C 언어의 사양과 약간 다른 점을 소개합니다.

C 언어에서는 다음과 같은 이유로 루프 카운터로 부동 소수점 변수를 사용하면, 루프 횟수가 처리계에 따라 달라질 수 있습니다.

다음의 위반 코드 예제에서는 루프 카운터로 부동 소수점 변수를 사용하고 있습니다.

10진 소수 0.1은 2진수에서는 순환 소수가 되므로, 2진 부동 소수점으로 정확하게 표현할 수 없습니다. 처리계에 따라 다르지만, 루프는 9회 또는 10회 반복됩니다.

처리계에 따라 정밀도의 한계가 다릅니다. 코드의 이식성을 유지하려면, 루프 카운터에 부동 소수점 변수를 사용하면 안 됩니다.

위반 코드(C 언어)

for (float x = 0.1f; x <= 1.0f; x += 0.1f) {
    /* 루프는 9회 또는 10회 반복 */
}

적합 코드(Go)

Go에서는, 부동 소수점 수가 정확히 표현되지 않는 경우 근사값으로 처리하는 것이 규칙으로 정해져 있기 때문에 루프 카운터의 횟수는 일정합니다.

하지만 부동 소수점 변수를 루프 카운터로 사용하는 것은 좋은 선택이 아닙니다.

var i int = 0
for x := 0.1; x <= 1.0; x += 0.1 {
    i++
    fmt.Printf("%v: %v\n", i, x)
}

실행 결과:

1: 0.1
2: 0.2
3: 0.30000000000000004
4: 0.4
5: 0.5
6: 0.6
7: 0.7
8: 0.7999999999999999
9: 0.8999999999999999
10: 0.9999999999999999

---