표현식이 무엇인지 알아보자

오늘은 표현식에 대해서 좀 이야기해볼까 한다. 표현식(Expressioin)은 너무 직역한 단어라 좀 이상한 감이 없잖아 있는데, 딱히 대체할만한 말이 떠오르지 않는다. 영어권에서는 표현식 대신에 평가식이라는 말도 사용한다. 필수적이거나 중요한 개념은 아닌데, 프로그래밍 언어 가이드를 보다보면 자주 등장하는 용어이고, 알고 있다면 학습에 도움이 될 수는 있겠다 싶다.

표현식은 수학에서 말하는 ‘수식’과 거의 같은 의미이다. 예를 들어 2+3 과 같은 표현은 수학에서 말하는 수식이며, 이를 프로그래밍 언어에서 표현식이라 부른다. 표현식에는 상수와 연산자외에도 상수나 변수 혹은 함수등이 포함될 수 있다. 극단적으로는 하나의 변수 이름만 있더라도 그것을 표현식이라 부를 수 있다.


표현식은 프로그래밍 언어의 구성 요소 중에서 가장 기본이 되는 부분인데, 뭐랄까, 수학에서 0과 1 그리고 2가 존재하는 것이 너무나 당연해서 다들 알려고도 하지 않는 것과 비슷하게 표현식에 대해서 짚고 넘어가는 경우는 별로 없는 것 같았다. 하지만 표현식은 (누구나 이렇게 쉽고 기본적인 내용에 대해서는 별로 관심을 두지 않기 마련이다.) 가장 기본적인 빌딩 블록이며, 이 말은 곧 프로그래머들이 작성하는 코드의 대부분을 차지하는 것이 바로 표현식이라는 것이다.

표현식의 가장 중요한 특징은 바로 ‘평가된다’는 것이다. 이를 테면, 변수 x 는 코드 상에서는 어떤 ‘값’에 대한 이름이지만, 그것은 표현식으로서 그 이름이 가리키는 값으로 평가될 것이다. (그래서 변수 하나만 써도 그것으로도 평가식이 된다.) 그리고 1 + 2 와 마찬가지로 x + 3 과 같은 표현 역시 표현식이 된다. 그리고 이 표현식은 그 결과가 요구되는 시점에 평가되어 하나의 값으로 압축된다.

표현식은 사실 알고 보면 별로 어렵거나 복잡한 개념은 아니지만, 그 정체를 알고 있는 것은 언어를 익히는데 있어서 매우 중요하다. 프로그래밍 언어의 문법적 측면에서 살펴볼 때, 우리는 이론상 무한히 다양한 코드를 작성할 수 있지만 그 코드들은 모두 해당 언어의 문법이 허용하는 범위 내에서 만들어질 수 있다. 이때 언어의 문법이란,  값을 계산하고 실행 흐름을 제어하는 방법을 제공하는 틀이다. 그리고 그 중에서 ‘값을 계산한다’는 부분에 해당하는 표현이 바로 표현식이라 하겠다.


구조적인 관점에서 모든 프로그래밍 코드는 ‘평가’된다. 모든 프로그램의 실행은 결국 메인함수의 실행과 동치이며, 결국 메인 함수를 평가하는 셈이다. 함수의 평가 결과는 그 함수의 리턴값이다. 또한 함수를 평가하기 위해서는 인자를 전달해야하고, 인자는 함수의 호출 시점에 평가된다. (다만 모든 언어가 인자를 호출 시점에 평가하지는 않는다. 언어에 따라서는 호출 시점에 평가되지 않을 수 있다. 함수 호출 직전에 인자를 평가하는 언어를 eager 하다 말하며, 반대로 표현식 자체를 함수로 넘기고 실제 값 계산 시점에 평가하는 언어를 lazy하다고 한다. 하스켈은 대표적인 lazy한 언어이다.)

표현식과 구문을 구분하기

파이썬에서 for ... in ... 으로 표현되는 반복문은 ‘문’이라는 이름에서 알 수 있듯이 구문(statement)이다. 보통 구문은 분기문이나 반복문처럼 프로그램의 흐름 제어에 관련한 문법을 의미한다. 그외에도 함수의 선언이나 클래스 정의 import 부분은 구문이다. 어떤 표현이 구문인지 표현식인지를 살펴보려면 x = .... 의 우변에 들어가서 작동하는지를 체크해보면 된다. (여기에는 함정이 하나 있는데 뒤에서 설명할 것이다.)

ifwhile 문에서 사용되는 ‘조건식’은 표현식이다. 이들 구문은 참/거짓의 값을 요구한다. 따라서 어떤 표현식은 참이거나 거짓으로 평가될 것이다. 파이썬에서 다음 표현식들은 거짓(False)으로 평가된다. 그리고 이 목록에 없는 것은 모두 참으로 평가된다.

  • False
  • None
  • 정수 0
  • 비어있는 리스트, 비어있는 사전, 비어있는 집합

표현식을 사용한 문법 정의

표현식이 무엇인지 알고 있다면, 몇 가지 복잡해 보이는 문법에 대한 설명을 간단하게 정리해서 표현할 수 있다. 예를 들면 리스트 축약 문법이 그렇다.

  • 리스트 축약 : [ 표현식 for 변수 in 반복가능 ] –> ‘반복가능’ 객체의 매 원소로부터 표현식의 결과로 구성되는 새 리스트를 만든다.
  • 2중 리스트 축약 : [표현식(x, y) for x in 반복1 for y in 반복2] –> 반복1, 반복2에 대해서 변수 x, y의 각각의 조합쌍으로부터 표현식의 결과를 리스트로 만든다.
  • 리스트 축약과 필터 : [ 표현식1 for 변수 in 반복가능 if 표현식2] –> ‘반복가능’ 객체의 매 원소로부터 ‘표현식2’가 참일 때에만 ‘표현식1’의 값으로 구성되는 새 리스트를 만든다.
  • 사전 축약 : { 표현식1: 표현식2 for 변수 in 반복가능 if 표현식 3} –> 표현식1:표현식2를 키:값 쌍으로 하는 사전을 만든다. 이 때 표현식 3으로 필터링한다.

그리고 이 때 리스트 축약은 ‘리스트로 평가’되기 때문에 그 자체로도 표현식이다. 따라서 리스트 축약을 중첩하여 다차원 리스트를 만드는 것도 결국 위 문법으로 이해할 수 있게 된다.

표현식과 관련된 함정

파이썬에는 여러 개의 이름에 같은 값을 바인딩하기 위해서 다음과 같은 구문을 쓸 수 있다. 그리고 이것은 보통 C를 조상으로 하는 많은 언어들이 지원하는 기능이다.

a = b = c = d = 1

하지만 엄밀하게 말하면 이것은 권장할만한 방법은 아니다. 물론, 동작은 한다. 개인적으로는 이것은 프로그래머들의 오랜 습관이 남긴 나쁜 예라고 생각한다. 왜냐하면 누군가는 이 코드를 볼 때, “d = 1 이고 다시 c = d 이고, b = c 이고, a = b” 니까, “바인딩 구문”이 우변으로 평가된다고 보는 것이다.

자 그럼 아래 코드는 어떻게 동작해야 할까?

if a = b = 1:
  print(a)

안타깝게도 구문오류(SyntaxError)가 난다. 왜? 파이썬에서 ‘바인딩 구문’은 구문(statement)이지 표현식이 아니기 때문이다. 따라서 a = b = 1a = (b = 1) 이 아니다. (후자의 경우에는 문법 오류가 난다.)

이것은 앞서 말했듯이 C언어 등에서 같은 값으로 여러 변수를 초기화하는 습관을 받아들인 흔적이고, a, b = 1, 1을 간단하게 쓴 셈이다. b = 1 구문은 평가식이 아니므로 바인딩 구문의 우변의 될 수 없다. 더 간단하게 다음과 같이 써도 문법 오류가 날 것이다.

if a = 1:  # <- a = 1 에서 구문 오류
  print(a)

언어에 따라서는 많은 구문이 아예 평가식인 언어도 있다. 예를 들어 Julia의 if 블럭은 전체가 하나의 표현식으로 평가되기 때문에 다음과 같이 쓸 수 있다.

이것은 마치 3항 연산자와 비슷한데, 줄리아는 3항 연산자도 지원하며, &&, ||에 의한 short-circuit도 지원한다.

# Julia
x = 3
y = if x % 2 == 0 
      1
    else
      2
    end
# y == 2

흥미로운 점은 코드의 대부분을 표현식으로 받아들이는 줄리아 역시 대입문을 구문으로 보며, 표현식으로 취급하지 않는다는 점이다.


반대로 C언어에서 if의 조건 부분에 대입 표현을 넣는 것은 잘 작동한다. 왜냐하면 C에서 = 연산자는 좌변에 우변 값을 대입하고 우변값을 결과로 리턴하는 연산자이기 때문이다. 여기서 알 수 있는 것은 C언어의 대입문은 사실 표현식이라는 점이다.

int a;
if(a = 1){ 
    printf("%d\n", a); 
}

개인적으로 대입문 자체가 구문이 아닌 표현식으로 평가되는 것은 언어의 일관성을 해치는 느낌을 주기 때문에 선호하지는 않는다. 물론 if 문에서 바로 대입문을 사용하는 것이 더 편리한 몇몇 특수한 상황이 있다. 예를 들어 정규식 매치 결과가 None이 아닐 때 특정한 코드 블럭을 실행하고 싶은 경우가 있을 수 있다. 현재는 정규식 패턴이 문자열에 매치하지 않으면 None이 리턴되기 때문에 다음과 같이 if 밖에서 먼저 매치해야 한다.

m = pat.match(s)
if m is not None:  
  # 개인적으로는 이렇게 써주는 게 명시적이서 좋다고 생각함
  print(m.groups(1), m.groups(2))

파이썬은 이러한 구문 표현을 허용하지 않았지만, 3.8에 이르러서 바다코끼리 연산자라 불리는 연산자를 이용해서 쓸 수 있게 되었다. 하지만 귀도 반 로썸은 이러한 표현이 ‘영리한’ 코드를 쓴답시고 오용될 가능성이 너무나 크기 때문에 그닥 달가워하지 않았다. 나 역시도 굳이 연산자를 새로 정의하면서까지 저걸 쓰도록 해주는 이유가 뭔지 모르겠다.

if m := pat.match(s):
  print(m.groups(1))

# 그런데 아래 코드보다 몇자나 줄어들었나? 딱 한 글자 줄었다.
# 그만큼 덜 명확해진 부분을 감수할 가치가 있나?

m = pat.match(s)
if m:
  pritn(m.groups(1))