2.5 문법 설탕

홍재민

2020년 9월 22일 20시 37분 (최근 수정: 2020년 10월 17일 16시 56분)


아래 내용은 인사이트 출판사의 제안으로 작성 중인 책의 초고입니다. 실제 출판 시에는 내용이 달라질 수 있습니다. 많은 의견 부탁드립니다.

들면 \(1 + 1\), \(2\), \(0 + 2\), \(3 - (1 - 0)\) 등은 모두 실행 결과로 \(2\)가 나오는 프로그램이다. 프로그램을 실행하는 입장에서는 프로그램은 실행 결과로만 말하는 존재이다. 그 코드가 어떤지는 중요하지 않다. (프로그램의 실행 시간은 고려하지 않는다고 생각하자.) \(1 + 1\)이나 \(3 - (1 - 0)\)이라는 프로그램 대신 \(2\)라는 프로그램을 사용해도 아무런 차이가 없다는 뜻이다.

같은 의미의 프로그램이 여러 개 있다는 점을 이용하면 언어의 의미를 바꾸지 않고도 언어에 기능을 추가할 수 있다. 언어에 이미 존재하는 기능만을 사용해 새로운 기능을 표현하는 것이다. 새로운 기능의 의미를 정의하는 것은 꽤 어려운 작업이다. 그 기능이 복잡할수록 더 어렵다. 따라서 의미를 정의하지 않고도 기능을 추가할 수 있다면 언어를 설계하는 입장에서는 매우 좋은 일이다. 물론 기능이 추가되었으니 언어를 사용하는 입장에서도 좋을 것이다.

예시를 위해, 산술에 식의 부호를 바꾸는 단항 연산자 -를 추가하고 싶다고 생각해 보자. 먼저, 언어의 의미에 직접 새 기능을 추가하는 방법부터 보겠다. 우선 다음과 같이 요약 문법을 바꾸어야 한다.

\(e\ ::=\ n\ |\ e + e\ |\ e - e\ |\ -e\)

가장 마지막의 \(-e\)가 추가된 기능에 해당한다. 이 기능의 의미는 다음과 같이 정의할 수 있다.

규칙 4:

\(\ \ \ \ \)\(e\)의 실행 결과가 \(n\)이면,

\(\ \ \ \ \)\(-e\)의 실행 결과가 \(-^\mathbb{Z} n\)이다.

앞에서와 마찬가지로 \(-e\)의 \(-\)는 요약 문법 표기의 일부로, \(e\)라는 나무로부터 \(-e\)라는 새 나무를 만든다. \(-^\mathbb{Z}\)는 수학에서 사용되는 부호 뒤집기 연산을 의미한다. 추가된 요약 문법과 의미를 통해 \(-(2 + 3)\)의 실행 결과가 \(-5\)라는 사실을 알아낼 수 있다.

요약 문법만 바꾼다고 끝은 아니다. 프로그래머가 코드에서 -를 사용할 수 있도록 구체적 문법과 파싱도 수정해야 한다. 해당 내용은 생략하도록 하겠다. 수정을 통해서 “-(2+3)”이 프로그램인 문자열이며 파싱 함수가 다음과 같이 작동하도록 했다고 가정하자.

그러면 이제 프로그래머가 “-(2+3)”이라는 프로그램을 작성했을 때 그 실행 결과가 \(-5\)라는 사실을 알 수 있다.

이번에는 언어의 의미를 바꾸지 않으면서도 단항 연산자 -를 언어에 추가하는 방법을 알아보겠다. 사실 우리는 이미 수학에서 모든 정수 \(n\)에 대해 \(-^\mathbb{Z}n = 0 -^\mathbb{Z} n\)이 성립한다는 사실을 알고 있다. 만약 \(e\)의 실행 결과가 \(n\)이라면 \(0 - e\)의 실행 결과가 \(-^\mathbb{Z} n\)이다. 그러므로 어떤 프로그램의 실행 결과의 부호를 바꾸고 싶다면 \(0\)에서 그 프로그램을 빼는, 새로운 프로그램을 만들면 된다. 이 발상을 통해서 요약 문법과 의미를 그대로 두면서도 단항 연산자 -를 추가할 수 있다. 먼저 구체적 문법을 수정하여 “-(2+3)”이 프로그램인 문자열이 되었다고 가정하자. 이제 다음과 같이 작동하게끔 파싱 함수를 수정할 수 있다.

산술의 의미로부터 \(0 - (2 + 3)\)을 실행한 결과가 \(-5\)임을 알아낼 수 있다. 그러므로 “-(2+3)”의 실행 결과는 \(-5\)이다.

프로그래머와 프로그램 사용자 입장에서는 요약 문법과 의미가 바뀌었는지는 크게 중요하지 않다. “-(2+3)”을 실행한 결과가 \(-5\)라는 사실만이 중요하다. 요약 문법과 의미가 단항 연산자 -를 고려하든 안 하든 간에 코드를 짤 때 그 연산을 사용할 수만 있다면 그것으로 충분한 것이다.

그러나 언어를 설계하는 입장에서는 구체적 문법과 파싱만 바꾸면 되는 것과, 구체적 문법, 파싱, 요약 문법, 의미를 모두 바꾸어야 하는 것은 매우 다르다. 특히 의미를 정의하는 것은 다른 요소들과 비교했을 때 가장 어렵고 복잡한 작업이다. 의미를 바꾸지 않으면서 언어에 기능을 추가하는 쪽을 언어 설계자가 선호할 수밖에 없는 것이다. 이처럼 요약 문법과 의미를 바꾸지 않으면서도 새로운 기능을 기존에 있는 기능으로 표현함으로써 언어에 추가하는 것을 문법 설탕(syntactic sugar)이라고 부른다. 위의 예시에서는 단항 연산자 -가 문법 설탕이다. 그리고 문법 설탕으로 표현된 기능을 파싱을 통해 요약 문법에 있는 기능으로 표현하는 작업을 설탕 제거(desugaring)라고 한다. 즉, “-(2+3)”은 설탕 제거를 거쳐 \(0 - (2 + 3)\)이라는 나무로 파싱된다.

다양한 언어에서 문법 설탕이 사용된다. 예를 들어, C 언어에서 배열의 원소에 접근하는 것은 설탕 제거를 통해 포인터 연산과 역참조(dereference)로 표현된다. 다음은 C11의 명세 중 일부를 발췌한 것이다.1

The definition of the subscript operator [] is that E1[E2] is identical to (*((E1)+(E2))).

예를 들어, arr[3]*(arr + 3)은 동일한 프로그램이다. 스칼라(Scala)에서도 문법 설탕을 찾을 수 있다. 스칼라에서 for 반복문은 설탕 제거를 통해 컬렉션의 foreach 메서드를 호출하는 것으로 바뀐다. 다음은 Scala 2.13의 명세 중 일부를 발췌한 것이다.2

A for loop for (p <- e) e'is translated to e.foreach { case p => e' }.

그러므로 for (x <- List(1, 2, 3)) println(x)List(1, 2, 3).foreach { case x => println(x) }는 같은 프로그램이다. 이 밖에도 다양한 언어에서 문법 설탕의 예시를 찾을 수 있다. 다만, 언어의 명세에 특정 기능이 문법 설탕이라고 명시되어 있는 경우는 잘 없다. 또한, 관점에 따라서 어떤 기능을 문법 설탕으로 간주할 수도 있고 언어의 요약 문법과 의미에 반영되어 있는 기능으로 간주할 수도 있다. 따라서 특정 기능이 문법 설탕이냐 아니냐를 구분하는 정확한 기준을 찾기 위해 노력할 필요는 없다. 복잡한 기능을 이미 있는 간단한 기능을 통해 제공할 수 있는 경우를 문법 설탕이라고 이해하는 것이 바람직하다.

문법 설탕이 반드시 언어 설계자에 의해 만들어지는 것은 아니다. 언어를 사용하는 프로그래머도 직접 문법 설탕을 언어에 추가할 수 있다. 리스프, C, 스칼라, 러스트(Rust) 등의 언어에서 볼 수 있는 매크로가 바로 이 경우에 해당한다. 다음의 C 프로그램을 생각해 보자.

#define MAX(a, b) a > b ? a : b
int main() {
  return MAX(1, 2);
}

매크로 전처리기에 의해 위 코드는 다음 프로그램으로 바뀐다.

int main() {
  return 1 > 2 ? 1 : 2;
}

따라서 두 프로그램은 파싱을 통해 완전히 동일한 요약 문법 나무로 바뀐다. 이는 프로그래머가 두 수 중 더 큰 수를 구하는 MAX라는 기능을 언어에 추가한 것으로 볼 수 있다. 이 기능은 언어의 의미나 요약 문법에는 아무런 변화를 주지 않는다. 오직 파싱에만 영향을 줄 뿐이다. 즉, MAX라는 기능이 프로그래머가 정의한 문법 설탕인 것이다. C의 매크로가 단순한 문자열 기반의 코드 수정을 통해 문법 설탕을 만들 수 있게 한다면, 리스프나 스칼라 등의 언어의 매크로는 훨씬 더 강력한 능력을 제공한다. 그런 언어에서는 매크로가 문자열을 기반으로 작동하는 것이 아니라 요약 문법 나무를 직접 수정하는 능력을 가지고 있다. 따라서 처음부터 그 언어에 있었던 것 같이 보이는 문법 설탕을 프로그래머가 만들어 낼 수 있다.

지금까지 프로그래밍 언어의 가장 중요한 두 요소인 문법과 의미에 대해 알아보았다. 문법은 언어의 겉모습을 결정하며 구체적 문법, 요약 문법, 파싱으로 구성된다. 구체적 문법이 프로그래머가 작성하는 코드의 모습을 정한다면, 요약 문법은 프로그램의 구조를 나무 형태로 나타낸다. 그리고 파싱이 프로그래머가 작성한 코드로부터 요약 문법 나무를 만들어 내는 과정이다. 의미는 프로그램의 실행 결과를 정의한다. 프로그램의 구조를 알아야 결과를 정할 수 있기 때문에 의미를 정의할 때는 요약 문법을 사용한다. 일반적으로 언어에 기능을 추가할 때는 구체적 문법, 요약 문법, 파싱, 의미를 모두 수정해야 한다. 그러나 복잡한 기능을 간단한 기능만으로 표현할 수 있다면 그 기능을 문법 설탕으로 제공하면 된다. 문법 설탕을 통해 요약 문법과 의미를 바꾸지 않고도 기능을 추가할 수 있다.

이번 장에서 다룬 개념은 프로그래밍 언어의 원리를 이해하는 데 있어 매우 중요하다. 우선 각각의 개념이 언어를 구성하는 중요 요소이기 때문에 하나라도 빠트린다면 언어를 제대로 설명할 수 없다. 구체적 문법을 잘 이해했다면 언어의 명세를 보면서 코드를 어떻게 작성해야 할지 더 쉽게 파악할 수 있을 것이고, 요약 문법과 의미를 잘 이해했다면 앞으로 책을 읽으면서 프로그래밍 언어에 등장하는 여러 기능이 어떻게 작동하는지 잘 이해하고 프로그래밍을 하는 데 도움 받을 수 있을 것이다. 그러나 이 장에서 얻어야 하는 가장 중요한 것은 구체적 문법을 요약 문법과 의미로부터 분리해 바라보는 능력이다. 구체적 문법이 언어마다 큰 차이를 보이는 반면, 요약 문법과 의미는 언어에 따라 바뀌는 정도가 적다. 1장에서 컴퓨터를 공부하는 사람의 비유를 통해 본 것처럼, 구체적 문법을 나머지로부터 분리해 생각할 수 없다면 새로운 언어를 접할 때마다 완전히 다른 개념이 등장하는 것처럼 보일 것이다. 그러나 구체적 문법만이 다르다는 것을 파악할 수 있다면 새로운 언어라고는 하지만 그다지 어색하게 느껴지지 않을 것이다. 기존에 알고 있던 요약 문법과 의미에 대한 지식은 거의 그대로 사용할 수 있기 때문이다.

3장에서는 이번 장에서 정의한 산술에 변수를 추가할 것이다. 드디어 본격적으로 프로그래밍 언어의 기본 원리를 파악하기 위한 여정이 시작된다.


  1. ISO/IEC9899:201x Committee Draft—April 12, 2011, p. 80, 6.5.2.1 Array subscripting, http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf↩︎

  2. Scala Language Specification Version 2.13, 6.19 For Comprehensions and For Loops, https://www.scala-lang.org/files/archive/spec/2.13/06-expressions.html#for-comprehensions-and-for-loops↩︎