C++ 연산자 우선순위 함정: flags & MASK == 0 같은 비트·비교·논리 연산자 버그
이 글의 핵심
연산자 우선순위 표를 전부 외울 필요는 없지만, 비트 연산자가 비교 연산자보다 낮다는 사실처럼 C에서 물려받은 몇 가지 규칙은 컴파일 에러 없이 틀린 결과를 만듭니다. 헷갈리기 쉬운 조합만 골라 실제로 어떻게 결합되는지 보여주고, 플래그 체크와 범위 체크에서 괄호로 의도를 드러내는 습관, 같은 식에서 변수를 두 번 증가시킬 때의 정의되지 않은 동작도 짚습니다.
연산자 우선순위란?
연산자 우선순위는 여러 연산자가 한 식에 섞여 있을 때 어떤 피연산자가 어떤 연산자에 묶이는지를 정하는 규칙입니다.
int x = 2 + 3 * 4; // 14 (곱셈 먼저)
int y = (2 + 3) * 4; // 20 (괄호 먼저)
여기서 꼭 구분해야 할 개념이 있습니다. 우선순위는 “실행 순서”가 아니라 식을 어떻게 묶어 읽는가(파싱)에 대한 규칙입니다. 2 + 3 * 4에서 *의 우선순위가 높다는 것은 이 식이 2 + (3 * 4)로 묶인다는 뜻이지, 3 * 4가 시간상 먼저 계산된다는 뜻이 아닙니다. 상수라서 차이가 없어 보이지만, f() + g() * h()에서 세 함수가 어떤 순서로 호출되는지는 우선순위와 무관하게 컴파일러가 정합니다. GCC와 Clang이 다른 순서로 호출하는 경우도 실제로 있습니다. 이 글의 뒷부분에서 다루는 증감 연산자의 정의되지 않은 동작도 이 차이에서 나옵니다. 우선순위와 결합 방향은 식의 구조를 정하고, 평가 순서(sequencing)는 별도의 규칙이 정합니다.
우선순위 표 (높음 → 낮음)
아래는 C++에서 자주 쓰는 연산자를 우선순위가 높은 것이 위로 오도록 묶은 요약입니다. 세부 우선순위는 cppreference 연산자 우선순위와 완전히 동일하진 않을 수 있으니, 애매하면 괄호를 쓰세요.
| 우선순위(대략) | 연산자 | 결합 방향 | 메모 |
|---|---|---|---|
| 최상 | :: | 왼쪽→오른쪽 | 범위 지정 |
| 높음 | a++, a--, a(), a[], ., -> | — | 후위, 호출, 첨자 |
++a, --a, +a, -a, !, ~, (type), *a, &a, sizeof, co_await | 오른쪽→왼쪽 | 단항 | |
.*, ->* | 왼쪽→오른쪽 | 멤버 포인터 | |
*, /, % | 왼쪽→오른쪽 | 곱·나눗셈 | |
+, - | 왼쪽→오른쪽 | 덧셈·뺄셈 | |
<<, >> | 왼쪽→오른쪽 | 시프트 (+보다 아래) | |
<=> | 왼쪽→오른쪽 | 3방향 비교 (C++20) | |
<, <=, >, >= | 왼쪽→오른쪽 | 비교 | |
==, != | 왼쪽→오른쪽 | 동등 | |
& | 왼쪽→오른쪽 | 비트 AND | |
^ | 왼쪽→오른쪽 | 비트 XOR | |
| | 왼쪽→오른쪽 | 비트 OR | |
&& | 왼쪽→오른쪽 | 논리 AND | |
|| | 왼쪽→오른쪽 | 논리 OR (단축 평가) | |
| 낮음 | ?:, throw | 오른쪽→왼쪽 | 삼항 (표준 표에서는 대입과 같은 단계) |
=, +=, -=, … | 오른쪽→왼쪽 | 대입 | |
, | 왼쪽→오른쪽 | 쉼표 |
비트 연산자가 함정인 이유: ==와 !=는 &보다 위에 있지만, &는 &&보다 위입니다. 따라서 flags & MASK == 0은 (flags & (MASK == 0))가 되어 의도와 다릅니다.
이 순서는 C에서 물려받은 역사적 유물입니다. C의 전신인 B 언어에는 &&와 ||가 없어서 &와 |가 논리 연산까지 맡았고, if (a == b & c == d)처럼 비교 결과를 묶는 용도였기 때문에 비교보다 낮은 우선순위가 자연스러웠습니다. 나중에 &&가 추가되었지만 기존 코드와의 호환성 때문에 &의 위치는 바뀌지 않았고, C++도 이를 그대로 이어받았습니다. 결과적으로 비트 연산자를 “산술 연산자의 일종”으로 생각하는 현대의 직관과 문법이 어긋나게 되었습니다. 표 전체를 외우기보다 “비트 연산자(&, ^, |)는 비교보다 낮다”는 이 한 가지만 확실히 기억해 두는 것이 실용적입니다.
또 하나 자주 부딪히는 곳은 스트림 출력입니다. <<는 원래 시프트 연산자라서 시프트의 우선순위를 그대로 가집니다. 그래서 std::cout << a & b;는 (std::cout << a) & b로 묶여 컴파일 에러가 나고, std::cout << x == y; 역시 (std::cout << x) == y가 되어 에러가 납니다. 반면 std::cout << a + b;는 +가 <<보다 높아서 의도대로 동작합니다. 출력할 식에 비교나 비트 연산이 있으면 괄호로 감싸야 합니다.
// 1. 후위 연산자
a++, a--, a[i], a(args), a.b, a->b
// 2. 단항 연산자
++a, --a, +a, -a, !a, ~a, *a, &a, sizeof
// 3. 곱셈/나눗셈/나머지
a * b, a / b, a % b
// 4. 덧셈/뺄셈
a + b, a - b
// 5. 비트 시프트
a << b, a >> b
// 6. 비교 연산자
a < b, a <= b, a > b, a >= b
// 7. 동등 비교
a == b, a != b
// 8. 비트 AND
a & b
// 9. 비트 XOR
a ^ b
// 10. 비트 OR
a | b
// 11. 논리 AND
a && b
// 12. 논리 OR
a || b
// 13. 삼항 연산자
a ? b : c
// 14. 대입 연산자
a = b, a += b, a -= b, a *= b, a /= b
결합 방향(associativity) 정리
- 왼쪽 결합(대부분의 이항 연산자):
a - b - c는(a - b) - c입니다. - 오른쪽 결합: 단항 연산자 일부, 대입
a = b = c는a = (b = c), 삼항의 중첩도 오른쪽 결합으로 해석됩니다. - 시프트와 덧셈:
1 << 2 + 3은 시프트보다+가 먼저라 1 << (2 + 3) 입니다. 비트 마스크 만들 때 자주 헷갈립니다.
흔한 실수: 비트 연산 중심
- 마스크 검사:
if (flags & MASK == 0)금지 →if ((flags & MASK) == 0). - 시프트 + 산술: 원하는 게
(1 << n) - 1이면 반드시 괄호. - 부호 있는 타입과 시프트: 구현 정의·미정 동작이 될 수 있으므로 마스크는 가능하면 부호 없는 정수로 (size_t 등).
부호 있는 시프트에 대해 조금 더 구체적으로 말하면, C++20부터 정수는 2의 보수 표현으로 표준화되어 음수의 오른쪽 시프트는 산술 시프트로 정의되었습니다. 하지만 시프트 양이 타입의 비트 수 이상이면 여전히 정의되지 않은 동작입니다. 1 << 31은 int가 32비트일 때 C++20 이전에는 부호 비트로 넘치는 동작이었고, 1 << 32는 어떤 버전에서도 정의되지 않습니다. 64비트 마스크가 필요하면 1ULL << n처럼 리터럴부터 넓은 부호 없는 타입으로 써야 합니다. uint64_t mask = 1 << 40;은 오른쪽이 int로 먼저 계산되므로 대입하는 타입이 64비트여도 소용이 없습니다.
괄호 사용 가이드
- 의도가 표준 우선순위에 의존하는지 동료가 바로 알 수 있으면 괄호는 생략 가능(예: 단순
a * b + c). - 비트·시프트·비교·논리가 한 줄에 섞이면 항상 괄호를 권장합니다.
- 매크로를 쓸 때는 인자마다 괄호로 감싸는 것이 기본입니다(우선순위 버그 방지).
매크로가 특히 위험한 이유는 텍스트 치환이라 우선순위가 호출하는 쪽 식과 섞이기 때문입니다. #define SQUARE(x) x * x를 SQUARE(a + 1)로 쓰면 a + 1 * a + 1로 펼쳐져 2a + 1이 됩니다. #define SQUARE(x) ((x) * (x))처럼 인자와 전체를 모두 괄호로 감싸야 하지만, 이래도 SQUARE(i++)는 i를 두 번 증가시킵니다. 현대 C++에서는 constexpr 함수를 쓰면 이런 문제가 원천적으로 사라집니다.
조건식 작성 요령
- 조건문에서는 널 포인터·범위 검사를
&&의 왼쪽에 두어 단축 평가로 안전하게 만듭니다(아래 “단축 평가” 절 참고). - 리뷰 시 복잡한 조건식은 중간
bool변수로 이름을 붙이면 우선순위 논쟁 자체가 사라집니다.const bool inRange = x >= lo && x < hi;처럼 이름을 붙이면 괄호보다 의도가 더 잘 드러납니다. - 컴파일러 경고를 켜 두면 대부분의 함정을 잡아 줍니다. GCC·Clang의
-Wall에 포함된-Wparentheses는flags & MASK == 0에 “suggest parentheses around comparison in operand of ’&’” 경고를,if (x = 10)에 “suggest parentheses around assignment used as truth value” 경고를 냅니다.
실전 예시
예시 1: 산술 연산
int a = 2 + 3 * 4; // 14 (* 먼저)
int b = 10 - 2 + 3; // 11 (왼쪽부터)
int c = 10 / 2 * 3; // 15 (왼쪽부터)
int d = 2 * 3 + 4 * 5; // 26 (* 먼저, + 나중)
// 괄호로 명확히
int e = (2 + 3) * 4; // 20
int f = 2 * (3 + 4); // 14
예시 2: 비교 연산
bool a = 5 > 3 && 2 < 4; // true (비교 먼저, && 나중)
bool b = 5 > 3 || 2 > 4; // true (비교 먼저, || 나중)
bool c = !false && true; // true (! 먼저)
// 복잡한 조건
int x = 10;
bool result = x > 5 && x < 15 || x == 20;
// (x > 5 && x < 15) || (x == 20)
&&가 ||보다 우선순위가 높다는 것은 수학의 곱셈과 덧셈 관계와 비슷합니다(논리곱이 논리합보다 먼저 묶임). 위 식은 의도대로 동작하지만 GCC·Clang은 &&와 ||가 괄호 없이 섞이면 -Wparentheses 경고를 냅니다. 규칙을 아는 사람에게도 한 번 더 생각하게 만드는 식이라는 뜻이므로, 이런 조합은 괄호를 치는 편이 낫습니다.
예시 3: 비트 연산
int a = 1 | 2 & 4; // 1 (& 먼저, | 나중)
int b = 1 | (2 & 4); // 1 (명시적)
int c = (1 | 2) & 4; // 0 (명시적)
// 시프트 연산
int d = 1 << 2 + 1; // 8 (+ 먼저, << 나중)
int e = (1 << 2) + 1; // 5 (명시적)
1 << 2 + 1이 8이 되는 것은 시프트가 덧셈보다 낮은 우선순위를 갖기 때문입니다. 시프트를 “2의 거듭제곱 곱셈”으로 생각하면 곱셈처럼 높은 우선순위를 기대하게 되는데, 실제로는 덧셈·뺄셈보다 아래에 있습니다. 하위 n비트 마스크를 만드는 (1 << n) - 1에서 괄호를 빠뜨리면 1 << (n - 1)이 되어 비트 하나짜리 값이 나옵니다. 컴파일 에러도 경고도 없는 경우가 많아 테스트로만 잡히는 버그입니다.
예시 4: 증감 연산자
int x = 5;
int a = x++ * 2; // 10 (x는 후위 증가)
// x는 이제 6
int y = 5;
int b = ++y * 2; // 12 (y는 전위 증가)
// y는 이제 6
int z = 5;
int c = z++ + ++z; // ❌ 정의되지 않은 동작: z를 한 식에서 순서 없이 두 번 수정
// "5 + 7 = 12"처럼 보일 수 있지만 컴파일러·최적화 수준마다 결과가 다를 수 있음
앞의 두 줄은 명확합니다. 후위 x++는 증가 전 값(5)을 식에 내놓고 x를 6으로 만들며, 전위 ++y는 먼저 6으로 만든 뒤 그 값을 내놓습니다. 문제는 마지막 줄입니다. +의 두 피연산자는 평가 순서가 정해져 있지 않고(unsequenced), 그 두 피연산자가 모두 z를 수정합니다. 표준은 이런 경우를 정의되지 않은 동작(UB)으로 규정하므로 결과가 12라는 보장이 없습니다. GCC는 이런 식에 -Wsequence-point(-Wall에 포함) 경고로 “operation on ‘z’ may be undefined”를 알려 줍니다. 결과를 예측하는 문제로 자주 출제되지만, 정답은 “예측할 수 없다”입니다.
결합성 (Associativity)
// 왼쪽 결합 (대부분)
int a = 10 - 5 - 2; // (10 - 5) - 2 = 3
// 오른쪽 결합 (대입, 단항)
int x, y, z;
x = y = z = 10; // x = (y = (z = 10))
int b = 2;
int c = ++b + ++b; // ❌ 정의되지 않은 동작 (결합 방향과 무관)
결합 방향은 같은 우선순위의 연산자가 연속될 때 어느 쪽부터 묶는지를 정합니다. 10 - 5 - 2가 왼쪽 결합이 아니라면 10 - (5 - 2) = 7이 되었을 것입니다. 대입이 오른쪽 결합이라 x = y = z = 10이 가능한 것도 같은 원리입니다. 대입식은 대입된 변수 자신을 결과로 내놓으므로 가장 오른쪽부터 차례로 값이 전달됩니다. 마지막 줄의 ++b + ++b는 흔히 “오른쪽부터 계산된다”고 설명되지만 이는 잘못된 설명입니다. 결합 방향은 +의 두 피연산자 중 어느 쪽을 먼저 평가하는지 정하지 않으며, 같은 변수를 순서 없이 두 번 수정하므로 앞의 예와 똑같이 정의되지 않은 동작입니다.
자주 발생하는 실수
실수 1: 비트 연산과 비교
// ❌ 의도와 다름
if (flags & MASK == 0) {
// flags & (MASK == 0) 로 해석됨
}
// ✅ 괄호 사용
if ((flags & MASK) == 0) {
// 올바른 의도
}
잘못된 쪽은 MASK == 0이 먼저 계산되어 MASK가 0이 아니면 false, 즉 0이 됩니다. 그러면 flags & 0은 항상 0이므로 조건문 전체가 항상 거짓입니다. 반대로 if (flags & MASK != 0)으로 쓰면 flags & 1이 되어 최하위 비트만 검사하게 되는데, MASK가 1인 경우에는 우연히 맞게 동작해서 버그가 오랫동안 숨어 있기도 합니다. 저도 비트 플래그를 처음 다룰 때 이 형태로 한참 헤맸는데, 첫 번째 플래그로 테스트했을 때만 통과하고 다른 플래그를 추가하자 이상하게 동작하는 식이었습니다. 이 패턴을 피하는 가장 확실한 방법은 C++에서 enum class와 연산자 오버로딩으로 플래그 타입을 만들거나, std::bitset의 test()처럼 이름 있는 함수로 검사하는 것입니다.
실수 2: *p++와 !a == b
int arr[] = {10, 20, 30};
int* p = arr;
int v = *p++; // *(p++): v = 10, p는 arr[1]을 가리킴 (값이 아니라 포인터가 증가)
(*p)++; // arr[1]이 21이 됨 (값 증가)
bool a = false;
int b = 0;
if (!a == b) {} // (!a) == b → 1 == 0 → false
if (!(a == b)) {} // 의도가 "a와 b가 다르다"라면 이렇게 (또는 a != b)
*p++는 후위 증감이 단항 *보다 우선순위가 높아 *(p++)로 묶입니다. 후위 증가는 증가 전 포인터를 내놓으므로 역참조는 현재 원소를 읽고, 그 뒤 포인터가 다음 원소로 이동합니다. while (*dst++ = *src++); 같은 C식 문자열 복사가 이 성질에 기대고 있어서 관용구로는 알아볼 수 있어야 하지만, 값을 증가시키려고 *p++를 쓰면 포인터가 움직여 버리는 조용한 버그가 됩니다. 값을 올리려면 (*p)++나 ++*p를 씁니다. !a == b도 비슷한 종류의 실수로, 단항 !가 먼저 묶여 a의 부정과 b를 비교하게 됩니다. Clang은 이런 식에 -Wlogical-not-parentheses 경고를 냅니다.
실수 3: 수학식처럼 쓴 a < b < c
int x = 5;
if (0 < x < 3) { // (0 < x) < 3 → true < 3 → 1 < 3 → 항상 true
// x가 5인데도 들어옴
}
if (0 < x && x < 3) { // ✅ 의도한 범위 검사
}
비교 연산자는 왼쪽 결합이라 0 < x < 3은 (0 < x) < 3이 되고, 앞 비교의 bool 결과(0 또는 1)가 다시 3과 비교되므로 조건은 항상 참입니다. 수학 표기나 Python(0 < x < 3이 연쇄 비교로 동작)에 익숙하면 쉽게 저지르는 실수이며, 문법적으로 완전히 합법이라 컴파일 에러가 나지 않습니다. GCC는 -Wparentheses로 “comparisons like ‘X<=Y<=Z’ do not have their mathematical meaning” 경고를 내므로, 경고를 켜 두면 대부분 잡힙니다.
실수 4: 논리 연산 순서
// ✅ 올바른 순서
if (ptr != nullptr && *ptr == 10) {
// 올바름 (&& 전에 != 먼저)
}
// ❌ 잘못된 순서
if (*ptr == 10 && ptr != nullptr) {
// ptr이 nullptr이면 크래시!
}
두 식은 우선순위 면에서는 똑같습니다. 둘 다 비교가 먼저 묶이고 &&가 나중에 묶입니다. 차이는 순전히 평가 순서에서 나옵니다. &&는 예외적으로 왼쪽 피연산자를 먼저 평가하고, 결과가 거짓이면 오른쪽을 아예 평가하지 않는다고 표준이 보장합니다. 그래서 널 검사가 왼쪽에 있어야 역참조가 안전해집니다. 두 번째 식에서 널 포인터를 역참조하는 것은 정의되지 않은 동작이라, 반드시 크래시가 난다는 보장도 없습니다. 최적화 컴파일러가 “역참조했으니 널이 아니다”라고 가정하고 뒤의 널 검사를 지워 버리는 경우도 있습니다.
실수 5: 대입과 비교
int x = 5;
// ❌ 대입 (항상 true)
if (x = 10) {
cout << "x는 " << x << endl; // 10
}
// ✅ 비교
if (x == 10) {
cout << "x는 10" << endl;
}
if (x = 10)은 x에 10을 대입하고, 대입식의 결과인 10이 참으로 해석되어 항상 조건문 안으로 들어갑니다. 그러면서 x의 원래 값까지 덮어씁니다. 이 실수를 막으려고 if (10 == x)처럼 상수를 왼쪽에 쓰는 “요다 조건” 스타일이 생겼지만, 지금은 컴파일러 경고(-Wparentheses, MSVC의 C4706)가 이를 잡아 주므로 가독성을 해치면서까지 쓸 필요는 없습니다. 의도적으로 대입 결과를 조건으로 쓸 때는 if ((n = read()) > 0)처럼 비교를 명시하거나, C++17의 if (auto n = read(); n > 0) 형태가 더 명확합니다.
실수 6: 증감 연산자
int arr[5] = {1, 2, 3, 4, 5};
int i = 0;
// ❌ 정의되지 않은 동작
int x = arr[i] + arr[i++];
// ✅ 명확하게 분리
int x = arr[i];
i++;
x += arr[i];
arr[i] + arr[i++]는 왼쪽 arr[i]가 i를 읽고, 오른쪽 arr[i++]가 i를 수정하는데 둘 사이에 순서가 없어서 정의되지 않은 동작입니다. 왼쪽이 증가 전 i를 읽을지 증가 후 i를 읽을지 알 수 없습니다. 올바른 코드는 의도를 먼저 정해야 쓸 수 있는데, 위의 분리 버전은 “현재 원소와 다음 원소의 합”이라는 해석을 택한 것입니다(두 예제가 같은 스코프에 x를 두 번 선언하므로 실제로는 별도 블록에 넣어야 합니다).
C++17에서는 일부 연산자의 평가 순서가 새로 정해졌다는 점도 알아 두면 좋습니다. 대입 a = b에서는 오른쪽이 먼저, a << b와 a >> b, a[b], a.b, a->b에서는 왼쪽이 먼저 평가됩니다. 그래서 C++17 이전에 UB였던 i = i++ + 1이나 std::cout << i << i++ 같은 식이 C++17부터는 정의된 동작이 되었습니다. 하지만 +, *, == 같은 대부분의 이항 연산자와 함수 인자의 평가 순서는 여전히 정해져 있지 않으므로, “한 식에서 같은 변수를 수정하면서 다른 곳에서 읽거나 수정하지 않는다”는 원칙을 지키는 것이 가장 안전합니다.
괄호 사용 권장
// 복잡한 표현식은 괄호로 명확히
bool result = ((a > b) && (c < d)) || (e == f);
// 비트 연산은 항상 괄호
if ((flags & MASK) != 0) {
// ...
}
// 시프트 연산도 괄호
int value = (1 << bits) - 1;
실용 예시
이 권장은 “우선순위를 모르는 사람을 위한 배려”만이 아닙니다. 괄호는 코드를 읽는 사람이 파서의 규칙을 머릿속에서 다시 실행하지 않아도 되게 만들어 주고, 나중에 누군가 식의 일부를 고칠 때 묶음이 깨지는 것을 막아 줍니다. 예를 들어 a > b && c < d || e == f에 조건 하나를 끼워 넣는 사람은 어느 위치에 넣어야 원래 의미가 유지되는지 매번 따져야 하지만, 괄호가 있으면 수정할 단위가 눈에 보입니다. 반대로 a * b + c처럼 누구나 아는 산술 우선순위에까지 괄호를 치면 오히려 소음이 되므로, 기준은 “섞이는 연산자 종류가 서로 다른 계열인가”로 잡는 것이 실용적입니다.
예시 1: 플래그 체크
const int READ = 1;
const int WRITE = 2;
const int EXECUTE = 4;
int permissions = READ | WRITE;
// 읽기 권한 체크
if ((permissions & READ) != 0) {
cout << "읽기 가능" << endl;
}
// 쓰기 권한 체크
if ((permissions & WRITE) != 0) {
cout << "쓰기 가능" << endl;
}
// 실행 권한 체크
if ((permissions & EXECUTE) == 0) {
cout << "실행 불가" << endl;
}
플래그를 int 상수로 정의하면 permissions & READ == 0 같은 실수를 타입 시스템이 전혀 막아 주지 못합니다. enum class Perm : unsigned { Read = 1, Write = 2, Execute = 4 };처럼 범위 있는 열거형을 쓰고 operator|, operator&를 직접 정의하면, 플래그와 정수를 섞은 식은 컴파일 에러가 되고 비교를 빠뜨린 식도 bool로 암묵 변환되지 않아 드러납니다. 검사를 has(permissions, Perm::Read) 같은 이름 있는 함수로 감싸면 괄호 문제 자체가 호출하는 쪽에서 사라집니다. 비트 플래그를 여러 모듈이 공유하는 코드라면 이런 작은 타입 하나가 우선순위 버그를 구조적으로 막는 가장 효과적인 방법입니다.
예시 2: 범위 체크
int x = 50;
// 범위 체크 (괄호 불필요하지만 명확)
if (x >= 0 && x <= 100) {
cout << "범위 내" << endl;
}
// 복잡한 조건
if ((x > 10 && x < 20) || (x > 30 && x < 40)) {
cout << "특정 범위" << endl;
}
예시 3: 계산식
// 수학 공식
double result = a * x * x + b * x + c;
// 명확한 괄호
double result2 = (a * x * x) + (b * x) + c;
// 평균 계산
double avg = (a + b + c) / 3.0;
단축 평가 (Short-circuit)
// && 연산자
if (ptr != nullptr && *ptr == 10) {
// ptr이 nullptr이면 *ptr 평가 안함
}
// || 연산자
if (x == 0 || y / x > 10) {
// x가 0이면 y / x 평가 안함 (0으로 나누기 방지)
}
단축 평가는 내장 &&와 ||에만 적용됩니다. 클래스에 operator&&나 operator||를 오버로딩하면 일반 함수 호출이 되어 양쪽 피연산자가 모두 평가되고 단축 평가가 사라집니다. 그래서 이 두 연산자와 쉼표 연산자는 오버로딩하지 않는 것이 관례입니다. 표현식 템플릿 라이브러리처럼 특별한 이유가 있을 때만 예외적으로 씁니다. 또 삼항 연산자 ?:도 선택되지 않은 쪽은 평가하지 않으므로 단축 평가와 비슷한 용도로 쓸 수 있지만, 삼항 연산자는 대입보다 우선순위가 높아 cond ? a : b = 5가 cond ? a : (b = 5)로 묶이는 등 다른 함정이 있습니다.
FAQ
Q1: 우선순위를 외워야 하나요?
A: 산술(*/+), 비교, &&/||, 대입의 큰 순서만 알면 대부분의 식을 읽을 수 있습니다. 여기에 “비트 연산자는 비교보다 낮다”, “시프트는 덧셈보다 낮다” 두 가지만 추가로 기억하고, 나머지는 괄호로 해결하는 것이 현실적입니다.
Q2: 가장 헷갈리는 연산자는?
A:
- 비트 연산(
&,|)과 비교·논리 연산이 섞인 식 - 대입(
=)과 비교(==) - 한 식에서 여러 번 쓰인 증감 연산자
Q3: 괄호를 많이 쓰면 느려지나요?
A: 아니요. 괄호는 파싱 단계에서 식의 구조만 정하고 사라지므로 생성되는 코드에 아무 영향이 없습니다.
Q4: 정의되지 않은 동작은 언제 생기나요?
A: 한 식 안에서 같은 변수를 순서 없이 두 번 수정하거나, 수정과 읽기가 순서 없이 섞이는 경우입니다(예: z++ + ++z, arr[i] + arr[i++]). arr[i++] = i처럼 대입이 끼는 식은 C++17부터 오른쪽이 먼저 평가되도록 정해져서 정의된 동작이 되었지만, 읽기 어려우므로 피하는 편이 좋습니다.
Q5: 단축 평가는?
A: 내장 &&와 ||는 왼쪽부터 평가하고 결과가 확정되면 오른쪽을 평가하지 않습니다. 오버로딩된 &&/||에는 적용되지 않습니다.
Q6: 연산자를 오버로딩하면 우선순위도 바꿀 수 있나요?
A: 아니요. 오버로딩은 연산자가 하는 일만 바꾸고, 우선순위와 결합 방향, 피연산자 개수는 내장 연산자와 똑같이 유지됩니다. 그래서 std::cout << a & b가 에러가 나는 것처럼, 스트림이나 수식 라이브러리에서 오버로딩된 연산자도 원래 연산자의 우선순위 함정을 그대로 물려받습니다. 거듭제곱을 표현하려고 ^를 오버로딩하면 a ^ 2 + 1이 a ^ (2 + 1)로 묶이는 식의 문제가 생기므로, 의미가 우선순위와 맞지 않는 연산자는 오버로딩하지 않는 것이 좋습니다.
같이 보면 좋은 글
- C++ 연산자 오버로딩: 멤버·비멤버 선택, 산술·비교·입출력 연산자 구현
- C++ ADL(인자 종속 조회): swap 관용구가 동작하는 이유와 엉뚱한 함수가 호출될 때
- C++ 사용자 정의 리터럴: 리터럴 연산자 문법, cooked·raw 오버로드, 접미사 규칙
- C++ 함수 객체
- 배열과 연결 리스트