POCU C언어 정주행 4회차 - scope(범위), goto문, const

2022. 12. 8. 23:47C언어 복습

1. scope (범위)

범위에 대해서 이야기를 하면 흔히들 ' { } ' 같은 중괄호 기호를 떠올리고는 한다. 하지만 내가 봤던 강의에서 다룬 범위는 총 4가지 개념이 있다.

  • 블록 범위
  • 파일 범위
  • 함수 범위
  • 함수 선언 범위

 

블록 범위

우리가 함수를 선언하고 정의할 때, 혹은 조건문이나 반복문을 사용할 때 쓰는 ' { } '가 사실 블록 범위이다. 함수를 정의할 때 쓰는 중괄호는 함수 범위가 아니냐고 이야기할 수 있는데 사실은 블록 범위이다. C언어에서는 블록이 시작될 때, 변수를 모두 한꺼번에 초기화를 하게끔 만들어 놓았는데 그래서 다음과 같이 코드를 짜면 컴파일 에러가 난다.

#include <stdio.h>

int main(void)
{
    int a = 10;
    printf("%d\n", a);
    int b = a + 2;
    return 0;
}

 

int b = a + 2; 에서 컴파일 오류가 나게 된다. C언어는 어떤 실행이 일어난 뒤에 그 밑에 또 다른 어떤 변수를 선언하는 것을 문법적으로 막아버렸기 때문이다. 또한 이것 때문에 코드의 줄 수가 길면 변수를 사용하기 전에 그 위에 변수를 선언하고 싶은 경우가 있는데 그것을 하지 못해서 꽤 불편한 상황이 나올 수 있다.

 

나는 아래와 같이 이런 식으로 코드를 짠 적이 별로 없는데 저런 식의 변수 선언을 막는 기준을 블록 단위로 하기 때문에 다음과 같이 코드를 짜면 컴파일 오류가 일어나지 않는다.

#include <stdio.h>

int main(void)
{
    int a = 10;
    
    printf("%d\n", a);

    {
        int b = a + 2;
    }

    /* int b는 이곳에서 사용할 수 없음 */

    return 0;
}

 

파일 범위

함수나 블록에 속하지 않고 파일 안에 존재하는 것을 말하며 대표적으로 전역 변수가 여기에 속한다. 다만 이것은 엄밀하게 말하면 트랜슬레이션 유닛의 범위라고 말하는 것이 엄밀하다고 말할 수 있다. 그리고 이런 범위에서 선언된 변수들은 데이터라는 영역에 들어가게 된다. 여기는 딱히 다룰 내용이 없으니 PASS!

 

함수 범위

C언어에서 말하는 함수 범위는 딱 1가지가 존재한다. 바로 goto문에서 쓰이는 레이블(label)인데 아마 C언어를 다들 처음 배울 때, goto문은 절대 쓰지 말라고 배운다. 나도 당연히 그랬고 거의 대부분의 교재, 강의 혹은 학교에 계신 교수님들이 그렇게 이야기를 하실 것이다. 근데 참 신기한 것이 이 강의는 그러지 않았다. 일단 goto가 악마다! 라고 이야기를 하면서도 어떤 경우에 쓰일 수 있다라고 하면서 무조건 쓰면 안된다라는 것은 좋지 않다고 이야기했다. 우선 함수 범위에 대해서 먼저 알아보고 그 다음에 goto문의 적절한 사용 예시라고 말하는 부분을 살펴보자.

#include <stdio.h>

int main(void)
{
    int i = 10;

    while (i <= 0)
    {
        printf("%d\n", i);
        i--;

        if (i % 2 == 0)
            goto exit;
    }

exit:
    return 0;
}

 

지금 본 것이 goto문의 한 예시이다. exit: 라고 되어있는 부분이 함수 내부에 레이블을 다는 부분인데 저것이 바로 함수 범위를 이야기한다. 나도 함수 범위라고 하면 그냥 함수 정의할 때 쓰는 중괄호의 범위를 이야기하는 줄 알았는데 강의에서 그게 아니라고 이야기했다. 함수 범위라는 단어의 뜻을 맥락에 따라 잘 파악할 수 있도록 하지 않으면 헷갈릴 것 같으니 주의해야겠다는 생각이 들었다.

 

함수 선언 범위

함수 선언 범위는 간단하게 아래의 코드가 있다고 하면

void Func(long double value, char arr[10 * sizeof(value)])
{
    /* 아주 멋진 코드 */
}

 

여기서 매개 변수가 선언되어 있는 범위를 이야기한다. 그리고 얘네끼리는 보다시피 서로 접근이 가능하다. 물론 저 코드자체가 컴파일러 입장에서는 큰 의미가 있는 것이 아니다. 왜냐하면 arr이라는 매개 변수에 [ ]안에 어떤 숫자를 넣어도 아무런 의미가 없으며 그냥 포인터의 시작 주소인 포인터를 받아오게 되기 때문이다. 하지만 프로그래머의 입장은 다르다.

 

"아, 값이 들어오는데 그 값의 자료형 크기의 10개만큼 메모리가 배열로 딸려오는 구나!"

 

이렇게 시각적으로 인지하는 것이 가능하기 때문에 이런 용도로 코드를 작성할 수 있는 것이다. 확실히 코드의 가독성에 도움이 된다는 것을 알 수 있을 것이다. 실제로 문서화의 개념으로 매개 변수로 배열을 받을 때, 메모리 상으로는 배열이 아니라 포인터로 배열의 시작 주소만 받아오는 것이어서 컴파일러는 배열의 길이를 알 수 없음에도 불구하고 문서화의 개념으로 [ ] 안에 배열의 길이를 써주는 경우가 있다.

 

 

2. goto문

드디어 goto문이다. "아니, 어차피 안쓸텐데 왜 굳이 글로 적는거야?"라고 말하고 싶을지도 모른다. 물론 악마같은 녀석이 맞다! 하지만 강의에서 goto문이 쓰일 수 있는 경우를 소개하고 있으며 최종적으로 goto문을 사용하는 best practice를 다음과 같이 제시했다.

  • 회사에서 같이 일하는 사람이 goto를 극도로 싫어하면 best고 나발이고 그냥 쓰지말 것 (가장 중요!)
  • goto문을 사용한다면 반드시 아래로만 점프할 것
  • 한 함수 내의 여러 개의 조건문이 공통된 코드를 실행할 때 사용할 것
  • 중첩 반복문을 빠져나올 때 사용할 것

이제 goto문이 쓰일 수 있는 경우에 대한 예제를 살펴보자.

 

다중 반복문의 탈출

다음과 같이 다중 반복문이 만들어져 있고 조건에 따라서 반복문을 탈출해야 한다고 가정해보자.

#define LENGTH 10

for (i = 0; i < LENGTH; i++)
{
    for (j = 0; j < LENGTH; j++)
    {
        for (k = 0; k < LENGTH; k++)
        {
            if (/* 조건을 충족 */)
            {
                /* 나가게 해줘! */
            }
            
            /* 아주 멋진 코드 */
        }
    }
}

/* 정말 멋진 코드 */

 

만약 이런 상황을 빠져나가야 한다면 어떻게 코드를 짜야할까? 만약 goto없이 코드를 짠다면 아래와 같이 조건문을 필요한 곳에 붙여나가고 bool을 위한 변수도 따로 만들어야 할 것이다.

#define LENGTH 10

int exit = 0;

for (i = 0; i < LENGTH; i++)
{
    for (j = 0; j < LENGTH; j++)
    {
        for (k = 0; k < LENGTH; k++)
        {
            if (/* 조건을 충족 */)
            {
                exit = 1;
            }
            
            /* 아주 멋진 코드 */
        }
        
        if (exit != 0)
            break;
    }
    
    if (exit != 0)
        break;
}

/* 정말 멋진 코드 */

 

하지만 다음과 같이 코드를 짠다면 굳이 if문을 추가하거나 bool을 위한 변수를 선언할 필요가 없어서 깔끔하다.

#define LENGTH 10

for (i = 0; i < LENGTH; i++)
{
    for (j = 0; j < LENGTH; j++)
    {
        for (k = 0; k < LENGTH; k++)
        {
            if (/* 어떤 조건이 충족 */)
            {
            	goto loopExit;
            }
            
            /* 아주 멋진 코드 */
        }
    }
}

loopExit:
	/* 정말 멋진 코드 */

 

예외 처리를 유연하게 하고 싶을 때

글로는 예외 처리에 대해서 이야기는 했는데 강의에서 말한 것을 더 정확히 전달하자면 함수 내에 있는 여러 개의 조건문이 공통된 코드를 실행해야 할 때라고 이야기했다. 아래와 같은 상황이 있다고 가정해보자.

if (/* 조건 1 */)
{
    PrintErrorLog(1);
    RollbackTransaction();
    return 1;
}

if (/* 조건 2 */)
{
    PrintErrorLog(2);
    RollbackTransaction();
    return 2;
}

if (/* 조건 3 */)
{
    PrintErrorLog(3);
    RollbackTransaction();
    return 3;
}

/* 그 이외의 다른 조건들... */

/* 실제 로직 코드들 */

return 0;

 

예외 처리를 할 때, early eixt이라고 해서 실제 로직에 해당하는 코드로 진입하기 전에 미리 조건문을 걸어놓고 그에 따른 예외 처리를 하고 있는 상황이다. 근데 코드를 보면 알겠지만 중복되는 코드가 너무 많아서 불편하다... 강의에서는 이걸 goto문을 이용해서 아래와 같이 해결했다.

if (/* 조건 1 */)
{
    errorCode = 1;
    goto Error;
}

if (/* 조건 2 */)
{
    errorCode = 2;
    goto Error;
}

if (/* 조건 3 */)
{
    errorCode = 3;
    goto Error;
}

/* 그 이외의 다른 조건들... */

/* 실제 로직 코드들 */

return 0;

Error:
    PrintErrorLog(errorCode);
    RollbackTransaction();
    return errorCode;

 

근데 사실 이렇게 보면 저 Error: 라는 레이블에 해당하는 부분을 함수로 따로 묶어주고 싶은 마음이 들 수 있다. 나도 그런 생각을 했었고 아마 실제로 내가 코드를 짜도 그런 방식으로 짜게 될 것이라는 생각을 했다. 근데 강의에서는 저런 경우라도 함수로 묶기 껄끄러운 경우를 이야기했다.

 

저 Error: 라는 레이블의 내용은 해당 함수 내에서 예외 처리를 하기 위한 것이기 때문에 높은 확률로 해당 함수에서만 쓰이게 된다. 이러면 하나의 함수에서만 쓰이는 내용을 가지고 또 다른 함수를 만들게 되는 격인데 이것이 유지 보수의 측면에서 좋지 않다는 것이다. 근데... 솔직히 잘 모르겠는 것이 나는 아무리 봐도 에러를 처리하는 코드가 많아지면 많아질수록 함수로 묶고 싶다는 생각이 들어서 이게 맞는 이야기인지는 잘 모르겠다. 아마 사람마다 다른 답이 나올 것이라고 생각한다.

 

단계적으로 여러 실행을 하다가 다시 되돌리고 싶을 때

이건 실제로 로버트 러브라는 분이 리눅스 커널 관련해서 토론을 하던 중에 goto문에 대한 이야기가 마구 쏟아져 나온 와중에 했던 이야기이다. 코드를 보면 알겠지만 어떤 실행을 하다가 에러가 나서 그것을 다시 되돌리는 코드이다. 근데 되돌릴 때 그냥 되돌리는 것이 아니고 이전에 했던 작업들까지 전부 되돌려야 하는 것이다. 이 예시는 정말 훌륭했다고 생각하는 것이 저걸 전부 함수로 만들고 조건걸고 하는 것이 딱 봐도 귀찮아보였기 때문이다.

    do A
    if (error)
        goto out_A;
    
    do B
    if (error)
        goto out_B;
    
    do C
    if (error)
        goto out_C;
    
    goto out;

out_C:
    undo C;

out_B:
    undo B;

out_A:
    undo A;
    
out:
    return ret;

 

case 레이블 하나를 실행한 후 다른 case를 실행하고 싶을 때

이건 강의를 하신 김포프님이 직접 짠 코드로 본인이 현업에서 일할 때 실제로 이런 방식으로 코드를 짜야하는 경우가 있었다며 소개해준 코드이다. 각 case별로 실행하고 싶은 코드가 있는데 그 코드가 끝나고 바로 다른 케이스를 실행하고 싶은 경우가 있었다고 한다. 근데 함수로 만들기에는 좀 껄끄러웠고 그러지 않기에는 코드의 중복이 일어났다고 하셨다. 그래서 다음과 같이 C#에서 goto문을 사용해야 하는 경우가 있었다고 한다. 아래는 김포프님이 보여준 C#코드이다.

switch (status)
{
    case ERequest.New:
        id = queueRequest(request);
        goto case ERequest.Pending;
    case ERequest.Pending:
        if (isComplete(id))
        {
            goto case ERequest.Complete;
        }
        break;
    case ERequest.Complete:
        break;
    default:
        throw new ArgumentOutOfRangeException("Error String");
}

 

이렇게 goto문을 쓰이는 경우를 알아봤는데 다들 어떻게 생각할지 잘 모르겠다. 아마 프로그래머들이 모여있는 카톡방에 주제로 던져주면 꽤 그럴듯한 토론이 이루어질 것 같다고 생각한다.

 

 

3. const에 대해서 (C/C++ vs C#)

C/C++같은 프로그래밍 언어를 학습한 적이 있다면 const를 다들 배워봤을 것이다. 그래서 const를 어떻게 사용하는지에 대한 내용은 없을 것이다. 다만 C/C++에서 const변수에 넣을 수 있는 것이 C#에는 넣을 수 없는 상황이 있어서 그런 경우를 설명하고 왜 C#같은 언어에서는 그런 것이 안되는지 그 원리를 한 번 짚고 넘어가려고 한다.

 

아래의 코드는 어떤 함수에서 매개 변수를 받아서 그것을 const 변수에 값으로 넣으려는 코드이다.

 

C/C++버전

void ExampleConstFunc(int num)
{
    const int constNum = num;
    
    /* constNum을 이용한 아주 멋진 코드 */
}

 

C# 버전

public void ExampleConstFunc(int num)
{
    // const int constNum = num; <- 컴파일 에러!
    
    // 이런 식으로 하드 코딩된 상수만 cosnt로 집어넣을 수 있다.
    const int constNum = 1;
}

 

솔직히 이런 부분은 C#보다 C/C++이 더 좋다고 생각하는 부분들 중 하나다. 이제 왜 이런 방식으로 동작하는지 알아야 할 필요가 있는데 그걸 알려면 우선 포인터를 알아야 한다. 근데 이 글은 이미 다들 프로그래밍 언어를 배운 적이 있다는 가정하에 복습을 하려는 사람들을 위한 글이기 때문에 포인터에 대한 설명은 굳이 하지 않겠다.

 

본론으로 들어가면 다들 C#, JAVA같은 언어는 포인터가 없다는 말을 들어본 적이 있는가? 그렇다면 사실 그것이 모든 변수가 포인터라서 그렇게 보이는 것 뿐이라는 사실도 알고 있는가? 바로 이 부분이 C#같은 언어에서만 저런 코드가 허용되지 않는 이유를 설명하는 중요한 열쇠가 된다. 그럼 여기서 C/C++과 C#같은 언어에서 변수를 선언하고 값을 넣을 때에 대한 차이를 알아보자.

 

만약 누군가가 2개의 변수를 선언하고 거기에 c라는 변수를 대입하는 연산을 했다고 가정하자.

학교 학술 동아리(팀 크리에이터) 세미나 자료 중 일부

 

그럼 메모리 상에서는 다음과 같은 방식으로 동작하게 된다.

학교 학술 동아리(팀 크리에이터) 세미나 자료 중 일부

이것이 C/C++과 C#의 결정적인 차이점이다. C/C++에서 대입 연산을 하면 해당 변수에 있는 값을 복사해서 가져오는 반면에 C#같은 언어는 대입 연산을 하면 값을 복사하는 것이 아니다. 모든 변수가 포인터와 같은 형식으로 이루어져 있기 때문에 어떤 변수로 대입 연산을 하면 그 데이터를 "가리킨다." 그렇기 때문에 오른쪽과 같은 그림이 나오게 되는 것이다. 그럼 이제 여기에 const의 개념을 적용시켜서 코드를 작성해보자.

public void ExampleFunc(Something c)
{
    // 둘 다 안되지만 일단 했다고 가정
    // 실제로는 컴파일 에러가 난다
    const Something a = c;
    const Something b = c;
    
    Something d = a + b;
    
    /* 아주 멋진 코드... 그런데? */
    
    c = d;
    
    // 대충 a, b를 사용하는 코드... 어라?
    // 분명 const인데 값이 바뀌었다?!?!
}

 

보이는가? C/C++이면 아무 문제없을 코드지만 C#같은 언어는 아주 큰 문제가 생긴다. 잘 생각해보자. a, b는 자신들이 const로 된 데이터를 "가리키고" 있다고 생각할 것이다. 따라서 a, b의 입장에서는 죽어도 그 데이터는 값이 바뀌지 않을 것이고 바뀌어서도 안된다고 생각할 것이다. 그렇기 때문에 a, b를 통해서 값을 변경시키려고 하면 a, b가 절대 가만히 있을 수 없는 것이다. 여기서 중요한 점은 c, d는 본인이 const로 된 데이터를 "가리키고" 있다고 생각하지 않는다는 것이다. 그래서 c, d를 통해서 값을 변경하려고 해도 a, b와 다르게 그냥 값의 변경을 허용하는 것이다.

 

이제 여기서 재앙이 일어난다. c = d; 라는 코드를 써서 c가 "가리키는" 데이터를 바꿨는데 이 값은 a, b가 현재 "가리키는" 데이터와 동일한 위치에 있는 데이터다. a, b는 본인들이 "가리키는" 데이터가 절대 바뀌면 안된다고 선언되어 있는데 아무렇지 않게 바뀌어버리는 어처구니 없는 상황이 연출된 것이다... 그렇기 때문에 C#같은 언어에서는 이런 상황을 방지하기 위해서 리터럴로 된 상수만 대입할 수 있도록 막아놓은 것이다. 바로 이것이 모든 변수가 포인터일 때 생기는 치명적인 단점이며 C/C++이 이런 부분은 C#보다 좋다고 말하고 싶은 이유도 이것이다.