
malloc lab 최대 난관이었던 realloc함수를 구현하다가 사용하게 된 goto가 큰 도움이 되었다. 존재는 알고 있었지만 직접 써본 건 처음이었다. 그런데 한 줄 한 줄 쓸 때마다 장단점을 확 알겠던데...
코드를 짜면서 체감됐던 것 + 조사하면서 알게 된 것들을 적어 볼 예정.
goto문을 사용해야겠다고 결정하게 된 이유는 조건문에서 탈출하기 위함이었다. 여러 겹 중첩된 조건문 안에서 탈출해도 되는 조건을 만족하는 경우가 생기는데, 반복문이었다면 break와 같은 코드로 제어했겠지만 조건문은 그런 기능이 없었다.
merge_rightbasecasebasecasefit한 공간이 있는가?조건문 중첩으로 필요한 조건을 만족시키게 되면 실제 재할당 로직을 실행시키도록 하면 되는데, 너무 깊게 중첩된 상태에서는 빠져 나가기 어렵다.
가장 직관적인 방법은 탈출 플래그🚩를 설정해 매 번 검사하는 방식이 있을 것이다.
위 그림처럼 세 번 중첩된 조건문에서 탈출하려면 error플래그를 설정하고, 세 번의 조건문 각각에서 검사하고 탈출해야 한다.
혹은 조건을 만족했을 때의 조건문 블록 안에 실행 코드를 작성하면 되는데, 이 코드가 만약 다른 조건문에서 똑같은 로직이나 코드를 사용한다면? 중복된 코드를 조건을 만족할때 마다 작성해야 하는 큰 낭비가 생긴다.
realloc함수의 흐름 일부를 가져온 것.
왼쪽 그림에서는 merge_*코드를 두 군데에서 호출하고 있다.
merge_rightmerge_rightmerge_bothmerge_both이런 식으로 특정 코드를 실행해도 되는 조건에 도달하는 경로가 언제나 한 가지라고 할 순 없기 때문에 각 조건문 안에 똑같은 코드가 여러 번 들어가게 될 수도 있다.
goto문은 이런 문제를 해결해준다.
우측 그림은 공통 코드를 실행하게 했을 경우를 나타내는데, 같은 조건일 때 다른 코드(내용은 같지만)를 실행하던 왼쪽 경우와 달리 동일한 라인으로 점프해서 같은 코드를 읽게 하고 있다. merge_both는 30줄 정도로 구현되어 있으니 위와 같이 단편적인 예시로도 그 만큼 코드가 짧아지게 된다.
heap_extend레이블에선merge_both와merge_right을 조건에 따라 호출하게 된다.
각각의 코드도 길이를 가지고 있기 때문에 잘못했다간 코드가 너무 길어질 것 같은데...
쉽게 생각해보면
1. 조건 1, 2에서heap_extend를 호출한다.
2.heap_extend에서는merge_both를 호출한다.
3.merge_both를 독립적으로 호출하는 조건 3, 4가 존재한다.
4.merge_both를 수행하는 코드는 결국 총 4번 입력되어야 한다.
goto문을 사용하지 않았다면 각 조건문 안에 실행 코드를 작성했을 것이다.
static void *realloc(...)
{
...
else{
if (right_available)
/*
* 우측 병합을 위한 코드
*
*/
else if (left_available)
/*
* 좌측 병합을 위한 코드
*
*/
else if (both_available)
/*
* 양쪽 병합을 위한 코드
*
*/
else
/*
* 힙 확장 후 재할당을 위한 코드
*
*/
}
}
만약 각각의 코드들이 긴 길이를 가지고 있었다면 4개의 조건이 한 눈에 들어오지 않을 정도로 길어졌을 것이다.
만약 goto문으로 조건 영역과 실행 코드 영역을 분리한다면 다음과 같을 것이다.
static void *realloc(...)
{
...
else{
/* =============== if - block =============== */
if (right_available)
goto merge_right;
else if (left_available)
goto merge_left;
else if (both_available)
goto merge_both;
else
goto heap_extend;
}
/* ============== execute - block ============== */
merge_right:
/*
* CODE
*/
merge_left:
/*
* CODE
*/
merge_both:
/*
* CODE
*/
heap_extend:
/*
* CODE
*/
}
함축해서 적어 보았기 때문에 두 구조 사이에 큰 차이는 없어 보일 수 있지만, 실제로는 다르다.
실제 코드 구현에서는 한 레이블 (label: 처럼 생긴 것)에서 다른 레이블을 호출하기도 하니까 만약 조건문 블록 안에 코드를 다 담고자 했으면 전체 길이도 크게 길어지고 코드의 흐름을 파악하기도 어렵다.
조건 영역을 분리해 놓으니 조건문을 설계하고 수정할 때 편리했다. 우선 조건들이 한 화면 안에 담겨 있으니 구상했던 조건이 잘 담겼는지 확인하기 좋았다.
게다가 각 블록에서 실행하는 것이 통짜 코드가 아니라 의미를 담고 있는 레이블이어서 어떤 코드가 실행되려고 하는지 파악하는 것도 도움이 되었다.
레이블은 점프할 수 있는 위치를 나타내는 것 뿐이지 함수처럼 블록을 제한하지는 않는다.
한 레이블로 점프한 뒤에 다른 분기 제어가 없으면 그 아래 있는 코드도 전부 실행한다는 뜻이다.
goto release_b;
release_c:
release_c_resource();
release_b:
release_b_resource();
release_a:
release_a_resource();
out:
return result;
이 코드의 실행 흐름은
release_b → release_a → out = return result가 된다.
그 아래 작성 된 코드 또한 활용할 수 있다는 것이다.
이 기능은 다음 작성할 Cleanup에 유용하게 사용된다.
C언어에는 자원을 자동으로 관리해주는 기능이 없다. 반환되지 않은 자원에 대한 제약도 없다. 프로그래머에게 권한을 더 주었다고는 하지만, 반대로는 큰 제약처럼 느껴지기도 한다.
자원을 할당하고 사용하는 과정에서 오류가 발생해서 프로그램이 종료되어야 한다고 해 보자. 아무 조치 없이 프로그램을 끝낼 수도 있겠지만 그렇게 되면 잘 알고 있는 메모리 누수가 발생한다. 프로그래머는 그런 상황을 피하기 위해 할당한 자원을 회수하는 코드를 작성하게 된다.
예시만 간단하게 들어보고 넘어가자
// 코드 중복 예제
b = acquire_b();
/* ============= 개별 처리 ============= */
if (b == NULL)
{
release_a(a);
return -1;
}
c = acquire_c();
if (c == NULL)
{
release_b(b);
release_a(a);
return -1;
}
/* ============== 하나의 레이블로 통일 ============= */
if (b == NULL)
goto release_a;
if (c == NULL)
goto release_b;
release_c:
release_c_resource();
release_b:
release_b_resource();
release_a:
release_a_resource();
out:
return result;
만약 자원을 a → b → c 순서로 획득하려고 했을 때, c를 얻는 과정에서 오류가 발생하면 b와 a를 회수해야 한다.
c == NULL일 때 release_b로 점프해서 그 아래 있는 release_a코드까지 실행하도록 되어 있다. Fall-Through의 쉬운 예시다.
물론 너무 당연하게 단점 또한 명확하다. 세상에 완벽한 게 어디 있겠어...
함수는 매개변수와 반환형을 선언할 때 표기하니 어느 정도 약속을 하고 들어가는 느낌이다.
라벨은 (함수도 마찬가지지만)라벨 이름에밖에 표기할 방법이 없으니 잘못된 조건에서 호출할 수 있다.
realloc함수에서 merge_방향을 잘못 호출하면 double free문제가 발생할 수 있는 것처럼.
라벨 위에 선행 조건을 주석으로 남기는 습관이 필요하다.
함수 작성하면서 많이 겪었던 문제였다. (사람마다 코딩 스타일이 다르겠지만) 나는 지역변수를 쓰이는 조건문 내에 선언하려고 하고 가급적이면 사용하는 라인이나 함수 근처에 작성하려고 한다. 교수님이 맨 위에 선언하라고 하셨던 기억이 있긴 한데
물론 되도록이면 스코프 초반에 모여 있게 선언하려고 하지만... 아무튼 goto문으로 라인을 뛰어 넘다 보면 변수 초기화도 건너 뛸 때가 있었다.
코드를 유지보수할 때도 취약점이 드러나는데, 리팩토링에 약하다고 표현한다.
next_bp may be used uninitialized
컴파일 과정에서 생긴 경고인데 next_bp변수가 초기화되지 않았을지도 모른다는 뜻이다.
초기화뿐 아니라 레이블 블록에 진입했을 때 사용할 변수에 저장되어 있어야 하는 값이 미리 계산되지 않았을 경우 결과에 영향을 줄 수 있다.
함수의 경우 매개변수로 전달받는데 goto의 경우엔 없기 때문에 변수의 계산을 확실히 해 주거나 진입 이후에 추가로 계산하는 식으로 확실히 해야 한다.
위에선 Fall-Through가 장점이라고 하지 않았는가? 항상 그렇지만도 않다.
코드를 병렬적으로 배치할 수 없기 때문에 레이블의 순서가 항상 생길텐데, 만약 위의 레이블이 모든 기능을 잘 수행해 냈다면 아래 레이블을 실행하는 것은 구조 상 문제일 수 있다.
다음 레이블의 기능을 실행할 필요가 없다면 Close레이블로 점프하게 하여 안전하게 종료하거나, return으로 즉시 종료시키는 방법을 택해야 한다.
함수의 경우에는 각 기능이 분리되어 있기 때문에 신경쓰지 않아도 될 부분인데 말이지
내가 구현한 realloc은 그 길이가 짧고 작동 방식이 단순해서, 그리고 goto문의 개수 자체도 적어서 흐름을 파악하기 쉬운 편이다.
그런데 길고 복잡한 코드에서 goto문을 과도하게 사용한다면?
A → C, B → A, C → E, D → B, E → C
이런 식으로 상호 호출하는 코드를 작성한다면 실행 순서를 머리속으로 추적하기 어려워진다.
goto문을 과도하게 사용하게 되면 실행은 되긴 되는데 구조를 프로그래머 본인도 몰라서 유지보수하기 불가능해지는 지경, 흔히 부르는 스파게티 코드가 되는 원인이 될 수 있다.
goto문의 사용을 삼가야 한다
- 평범한 if/else, for, while로 더 명확하게 표현할 수 있을 때. 중첩되지 않은 조건 / 반복문은 빠져나오기 쉽다.
- 라벨과 점프가 함수 전체에서 서로 교차할 때. 스파게티 코드가 되지 않도록 조심하자.
- 목적지의 선행 조건이 복잡할 때.
- 점프하면서 변수 초기화나 자원 획득을 건너뛸 때. 컴파일러가 경고해주긴 하지만 프로그램 흐름에 문제가 된다.
- 라벨 코드가 독립된 함수로 자연스럽게 분리될 수 있을 때
goto문은 함수로 대체될 수 있는 것 아닌가? 하는 의문이 중간중간 생기긴 했다.
틀린 생각은 아니다. 레이블은 마치 함수처럼 독립적으로 작동하는 것 처럼 보이기는 한다. 가장 큰 차이점은 함수는 실행을 마치고 원래 위치로 돌아온다는 것이고 goto는 흐름 자체를 바꾼다는 것이겠다.
지금 realloc함수는 사용하는 변수의 수가 많아서 함수로 만든다면 매개변수 수가 너무 많아질 것 같기는 하다. 아마 구조체를 만들어 사용하면 해결될 것 같은데...
그렇다고 해도 지금 선언한 변수들이 원래 사용하던 변수들의 상호 계산 결과를 알아보기 쉽게 이름을 지어 준 것에 불과해서 또 다른 오버헤드가 발생할 것 같다.
글이 매번 goto 고트하네요