Review PL KWD (1) - 수식의 계산순서
프로그래밍 언어를 이해하는데 중요한 키워드들이 여러 개 있습니다. 키워드를 중심으로
관련된 주제들을 모아서 살펴보는 글을 작성해 보려고 합니다.
소프트웨어 개발과 프로그래밍 언어에 관련된 이야기들을 키워드를 중심으로 풀어보는 시리즈입니다.
첫번째 키워드는 계산순서(evaluation order)입니다. 이것은 프로그래밍 언어의 패러다임과 깊이 연관되는 상당히 어려운 주제인데, 명령형 프로그래밍 언어부터 시작해서 차례로 살펴보겠습니다. 그리고 다음 글에서 lazy evaluation이라고 하는 좀 다른 프로그램의 실행 순서의 개념을 살펴보겠습니다.
명령형 프로그래밍 언어는 명령문(문장)을 차례대로 수행해 가는 방식의 프로그램을 작성하게 됩니다. 순서는 제어구조에 의해 결정될 것입니다. 문장은 수식을 포함하게 되는데, 명령형 프로그래밍 언어에서 수식의 계산 순서는 수식과 연산의 의미적 순서와 컴파일러에 의한 스케쥴링에 영향을 받습니다. 연산의 의미적 순서는 우선순위와 결합규칙, 그리고 언어에서 정한 순서를 말합니다. 먼저 수식의 우선순위와 결합규칙은 수학적인 개념을 그대로 프로그래밍 언어에 옮긴 것으로 곱하기(*)는 더하기(+)보다 우선순위가 높고 둘다 좌결합 규칙을 가집니다. 그런데 이러한 우선순위는 연산자 간의 순서만 정합니다. 사실 좌변과 우변 중에 어느 쪽을 먼저 계산할지는 우선순위가 정하는 것이 아닙니다. 이것을 보여주는 예가 다음과 같은 수식입니다.
a = f(b * 2 > c, d + e);
⓵ b로드 ⓶ *2 ⓷ c로드 ⓸ > 계산 ⓹ d로드 ⓺ e로드 ⓻ +계산 ⓼ f호출 ⓽ a에 저장
그럼 그 순서는 어떻게 정해질까요? 가장 자연스러운 방법은 왼쪽에서 오른쪽으로 사람이 계산할 때 하듯이 ⓵부터 ⓽까지 차례로 하는 방법일 것입니다. 그런데 사실 컴파일러는 그 방법을 좋아하지 않습니다. 이것은 언어마다 약간씩 차이를 보이는데, 컴파일러는 명령문 스케쥴링이라는 코드 개선 작업을 통해 사실 명령문의 순서를 예측할 수 없게 바꿔버릴 수 있습니다. 위의 수식 예는 우선순위에 의해 ⓵번 다음에 ⓶번, 그리고 ⓶ 다음에 ⓸번, ⓸보다 ⓷ 먼저, ⓻보다 ⓹, ⓺이 먼저 수행되어야 하는 순서를 지켜야 하지만 그 이외의 것은 사실 어떤 순서가 되도 상관이 없습니다. 그러므로 다음과 같은 순서가 모두 가능하다는 것입니다.
1) ⓵ ⓷ ⓹ ⓺ ⓶ ⓸ ⓻ ⓼ ⓽
2) ⓷ ⓵ ⓶ ⓹ ⓺ ⓻ ⓸ ⓼ ⓽
3) ⓹ ⓺ ⓻ ⓵ ⓶ ⓷ ⓸ ⓼ ⓽
4) ⓷ ⓹ ⓺ ⓵ ⓻ ⓶ ⓸ ⓼ ⓽
컴파일러는 이러한 여러 가지 순서 중에서 가장 유리하다고 판단되는 것을 선택할 것입니다. 어떤 순서가 좋을지 결정하는 것은 굉장히 지능적이고 복잡한 알고리듬이 적용되어서 (많은 경우 최적을 구할 수 없으므로) 최선의 것으로 선택하게 됩니다. 여기서 기준은 로드 지연(메모리 IO)을 줄이는 것으로 로드 회수를 줄이고, 불가피한 로드에 대해서는 CPU와 파이프라인이 공회전해야 하는 동안에 다른 일을 하게 해서 총 필요 시간을 줄이는 스케쥴링을 찾는 것입니다.
이러한 방식으로 수식의 계산 순서는 컴파일러에 의해 어떻게 변할지 모르는 일입니다. (자바 언어는 이것을 제약해서 왼쪽에서 오른쪽으로 계산하라고 정하고 있습니다. 성능보다는 안전성을 선호하는 자바 언어의 특징이 여기서도 나오는 거겠죠?) 그럼 그것이 프로그램의 수행에 어떤 영향을 미칠까요?
명령형 프로그램의 특징은 변수에 값을 저장하고 다음 계산을 반복하면서 변수(메모리)에 결과값이 계산되면 프로그램이 할 일이 완료됩니다. 이것은 명령형 언어에서 계산 자체 못지 않게 그것을 메모리에 저장하는 효과(부수효과)가 중요함을 나타냅니다. 그런데 부수효과가 있는 수식은 계산순서에 따라 결과값이 영향을 받게 됩니다.
다음 C 코드 예제를 살펴봅시다. 프로그램에서 순서가 바뀔 수 있는 대표적인 예가 매개변수 자리에 오는 수식들입니다. 함수를 호출할 때 매개변수 수식이 먼저 계산되어야 함수가 호출될 수 있는데 (매개변수의 값을 전달해야 하므로) 이 때도 매개변수 부분의 수식이 어느 것이 먼저 수행될지 알 수 없습니다. 아래 코드는 이러한 계산 순서의 변경을 보여줍니다.
#include <iostream>
using namespace std;
void print(int* ap, int* bp, int* cp) {
cout << *ap << ' ' << *bp << ' ' << *cp << endl;
}
int main() {
int arr[] = {10, 20, 30, 40, 50, 60};
int* ptr = arr;
print(ptr++, ptr+2, ptr++);
system("PAUSE");
return 0;
}
이 코드를 실행하면 결과는 예상과 다르게 20 50 10으로 출력됩니다(이것은 환경이나 실행조건에 따라 달라질 수 있습니다. 비쥬얼 스튜디오 결과임). 컴파일러는 매개변수 수식 중에서 세 번째 것을 먼저 하고 그 다음 첫번째 것을 하고 그리고 두 번째 것을 마지막에 계산했습니다. 이 경우도 우리는 그 순서를 예상할 수 없으므로 가장 좋은 방법은 부수효과가 발생하는 식은 차례로 값을 계산한 후 그것을 매개변수로 주는 것입니다.
이처럼 부수효과가 있는 수식은 계산 순서에 영향을 받습니다. 부수효과는 명령형 언어에서는 핵심적인 동작이므로 그것을 없앨 수는 없고 원하는 순서가 보장되게 하고 싶으면 문장을 독립시켜서(변수에 저장) 코드를 작성해야 합니다. 문장의 순서는 컴파일러가 바꿀 수 없습니다.
여기까지는 명령형 프로그래밍 언어(imparative programming language)의 순차적 계산(과 부수효과) 방식의 계산 순서를 살펴보았는데, 다음으로 계산순서에서 중요한 이슈가 지연계산(lazy evaluation)입니다. 이것은 지금 본 것만큼 중요하고 더 어려운 주제이므로 다른 글에서 다루려고 합니다. 지연계산이란 필요할 때까지 계산을 미루었다가 계산하는 방식입니다.