[C++] 우리 요딴 문제는 내지 않기로 해요
C/C++은 현대에 와서 '어려운 프로그래밍 언어' 중 하나로 손꼽히는 처지가 되었습니다. 여러가지 이유가 있겠지만, 저는 감히 그 이유를 '함정에 빠지기 쉽다'고 하겠습니다.
개중에 하나는, '정의되지 않은 동작(undefined behavior, 이하 UB)'입니다. C/C++에만 존재하는 이 요상한 개념은 말 그대로 표준에서 어떻게 동작할지 정의하지 않은 동작들인데요. 그냥 컴파일러가 무슨 짓을 할 지 모르는 겁니다. 왜냐하면 정의된 게 없으니까. 무얼 하든 컴파일러 마음입니다.
비슷한 개념으로는 unspecified behavior(이하 UsB)가 있습니다. 마땅히 통용되는 번역은 없는 것으로 알고 있기에 그냥 UsB라고 하겠습니다. 이건 표준에서 어떤 동작으로 이어질 지 후보만 추려놓은 개념입니다. 그 후보들 사이에서 어떤 동작이 발생할지는 역시 몰라요. 정하지 않았기 때문에.
한편, UsB의 하위 개념으로 implementation-defined behavior(이하 IdB)라는 것도 있습니다. 이건 UsB랑 비슷한데, 표준에서 정의해둔 후보들 중에서 어떤 동작을 발생할지 구현체가 명확하게 정하고 문서화까지 해야한다는 것을 의미합니다.
예를 들어, 라면을 끓이기 위한 병법서가 있다고 합시다.
UB는 아래와 같은 식으로 적혀있는 것을 말합니다.
라면의 포장을 뜯는다. 이 때, 라면의 종류가 뭔지는 나도 모른다.
혹은, 아래와 같을 수도 있죠.
라면의 포장을 뜯는다. (라면의 종류가 뭔지는 아예 안적혀있음)
UsB는 아래와 같은 식으로 적혀있는 것을 말합니다.
라면의 포장을 뜯는다. 이 때, 라면은 아래 중 하나라야 한다.
- 신라면 매운맛
- 무파마
- 너구리
- 삼양라면
또한, IdB는 아래와 같은 식으로 적혀있는 것을 말합니다.
라면의 포장을 뜯는다. 이 때, 라면은 아래 중 하나라야 한다. 단, 이 중에 하나로 확실히 정해놔야한다.
- 신라면 매운맛
- 무파마
- 너구리
- 삼양라면
반면, 잘 정의된 defined behavior는 아래와 같은 식으로 적혀있습니다.
라면의 포장을 뜯는다. 이 때, 라면은 신라면 매운맛이다.
이것으로 UB, UsB, IdB에 대한 설명은 잘 전달됐으리라 생각합니다.
생각보다 어렵지 않은 개념이지만 꽤 많은 사람들이 잘 모르더군요. 심지어는 C/C++을 '가르치는' 사람들조차도 그렇습니다. 이 문제는 퍽 심각하다고 봅니다. 응시자들을 평가하기 위한 시험에서조차 출제자들이 이러한 개념을 잘 몰라서 오류를 저지르기 일쑤니까요.
실제 사례를 살펴봅시다.
2021년 2회 정보처리기능사 실기 문제 中
다음 C언어로 구현된 프로그램을 분석하여 그 실행 결과를 쓰시오.
int main(int argc, char* argv[]) {
int i;
char str[4];
str[0] = 'K';
str[1] = 'O';
str[2] = 'R';
str[3] = 'E';
str[4] = 'A';
for (i=0; i < 5; i++) {
printf("%c", str[i]);
}
return 0;
}
얼핏 보기에는 정답이 "KOREA"일 것 같지만, str의 길이는 4라는 것에 주목해야합니다. 분명히 str의 길이는 4인데, str[4]에 'A'를 대입하고 있군요. str[4]는 *(str + 4)와 동치입니다. 그냥 포인터 연산이니까 str[4]에 접근하는 것이 가능할까요?
C99의 draft인 N1256 문서를 살펴봅시다. 말이 어렵게 쓰여있지만 포인터 변수에 정수를 더한 결과가 배열 범위를 벗어나는 경우 UB라는 얘기입니다. 참고로 표준에서는 shall, shall not을 어기는 경우도 UB인 것으로 보기 때문에 마지막 문장도 UB라는 뜻입니다.
그러므로, *(str + 4)라는 연산 자체가 UB이고 이 코드의 실행결과는 알 수 없습니다. 컴파일러마다 다르고, 같은 컴파일러라도 버전에 따라 다를 수 있으며 옵션에 따라서도 다를 수 있습니다.
즉, 이 문제는 명백하게 출제오류입니다. 이 문제의 정답이 "KOREA"가 되려면, 적어도 이러한 표준을 무시할 수 있는 단서조항을 달았어야합니다. 하지만 일반적으로 선형적인 메모리 모델을 가정할테니 여기까지는 (그러기 싫지만) 꾹 참고 이해해줄 수 있습니다.
다음 예시도 보죠.
2019년 게임프로그래밍전문가 1회 A형 문제 中
다음 코드를 실행한 출력결과 값은?
#include <iostream>
using namespace std;
void PrintFunc(int nFirst, int nSecond, int nThird)
{
int value = nFirst + nSecond + nThird;
printf("%d", value);
}
int main() {
int x = 1;
PrintFunc(++x, x++, --x);
return 0;
}
사실 이건 무슨 답을 의도했는지도 잘 모르겠습니다. 출제자가 과연 어떤 오개념을 갖고 있었는지 감도 못 잡겠어요. PrintFunc(2, 1, 0); 같은걸 기대한 것일까요?
하여튼간에 이 코드에서 살펴봐야할 것은 두 가지입니다. 이번에는 C++ 표준을 기준으로 살펴봅시다(코드가 C++ 기반이므로).
아래 설명은 모두 C++14 기준입니다.
1. 함수 인자 사이의 평가 순서는 UsB입니다.
2. 같은 객체에 대한 side-effect가 서로에 대해 unsequenced이면 UB입니다.
일단 1번부터 살펴보죠. 여러분은 혹시 함수 인자가 특정한 순서에 따라 평가된다(evaluate)고 생각하시나요? 그렇다면 틀렸습니다. 함수 인자의 평가 순서는 UsB입니다.
C++14의 final working draft인 N4140을 봅시다. 함수 인자의 평가 순서는 unspecified라고 명확하게 적혀있습니다.
(부가 정보)
C++17부터 함수 인자가 왼쪽부터 오른쪽으로(LTR) 평가된다고 잘못 알고 계신 경우, 아래 '더보기' 를 클릭하여 자세한 설명을 확인하세요.
이것은 완전한 오해라고 말씀드리겠습니다.


위 내용은 C++17의 accepted proposal인 P0145의 내용 중 일부입니다. 여기서 4번 항목을 보고 함수 인자 평가 순서가 LTR로 보장된다는 오해가 생긴 모양입니다. 그러나 이전 페이지의 내용을 잘 읽어보면, '다음 표현식은 a, b, c, d 순서로 평가된다' 라고 쓰여있습니다. 만약 a(b1, b2, b3)에서 b1, b2, b3 순서로 함수 인자를 평가하고자 하는 의도였다면 a(b, c, d)라고 기재했을 것입니다.
이 proposal의 주된 내용 중 하나는 postfix-expression의 평가 순서를 LTR로 하자는 것입니다. 발췌해 온 부분은 이 내용을 말하고 있는 것이지, 함수 인자 평가 순서를 LTR로 보장한다는 말이 아닙니다.
cdecl 호출 규약에 의해 함수 인자가 오른쪽부터 왼쪽으로(RTL) 평가된다고 잘못 알고 계신 경우, 아래 '더보기' 를 클릭하여 자세한 설명을 확인하세요.
cdecl에서 정의하는 것은 함수 인자를 오른쪽에서 왼쪽으로 스택에 push한다는 것입니다. 이는 evaluation과는 별개의 이야기입니다.
기타 다른 이유에 따라 함수 인자에 대한 평가 순서가 존재한다고 잘못 알고 계신 경우, 아래 '더보기' 를 클릭하여 자세한 설명을 확인하세요.

당신이 잘못 알고 계신겁니다. 그 잘못된 지식을 잊으세요.
반박시 제 말이 맞습니다.
중요한 것은 함수 인자 사이의 평가 순서가 UsB라는 사실입니다. 그러므로 이 문제의 답은 벌써 알 수 없습니다. PrintFunc(++x, x++, --x); 형태의 호출에서 뭐가 먼저 평가될 지 모릅니다.
다음으로 2번 내용에 대해 살펴봅시다. 너무 길어서 뭐라고 했는지 까먹으셨죠? '같은 객체에 대한 side-effect가 서로에 대해 unsequenced이면 UB입니다.' 라고 했었답니다.
일단 side-effect에 대해서 알아야하는데요. side-effect란, 어떠한 객체를 수정하는 모든 행위를 말합니다. 변수에 값을 대입하거나, 문자열 출력 함수를 출력하는 등의 행위가 여기에 해당합니다.
여기에 더해 sequencing에 대해서 알아야하는데, C에서의 sequence point가 C++11부터는 sequencing으로 대체되었습니다. C에서의 sequence point란, '이 앞에서 연산들이 다 마무리 된다'는 개념입니다. 예를 들어, 세미콜론은 sequence point입니다. 세미콜론 앞에 있는 연산들은 세미콜론 이후에 나오는 코드들이 실행되기 전에 완료되는 것이 보장되니까요.
이런 개념이 C++11에서는 sequencing으로 대체됩니다. 이제 연산 순서와 관련된 규칙은 sequencing에 의해 정의되며, 아래 3가지 종류가 있습니다.
1. sequenced before/after: 다른 표현식의 평가 전이나 후에 순차적으로 이루어짐.
2: indeterminately sequenced: 순차적으로 평가되나, 뭐가 먼저 평가되는지는 모름(unspecified).
3. unsequenced: 표현식들의 평가가 순차적으로 이루어지지 않아 병렬로 평가될 수 있음.
이 때, 함수 인자간의 평가는 unsequenced로 정의됩니다.
그리고 서로에 대해 unsequenced인 연산이 동일한 scalar object(int 같은거)에 대해 side-effect를 가지면 UB입니다.
문제의 코드를 보죠. Func(++x, x++, --x);에서 함수 인자간의 평가는 unsequenced인데 동일한 scalar object인 변수 x에 대해 전위 증가, 후위 증가, 전위 감소 총 3개의 side-effect가 발생했습니다. 그러므로 이 코드는 UB이고, 이 문제는 출제 오류입니다.
참 여러모로 문제가 많은 코드였네요.
C++은 정말, 정말로 어려운 언어입니다. 하지만 응시자들을 평가하기 위해 시험 문제를 출제하는 위치에 있는 사람이라면, 이러한 내용들을 몰라서는 안됩니다.
이 문제들은 누구를 평가할 수가 없는 문제입니다. 누가누가 오답을 잘 맞추냐, 누가누가 출제자와 동일한 오개념을 가지고 있냐를 판가름할 수는 있겠습니다. 허나 그게 무슨 의미가 있을까요?
그냥 자기가 쓰는 컴파일러에서 별 시덥잖은 코드 돌려본 다음에 문제로 내는거, 무슨 의미가 있겠습니까? 이러한 동작에는 규칙도 없었을텐데 도대체 무슨 생각을 가지고, 무엇을 평가하고자 문제를 출제하신겁니까? 더 책임감 있게 문제를 내야하지 않을까요?
...라는 생각을 해봅니다.
그럼, 이만~~