별 것 없을 것 같은 상수의 별 것

변수보다 복잡한 Go의 상수

상수의 타입과 한계

명시적이지 않은 듯 명시적인 Go에서 한 것처럼 reflect 패키지를 이용해 타입을 명시하지 않은 상수의 타입에 대해 알아보자.

package main

import "fmt"
import "reflect"

func main() {
	const A = 1
	const B = 3.14
	const C = 1.0 + 1.0i
	const (
		D = iota
		E
    )
	
	fmt.Println(reflect.TypeOf(A))
	fmt.Println(reflect.TypeOf(B))
	fmt.Println(reflect.TypeOf(C))
	fmt.Println(reflect.TypeOf(D))
	fmt.Println(reflect.TypeOf(E))
}

위 코드의 실행 결과는 다음과 같다.

int
float64
complex128
int
int

변수의 경우와 같은 규칙이 적용된다.


그런데 정말 상수가 그 타입인 것일까, 상수를 읽을 때 그 타입이 되는 것일까? 무슨 소린고 하니,

1
2
3
4
5
6
7
8
package main

import "fmt"

func main() {
	var i = 256 << 100
	fmt.Println(i)
}

이 코드는 다음과 같은 오류가 발생한다.

./main.go:6:13: constant 324518553658426726783156020576256 overflows int

int에 담을 수 있는 한계를 벗어났다는 것이다. 100비트나 왼쪽으로 옮겼으니—\( 256 \times 2^{100} \)— 당연한 결과다.

그런데 varconst로 바꾸면 다음과 같은 오류가 발생한다.

./main.go:7:16: constant 324518553658426726783156020576256 overflows int

오류 발생 시점이 달라졌다. 7라인, Println()을 호출할 때 오류가 발생한다.

그렇다면 const i = 256 << 100 코드로 값을 할당할 때는 오류가 발생하지 않는다는 의미일까?

package main

import "fmt"

func main() {
	const i = 256 << 100
	const j = 2 << 100
	fmt.Println(i / j)
}

위 코드는 다음과 같이 정상적인 결과가 나온다.

128

i, j 각각에는 정상적으로 의도한 값이 할당돼 있는 것처럼 보인다.

이 결과는 int보다 큰 값을 표시할 수 있는 float을 이용해 i를 출력해 보면 좀 더 명확해 진다.

package main

import "fmt"

func main() {
	const i = 256 << 100
	fmt.Println(float64(i))
}
3.2451855365842673e+32

위 결과에서처럼 i에는 의도한 \(256 \times 2^{100}\) 값이 정확히 담겨있다. 내부적으로 const 처리를 어떻게 하는지에 대해서는 숙제로 남겨두고, 결과만 봤을 때는 const를 읽을 때 타입이 결정되는 것 같다.

iota에 대해 좀 더 자세히

iota 초기값

iota는 0부터 시작해 호출할 때마다 1씩 증가한다.

아래 코드의 결과를 보면 이 문구는 명확해 보인다.

package main

import "fmt"

func main() {
	const (
		Zero = iota
		One
		Two
	)
	
	fmt.Println(Zero)
	fmt.Println(One)
	fmt.Println(Two)
}
0
1
2

그런데 C에서 enum은 초기값을 달리해 다음과 같이 쓸 수 있다.

enum {
	minus_one = -1,	/* = -1 */
	zero,			/* = 0 */
	one				/* = 1 */
};

Go의 iota는 어떨까?

package main

import "fmt"

func main() {
	const (
		minusOne = -1
		zero = iota
		one
	)
	
	fmt.Println(zero)
	fmt.Println(one)
}

iotazero에서 처음 호출했으니 zero가 0이 되면 좋을텐데 위 코드의 결과는 다음과 같다.

1
2

zero의 값이 0이 아닌 1이다. 좀 더 명확히 하기 위해 minusTwo = -2를 맨 앞에 추가하면 결과는 다음과 같다.

const (
	minusTwo = -2
	minusOne = -1
	zero = iota
	one
)
2
3

즉, iota는 상수 그룹 안에서 어딘가에 쓰이면, 그 앞뒤로는 모두 암시적으로(implicitly) 모두 iota가 쓰인 것으로 본다. 따라서 위 예에서는 zeroiota가 세 번째 쓰인 것으로 처리돼 2의 값을 갖게 된다. 이 규칙은 타입이 달라도 유효하다.

const (
	minusTwo = "-2"
	minusOne = "-1"
	zero = iota
	one
)
2
3

한 줄에 두 번 쓴 iota

상수도 변수처럼 comma(,)를 이용해 한 줄에 복수개를 선언할 수 있다. 이 경우 iota의 값은 어떻게 될까?

package main

import "fmt"

func main() {
	const (
		zero, one = iota, iota
		two, three
	)
	
	fmt.Println(zero)
	fmt.Println(one)
	fmt.Println(two)
	fmt.Println(three)
}
0
0
1
1

iota는 호출할 때마다 값이 증가한다고 했는데 예상과 결과가 다르지 않은가.

이에 대해 Go spec.에는 다음과 같이 기술돼 있다.

Multiple uses of iota in the same ConstSpec all have the same value.

comma(,)로 구분해 붙여 쓴 복수개의 상수는 모두 하나의 ConstSpec에 속하기 때문에 같은 iota값을 갖게 된다. 단, 똑같이 한 줄에 썼더라도 세미콜론을 이용해 ConstSpec을 나눈 경우에는 iota값이 증가한다.

const (
	zero, one = iota, iota; two, three
)
0
0
1
1

중간값 지정

C에서 enum을 쓸 때는 중간값을 지정하는 것도 가능하다.

enum {
	zero = 0,
	one,      /* = 1 */
	two,      /* = 2 */
	five = 5,
	six,      /* = 6 */
	seven     /* = 7 */
};

Go에서 iota를 쓸 때는 이 부분에서 주의해야 한다.

package main

import "fmt"

func main() {
	const (
		zero = iota
		one
		two
		five = 5
		six
		seven
	)
	
	fmt.Println(six)
	fmt.Println(seven)
}
5
5

상수 그룹에서 literal로 값을 지정하고 이후 상수들에는 값을 지정하지 않을 경우 이전 상수의 값을 그대로 따른다. 따라서 sixseven은 모두 5가 된다. 그렇다고 sixiota로 값을 지정해 주면… 4가 된다. 이러한 경우에는 상수 그룹을 나누는 것이 현명하다.

iota 유지 범위

위에서 상수 그룹이라는 용어를 사용했는데, const 뒤 괄호로 묶여 있는 단위를 의미한다. iota의 값은 이 단위 안에서만 유지된다. 따라서 아래 상수들의 값은 모두 0이다.

const zero = iota
const one = iota
const two = iota

복잡한 상수 그룹

지금까지 확인한 Go의 상수 그룹과 iotaenum에 비해 뭔가 불편해 보인다. 하지만 실망하지 마시라. 이런 것도 가능하다.

package main

import "fmt"

func main() {
	const (
		bytes = 1 << (iota * 10)
		kilobytes
		megabytes
		gigabytes
	)
	
	fmt.Println(bytes)
	fmt.Println(kilobytes)
	fmt.Println(megabytes)
	fmt.Println(gigabytes)
}
1
1024
1048576
1073741824