산술 연산자
산술 연산자 (Arithmetic operators)
C의 산술 연산자는 피연산자에 표준 수학 연산을 적용해요. 더하기, 빼기처럼 단순해 보이는데, 정수 오버플로가 왜 위험한지, 나눗셈 결과가 어떻게 반올림되는지, 포인터 산술은 어떻게 되는지 등 C 특유의 규칙이 숨어 있어요. 이 페이지에서 산술 연산자를 처음부터 끝까지 정리해 볼게요.
본문
산술 연산자는 피연산자에 표준 수학 연산을 적용해요.
| 연산자 | 연산자 이름 | 예 | 결과 |
|---|---|---|---|
+ |
단항 플러스 | +a |
승격(promotion) 후의 a 값 |
- |
단항 마이너스 | -a |
a의 음수 |
+ |
덧셈 | a+b |
a와 b의 합 |
- |
뺄셈 | a-b |
a에서 b를 뺀 값 |
* |
곱셈 | a*b |
a와 b의 곱 |
/ |
나눗셈 | a/b |
a를 b로 나눈 값 |
% |
나머지 | a%b |
a를 b로 나눈 나머지 |
~ |
비트 NOT | ~a |
a의 비트 NOT |
& |
비트 AND | a&b |
a와 b의 비트 AND |
| |
비트 OR | a|b |
a와 b의 비트 OR |
^ |
비트 XOR | a^b |
a와 b의 비트 XOR |
<< |
비트 왼쪽 시프트 | a<<b |
a를 b만큼 왼쪽으로 시프트 |
>> |
비트 오른쪽 시프트 | a>>b |
a를 b만큼 오른쪽으로 시프트 |
오버플로 (Overflows)
부호 없는 정수 산술은 항상 2ⁿ 모듈로로 수행돼요. 여기서 n은 해당 정수의 비트 수예요. 예를 들어 unsigned int에서 UINT_MAX에 1을 더하면 0이 되고, 0에서 1을 빼면 UINT_MAX가 돼요.
부호 있는 정수 산술이 오버플로하면(결과가 결과 타입에 맞지 않으면) 동작은 정의되지 않아요. 표현 규칙에 따라 순환(wrap around)될 수도 있고(보통 2의 보수), 어떤 플랫폼이나 컴파일러 옵션(예: GCC·Clang의 -ftrapv)에 따라 트랩할 수도 있으며, 컴파일러가 완전히 최적화해 없앨 수도 있어요.
부동소수점 환경
#pragma STDC FENV_ACCESS가 ON으로 설정되면, 모든 부동소수점 산술 연산자는 현재 부동소수점 반올림 방향을 따르고, math_errhandling에 지정된 대로 부동소수점 산술 오류를 보고해요. 단, 정적 초기화의 일부인 경우는 예외예요(이 경우 부동소수점 예외가 발생하지 않고 반올림 모드는 가장 가까운 값이에요).
부동소수점 수축 (Floating-point contraction)
#pragma STDC FP_CONTRACT가 OFF로 설정되지 않는 한, 모든 부동소수점 산술은 마치 중간 결과가 무한한 범위와 정밀도를 가진 것처럼 수행될 수 있어요. 즉, 식을 쓰인 그대로 계산했을 때 관찰될 반올림 오류와 부동소수점 예외를 생략하는 최적화가 허용돼요. 예를 들어 (x*y)+z를 단일 FMA(Fused Multiply-Add) CPU 명령으로 구현하거나, a=x*x*x*x;를 tmp=x*x; a=tmp*tmp로 최적화하는 것을 허용해요.
수축과는 별개로, 부동소수점 산술의 중간 결과는 타입이 나타내는 것과 다른 범위와 정밀도를 가질 수 있어요. FLT_EVAL_METHOD 참고.
단항 산술 (Unary arithmetic)
단항 산술 연산자 표현식의 형태는 다음과 같아요.
| 형태 | (번호) |
|---|---|
+ 표현식 |
(1) |
- 표현식 |
(2) |
- 단항 플러스 (승격)
- 단항 마이너스 (부정)
단항 플러스와 단항 마이너스는 둘 다 먼저 피연산자에 **정수 승격(integral promotions)**을 적용하고 나서,
- 단항 플러스는 승격 후의 값을 반환하고
- 단항 마이너스는 승격 후 값의 음수를 반환해요 (NaN의 음수는 또 다른 NaN이라는 점 제외)
표현식의 타입은 승격 후의 타입이고, **값 카테고리(value category)**는 비-lvalue예요.
참고 (Notes)
단항 마이너스는 전형적인(2의 보수) 플랫폼에서 INT_MIN, LONG_MIN, LLONG_MIN에 적용될 때 부호 있는 정수 오버플로로 인한 정의되지 않은 동작을 일으켜요.
C++에서는 단항 연산자 +를 배열과 함수 같은 다른 내장 타입에도 쓸 수 있지만, C에서는 그렇지 않아요.
#include <stdio.h>
#include <complex.h>
#include <limits.h>
int main(void)
{
char c = 'a';
printf("sizeof char: %zu sizeof int: %zu\n", sizeof c, sizeof +c);
printf("-1, where 1 is signed: %d\n", -1);
// Defined behavior since arithmetic is performed for unsigned integer.
// Hence, the calculation is (-1) modulo (2 raised to n) = UINT_MAX, where n is
// the number of bits of unsigned int. If unsigned int is 32-bit long, then this
// gives (-1) modulo (2 raised to 32) = 4294967295
printf("-1, where 1 is unsigned: %u\n", -1u);
// Undefined behavior because the mathematical value of -INT_MIN = INT_MAX + 1
// (i.e. 1 more than the maximum possible value for signed int)
//
// printf("%d\n", -INT_MIN);
// Undefined behavior because the mathematical value of -LONG_MIN = LONG_MAX + 1
// (i.e. 1 more than the maximum possible value for signed long)
//
// printf("%ld\n", -LONG_MIN);
// Undefined behavior because the mathematical value of -LLONG_MIN = LLONG_MAX + 1
// (i.e. 1 more than the maximum possible value for signed long long)
//
// printf("%lld\n", -LLONG_MIN);
double complex z = 1 + 2*I;
printf("-(1+2i) = %.1f%+.1f\n", creal(-z), cimag(-z));
}
가능한 출력:
sizeof char: 1 sizeof int: 4
-1, where 1 is signed: -1
-1, where 1 is unsigned: 4294967295
-(1+2i) = -1.0-2.0
가산 연산자 (Additive operators)
이진 가산 산술 연산자 표현식의 형태는 다음과 같아요.
| 형태 | (번호) |
|---|---|
lhs + rhs |
(1) |
lhs - rhs |
(2) |
-
덧셈: lhs와 rhs는 다음 중 하나여야 해요.
- 둘 다 산술 타입인 경우 (복소수·허수 포함)
- 하나는 완전 객체 타입에 대한 포인터이고, 다른 하나는 정수 타입인 경우
-
뺄셈: lhs와 rhs는 다음 중 하나여야 해요.
- 둘 다 산술 타입인 경우 (복소수·허수 포함)
- lhs가 완전 객체 타입에 대한 포인터이고, rhs가 정수 타입인 경우
- 둘 다 (한정자를 무시하고) 호환되는 타입의 완전 객체에 대한 포인터인 경우
산술 덧셈과 뺄셈
두 피연산자가 모두 산술 타입이면,
-
먼저 **통상 산술 변환(usual arithmetic conversions)**이 수행되고
-
그다음 변환 후의 피연산자 값이 일반적인 수학 규칙에 따라 더해지거나 빼져요 (뺄셈의 경우 rhs를 lhs에서 뺌). 단 다음은 예외예요.
- 한 피연산자가 NaN이면 결과는 NaN
- 무한대 빼기 무한대는 NaN이고
FE_INVALID가 발생 - 무한대 더하기 음의 무한대는 NaN이고
FE_INVALID가 발생
복소수·허수의 덧셈과 뺄셈은 다음과 같이 정의돼요 (두 피연산자가 모두 허수면 결과 타입은 허수, 한쪽이 실수이고 다른 쪽이 허수면 결과 타입은 복소수라는 점에 주의하세요. 통상 산술 변환이 지정한 대로예요):
| + 또는 - | u | iv | u + iv |
|---|---|---|---|
| x | x ± u | x ± iv | (x ± u) ± iv |
| iy | ±u + iy | i(y ± v) | ±u + i(y ± v) |
| x + iy | (x ± u) + iy | x + i(y ± v) | (x ± u) + i(y ± v) |
포인터 산술
-
포인터
P가 인덱스I의 배열 요소를 가리킨다면,P+N과N+P는 같은 배열의 인덱스I+N요소를 가리키는 포인터P-N은 같은 배열의 인덱스I-N요소를 가리키는 포인터
동작이 정의되는 것은 원래 포인터와 결과 포인터가 모두 같은 배열의 요소나 그 배열의 끝 바로 다음을 가리킬 때뿐이에요. 배열의 첫 요소를 가리키는
p에서p-1을 실행하는 것은 정의되지 않은 동작이고 어떤 플랫폼에서는 실패할 수 있다는 점을 기억하세요. -
포인터
P1이 인덱스I(또는 끝 바로 다음)의 배열 요소를 가리키고,P2가 같은 배열의 인덱스J(또는 끝 바로 다음)의 요소를 가리킨다면,P1-P2의 값은I-J와 같고 타입은ptrdiff_t예요 (부호 있는 정수 타입으로, 보통 선언할 수 있는 가장 큰 객체 크기의 절반 정도예요)
동작이 정의되는 것은 결과가
ptrdiff_t에 맞을 때뿐이에요.
포인터 산술을 위해, 어떤 배열의 요소도 아닌 객체에 대한 포인터는 크기 1의 배열의 첫 요소에 대한 포인터로 취급돼요.
// work in progress
int n = 4, m = 3;
int a[n][m]; // VLA of 4 VLAs of 3 ints each
int (*p)[m] = a; // p == &a[0]
p = p + 1; // p == &a[1] (pointer arithmetic works with VLAs just the same)
(*p)[2] = 99; // changes a[1][2]
곱셈 연산자 (Multiplicative operators)
이진 곱셈 산술 연산자 표현식의 형태는 다음과 같아요.
| 형태 | (번호) |
|---|---|
lhs * rhs |
(1) |
lhs / rhs |
(2) |
lhs % rhs |
(3) |
- 먼저 통상 산술 변환이 수행돼요. 그다음...
곱셈 (Multiplication)
이진 연산자 *는 피연산자들(통상 산술 변환 후)의 곱셈을 일반적인 산술 정의에 따라 수행해요. 단 다음은 예외예요.
- 한 피연산자가 NaN이면 결과는 NaN
- 무한대 곱하기 0은 NaN이고
FE_INVALID가 발생 - 무한대 곱하기 0이 아닌 값은 무한대 (복소수 인자에서도 마찬가지)
C에서는 무한대 부분이 하나라도 있는 복소수 값은 (다른 부분이 NaN이어도) 무한대이므로, 일반적인 산술 규칙은 복소수×복소수 곱셈에는 적용되지 않아요. 부동소수점 피연산자의 다른 조합은 다음 표를 따라요:
| * | u | iv | u + iv |
|---|---|---|---|
| x | xu | i(xv) | (xu) + i(xv) |
| iy | i(yu) | −yv | (−yv) + i(yu) |
| x + iy | (xu) + i(yu) | (−yv) + i(xv) | 특수 규칙 |
무한대 처리를 제외하면, 복소수 곱셈은 중간 결과가 오버플로하는 것이 허용되지 않아요. 단 #pragma STDC CX_LIMITED_RANGE가 ON으로 설정된 경우는 예외예요. 그 경우 프로그래머가 피연산자의 범위를 제한하고 무한대를 처리할 책임을 진다는 전제로, 값을 (x+iy)×(u+iv) = (xu-yv)+i(yu+xv)처럼 계산할 수 있어요.
부당한 오버플로를 금지했음에도 복소수 곱셈은 거짓 부동소수점 예외(spurious floating-point exception)를 발생시킬 수 있어요 (그렇지 않으면 비오버플로 버전을 구현하기가 지나치게 어렵기 때문이에요).
#include <stdio.h>
#include <complex.h>
#include <math.h>
int main(void)
{
// TODO simpler cases, take some from C++
double complex z = (1 + 0*I) * (INFINITY + I*INFINITY);
// textbook formula would give
// (1+i0)(∞+i∞) ⇒ (1×∞ – 0×∞) + i(0×∞+1×∞) ⇒ NaN + I*NaN
// but C gives a complex infinity
printf("%f + i*%f\n", creal(z), cimag(z));
// textbook formula would give
// cexp(∞+iNaN) ⇒ exp(∞)×(cis(NaN)) ⇒ NaN + I*NaN
// but C gives ±∞+i*nan
double complex y = cexp(INFINITY + I*NAN);
printf("%f + i*%f\n", creal(y), cimag(y));
}
가능한 출력:
inf + i*inf
inf + i*nan
나눗셈 (Division)
이진 연산자 /는 첫 번째 피연산자를 두 번째로 나눠요(통상 산술 변환 후), 일반적인 산술 정의에 따라요. 단 다음은 예외예요.
- 통상 산술 변환 후의 타입이 정수 타입이면, 결과는 대수적 몫(분수가 아니라)이고 구현 정의 방향으로 반올림돼요(C99까지) / 0 방향으로 절사돼요(C99부터)
- 한 피연산자가 NaN이면 결과는 NaN
- 첫 번째 피연산자가 복소수 무한대이고 두 번째 피연산자가 유한하면,
/연산자의 결과는 복소수 무한대 - 첫 번째 피연산자가 유한하고 두 번째 피연산자가 복소수 무한대이면,
/연산자의 결과는 0
C에서는 무한대 부분이 하나라도 있는 복소수 값이 무한대이므로(다른 부분이 NaN이어도), 일반적인 산술 규칙은 복소수÷복소수 나눗셈에는 적용되지 않아요. 부동소수점 피연산자의 다른 조합은 다음 표를 따라요:
| / | u | iv |
|---|---|---|
| x | x/u | i(−x/v) |
| iy | i(y/u) | y/v |
| x + iy | (x/u) + i(y/u) | (y/v) + i(−x/v) |
무한대 처리를 제외하면, 복소수 나눗셈은 중간 결과가 오버플로하는 것이 허용되지 않아요. 단 #pragma STDC CX_LIMITED_RANGE가 ON으로 설정된 경우는 예외예요. 그 경우 프로그래머가 피연산자의 범위를 제한하고 무한대를 처리할 책임을 진다는 전제로, 값을 (x+iy)/(u+iv) = [(xu+yv)+i(yu-xv)]/(u²+v²)처럼 계산할 수 있어요.
부당한 오버플로를 금지했음에도 복소수 나눗셈은 거짓 부동소수점 예외를 발생시킬 수 있어요.
두 번째 피연산자가 0이면 동작은 정의되지 않아요. 단 IEEE 부동소수점 산술이 지원되고 부동소수점 나눗셈이 일어나는 경우는 예외예요. 그 경우,
- 0이 아닌 수를 ±0.0으로 나누면 올바른 부호의 무한대가 되고
FE_DIVBYZERO가 발생 - 0.0을 0.0으로 나누면 NaN이 되고
FE_INVALID가 발생
나머지 (Remainder)
이진 연산자 %는 첫 번째 피연산자를 두 번째로 나눈 나머지를 산출해요 (통상 산술 변환 후).
나머지의 부호는, 몫 a/b가 결과 타입으로 표현 가능하면 (a/b)*b+a%b==a가 되도록 정의돼요.
두 번째 피연산자가 0이면 동작은 정의되지 않아요.
몫 a/b가 결과 타입으로 표현 가능하지 않으면 a/b와 a%b 둘 다의 동작은 정의되지 않아요 (즉 2의 보수 시스템에서 INT_MIN%-1은 정의되지 않아요).
나머지 연산자는 부동소수점 타입에서는 동작하지 않아요. 그 기능은 라이브러리 함수
fmod가 제공해요.
비트 논리 (Bitwise logic)
비트 연산자 표현식의 형태는 다음과 같아요.
| 형태 | (번호) |
|---|---|
~ rhs |
(1) |
lhs & rhs |
(2) |
lhs | rhs |
(3) |
lhs ^ rhs |
(4) |
- 비트 NOT
- 비트 AND
- 비트 OR
- 비트 XOR
여기서
| lhs, rhs | - | 정수 타입의 표현식 |
|---|
먼저 &, ^, | 연산자는 두 피연산자에 통상 산술 변환을 수행하고, ~ 연산자는 유일한 피연산자에 정수 승격을 수행해요.
그다음, 대응하는 이진 논리 연산이 비트 단위로 적용돼요. 즉 피연산자의 대응하는 비트에 논리 연산(NOT, AND, OR, XOR)을 적용해서 결과의 각 비트가 설정되거나 지워져요.
비트 연산자는 비트 집합과 비트 마스크를 조작하는 데 흔히 쓰여요.
(승격 후의) 부호 없는 타입에서 표현식
~E는 결과 타입이 표현할 수 있는 최댓값에서 원래 값E를 뺀 것과 같아요.
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint32_t a = 0x12345678;
uint16_t mask = 0x00f0;
printf("Promoted mask:\t %#010x\n"
"Value:\t\t %#x\n"
"Setting bits:\t %#x\n"
"Clearing bits:\t %#x\n"
"Selecting bits:\t %#010x\n",
mask, a, a | mask, a & ~mask, a & mask);
}
가능한 출력:
Promoted mask: 0x000000f0
Value: 0x12345678
Setting bits: 0x123456f8
Clearing bits: 0x12345608
Selecting bits: 0x00000070
시프트 연산자 (Shift operators)
비트 시프트 연산자 표현식의 형태는 다음과 같아요.
| 형태 | (번호) |
|---|---|
lhs << rhs |
(1) |
lhs >> rhs |
(2) |
- lhs를 rhs 비트만큼 왼쪽으로 시프트
- lhs를 rhs 비트만큼 오른쪽으로 시프트
여기서
| lhs, rhs | - | 정수 타입의 표현식 |
|---|
먼저 각 피연산자에 정수 승격이 개별적으로 수행돼요 (주의: 다른 이진 산술 연산자와 달리, 이들은 모두 통상 산술 변환을 수행하지 않아요). 결과의 타입은 승격 후의 lhs 타입이에요.
rhs가 음수이거나 승격된 lhs의 비트 수보다 크거나 같으면 동작은 정의되지 않아요.
부호 없는 lhs에 대해 LHS << RHS의 값은 LHS * 2ᴿᴴˢ를 반환 타입 최댓값 + 1로 모듈로 감소시킨 값이에요 (즉 비트 왼쪽 시프트가 수행되고 대상 타입에서 밀려나간 비트는 버려져요). 음이 아닌 값을 가진 부호 있는 lhs에 대해 LHS << RHS의 값은, 승격된 lhs 타입으로 표현 가능하면 LHS * 2ᴿᴴˢ이고, 그렇지 않으면 동작은 정의되지 않아요.
부호 없는 lhs와 음이 아닌 값을 가진 부호 있는 lhs에 대해 LHS >> RHS의 값은 LHS / 2ᴿᴴˢ의 정수 부분이에요. 음수 LHS에 대해 LHS >> RHS의 값은 구현 정의인데, 대부분의 구현에서 산술 오른쪽 시프트를 수행해요 (그래서 결과가 음수로 유지돼요). 따라서 대부분의 구현에서 부호 있는 LHS를 오른쪽으로 시프트하면 새로 생기는 상위 비트가 원래의 부호 비트로 채워져요 (음이 아니면 0, 음수면 1).
#include <stdio.h>
enum { ONE = 1, TWO = 2 };
int main(void)
{
char c = 0x10;
unsigned long long ulong_num = 0x123;
printf("0x123 << 1 = %#llx\n"
"0x123 << 63 = %#llx\n" // overflow truncates high bits for unsigned numbers
"0x10 << 10 = %#x\n", // char is promoted to int
ulong_num << 1, ulong_num << 63, c << 10);
long long long_num = -1000;
printf("-1000 >> 1 = %lld\n", long_num >> ONE); // implementation defined
}
가능한 출력:
0x123 << 1 = 0x246
0x123 << 63 = 0x8000000000000000
0x10 << 10 = 0x4000
-1000 >> 1 = -500
더 알아보기
- 산술 연산자는 대입 연산자(복합 대입)와 증감 연산자와 바로 이어져요.
- 통상 산술 변환·정수 승격은 변환(conversion) 문서에서 자세히 다뤄요.
- cppreference의 Arithmetic operators 원문에서 표준 절 번호(C17/C11/C99/C89)를 더 볼 수 있어요.