back

비좌변값 배열(non-lvalue array) #3

11년 전 작성

이 글은 시리즈 글의 일부입니다.


이번이 "비좌변값 배열" 이야기의 마지막이 되길 바라며...

과거 일종의 C 언어 (넌센스?) 퀴즈로 나왔던 문제 중 하나가 배열을 값에 의한 호출(call by value)로 전달했다가 반환해주는 함수를 작성해 보라는 것이었습니다. 모든 배열은 함수로 오가는 상황에서 포인터로 변환(decay)되기 때문에 포인터가 아닌 배열을 그대로 값에 의한 호출로 전달하라는 것은 마치 불가능한 코드를 작성해 보라는 것처럼 느껴지는 퀴즈였습니다.

문제에 대한 답의 핵심은 바로 구조체에 있습니다. 배열은 C 언어에서 "1급 시민"이 아니지만 구조체는 거의 "1급 시민"이기 때문에 구조체 안에 배열을 넣음으로써 배열을 복사해 값으로 전달하고 반환하는 작업을 구조체에 맡길 수 있습니다. 즉, 이 문제의 답은

struct tag {
    int a[10];
} func(struct tag param)
{
    return param;
}

이런 형태가 됩니다.

함수의 반환값은 좌변값이 아니기 때문에(우변값이기 때문에) 반환하는 값이 덩치 큰 구조체라고 해도 그 역시 구조체 "값"에 해당합니다. 실제로 언어를 구현하는(컴파일러를 작성하는) 입장에서는 내부적으로 메모리 상에 남몰래 만들어 둔 임시 구조체 변수를 사용하겠지만, 어쨌든 프로그래머에게 보이기에는 구조체 "변수"(좌변값)가 아닌 구조체 "값"(우변값)처럼 보이도록 해야 합니다. 따라서

&func()

이런 수식은 &의 피연산자가 좌변값이 아니라는 오류가 발생하도록 구성해 주어야 합니다.

그럼 이제 아주 재밌는 현상이 발생합니다. 구조체는 "1급 시민"에 가까운 녀석이기에 구조체 "값"이 존재하는데 그런 구조체가 "1급 시민" 근처에도 못 가는 배열을 멤버로 포함할 수 있게 됩니다. 그럼 그 구조체 "값" 안에 있는 배열은 정체가 무엇일까요? "배열 값"(우변값)이 되는 걸까요? (드디어 이 시리즈 글의 제목인 비좌변값 배열 non-lvalue array 혹은 rvalue array, array value의 개념이 나오네요!) 다시 말해 위의 함수를 아래와 같이 호출한 녀석의 정체가 다소 애매해 집니다.

func().a

C90 표준이 쓰여질 당시에는 이와 같은 가능성을 염두에 두지 못한 탓에 C90 표준은 배열의 포인터로의 변환(decay)을 아래와 같이 기술하게 됩니다.

이미지

모바일 배려와 친절한 번역 나갑니다...

Except when it is the operand of the sizeof operator or the unary & operator, or is a character string literal used to initialize an array of character type, or is a wide string literal used to initialize an array with element type compatible with wchar_t, an lvalue that has type "array of type" is converted to an expression that has type "pointer to type" that points to the initial element of the array object and is not an lvalue.

sizeof 연산자나 단항 & 연산자의 피연산자로 쓰이는 경우가 아니거나 혹은 문자형 배열을 초기화할 때 사용되는 문자열 상수, wchar_t와 호환되는 요소형의 배열을 초기화할 때 사용되는 광역 문자열 상수가 아닌 경우, "_type_의 배열"을 데이터형으로 갖는 좌변값은 해당 배열 대상체의 첫 요소를 가리키는 "_type_을 가리키는 포인터"를 데이터형으로 갖는 수식으로 변환되고 더 이상 좌변값이 아니다.

여기서 문제는 바로 배열이 포인터로 변환되려면 좌변값이어야 한다는 것입니다. 다시 말해, 배열이기는 한데 좌변값이 아닌 배열은 어떻게 하라는 것인지 제대로 기술이 되어 있지 않습니다.

이에 대해 저는 표준이 무엇인가를 정의해 주지 않으면 정의되지 않은 행동(undefined behavior)이 되므로 위와 같은 구조 자체가 오류라고 생각했고, C90 표준 이후 표준 문서의 편집자(editor)를 맡은 Larry Jones는 그냥 포인터로 변환되지 않고 배열로 남을 뿐 오류인 것은 아니라는 입장이었습니다. Larry Jones의 의견에 따르면

func().a;
sizeof(func().a)

와 같은 수식은 C90에서도 문제가 없고 (첫번째는 배열을 void형 수식으로 변환해 무시해 버리는 것이며 - 세미콜론에 유의하세요 -, 두번째는 배열의 크기를 물어보는 것입니다),

func().a[0]
*(func().a)

와 같은 수식은 []* 모두 배열이 아닌 포인터를 피연산자로 요구하기 때문에 잘못된 수식이 됩니다.

물론, Larry Jones 역시 지리한 논의 끝에 C90 표준이 "비좌변값 배열" 혹은 "배열값"을 제대로 설명하고 있지 못하다는 부분에 대해 어느 정도 인정했지만, 해당 논의가 이미 C99 표준이 나온 이후에 이루어진 것이라 표준 위원회(ISO/IEC Committee)를 통한 유권 해석을 받을 수 있는 상황은 아니었습니다.

C99에서는 상황이 조금 다릅니다. C99는 "좌변값"이라는 개념이 가지고 있는 본질적인 문제(나중에 기회가 되면 이것도 다뤄볼까 합니다)를 해결하기 위해 "좌변값"을 아주 엉성한 개념으로 만들어 버리고(가장 불만이 있는 부분 중 하나입니다), 이에 맞춰 배열이 포인터로 변환되는 문맥에서 "좌변값" 운운하는 부분을 제거하여 매우 만족스럽지는 않지만 일단 실질적인 문제는 사라지게 됩니다.

다시 말해 C99에서는 위의 마지막 두 수식도 정상이며 의도한 대로 동작을 하게 됩니다. 다만, 그렇게 나온 배열의 요소를 수정하려는 시도(예를 들어, func().a[0] = 0;)는 아래와 같은 새로운 규정을 두어 막게 됩니다.

이미지

모바일 배려와 불친절한 해석...

If the expression that denotes the called function has type pointer to function returning an object type, the function call expression has the same type as that object type, and has the value determined as specified in 6.8.6.4. Otherwise, the function call has type void. If an attempt is made to modify the result of a function call or to access it after the next sequence point, the behavior is undefined.

호출되는 함수를 의미하는 수식의 데이터형이 대상체 데이터형을 반환하는 함수 포인터이면, 함수 호출 수식의 데이터형은 그 대상체 데이터형이 되고, 값은 6.8.6.4(return 문을 설명하는 부분입니다)에서 명시된 대로 결정된다. 그 외의 경우에 함수 호출은 void형을 갖는다. 만약 함수 호출의 결과를 수정하려 하거나 다음 시퀀스 포인트 이후 접근하려 한다면 행동은 정의되지 않는다.

하지만, 이제 이게 얼마나 이상한 이야기가 되는지 모릅니다. C90 표준에서부터 배열은 좌변값 뿐이 없다고 가정하고 정의된 부분이 많고, 배열에서 파생된 포인터는 항상 메모리 상에 존재하는 대상체(변수)를 가리키는 것으로 기술되어 있었습니다. 그런데 이제 와서 딸랑 비좌변값 배열도 포인터로 변환될 수 있다고 허락해 준다고 해서 나머지 부분과 논리적 모순 없이 잘 정의가 되는 것은 결코 아닙니다. 여전히 포인터는 메모리 상에 존재하는 대상체를 염두에 두고 기술되어 있는데 그런 부분은 그냥 두고 비좌변값 배열도 포인터로 바뀔 수 있다고만 하면 의도야 충분히(?) 이해할 수 있더라도 표준 각 부분의 논리적 충돌은 불가피합니다.

이 논리적 문제는 바로 C 표준이 컴파일러 구현보다 훨씬 더 추상적인 관점에서 문제를 해결하려 한 데 있습니다. 컴파일러는 실제로는 구조체, 공용체 반환값을 진짜 "값"의 개념이 아닌 내부적으로 만들어 사용하는 임시 "대상체(변수)"로 구현하고 있습니다. 따라서, 함수가 반환하는 구조체 값 안에 있는 배열도 사실상 정상적인 변수 안에 들어있기 때문에 이를 포인터로 변환하고 각종 포인터 연산을 적용하는 것이 크게 문제 되지 않습니다 - 그 임시 변수가 사라지기 전까지는요. 그렇다면 표준도 이 문제를 컴파일러 구현을 따라 기술했다면 골치 아픈 논리적 모순에 빠지지 않을 수 있었습니다. 그리고 오래 전에 이미 C++ 표준은 (다른 문제를 위해서이긴 하지만) "임시 대상체(temporary object)"의 개념을 도입해서 C 표준이 겪는 문제를 겪고 있지 않았습니다.

결국 마땅한 해결책이 없음을 깨달았는지 표준화 위원회는 C11(C2011)이 되어서야 아래와 같은 부분을 추가하게 됩니다.

이미지

모바일 배려와 그냥 그런 번역입니다...

A non-lvalue expression with structure or union type, where the structure or union contains a member with array type (including, recursively, members of all contained structures and unions) refers to an object with automatic storage duration and temporary lifetime. Its lifetime begins when the expression is evaluated and its initial value is the value of the expression. Its lifetime ends when the evaluation of the containing full expression or full declarator ends. Any attempt to modify an object with temporary lifetime results in undefined behavior.

(재귀적으로 포함하는 경우를 포함해) 배열형 멤버를 갖는 구조체와 공용체형의 비좌변값 수식은 자동 기억수명과 _임시 수명_을 갖는 대상체를 참조합니다. 해당 대상체의 수명은 수식이 평가될 때 시작되고 초기값은 그 수식의 값이 됩니다. 수명은 포함하는 전체 수식이나 전체 선언자가 종료될 때 끝나며, 임시 수명을 갖는 대상체를 수정하려하면 정의되지 않은 행동이 됩니다.

비좌변값 배열이 나올 수 있는 유일한 정상적인 경우는 배열이 구조체나 공용체 멤버로 포함되어 구조체값 혹은 공용체값이 되는 경우 뿐이기 때문에 해당 경우를 딱 꼬집어서 기술하고 있으며, 자동 기억수명(automatic storage duration)과 새로 도입된 임시 수명(temporary lifetime)의 개념을 도입해 기술한 부분은 정확하게 일반적인 컴파일러가 구조체값, 공용체값을 구현하는 방식을 담은 것입니다. 예를 들어 beluga에서는

if (TY_ISSTRUNI(rty)) {
    t3 = sym_new(SYM_KTEMP, LEX_AUTO, TY_UNQUAL(rty), sym_scope);
    if (rty->size == 0)
        err_issue_s(ERR_EXPR_RETINCOMP, ty_outtype(rty));
}

함수의 반환형(rty)이 구조체나 공용체일 때(TY_ISSTRUNI()) 자동 기억수명(LEX_AUTO)을 갖는 임시 변수(SYM_KTEMP)를 현재 통용범위(scope, sym_scope) 안에 만들어 함수의 결과값을 담는 데 사용합니다. 또한, lcc가 구조체간 복사를 최소화하기 위해 반환값 최적화(return value optimization)을 수행하고 후손인 beluga 역시 해당 최적화를 그대로 가져왔기 때문에 임시 대상체를 수정하거나 약속된 수명 이후에 사용하는 행동은 최적화 적용 여부에 따라 예측 불가능한 결과를 가져오게 됩니다.

하... 가급적 3편의 글로 마무리하려고 했는데 너무 설명이 장황한건지 쓸데 없이 먼 길을 돌아온건지 한 편 더 가야겠네요... ㅜㅜ