POCU C언어 정주행 3회차 - 평가 순서, Sequence Point

2022. 12. 8. 18:48C언어 복습

1. 평가 순서

첫 문장부터 뜬금없긴 한데 이 문제를 풀 수 있겠는가? 그냥 간단한 코드를 보고 실행이 어떻게 될지 맞추면 되는 아주 간단한 문제이다.

#include <stdio.h>

int g_num = 0;

int AddOne()
{
    return ++g_num;
}

int SubstractOne()
{
    return --g_num;
}

int main(void)
{
    printf("%d, %d\n", AddOne(), SubstractOne());
    return 0;
}

그냥 값이 0으로 초기화된 전역 변수에 1씩 더하고 빼는 연산을 할 뿐이다. 대부분의 사람들은 이 코드를 보고 다음과 같은 흐름으로 프로그램이 실행될 것이라고 판단한다.

printf("%d, %d\n", AddOne(), SubstractOne());
printf("%d, %d\n", 1 /* g_num이 1증가된 채로 반환 */, SubstractOne());
printf("%d, %d\n", 1, 0 /* g_num이 1인 상테에서 1을 빼서 0을 반환 */));

혹시라도 위와 같은 흐름으로 생각한 것이 아니라면 다음과 같이 생각했을지도 모른다.

 

"이렇게 쉬운 문제였으면 언급하지 않았겠지? 뭔가가 있어. SubstractOne()부터 호출될거야!"

 

만약 이렇게 생각했다면 그 사람은 결과가 1, 0이 아니라 0, -1이 출력될 것이라고 생각할 것이다. 결론부터 말하자면 사실 표준에서는 어떻게 출력이 되어야 하는지 명시하고 있지 않다... 강의에서는 이것을 "평가 순서"가 명시가 되어있지 않다고 이야기한다.

 

당신이 만약 이 문제를 틀렸다면 C언어에서 위와 같은 상황에서 평가 순서가 어떻게 되어있는지 모르기 때문이다. 이제 평가 순서가 대체 무엇인지 알아보자. 우선 여기서 말하는 "평가"라는 것이 대체 무엇일까? 필자는 편의를 위해 다음과 같이 정의하려고 한다. (반박시 님 말이 다 맞음...)

 

"어떤 연산이나 함수 호출을 위해 값을 불러오는 것"

 

필자는 지금부터 이것을 평가라고 이야기하려 한다. 굳이 "내가 이렇게 정의하겠다"라고 말하는 이유는 평가라는 것의 정확한 정의가 무엇인지 찾아봤는데 잘 나오지 않는 것 같아서 그냥 내가 정의를 저렇게 했다... 다시 말해 귀찮기도 하고 설명의 편의를 위해 그냥 그럴듯 하게 정의를 해버린 것이다. 그래도 이야기를 이해하는 것에는 별 문제가 없을 것이라고 생각한다.

 

잡담이 좀 길었다. 어쨋든 정의에 집착하지 말고 전체적인 내용에 집중하자. 우리는 평가가 무엇인지 알게 되었다. 그럼 평가 순서는 무엇인가? 간단하게 말하면 앞서 말한 평가의 정의에 해당하는 부분을 매기는 순서다. 즉, 어떤 연산이나 함수 호출을 위해 값을 불러오는 것의 순서인 것이다. 그럼 여기서 위의 코드에서 이 부분을 다시 보자.

printf("%d, %d\n", AddOne(), SubstractOne());

printf함수를 호출하기 위해서 AddOne(), SubstractOne() 이라는 함수를 각각 호출하고 있지 않은가? 그럼 여기서 말하는 평가 순서를 따진다는 이야기는 저 두 함수 중에서 어떤 함수가 먼저 호출이 되는가를 따진다고 보면 될 것이다. 그리고 놀랍게도 어떤 경우인지에 따라 지금과 같아 보임에도 불구하고 다른 결과가 나오는 경우가 있다. 조금 이따가 알아보자.

 

 

2. Sequence Point

Sequence는 순서라는 뜻을 가지고 있다. Sequence Point말고 Sequencing이라는 말도 있는데 여기서는 Sequence Point라고 말하겠다. 직역을 하면 순서 포인트라는 것인데 좀 더 풀어서 해석하면 "순서를 나누는 포인트 지점" 정도로 해석을 할 수 있을 것이다. 그리고 여기서 말하는 순서는 어떤 작업이나 연산의 순서를 의미한다.

 

아직 뭔지 잘 모르겠다면 우리가 맨 처음 C언어를 배울 때 작성했을 법한 아래의 코드를 보도록 하자.

#include <stdio.h>

int main(void)
{
    printf("Hello World!\n");
    return 0;
}

여기서 ' ; ' (세미콜론)을 아무렇지 않게 당연하다듯이 찍었는데 이것이 Sequence Point의 한 예이다. 저것을 찍었다는 것은 찍힌 지점까지의 연산이나 작업 등이 끝나기 전까지 절대 다음 작업으로 넘어가지 않는다는 것을 보장한다는 뜻이다. 그래서 프로그램이 정상적으로 동작해서 끝난다면 절대 무슨 일이 있어도 printf가 끝나기 전에는 return 0; 라는 일은 일어날 수 없게 되는 것이다.

 

그리고 이런 Sequence Point를 이용해서 평가 순서를 강제하는 것이 가능하다. 아래는 하나의 Sequence Point를 의미하는 것들을 나열한 것이다.

  1. ' ; ' (세미콜론)
  2. ||, &&, ? : (논리 연산자 3가지)
  3. ' , ' (쉼표) - int a = 10, b = 5; 같은 식으로 코드를 짰다면 반드시 a = 10이후에 b = 5라는 연산이 일어난다. 여기서 헷갈리지 말아야 할것은 이후 "3. 함수를 호출할 때, 매개 변수의 평가 순서" 에서 나오겠지만 이것과 다르다.
  4. 함수의 호출과 리턴 - 바깥에서 함수가 일단 호출이 되었다면 그 함수가 리턴될 때까지 바깥에서는 그 어떤 다음 작업으로도 넘어갈 수 없다.

 

 

3. 함수를 호출할 때, 매개 변수의 평가 순서

앞서 1에서 설명했던 문제를 제대로 이해하려면 지금 이 내용을 반드시 제대로 이해할 필요가 있다. 앞서 봤던 코드의 결과는 표준에 따르면 unspecified라고 이야기 한다. specify가 영어로 명시하다라는 뜻인데 unspecified는 명시되지 않았다는 뜻이다. 여기까지 얘기하면 이렇게 생각할지도 모른다.

 

"결국 어떻게 될지 모른다는 뜻이잖아? 그냥 그렇게 얘기하면 되지않나? 왜 이렇게 길게 얘기하는 거야?"

 

다른 경우가 존재하기 때문이다! undefined라는 것이 존재하는데 이것과 unspecified의 결정적인 차이는 표준에서 특정 경우의 수를 강요하고 있는가에 대한 것이다. 아까 봤던 코드를 다시 보면 다음과 같은 생각을 할 수 있다.

printf("%d, %d\n", AddOne(), SubstractOne());
  • AddOne()이 먼저 호출된다.
  • SubstractOne()이 먼저 호출된다.

생각할 수 있는 경우의 수는 이 2가지이다. 이외의 경우의 수는 딱히 없을 것이라고 생각한다. 그렇다! 표준에서도 저 2가지 중 하나의 결과가 나올 것을 강요하고 있다. 계속 읽다보면 나오겠지만 undefined와 완전히 다르다. 정리하면 위와 같은 코드에서는 printf가 완전히 호출되기 전에 두 함수가 먼저 호출되고 먼저 끝날 것을 보장하고 있지만 둘 중 어느 것이 먼저 호출되는지는 명시하고 있지 않다.

 

그렇다면 undefined인 경우는 어떤 것이 있을까? 아래의 코드를 보고 어떤 결과가 나올지 맞춰보자.

#include <stdio.h>

int Func(int a, int b)
{
    return a - b;
}

int main(void)
{
    int num = 0;
    printf("%d\n", Func(++num, ++num);
    return 0;
}

 

다음과 같은 경우의 수를 떠올릴 수 있다.

  • a에 1, b에 2가 들어가서 -1이 반환되는 경우
  • a에 2, b에 1이 들어가서 1이 반환되는 경우

사실 이 2가지 경우의 수 이외에는 다른 경우의 수가 떠오르지 않을 수 있는데 주의해야 한다.

 

절대 그렇지 않다!

 

위의 상황은 명백한 undefined에 대한 상황으로 결과가 어떻게 나올지 전혀 모른다. 즉, 위에서 제시한 2개의 경우의 수가 아닌 다른 답이 나올 수 있는 것이다. 말 그대로 "결과가 정의되지 않기 때문"이다. 위에서 제시한 경우의 수가 아닌 다른 경우의 수를 들어보자면 아래와 같다.

 

사실 CPU, 컴파일러마다 어떻게 나올지는 모르지만 앞, 뒤에 존재하는 ++num에는 Sequence Point가 존재하지 않기 때문에 동시에 연산이 일어나는 것이 가능하다! CPU가 여러 개의 코어 혹은 여러 개의 파이프 라인에서 저 2가지 연산을 동시에 실행한다고 하면 앞에 있는 ++num때문에 32비트 중에 16비트를 업데이트하다가 갑자기 뒤에 있는 ++num을 업데이트해서 16비트를 잘못 덮어씌울 수 있다는 뜻이다...

 

그래서 undefinedunspecified와 비교했을 때, 비교도 안되게 위험하며 이런 코드는 절대 짜면 안된다. unspecified처럼 ANSI에서 "야, 이거 아니면 이런 결과가 나올거야."같은 보장을 절대 해주지 않기 때문이다. undefined는 말 그대로 결과가 정의되지 않아서 어떤 결과가 나올지 알 수 없다. 따라서 다음과 같은 주의사항에 해당하는 부분은 undefined에 해당하는 부분이며 반드시 인지하고 있다가 이런 실수가 일어나지 않도록 해야 한다.

 

"절대 하나의 Sequence Point 내에서 한 변수의 값을 2번 이상 변경하지 말자. 특히 아래와 같은 코드는 모두 위험한 경우이니 절대 무슨 일이 있어도 작성하면 안된다!"

Func(num++, ++num);
Func(num = -1, num = -1);
Func(num, num++);

 

 

4. 연산자 우선 순위와 평가 순서

이번에도 어김없이 문제를 하나 내보겠다.

 

int i = 0, num = 0; 일 때, 아래 3개의 연산에서 num에는 어떤 결과가 들어갈까?

int num = 0;
int i = 1;

num = i+++i;
num = ++i + i++;
num = ++i +++i;

사실 보기만 해도 머리가 아프고 실전에서 이런 코드를 짜는 사람이 있다면... 그 사람은 멀리하는 것이 좋다. 그리고 혹시라도 이 문제를 풀면서 재미를 느낀 사람에게도 굳이 풀어야 하냐는 반응을 보인 사람에게도 이 얘기를 해주고 싶다.

 

"Sequence Point가 없어서 undefined이며 결과를 절대 알 수 없다."

 

사실 방금 전에 "하나의 Sequence Point 내에서 한 변수의 값을 2번 이상 변경하지 말자"라고 이야기했다. 사실 다들 착각할 수 있는 것이 ' + ', ' = ' 같은 연산자는 연산의 우선 순위가 결정이 되어있고 무조건 연산자 우선 순위에 맞게 연산이 일어나게 되어있는 것은 맞다. 실제로 내부에서 덧셈 연산이 먼저 일어난 이후에 대입 연산이 일어나게 되며 이것은 정해져 있는 거라서 바뀌지는 않는다. 그러나 이것은 연산자 우선 순위일 뿐이며 평가 순서와는 어떤 관계도 없다.

 

그렇기 때문에 같은 원리로 아래와 같은 코드를 짰을 때 결과가 어떻게 나올지 어떤 함수가 먼저 호출이 되는지 모른다.

#include <stdio.h>

int Add(int a, int b)
{
    printf("Add\n");
    return a + b;
}

int Substract(int a, int b)
{
    printf("Substract\n");
    return a - b;
}

int Divide(int a, int b)
{
    printf("Divide\n");
    return a / b;
}

int main(void)
{
    int a = 10, b = 20;
    printf("%d\n", Add(a, b) - Substract(a, b) * Divide(a, b));
    return 0;
}

참고로 이 경우는 앞서 말했지만 함수의 호출 자체는 Sequence Point이기 때문에 undefined가 아닌 unspecified에 해당한다. 여기서 나올 수 있는 경우의 수는 6개일텐데 모두 컴파일러마다 다른 결과를 가져올 것이다. 어쨋든 6개 중 어떤 결과가 나온다고 해도 모두 ANSI 표준에 맞는 결과라는 것을 알고 있으면 된다.