이 글은 시리즈 글의 일부입니다.
- 비좌변값 배열(non-lvalue array) #1
- 비좌변값 배열(non-lvalue array) #2
- 비좌변값 배열(non-lvalue array) #3
- 비좌변값 배열(non-lvalue array) #4
초기 컴파일러의 주요하고도 유일한 목적은 올바른 프로그램을 올바르게 번역해 주는 것이었습니다. 잠재적으로 문제가 될 수 있는 구조에 대해 경고해 주는 친절은 꿈도 꾸기 어려웠고 잡을 수 있는 오류에 대해 이해가 쉬운 메시지를 내주는 것만으로도 감지덕지였습니다. 때문에 당시에는 컴파일러가 내는 이해하기 어려운 메시지에 대한 불만에 "gcc
는 lint
가 아니다"라는 답이 당당하게 등장하기도 했습니다 (유즈넷에서인지 어떤 책에선지 봤던 문구인데 다시 찾으려 하니 못찾겠네요).
하지만, 이제는 시대가 바뀌어서 올바른 프로그램을 올바르게 번역하는 것 이외에 올바르지 않은 프로그램을 적절하게 진단해주는 것 역시 컴파일러가 해주어야 하는 일이 되었고, 덕분에 정적 분석기(static analyzer)가 하는 많은 검사를 컴파일러가 대신 하게 되었습니다. (물론, 아직도 자바스크립트(JavaScript)의 경우에는 v8 같은 자바스크립트 엔진이 불친절하여 JSLint라는 도구의 도움을 필요로 합니다.)
기반 지식을 설명하는데 생각보다 오래 걸려 먼 길을 돌아왔지만 원래 이 글을 작성하려한 가장 큰 목적은 바로 이 비좌변값 배열(언어 표준의 해석)이 beluga
(컴파일러 구현)에 어떤 영향을 주었는지를 소개하려 함입니다 - 다시 말해, 글의 솔직한 목적은 beluga
용 찌라시입니다.
사용자가 컴파일러를 특정 표준 모드로 동작시킨다는 것은 해당 표준에서 지원하는 새로운 기술을 사용하기 위한 목적도 있겠지만 해당 표준에 입각해 코드를 엄격하게 해석한 결과를 확인하고 싶은 경우가 더 많습니다 - 보통 기본 모드에서 동작하는 컴파일러는 지원하는 표준 모드를 통틀어 유용한 기술을 모두 포함하여 동작하기 때문에(그렇기 때문에 기본 모드는 보통 비표준 모드입니다) 딱히 특정 기술의 필요성만으로 표준 모드를 켜는 경우는 많지 않습니다. 따라서 beluga
도 C90 표준 모드가 아닐 때에는 비좌변값 배열에 대한 별도의 처리를 해줄 필요는 없습니다.
$ cat > foo.c
struct tag {
int a[10];
} g();
int f(void) {
struct tag *p = &g();
return g().a[0];
}
$ ./build/beluga -Wv foo.c
foo.c:3:3: warning - missing prototype
} g();
^
foo.c:6:21: ERROR - lvalue required
struct tag *p = &g();
^
foo.c:6:16: warning - local `p' defined but not referenced
struct tag *p = &g();
^
(예에서 -W
옵션은 가능한 경고를 모두 켜주며, -v
옵션은 오류가 발생한 라인을 보여줍니다.)
예에서 보이듯 구조체 반환값을 충분히 우변값으로 처리하고 있고, 배열 멤버에 접근할 경우 어차피 임시 변수 내의 배열 멤버이기 때문에 큰 어려움 없이 동작하고 있습니다. 문제는 C90 표준 모드일 때 비좌변값 배열에 대한 오류 처리를 어떻게 해주어야 하는지입니다. 간단히는 비좌변값 배열 사용이 전체 수식(full expression)을 이루는 경우나
g().a; /* 문제 없음 */
sizeof
의 피연산자인 경우
sizeof(g().a); /* 문제 없음 */
를 제외한 경우를 모두 진단해주면 됩니다. 하지만, 비좌변값 배열 접근이 온전히 전체 수식을 이루지 않거나
g().a + 0; /* 오류 */
sizeof
의 직접적인 피연산자로 주어지지 않은 경우에는 적절한 진단이 필요합니다.
sizeof(g().a + 0); /* 오류 */
beluga
는 재귀 하향 파서(recursive descent paresr)를 사용하고 있습니다. 때문에 수식을 구성하는 최상위 비단말 기호(non-terminal symbol)부터 파서의 동작이 시작되므로 전체 수식이나 sizeof
연산자와 비좌변값 배열의 출현 사이에 개입하는 연산이 존재하는지 확인하면 오류가 되는 경우와 그렇지 않은 경우를 구분하여 오류가 되는 경우만 진단이 가능합니다. 혹은 구성된 트리의 자식 노드에서 부모 노드를 확인할 수 있는 포인터가 구성되어 있다면 보다 손쉽게 구현이 가능합니다.
하지만, 저는 비록 C90 표준이 문제가 되지 않는다고 해석되는 경우라 해도 프로그램 상에 비좌변값 배열이 나오고 있음을 프로그래머에게 전달하고 싶었습니다. 때문에 배열이 포인터로 변환되도록 처리해 주는 부분에 비좌변값 배열이 올 경우 진단해주도록 하였습니다.
if (TY_ISARRAY(p->type)) {
assert((p->op != OP_RIGHT) || !p->u.sym);
if (main_opt()->std == 1 && p->op == OP_RIGHT)
err_issuep(&p->pos, ERR_EXPR_NLVALARR, p);
/* ... */
}
주어진 트리(tree)의 데이터형이 배열인 경우(TY_ISARRAY(p->type)
), C90 표준 모드이면(main_opt()->std == 1
) 비좌변값인지 검사하여(p->op == OP_RIGHT
) 필요시 진단 메시지를 내주고 있습니다(err_issuep()
).
사실 비좌변값 배열을 사용하는 부분은 표준이 오류 메시지를 강요하지 않는 정의되지 않은 행동이기 때문에 이 정도만으로도 충분히 친절한 측에 속하지만, C90 표준이 허용해 주는 경우와 그렇지 않은 경우 모두 비좌변값 배열이 쓰였다는 경고가 출력되어 프로그래머에게 어느 부분이 잘못되었는지 전달하지 못하게 됩니다. 그래서 처음에는 여기에 더해 앞서 설명한 방법으로 문제가 되는 경우에 대한 오류 메시지를 추가하려 하였습니다. 그러나, 설명으로는 간단해 보이는 방법이지만 평소 만나기 어려운 오류 메시지 하나를 위해 수식을 파싱하는 많은 함수를 수정하고 또 다양한 수식에 대해 테스트하는 작업이 그리 달가운 작업은 아닙니다. 때문에 좀 더 간편하면서 충분히 효과적인 방법을 고민하기 시작했습니다.
beluga
의 조상인 lcc
는 수식의 데이터형을 다룰 때 문제가 될 상황을 최소화하여 코드의 복잡도를 낮추는 방법을 사용하고 있습니다. 피연산자로 잘못된 데이터형이 주어진 수식의 결과형을 보다 단순한 데이터형으로 강제 설정하여 이후 과정에서 문제가 될 가능성을 최소화한 것입니다. 이렇게 할 경우 프로그래머가 고려해야 하는 상황이 줄어들기 때문에 코드도 간결해지고 예상치 못한 문제가 발생할 가능성도 줄게 됩니다. 하지만, 해당 컴파일러를 사용하는 사용자 입장에서는 겉으로는 보이지 않는 데이터형이 어느 순간부터 튀어나오기 때문에 추가 오류가 발생할 경우 오류 메시지를 이해하는 데 어려움이 생기게 됩니다.
이런 이유로 beluga
에서는 매우 골치 아픈 과정을 거쳐 수식에 거의 모든 데이터형이 자유롭게 나와도 적절히 처리할 수 있도록 수정하였습니다. 때문에 이번 비좌변값 문제를 해결할 때는 그런 고생의 덕을 좀 보려고 오류가 나는 경우를 정확히 짚어 처리해 주는 방식이 아니라 간단히 비좌변값 배열을 포인터로 변환하지 않는 방식으로 접근하였습니다 - 사실, 이것이 표준의 해석에도 부합하는 방식입니다. 전체 수식은 수식의 결과를 void
형으로 변환하도록 정의되어 있는데 배열 역시 void
형으로 변환되는데 문제가 없으며, sizeof
는 피연산자로 배열을 취하게 되어 있어 문제가 되지 않습니다. 그 외의 경우에는 배열이 아닌 포인터를 기대하기 때문에 배열이 주어지면 자연스럽게 오류로 처리됩니다 - beluga
처럼 수정되지 않은 lcc
는 이 방법을 적용하면 여러 상황에서 배열을 다룰 준비가 되어 있지 않아 컴파일러가 종료되거나 이해하기 어려운 오류 메시지가 나오는 현상이 발생합니다.
if (TY_ISARRAY(p->type)) {
assert((p->op != OP_RIGHT) || !p->u.sym);
if (main_opt()->std == 1 && p->op == OP_RIGHT) {
err_issuep(&p->pos, ERR_EXPR_NLVALARR, p);
return p;
}
/* ... */
}
최종 코드를 보면 비좌변값 배열인 경우 특별한 변환 없이 주어진 트리를 그대로 반환(return p;
)하는 것을 확인할 수 있습니다. 그리고 그 결과는 아래와 같습니다.
$ cat > foo.c
struct tag {
int a[10];
} g();
int f(void) {
int i = sizeof(g().a) +
sizeof(g().a[0]);
g().a;
g().a + 1;
return g().a[0];
}
$ ./build/beluga -Wv --std=c90 foo.c
foo.c:3:3: warning - missing prototype
} g();
^
foo.c:6:23: warning - non-lvalue array does not decay to pointer in C90
int i = sizeof(g().a) +
^
foo.c:7:23: warning - non-lvalue array does not decay to pointer in C90
sizeof(g().a[0]);
^
foo.c:7:25: ERROR - pointer required but `array' given
sizeof(g().a[0]);
^
foo.c:8:8: warning - non-lvalue array does not decay to pointer in C90
g().a;
^
foo.c:9:8: warning - non-lvalue array does not decay to pointer in C90
g().a + 1;
^
foo.c:9:11: ERROR - operands of + have illegal types `array [10] of int' and `int'
g().a + 1;
^
foo.c:10:15: warning - non-lvalue array does not decay to pointer in C90
return g().a[0];
^
foo.c:10:17: ERROR - pointer required but `array' given
return g().a[0];
^
foo.c:10:17: ERROR - illegal return type; `array [10] of int' found but `int' expected
return g().a[0];
^
foo.c:6:9: warning - local `i' defined but not referenced
int i = sizeof(g().a) +
^
비좌변값 배열이 출현하는 모든 경우에 사실을 알리는 경고를 내주고 이를 포인터로 변환해주지 않아 이후 문제가 되는 경우([]
를 적용하거나 +
를 적용하는 등)는 올바르게 오류로 처리됨을 알 수 있습니다.
이렇게 일반 프로그래머에게는 거의 영향을 주지 않는 사소한 부분이지만 컴파일러를 구현하는 입장에서는 표준의 미묘한 해석 차이가 컴파일러 구현의 많은 부분을 수정해야 하는 어려움을 가져다 주는 경우가 잦습니다.
정작 기반 지식을 설명하는데 글의 대부분(3/4)을 할애하고 beluga
부분은 김이 빠질 정도로 간단히 끝났지만, 취미로 진행하는 프로젝트임에도 개인적으로는 이 부분을 수정하는데 상당한 고민과 시간을 할애해야 했습니다. 비좌변값 배열과 관련해서는 beluga
홈페이지를 통해 직접 테스트해 볼 수 있습니다... 라고 적었는데 생각해보니 홈페이지에서는 표준 모드가 비활성화되어 있어 조만간 웹에서도 옵션을 설정할 수 있도록 인터페이스를 추가할 계획입니다.
이제서야 비좌변값 배열 설명을 빙자한 beluga
광고(일기?) 글을 마무리 지었네요.