산술 연산자
산술 연산자 (Arithmetic operators)
숫자 값을 대상으로 분류를 계산해야 할 때, Go에서 가장 먼저 떠올리는 연산자들이 바로 산술 연산자예요. 덧셈부터 시작해서 정수의 나눗셈, 비트 연산, 시프트까지 — 실제로 프로그램을 짜다 보면 이 연산자들의 미묘한 규칙들과 자주 마주치게 되는데요, 하나씩 정리해 보면 훨씬 수월해져요.
출처: Go Specification
본문
산술 연산자는 숫자(numeric) 값에 적용되어 첫 번째 피연산자와 같은 타입의 결과를 내놓아요. 표준 산술 연산자 네 가지(+, -, *, /)는 integer(정수), floating-point(부동소수점), complex(복소수) 타입에 모두 적용되고, +는 strings(문자열)에도 적용돼요. 비트 논리 연산과 시프트(shift) 연산자는 정수에만 적용되죠.
+ sum integers, floats, complex values, strings
- difference integers, floats, complex values
* product integers, floats, complex values
/ quotient integers, floats, complex values
% remainder integers
& bitwise AND integers
| bitwise OR integers
^ bitwise XOR integers
&^ bit clear (AND NOT) integers
<< left shift integer << integer >= 0
>> right shift integer >> integer >= 0
피연산자 타입이 type parameter(타입 매개변수)라면, 연산자는 그 타입 집합에 속한 각 타입에 대해 적용되어야 해요. 피연산자들은 타입 매개변수가 instantiated(인스턴스화)된 타입 인자(type argument)의 값으로 표현되고, 연산은 그 타입 인자의 정밀도로 계산돼요. 예를 들어 다음 함수를 살펴볼게요:
func dotProduct[F ~float32|~float64](v1, v2 []F) F {
var s F
for i, x := range v1 {
y := v2[i]
s += x * y
}
return s
}
여기서 곱 x * y와 덧셈 s += x * y는 F의 타입 인자가 무엇이냐에 따라 각각 float32 또는 float64 정밀도로 계산돼요.
정수 연산자 (Integer operators)
두 정수 값 x와 y에 대해, 정수 몫 q = x / y와 나머지 r = x % y는 다음 관계를 만족해요:
x = q*y + r and |r| < |y|
여기서 x / y는 0을 향해 잘려요("truncated division(절사 나눗셈)").
x y x / y x % y
5 3 1 2
-5 3 -1 -2
5 -3 -1 2
-5 -3 1 -2
이 규칙의 유일한 예외는, 피제수(dividend) x가 x의 int 타입에서 가장 작은(가장 음인) 값인 경우예요. 이때 몫 q = x / -1은 2의 보수(two's-complement) integer overflow(정수 오버플로) 때문에 x와 같아져요 (r = 0):
x, q
int8 -128
int16 -32768
int32 -2147483648
int64 -9223372036854775808
제수가 constant(상수)라면 0이면 안 돼요. 제수가 실행 시점에 0이라면 run-time panic(런타임 패닉)이 발생해요. 피제수가 음수가 아니고 제수가 2의 거듭제곱 상수라면, 나눗셈은 오른쪽 시프트로, 나머지 계산은 비트 AND 연산으로 바꿀 수 있어요:
x x / 4 x % 4 x >> 2 x & 3
11 2 3 2 3
-11 -2 -3 -3 1
시프트 연산자는 오른쪽 피연산자가 지정하는 시프트 횟수만큼 왼쪽 피연산자를 옮겨요. 시프트 횟수는 음수가 아니어야 하고, 실행 시점에 음수라면 run-time panic(런타임 패닉)이 발생해요. 왼쪽 피연산자가 부호 있는 정수라면 산술 시프트(arithmetic shift), 부호 없는 정수라면 논리 시프트(logical shift)를 구현해요. 시프트 횟수에는 상한이 없어요. 시프트는 왼쪽 피연산자를 시프트 횟수 n에 대해 1씩 n번 옮기는 것처럼 동작해요. 그 결과 x << 1은 x*2와 같고, x >> 1은 x/2와 같되 음의 무한대 쪽으로 잘려요.
정수 피연산자의 경우 단항(unary) 연산자 +, -, ^는 다음과 같이 정의돼요:
+x is 0 + x
-x negation is 0 - x
^x bitwise complement is m ^ x with m = "all bits set to 1" for unsigned x
and m = -1 for signed x
정수 오버플로 (Integer overflow)
unsigned integer(부호 없는 정수) 값의 경우, +, -, *, << 연산은 2n을 법(modulus)으로 하는 모듈로 연산으로 계산돼요. 여기서 n은 부호 없는 정수 타입의 비트 폭(bit width)이에요. 느슨하게 말하면, 이 부호 없는 정수 연산들은 오버플로가 나면 상위 비트를 버리고, 프로그램은 "값이 돌아감(wrap around)"에 의존할 수 있어요.
부호 있는 정수의 경우, +, -, *, /, << 연산은 합법적으로 오버플로될 수 있고, 그 결과 값은 존재하며 부호 있는 정수 표현, 연산, 그리고 피연산자에 의해 결정적으로 정의돼요. 오버플로는 run-time panic(런타임 패닉)을 일으키지 않아요. 컴파일러는 오버플로가 발생하지 않는다는 가정 하에 코드를 최적화하면 안 돼요. 예를 들어 x < x + 1이 항상 참이라고 가정하면 안 되죠.
부동소수점 연산자 (Floating-point operators)
부동소수점과 복소수의 경우, +x는 x와 같고 -x는 x의 부정(negation)이에요. 0으로 나누는 부동소수점·복소수 나눗셈의 결과는 IEEE 754 표준 이상으로 지정되어 있지 않아요. run-time panic(런타임 패닉)이 발생하는지 여부는 구현에 따라 달라져요(implementation-specific).
구현은 여러 부동소수점 연산을 하나의 융합(fused) 연산으로 합칠 수 있어요. 구문(statement)을 가로질러도 가능하며, 그 결과는 각 명령을 개별적으로 실행하고 반올림해 얻은 값과 다를 수 있어요. 명시적인 floating-point type(부동소수점 타입) conversion(변환)은 대상 타입의 정밀도로 반올림하는데, 이는 그 반올림을 버리는 융합을 막아줘요.
예를 들어 일부 아키텍처는 중간 결과 x*y를 반올림하지 않고 x*y + z를 계산하는 "fused multiply and add"(FMA) 명령을 제공해요. 다음 예시들은 Go 구현이 그 명령을 언제 사용할 수 있는지 보여줘요:
// FMA allowed for computing r, because x*y is not explicitly rounded:
r = x*y + z
r = z; r += x*y
t = x*y; r = t + z
*p = x*y; r = *p + z
r = x*y + float64(z)
// FMA disallowed for computing r, because it would omit rounding of x*y:
r = float64(x*y) + z
r = z; r += float64(x*y)
t = float64(x*y); r = t + z
문자열 연결 (String concatenation)
문자열은 + 연산자나 += 대입 연산자로 연결할 수 있어요:
s := "hi" + string(c)
s += " and good bye"
문자열 덧셈은 피연산자들을 연결해 새 문자열을 만들어 내요.
더 알아보기
- 정수
%(나머지) 연산은 결과의 부호가 항상 피제수(x)를 따라간다는 점을 기억해 두면, 음수가 섞인 나눗셈에서 헷갈리지 않아요. - 시프트 연산자는
<<,>>가 곱셈·나눗셈의 2의 거듭제곱 버전처럼 동작한다고 생각하면 직관적이에요. 다만 내림 방향(0으로 vs 음의 무한대로)이 다르다는 점만 주의하면 돼요. - 부호 없는 정수의 오버플로 "wrap around"는 Go가 정식으로 보장하는 동작이라, 카운터나 해시 계산처럼 감싸는 연산에 의존하는 코드를 짤 때 안심하고 쓸 수 있어요.