포인터와 참조가 가변 상태를 표현하는 방식, 암시적 역참조, 값/상태의 구분을 통해 lvalue/rvalue, 복사 의미론, 참조 비교 등의 개념을 재정리하며 Algol-68의 설계를 되짚는다.
14분 읽기
2015년 4월 15일
새로운 계열의 프로그래밍 언어에서의 참조와 포인터는 1968년 기술에도 여전히 못 미치는가?
Enter를 누르거나 클릭하여 이미지를 전체 크기로 보기

지난 10년 동안 조용히 무르익어 이제는 주류로 올라선 새로운 흐름이 있다. 메모리 할당과 포인터가 효율적이고, 안전하며, 똑똑한 프로그래밍 언어들이다. 이들은 정적 타입을 갖고 컴파일 가능한 명령형 언어로서, 포인터의 힘과 즐거움을 프로그래머에게 다시 돌려주고자 한다. 이들의 컴파일러는 가능한 한 컴파일 타임에 포인터 사용을 추적하여 불필요한 가비지 컬렉션을 없애고, 동적 메모리의 할당/해제를 효율적이고 결정적으로 만들도록 돕는 것을 목표로 한다.
여기서 가장 생생한 예는 Rust이며, Go와 최근의 C++ 발전 또한 이러한 흐름을 보여준다.
이런 발전이 마침내, 변수와 lvalue, 포인터와 가변성에 대한 개념을 다시 점검하고 정리할 기회를 제공한다.
“이 아이디어에 대해 상당한 회의가 나올 것을 예상하므로, 역사적 관점에서 설명해 보겠다.”
— Chris Messina, Hashtag Data Positive
Enter를 누르거나 클릭하여 이미지를 전체 크기로 보기

Computer History Museum 제공 이미지. © Digital Equipment Corporation (DEC)
예를 들어, _x_를 정수 변수라고 하자. 그러면 토큰 ‘x’와 ‘5’는 같은 타입을 가진다.
타입은 값들의 집합을 정의하고 그 값들에 대해 무엇을 할 수 있는지를 규정한다. 하지만 _x_에는 할 수 있고 5에는 할 수 없는 것이 하나 있는데, 바로 대입(assignment)이다. 1960년대의 타입 시스템은 대입 가능한 표현식과 대입 불가능한 표현식, 혹은 달리 말해 가변 상태(mutable state)와 불변 값(immutable values)의 차이를 표현할 만큼 풍부하지 않았다.
이 때문에 Christopher Strachey가 논문 ‘Fundamental Concepts in Programming Languages’에서 설명한 L-값과 R-값의 도입으로 이어졌다. 이 논문은 1967년 당시 기술 수준을 훌륭하게 정리한 조사 보고서다. L-값은 위치(location), 즉 “대입문의 왼쪽에 올 수 있는 주소 같은 객체”를 나타낸다. R-값은 “오른쪽에 올 수 있는 내용(contents) 같은 객체”다. 위치(L-값)는 내용(R-값)을 가지며, 일반적으로 그 내용은 바뀔 수 있다. _x_는 L-값과 R-값을 모두 가지지만, 5는 R-값만 가진다.
배열 인덱싱(예: a[1]) 같은 더 복잡한 표현식도 L-값을 가질 수 있다. 이 예에서는 a 자체가 L-값을 갖는 한 그렇다.
1년 뒤, Algol-68은 L-값 개념을 타입 시스템으로 밀어 넣어 이를 _참조(references)_라고 불렀다. 어떤 타입 _T_에 대해서도 C의 포인터 타입처럼 ref_T_라는 타입이 있다.
이는 (예를 들어 _x_와 5처럼 같은 타입의 표현식에 서로 다른 동작이 적용되는) 타입 시스템의 비일관성을 제거했고, 다음의 두 가지 단순한 원칙을 통해 L-값과 R-값 자체의 필요성도 없앴다.
이를 더 풀어보자.
(또는 포인터다. 두 단어를 서로 바꿔 써도 된다.)
_x_가 타입 int의 변수라고 말할 때, 이는 _x_가 _int_에 대한 참조 타입의 값에 바인딩되어 있음을 뜻한다. 이 바인딩은 최종적이며, _x_가 가리키는 값의 저장 공간은 어딘가에 할당된다: 스택, 힙, .bss, 레지스터, 또는 컴파일러가 결정하는 어느 곳이든.
Algol-68의 엄격한 문법으로는, 각 선언이 식별자를 선언된 타입의 표현식에 바인딩하므로 다음과 같이 쓴다:
int five = 5; # five는 상수 #
ref int var = loc int; # var는 사실상 int 변수,
또는 ref int 상수 # (Algol-68에서 키워드는 식별자와 다른 네임스페이스에 속하는 기호이며, 문헌에서는 종종 굵게 인쇄된다. 실제 프로그램 텍스트에 입력하는 관례는 여러 가지가 있다.)
여기서 ‘로컬 생성기(local generator)’라 불리는 loc int는 타입 int 값에 대한 로컬 저장소를 할당하며, 그 수명은 현재 블록으로 제한된다. 따라서 _five_는 값 5의 이름(즉 상수)이고, _var_는 사실상 변수다. 수명이 무제한인 저장소를 할당하려면 ‘힙 생성기(heap generator)’를 사용할 수 있다.
이 방식의 변수 선언은 다소 장황하고 관습적이지 않지만, 약간의 문법 설탕을 더하면 쉽게 개선된다:
int var; # ref int var = loc int의 축약형 # ‘=’가 없다는 점에 주목하라. 이것이 엄격 문법과 구분해 준다.
이는 초기화를 포함한 변수 선언에도 확장된다:
int initialised var := 239; 이는 다음의 축약형이다.
ref int initialised var = loc int;
initialised var := 239; (Algol-68은 대입에 ‘:=’를 사용하고, 선언에서의 값 바인딩과 동등 비교에는 모두 ‘=’를 사용한다. 또한 식별자에 공백을 허용한다.)
이제 _var_는 타입 ref int인데, 값을 얻으려면 역참조가 필요하지 않을까?
var := int(var) + 88 + five; # var를 93 증가 #
그렇다면 매우 불편할 것이다. 그래서 단순하고 우아한 해법이 고안되었다: 역참조를 암시적 타입 변환으로 만드는 것이다.
(loc int를 사용하더라도 _var_가 대입과 역참조로만 쓰인다면 실제 메모리 할당은 필요 없을 수 있다. 컴파일러는 _var_를 레지스터에 둘 수 있다.)
우리는 정수 승격이나 포인터 업캐스트(OOP 언어에서 파생→기반 클래스) 같은 암시적 타입 변환(Algol-68에서는 적절하게도 _coercions_라 부른다)에 익숙하다. 역참조도 그런 변환 중 하나가 될 수 있다.
C와 C++에서는 숫자 연산과 같은 연산자들로 포인터 산술을 수행하기 때문에 잘 맞지 않는다. 암시적 역참조는 모호성을 낳을 것이다. 하지만 대부분의 다른 언어에서는 자연스럽게 느껴질 것이다.
예를 들어 Go의 메서드, 선택자(selectors), 인덱스 표현식(index expressions)을 보자. Go 명세에는 타입 *_T_를 T 대신, 값 *_x_를 x 대신 사용할 수 있게 하는 특별 규칙과 예외가 곳곳에 박혀 있다. 역참조를 암시적 변환으로 만들었다면 명세는 훨씬 더 단순하고 우아했을 것이다.
사실 명령형 언어로 프로그래밍할 때 우리는 포인터가 _lvalue_로 위장한 형태를 늘 사용한다. 명시적 연산자 없이도 _lvalue_를 _rvalue_로 역참조하는 것이 묵인된다. lvalue 개념은 문법적으로는 기묘한데, 의미론적으로는 포인터와 구분되지 않기 때문이다.
위 개념들이 타입 추론에 미묘하게 직관에 반하는 효과를 준다. 제약이 없으면 변수는 참조 타입으로 추론된다. Algol-68에는 타입 추론이 없지만, let 키워드를 추가해 암시적 타입의 식별자를 선언할 수 있게 했다고 가정해 보자:
int a := 239; # a의 타입은 ref int #
let b := a; # b의 타입은 ref ref int #
let c = a; # c의 타입은 ref int # 또는, let에 실제 우변 타입을 대입하고 ‘:=’의 문법 설탕을 펼치면:
ref int a = loc int;
a := 239;
ref ref int b = loc ref int;
b := a;
ref int c = a; 여기서 _b_는 _a_의 주소로 초기화된 변수이고, _c_는 값 239의 별칭이 아니라 _a_의 별칭이다. 이는 아마 기대와 다르겠지만, 그럼에도 논리적이다. * 같은 명시적 역참조 연산자를 사용하는 것이 단순하고 실용적인 해법이 될 수 있다.
명령형 프로그램은 _값(values)_과 _상태(state)_를 다룬다. 상태는 가변이고, 값은 그렇지 않다.
포인터의 중요한 기능 중 하나는 프로그램 상태의 어느 부분이 읽히거나 쓰일지를 식별하는 것이다. 어떤 것이 _가변(mutable)_이라고 말하는 것은, 단지 그것에 대한 포인터가 उपलब्ध하다는 뜻이다.
Rust 프로그램의 다음 조각을 보자:
let program = "+ + * - /";
let mut accumulator = 0;
여기서는 불변인 _program_과 가변인 _accumulator_를 선언하며, 타입은 각각 str, i32로 추론된다. (Rust에는 int 타입이 없다.) 불변을 기본으로 하고 가변성은 명시적으로 선언하게 하는 진짜로 영리한 아이디어 뒤에는, 어떤 문맥에서는 값처럼 행동하고 다른 문맥에서는 상태 참조(“lvalue”)처럼 행동하는 이름으로서의 변수라는 오래된 개념이 그대로 있다. 언어 명세 자체가 포인터/참조 타입이 존재하고 사실상 같은 일을 함에도 불구하고 식별자에 ‘mutable’ 플래그를 불필요하게 태깅하도록 규정하고 있다.
Rust는 주소를 취하는 연산자도 ‘&’와 ‘&mut’ 두 가지로 구분하며, 각각 불변 참조와 가변 참조를 얻는다. 이는 서로 다른 빌림(borrowing) 의미론 때문에 중요하다. 하지만 여기서 제안하는 것처럼 lvalue가 없는 언어에서는 주소 취하기 연산자가 없다. 주소를 취할 수 있는 것은 이미 참조이기 때문이다. 그리고 이 참조는 가변이다. 그렇다면 불변 참조는 어떻게 얻을까?
더 나은 질문은, 불변 참조라고 말할 때 실제로는 대부분 ‘값’을 의미하는 것이 아닌가? 최적화는 컴파일러의 관심사로 치워두고, 우리가 무엇을 말하고 싶은지에 집중해 보자. 어렵다. 우리는 효율을 위해 복합 값을 참조/포인터로 전달하는 데 너무 익숙해져서, 크기에 따라 값을 전달하는 최선의 기법을 컴파일러가 결정한다는 발상 자체가 낯설게 느껴진다. 의미론적으로 불변 참조를 사용하는 것과 _그 참조가 가리키는 값 자체_를 사용하는 것의 유일한 차이는, 저장된 값이 다른 가변 참조를 통해 교체될 때 무슨 일이 벌어지는가이다.
이 글쓴이의 업데이트를 받으려면 Medium에 무료로 가입하세요.
더 빠른 로그인 위해 기억하기
Rust처럼 컴파일 타임에 참조의 수명과 소유권을 추적하는 언어에서는, 여기서 논의한 개념을 통해 많은 복잡성을 제거할 잠재력이 크다. 그러나 이 주제를 철저히 다루는 것은 이 글의 범위를 벗어난다.
물론 가변 상태의 표현만이 포인터를 갖는 이유는 아니다. 연결 구조(linked structures)도 있다. 이 두 역할은 아마 공통점이 없을지도 모른다. 예를 들어 불변 연결 리스트에서는 대입을 금지하는 포인터를 갖는 것도 전혀 문제 없다.
이중성은 자연에서도 잘 알려져 있다. 예컨대 중력 질량과 관성 질량, 또는 파동과 입자. 포인터가 두 역할을 똑같이 잘 수행한다면, 이를 활용하지 않을 이유가 없다.
포인터는 함수/프로시저에 큰 복합 값을 인자로 전달할 때 복사를 피하기 위해 실무에서 자주 쓰인다. 이 역사적 기묘함은, 개념적으로 포인터가 무엇을 위한 것인가와는 거의 관계가 없다.
C가 함수에 인자를 전달할 때는 인자를 _복사_한다. 아마 그 무렵 만들어진 많은 언어들도 값에 의한 전달(pass-by-value) 의미론을 사용하며 마찬가지일 것이다. 당시에는 전적으로 논리적이었다. 호출자와 피호출자는 서로 다른 컨텍스트를 가지므로, 피호출자에게 전달되는 모든 값은 그 컨텍스트로 복사되어야 했다. _어차피 컴파일러가 복사를 해야 했던 것_이므로, 언어는 자연스럽게 프로그래머가 이를 활용할 수 있게 했다. 형식 매개변수는 호출자가 초기화하는 지역 변수로 간주되었다.
이 사실은 명령형 언어에서 매개변수 전달을 바라보는 우리의 사고방식에 깊은 영향을 끼쳤다. C 이후에 나온 많은 주요 언어들이 우리가 너무 익숙해진 탓에 이 호출 시 복사(copy-on-call) 의미론을 여전히 따른다.
하지만 무고한 작은 최적화로 시작한 것이 데이터 타입이 더 추상화되고, 프로그래머가 _어떻게_가 아니라 _무엇_으로 사고하도록, 그리고 의미하는 바를 말하도록 장려되면서 곧 격렬하게 역효과를 냈다. 호출 시 복사 의미론을 유지하는 비용이 커졌다—성능, 프로그램 복잡도, 언어 복잡도 측면에서. C++에서는 호출된 함수가 인자를 수정할 의도가 전혀 없고 복사본이 필요하지 않더라도 복사 생성자가 호출되곤 했다. 이를 해결하기 위해 C++는 _copy elision_과 move semantics(rvalue 참조와 이동 생성자 포함)를 도입했고, 그 결과 컴파일러와 표준 라이브러리를 크게 재작업하게 되었으며 언어에 상당한 복잡성이 유입되었다.
이 글의 첫 번째 요점을 상기하자: 변수는 참조다. 사실상 이는 C와 많은 다른 언어에서 형식 매개변수와 실제 인자의 타입이 서로 다르다는 뜻이다. 다음에서
double sin(double x);
실제 인자의 타입은 double이지만, 형식 매개변수 _x_는 사실 _double_에 대한 참조다.
Algol-68에서 취한 접근은 이 혼선을 피한다. 값은 프로시저에 값으로 전달되고, 참조는 참조로 전달된다:
proc max = (real a, b) real: if a > b then a else b fi;
procincrement = (ref int n): n +:= 1;
_max_에서 매개변수 a, _b_는 타입 real의 값에 바인딩되며 대입할 수 없다. 반면 _increment_는 예전에 ‘참조에 의한 전달’이라 불리던 것을 보여준다(참조가 진짜 데이터 타입이 되기 전). _n_은 대입 가능하며 호출자 컨텍스트의 변수에 바인딩되어 증가된다. _increment_는 실제 인자로 5를 받아 호출할 수 없다.
프로시저가 인자의 수정 가능한 복사본을 원한다면, 명시적으로 만들어야 한다. 의미하는 바를 말하라. 그리고 진입 시점에 이미 복사가 만들어진 상태라면, 컴파일러가 추가 복사를 최적화로 제거하도록 하라.
매개변수가 큰 복합 값이더라도, 프로그래머가 호출을 최적화하기 위해 ‘불변 참조’를 넘기도록 강요받아서는 안 된다. 이는 컴파일러(그리고 ABI 명세)가 처리할 수 있다.
값은 종종 배열과 구조체로 구성된 복합 값이다. 배열 인덱싱과 구조체 멤버 접근 연산자는 복합 값에서 개별 요소/멤버의 값을 추출하는 데 쓰인다. 하지만 기존 값의 선택된 요소를 교체하여 새로운 복합 값을 생성하고 싶기도 하다.
한 가지 방법은 복합 값, 인덱스 또는 멤버 이름, 그리고 교체할 요소 값을 입력으로 받아 수정된 복합 값을 반환하는 연산자를 도입하는 것이다. 정수 배열을 예로 들면, 이 연산자는 다음 replace 프로시저와 동등하다:
proc replace = ([]int a, int index, int value) []int: ...
이는 _a_와 같은 배열을 반환하되, _index-_번째 요소가 _value_로 설정된 것이다. (이 연산자의 구체 문법은 더 예쁘게 만들 수 있지만, 명확성을 위해 당분간 이 프로시저로 두자. 또한 구조체에는 보통 멤버 이름을 프로시저에 전달할 방법이 없으므로, 프로시저 호출 문법을 사용할 수 없다는 점에 유의하라.)
대안으로, 대부분(아마 전부)의 명령형 언어가 쓰는 요령이 있다. 복합 값이 변수에 저장되어 있다면, 그 변수의 일부를 대입을 통해 수정할 수 있게 하는 것이다. 예를 들어 _a_가 intarray 변수라면,
a := replace(a, 5, 239);
대신 다음처럼 대입할 수 있다:
a[5] := 239;
현대 컴파일러는 _replace_가 외부 라이브러리 함수가 아니라 내장 연산자라면 두 경우에 동일한 코드를 생성할 것이다. 하지만 후자의 관용구는 전통적이고 널리 퍼져 있으며, 우리가 생각하는 방식과도 맞는다. 이를 구현하는 대부분의 언어는 [] 연산자가 두 종류 필요하다는 점에 주목하라. 하나는 배열 값에서 요소 값을 만들고, 다른 하나는 배열 참조에서 요소 참조를 만든다. (C는 배열 값과 참조를 구분하지 않으므로 [] 하나로 빠져나간다.)
이를 염두에 두면, 두 경우 모두에서 암시적 역참조가 잘 작동함을 쉽게 알 수 있다. ‘replace’ 예에서는 첫 번째 인자가 _a_의 암시적으로 역참조된 값을 받으며, 그 다음 _a_에 반환값이 대입된다. 전통적 관용구도, 문맥에서 적용 가능한 타입이 되는 순간 암시적 역참조가 멈춘다면 잘 동작한다. 위 예에서 _a_의 타입은 ref []int인데, 이는 두 [] 연산자 중 하나에 이미 적합한 타입이므로 _a_를 역참조할 필요가 없다. 이 [] 연산자는 ref int를 반환하는데, 이는 _a_의 5번째 요소 저장 위치에 대한 참조이며, 여기에 새 값이 대입된다.
가능한 한 타입 변환을 최소로 사용하는 규칙은 꽤 흔하며 다른 많은 상황에서도 잘 동작한다.
변수가 문법적으로 참조와 구분되지 않으면 비교 연산자에서 약간의 어려움이 생긴다:
int a := 5;
int b := 5;
if a = b then ...
(Algol-68 표기를 사용하며, 비교는 등호 하나로 나타낸다.)
= 연산자를 어떤 타입에도 적용할 수 있다면 위 비교는 _a_와 _b_의 참조를 비교하게 되어 false가 된다. 이는 매우 직관에 반한다. 하지만 암시적 역참조 때문에 a = 5는 true가 될 것이다.
Algol-68의 해법은 =와 ≠를 비참조 타입에만 지정하고, 참조를 비교하기 위한 별도 연산자 :=:와 :≠:를 도입하는 것이었다. 여전히 미묘함이 남는데, 다음 C 코드를 보자:
int a = 5;
int *x = &a;
int *y = &a;
x == y; // true
그리고 겉보기엔 유사한 Algol-68 조각은:
int a := 5;
ref int x := a; # x는 a의 주소를 담는 변수 #
ref int y := a; # y는 x와 같은 값을 담는 변수 #
x :=: y; # ref ref int들을 비교하므로 false #
하지만 이런 경우에는 어느 곳에 어떤 참조가 있는지 추적하기 쉽고, 해당되는 곳에 명시적 역참조를 적용하면 된다(위 예에서는 _x_나 y 중 하나에).
여기서 설명한 개념들은 Algol-68 설계에 직교성(orthogonality) 원칙을 적용한 데서 비롯되었다:
“언어가 설명하기 쉽고, 배우기 쉽고, 구현하기 쉽도록 독립적인 원시 개념의 수를 최소화했다. 한편으로는 표현력을 최대화하면서 해로운 중복을 피하려고, 이러한 개념들을 ‘직교적으로’ 적용했다.”
[ALGOL 68 Revised Report, section 0.1.2 Orthogonal design]
그 당시의 프로그래밍 언어에는 작은 개별 틈새를 차지하는 독립적인 특정 구성요소가 많이 있었는데, 다음과 같이 더 추상적인 개념이 균일하게 적용되며 대체되었다:
프로시저는 값이 되었고, 직교성 원칙을 따르면 다른 모든 값처럼 사용할 수 있었다: 대입할 수 있고, 프로시저 호출에 넘기고, 프로시저로부터 반환할 수 있으며, 프로그램 어디서나 리터럴로 입력할 수 있다. 이 개념은 훗날 ‘일급 함수(first-class functions)’, ‘익명(람다) 함수’, ‘클로저’라고 불리게 될 것들을 포괄했다.
프로그래밍 언어 설계는 때때로 우아하지 못하고 과도하게 복잡해지기 쉽다. 예를 들어 명령형 언어에서, _가변 상태_를 표현하는 포인터/참조의 역할과(역참조를 포함한) 암시적 타입 변환은 두 가지 단순하면서도 우아하고 쉽게 적용 가능한 개념인데, 종종 간과된다. 그러나 다음과 같은 방식으로 언어를 훨씬 더 깔끔하게 만들 수 있다:
이 아이디어들이 그렇게 좋다면 왜 더 자주 듣지 못할까? 아마도 C의 역할과 그것이 프로그래밍 언어에 끼친 영향 때문일 것이다.
역사적으로 1960년대 후반에 고안된 몇몇 선구적 개념은, 단순하고 효율적이며 직관적인 구현이 필요했던 C 같은 시스템 프로그래밍 언어에는 너무 무겁다고 여겨졌다. C는 조건 연산자 ?:와 void 타입 같은 유용한 구성요소를 Algol-68에서 일부 가져왔지만, 그 밑바탕의 개념은 가져오지 않았다. 마찬가지로 C++는 처음에 C 위의 얇은 층으로 겸손하게 시작해 시간이 흐르며 유기적으로 성장했다. 이제 C와 C++는 해당 영역에서 매우 잘 알려지고 영향력 있는 언어로 남아 있으며, 그 기원을 압도해 버려 언어 설계자들의 시야에서 그 뿌리를 가리고 있다.
더 나은 C를 찾는 여정에 서 있는 지금, 그때 빠졌던 것들을 다시 돌아볼 좋은 시기일지도 모른다.
Cyril Schmidt와 /r/rust의 레딧 이용자들이 이 글의 초안을 읽어 준 것에 감사한다.