본문 바로가기

연산자 우선순위

여러 연산자가 섞인 식이 우선순위와 결합 방향으로 어떻게 묶이는지 정리하고, 묶임과 실제 계산 순서가 다른 규칙이라는 점, 괄호로 의도를 밝히는 방법, 묶인 부분식마다 타입이 따로 정해져 정수 나눗셈의 결과가 바깥에서 돌아오지 않는 모습을 확인합니다.

한 식에 여러 연산자가 함께 쓰이면, 먼저 어느 피연산자가 어느 연산자에 속하는지를 정해야 합니다. 이 묶음을 정하는 규칙이 연산자 우선순위(operator precedence)와 결합 방향(associativity)이며, 3장에서 연산자마다 조금씩 본 내용을 이 글에서 한데 모읍니다.

중요한 것은 우선순위가 식의 모양을 정할 뿐, 피연산자를 어떤 순서로 계산하는지는 정하지 않는다는 점입니다. 이 글은 두 규칙을 구별하고, 괄호로 의도를 밝히는 방법과, 묶인 부분식마다 타입이 따로 정해지는 모습을 봅니다.

C++ 복합 표현식을 구문 묶임, 연산자별 의미, 실제 평가 순서로 나누고 네 예에서 우선순위와 결합 방향이 sequencing과 다름을 비교한 다이어그램

GROUPING IS NOT EVALUATION ORDER

우선순위의 묶임과 실제 평가 순서는 다른 규칙이다

우선순위와 결합 방향은 구문 트리를 만들고, sequencing은 각 연산자의 별도 계약이 정합니다.

5 + 3 * 2의 피연산자 포함 트리바깥덧셈의왼쪽피연산자는5이고오른쪽피연산자는곱셈이다. 곱셈의두피연산자는3과2여서값6을만들고덧셈값은11이다. 화살표는부모연산자의피연산자포함관계이며좌우자식의실행순서보장이아니다.+값115int*값632
5 + 3 * 2의 피연산자 포함 트리바깥덧셈의왼쪽피연산자는5이고오른쪽피연산자는곱셈이다. 곱셈의두피연산자는3과2여서값6을만들고덧셈값은11이다. 화살표는부모연산자의피연산자포함관계이며좌우자식의실행순서보장이아니다.+값115int*값63int2int
  1. 묶임을 확정

    명시적 괄호, 우선순위, 같은 단계의 결합 방향으로 어느 피연산자가 어느 연산자에 속하는지 적습니다.

  2. 연산자 계약 적용

    각 노드에서 허용 타입, 변환, 전제 조건, 결과 타입·값 범주와 부수 효과를 확인합니다.

  3. 평가 순서를 별도 확인

    &&, ||, ?:, 대입, 콤마처럼 순서를 정한 경우만 믿고, 보장이 없으면 부수 효과를 분리합니다.

5 + 3 * 2

묶임 5 + (3 * 2)

결과 안쪽 곱의 값 6을 사용해 11을 만듭니다.

10 - 5 + 2

묶임 (10 - 5) + 2

결과 7 · 경계 왼쪽 결합은 일반적인 피연산자 실행 방향이 아닙니다.

a = b = 10

묶임 a = (b = 10)

sequencing 기본 제공 대입은 오른쪽 피연산자를 왼쪽보다 먼저 평가합니다.

f() + g()

묶임 두 호출 결과가 +의 피연산자입니다.

sequencing 어느 호출이 먼저인지 우선순위로 정할 수 없습니다.

괄호는 묶임을 명확하게 하지만 보통 새로운 sequencing을 만들지 않습니다. 순서가 중요한 부수 효과는 중간 변수와 별도 문장으로 표현합니다.

우선순위와 결합 방향

우선순위가 높은 연산자는 주변의 피연산자를 더 강하게 붙잡습니다. 5 + 3 * 2는 *가 +보다 우선순위가 높아 5 + (3 * 2)로 묶이므로, 3 * 2의 6이 +의 오른쪽 피연산자가 되어 결과는 11입니다. 그림의 트리처럼 바깥의 +가 5와 곱셈을 피연산자로 가지며, 이 트리는 어느 연산자가 어느 값을 받는지를 보여 줄 뿐 왼쪽과 오른쪽 중 무엇을 먼저 계산하는지를 보여 주지는 않습니다.

결합 방향은 우선순위가 같은 연산자가 이어질 때 어느 쪽으로 묶을지를 정합니다. 이항 +와 -는 왼쪽으로 결합하므로 10 - 5 + 2는 (10 - 5) + 2인 7이고, 대입은 오른쪽으로 결합하므로 a = b = 10은 a = (b = 10)입니다. 결합 방향도 묶는 방향일 뿐, 피연산자를 언제나 왼쪽부터 계산한다는 뜻은 아닙니다.

주요 연산자의 우선순위

C++ 표준은 우선순위를 숫자로 매긴 표가 아니라 식의 문법으로 정합니다. 다음 표는 지금까지 배운 연산자에, 뒤의 장에서 자세히 다룰 함수 호출, 첨자, 멤버 접근과 C++20의 세 방향 비교 <=>를 더해 높은 쪽부터 정리한 것이며, <=>는 C++ 20 기능 소개에서 다룹니다.

단계연산자결합 방향
높음함수 호출 f(), 첨자 a[i], 멤버 a.m과 p->m, 후위 x++왼쪽
전위 ++x, 단항 +x, -x, !x, ~x, *p, &x, sizeof오른쪽
*, /, %왼쪽
+, -왼쪽
<<, >>왼쪽
C++20의 세 방향 비교 <=>왼쪽
<, <=, >, >=왼쪽
==, !=왼쪽
비트 AND &왼쪽
비트 XOR ^왼쪽
비트 OR |왼쪽
논리 AND &&왼쪽
논리 OR ||왼쪽
조건 ?:, 대입 =, 복합 대입 += 등오른쪽
낮음쉼표 ,왼쪽

이 표에서 특히 헷갈리기 쉬운 것은 세 가지입니다. 시프트가 덧셈보다 낮아 1 << 2 + 1은 1 << 3인 8이 되고, 같음 비교가 비트 AND보다 높아 마스크 검사에는 괄호가 필요하며, 논리 AND가 논리 OR보다 높아 둘을 섞으면 AND가 먼저 묶입니다. 클래스가 연산자를 직접 정의해도 우선순위와 결합 방향은 바뀌지 않습니다.

묶임과 계산 순서

우선순위로 식의 모양이 정해져도, 피연산자를 계산하는 순서는 대부분 정해지지 않습니다. f() + g()는 +의 두 피연산자로 f()와 g()를 받지만, 둘 가운데 무엇을 먼저 부를지는 컴파일러가 정합니다. 두 함수가 같은 변수를 바꾸거나 화면에 출력한다면, 컴파일러에 따라 결과의 순서가 달라질 수 있습니다.

계산 순서가 정해져 있는 연산자도 있습니다. 앞에서 본 것처럼 &&와 OR, 쉼표 연산자는 왼쪽을 먼저 계산하고, 조건 연산자는 조건을 먼저 계산한 뒤 고른 쪽만 계산하며, C++17부터 대입은 오른쪽을, <<와 >>는 왼쪽을 먼저 계산합니다. 하지만 이것은 그 연산자마다의 규칙이어서, 우선순위 표만 보고 알 수는 없습니다.

괄호도 마찬가지로 묶음만 정합니다. (f()) + (g())의 괄호는 두 호출의 순서를 정하지 않습니다. 부수 효과의 순서가 중요하다면 int a = f(); int b = g(); int sum = a + b;처럼 문장을 나누어, 계산 순서를 코드에 직접 드러냅니다.

더 깊이: 순서가 없는 계산의 두 종류

표준은 계산 순서를 한 계산이 다른 계산보다 앞선다(sequenced before)는 관계로 설명합니다. 두 계산 사이에 이 관계가 없으면 다시 두 경우로 나뉩니다. 순서가 정해지지 않았을 뿐 겹치지는 않는(indeterminately sequenced) 경우에는 둘 중 하나가 먼저 끝나지만 어느 쪽인지 알 수 없고, 순서가 없는(unsequenced) 경우에는 컴파일러가 두 계산의 명령을 섞어 실행할 수도 있습니다. 같은 메모리 위치를 바꾸는 두 부수 효과가 서로 순서가 없거나, 바꾸는 쪽과 그 값을 읽는 쪽 사이에 순서가 없으면 정의되지 않은 동작(undefined behavior)입니다.

C++17은 대입과 시프트 같은 연산자의 순서를 정해 예전의 정의되지 않은 동작을 줄였습니다. 그래서 i = i++ + 2;와 std::cout << i << i++;, a[i] = i++;는 C++17부터 결과가 정해진 식입니다. 함수 인자 사이의 순서는 여전히 정해지지 않았지만 C++17부터는 서로 겹치지 않으므로, f(++i, ++i)는 정의되지 않은 동작에서 결과만 정해지지 않은 동작(unspecified behavior)으로 바뀌었습니다.

반면 i = ++i + i++;나 n = ++i + i;처럼 +의 두 피연산자가 같은 변수를 바꾸거나 읽는 식은 C++17에서도 정의되지 않은 동작입니다. +의 두 피연산자 사이에는 여전히 순서가 없기 때문입니다. 어느 식이 어느 표준부터 정의되는지는 cppreference의 계산 순서 문서의 예로 확인할 수 있지만, 표준이 허용하는 식이라도 읽기 어려우므로 문장을 나누어 씁니다.

괄호로 의도 밝히기

기본 우선순위와 다른 묶음이 필요하면 괄호를 씁니다. 괄호로 감싼 부분은 바깥 연산자의 피연산자 하나로 묶입니다.

int a = 10, b = 5, c = 2;
int byPrecedence = a + b * c;
int byIntent = (a + b) * c;

bool enabled = true, ready = false, valid = false;
bool defaultGroup = enabled || ready && valid;
bool explicitGroup = (enabled || ready) && valid;

byPrecedence는 a + (b * c)인 20이고, byIntent는 괄호 때문에 30입니다. 논리식에서 defaultGroup은 &&가 먼저 묶여 enabled || (ready && valid)이므로 참이지만, explicitGroup은 valid가 거짓이어서 거짓입니다. 이처럼 괄호를 더하는 것이 읽기만 좋게 하는 것인지, 식의 뜻까지 바꾸는 것인지를 확인해야 합니다. 우선순위를 확신하기 어려운 식에는 괄호를 넣어, 읽는 사람이 표를 찾아보지 않아도 되게 합니다.

부분식마다 정해지는 타입

일반 산술 변환은 식 전체에 한 번 적용되는 것이 아니라, 묶인 부분식마다 따로 적용됩니다.

2 곱하기 double 값과 int 나누기 3의 두 부분식이 각각 double 21과 int 1을 만든 뒤 바깥 덧셈에서 공통 double 타입으로 변환되어 22가 되는 타입 흐름

TYPE FLOW FOLLOWS THE PARSE TREE

정수 나눗셈 결과는 바깥 변환으로 복원되지 않는다

2 * d_val + i_val / 3은 두 부분식에서 각각 변환·연산한 뒤 바깥쪽 +에서 다시 공통 타입을 정합니다.

두 부분식의 결과 타입이 바깥 덧셈에 합쳐진다좌곱셈double21.0과우정수나눗셈int1이독립자식이다. 둘의결과가바깥덧셈에들어가int1은double1.0으로변환되어22.0이된다. 세로배치는왼쪽다음오른쪽의실행보장이아니다.왼쪽 부분식2 * d_valdouble · 21.0오른쪽 부분식i_val / 3int · 1바깥 덧셈21.0 + 1.0double · 22.0각 부분식의 결과가 합쳐짐두 자식의 순서는 보장 안 함

본문의 입력값

double d_val = 10.5;
int i_val = 4;
복합 산술 표현식의 부분식별 타입 변환 트리 왼쪽 곱셈은 정수 2를 double로 변환해 double 21을 만들고 오른쪽 나눗셈은 int끼리 계산해 int 1을 만든다. 두 결과가 바깥 덧셈에서 double 22로 합쳐진다. 1 → 1.0 왼쪽 부분식 2 * d_val · int → double 21.0 · double 오른쪽 부분식 i_val / 3 · int / int 소수부를 버린 1 · int 바깥 덧셈 21.0 + 1.0 22.0 · double
  1. 구문 묶임

    (2 * d_val) + (i_val / 3)으로 두 자식 부분식을 나눕니다.

  2. 왼쪽 곱셈

    2가 double로 변환되어 21.0을 만듭니다.

  3. 오른쪽 나눗셈

    int / int의 결과는 소수부를 버린 1입니다.

  4. 바깥 덧셈

    1이 double로 변환되어 최종 22.0을 만듭니다.

현재 변환 위치

i_val / 3이 먼저 int 결과 1을 만들고, 바깥 덧셈에서 그 결과를 double로 변환합니다.

나눗셈 전에 변환

static_cast<double>(i_val) / 3은 나눗셈 자체를 실수 연산으로 만들어 약 1.333을 남깁니다.

두 자식 부분식의 값은 바깥 덧셈 결과보다 먼저 필요하지만, 기본 제공 +는 왼쪽 자식을 오른쪽 자식보다 먼저 평가한다고 보장하지 않습니다.

subexpression_types.cpp
#include <iomanip>
#include <iostream>

int main() {
    double d{10.5};
    int i{4};
    std::cout << std::fixed << std::setprecision(3)
              << 2 * d + i / 3 << ' '
              << 2 * d + static_cast<double>(i) / 3 << '\n';

    bool enabled{true}, ready{false}, valid{false};
    std::cout << std::boolalpha
              << (enabled || ready && valid) << ' '
              << ((enabled || ready) && valid) << '\n';
}
22.000 22.333
true false

그림처럼 2 * d + i / 3은 (2 * d) + (i / 3)으로 묶입니다. 왼쪽 부분식 2 * d는 2가 double로 바뀌어 21.0이 되지만, 오른쪽 부분식 i / 3은 두 피연산자가 모두 int여서 정수 나눗셈으로 1이 됩니다. 바깥의 +가 이 1을 double로 바꾸어 22.0을 만들지만, 이미 버려진 0.333은 돌아오지 않습니다. 오른쪽 나눗셈을 실수 나눗셈으로 하려면, 둘째 식처럼 그 부분식 안에서 i를 double로 바꿔야 21.0 + 1.333…인 22.333을 얻습니다.

복잡한 식을 읽는 순서

복잡한 식은 네 단계로 읽습니다. 먼저 괄호와 우선순위로 부분식의 구조를 적고, 각 부분식에서 연산이 성립하는 조건과 결과 타입을 확인합니다. 그다음 부수 효과가 있다면 계산 순서가 정해져 있는지를 따로 확인하고, 한눈에 확신하기 어려운 식은 괄호와 중간 변수, 별도의 문장으로 다시 씁니다. 우선순위 표는 식을 읽는 도구이며, 정확한 코드는 묶임과 연산의 뜻, 실제 계산 순서를 서로 바꾸어 말하지 않습니다.

정리

  • 우선순위는 어느 피연산자가 어느 연산자에 속하는지를 정하고, 결합 방향은 우선순위가 같은 연산자가 이어질 때 묶는 방향을 정합니다.
  • 시프트는 덧셈보다, 같음 비교는 비트 AND보다 낮거나 높은 등 직관과 다른 순서가 있으므로 헷갈리는 식에는 괄호를 씁니다.
  • 우선순위와 괄호는 식의 모양만 정하며, f() + g()처럼 피연산자를 계산하는 순서는 대부분 정해지지 않습니다.
  • &&, OR, 쉼표, 조건 연산자, C++17의 대입과 시프트처럼 계산 순서가 정해진 연산자는 그 연산자마다의 규칙입니다.
  • 일반 산술 변환은 묶인 부분식마다 적용되므로, 안쪽에서 정수 나눗셈으로 버린 값은 바깥의 변환으로 돌아오지 않습니다.

연습 문제

1. 입력을 바꾼 계산

subexpression_types.cpp에서 i만 5로 바꾸면 첫 줄은 어떻게 출력되나요?

해설 보기

5 / 3의 정수 몫도 1이므로 앞의 식은 여전히 22.000입니다. 뒤의 식은 21.0 + 5.0 / 3.0 = 22.6666…이어서 소수점 아래 세 자리로 22.667이 출력됩니다.

2. 같은 결과가 나오는 입력

enabled가 false, ready가 true, valid가 true이면 둘째 줄의 두 식은 각각 무엇인가요? 이 결과로 두 식이 같은 뜻이라고 말할 수 있나요?

해설 보기

enabled || (ready && valid)는 ready && valid가 참이어서 참이고, (enabled || ready) && valid도 ready와 valid가 참이어서 참입니다. 하지만 본문의 입력 true, false, false에서는 두 식이 다르므로, 한 입력에서 결과가 같았다고 두 식이 같은 뜻이 되지는 않습니다.

3. 시프트와 덧셈

int x = 1 << 2 + 1;에서 x는 얼마인가요? (1 << 2) + 1과 같은가요?

해설 보기

덧셈이 시프트보다 우선순위가 높으므로 1 << (2 + 1), 즉 1 << 3이 되어 x는 8입니다. (1 << 2) + 1은 4 + 1인 5이므로 다릅니다. 시프트와 산술 연산을 섞을 때는 괄호로 의도를 밝힙니다.

이것으로 3장의 연산자와 표현식을 마칩니다. 다음 장의 조건문 if, else if, else에서는 지금까지 만든 조건식으로 실행할 문장을 고르는 if 문과, 여러 조건을 차례로 검사하는 방법을 다룹니다.