洪民憙 (홍민희) 블로그

이하의 글은 2011년에 쓴 것입니다. 오래된 글인 만큼, 현재의 생각과 전혀 다른 내용도 많이 포함되어 있고, 당시와는 상황이 많이 달라진 점도 있습니다. 또한, 그 당시에 잘못 알려졌던 정보도 포함되어 있을 수 있습니다. 어찌됐든 저는 제 오래된 글이 회자되는 것을 저어합니다. 읽기에 앞서 양해를 부탁드립니다.

Polymorphism, multimethods and notations

90년대에 (그리고 지금까지도) 프로그래머들 사이에서 가장 성공했던 사기1 중 하나는 이른바 OOP일 것이다. OOP가 무엇일까? 사람들이 OOP의 inherent한 특성과 우연한 특성을 함께 생각하는 경우가 많은데, 장담하건대 OOP는 언어 차원에서 암시적으로 구현한 타입 쿼리(type query)이다. 그리고 우리는 이걸 다형성(polymorphism)이라고 부른다.

다형성은 메서드 호출시 특정한 인자에 대해 발생하는데, 그 특정한 인자를 Smalltalk 등에서는 리시버(receiver)라고 부른다. 예를 들어 array at: 0에서 array가 바로 리시버다. Java 등으로 치면 object.method(a, b)에서 object가 리시버다. 대개 리시버가 메서드 앞쪽에 따로 있는 것이 OOP라고 생각하는 경향이 있는 것 같지만 꼭 그래야 OOP인 것은 아니다.2

CLOS 등을 보면 (method object a b) 같이 일반 호출과 동등한 표기를 사용한다. 다만 리시버가 외따로 앞에 있으면 그것이 그 호출(Smalltalk 식으로 말하자면, 메세지)에서 유일한 리시버며, 그 리시버 객체의 타입에 따라 다른 작동이 일어난다—즉 다형성이 발생한다는 사실을 조금 더 잘 드러낼 뿐이다.

90년대에 (주로 진짜 OOP 언어가 아니라는 식으로) 저평가 받다가 2000년대 들어서 부활한 언어가 있다. JavaScript가 바로 그 주인공인데, 이제는 다들 알다시피 90년대에 JavaScript의 객체 모델이 진정한 OOP가 아니라고 저평가되었던 이유는 사람들이 클래스 기반 OOP 말고도 프로토타입 기반 OOP가 있다는 것을 몰랐기 때문이다.3 클래스 기반 OOP와 프로토타입 기반 OOP의 차이는 다형성이 클래스 수준으로 일어나느냐 인스턴스 수준으로 일어나느냐의 차이밖에 없다. 그렇기 때문에 클래스 기반 OOP는 특수화 역시 클래스 수준으로 일어나며, 그래서 인스턴스를 상속하는 대신에 클래스를 상속하게 된다. 마찬가지로 프로토타입 기반 OOP는 특수화가 인스턴스 수준으로 일어나므로, 필연적으로 클래스를 상속하는 대신 인스턴스를 상속—정확한 용어로는, 복제(clone)하게 되는 것이다.

대부분의 OOP 언어에서처럼, 리시버가 하나인 경우의 메서드 디스패치를 싱글 디스패치(single dispatch)라고 한다. 하지만 언제나 리시버가 하나일 필요는 없다. 리시버는 둘 이상일 수도 있으며 심지어 없을 수도 있다. 이것은 매우 자명해서, 오히려 싱글 디스패치를 강제하는 언어에서 이것이 더 잘 드러나기도 한다. Java와 같은 언어에서 클래스 메서드는 왜 필요한가? 우리는 리시버가 필요없을 때 클래스 메서드를 만든다. C++나 Python 같은 언어에서는 클래스에 종속되지 않는 함수를 만들어서 쓸 수 있다. 몇몇 무식한 사람들은 그것이 C++나 Python이 OOP를 덜 지원한다는 증거라고 이야기하기도 하지만, C++나 Python이 실제로 OOP를 덜 지원할 수는 있어도 클래스에 종속되지 않는 함수를 정의할 수 있다는 사실이 결코 그 이유는 되지 못할 것이다. 리시버가 필요 없으면 리시버 없는 연산을 정의할 수도 있어야 한다. 전역 함수는 매우 자연스러운 추상화 방법이다.4

리시버가 없는 경우도 있지만 둘 이상 필요한 경우도 있다. 가장 흔한 경우는 사칙 연산을 정의할 경우다. 다형성은 앞쪽 피연산자 뿐만 아니라 뒤쪽 피연산자에도 적용되어야 하므로, 리시버는 두 개다. 하지만 리시버를 하나밖에 지원하지 않는, 즉, 멀티플 디스패치(multiple dispatch)를 지원하지 않는 대부분의 언어에서는 다른 한쪽 피연산자에 대해 언어 수준에서 해줘야 할 타입 쿼리를 직접 코딩할 수밖에 없다.

class integer extends number
  number +(number operand)
    if operand isa integer
      ...
    else if operand isa rational
      ...
    else if operand isa complex
      ...
    else if operand isa real
      ...

class rational extends number
  number +(number operand)
    if operand isa integer
      ...
    else if operand isa rational
      ...
    else if operand isa complex
      ...
    else if operand isa real
      ...

class complex extends number
  ...


class real extends number
  ...

말이 타입 쿼리지 사실 if문 나열에 지나지 않는다.5 OOP의 목적을 if문 없는 브랜치라고 하는 다소 거친 주장이 틀리지 않다면, 저 코드는 명백하게 OOP는 아닌 것이다. 자, 만약 우리가 쓸 언어가 다형성을 전혀 지원하지 않는다면, 즉, 리시버를 하나도 쓸 수 없다면 저 코드를 어떻게 바꿔야 할까?

number +(number a, number b)
  if a isa integer and b isa integer
    ...
  else if a isa integer and b isa rational
    ...
  else if a isa integer and b isa complex
    ...
  else if a isa integer and b isa real
    ...
  else if a isa rational and b isa integer
    ...
  ...

자, 이쯤해서 내가 맨 처음에 했던 주장을 (조금 거칠지만) 다시 인용하겠다: 장담하건대 OOP는 언어 차원에서 암시적으로 구현한 타입 쿼리이다. 그리고 리시버는 언어에서 암시적으로 타입 쿼리를 구현해줄 인자를 뜻하는 말이다. CLOS 등에서는 멀티메서드(multimethod)라는 것을 지원하는데, 멀티플 디스패치를 지원하는 메서드를 뜻한다. 즉, 리시버를 여러개 둘 수 있는 메서드를 뜻한다! 이러한 멀티메서드로는 위 코드를 매우 이상적인 형태로 고칠 수 있다.

number +(integer a, integer b)
  ...

number +(integer a, rational b)
  ...

number +(rational a, complex b)
  ...

이쯤되면 내가 하고 싶은 말을 예상하는 독자도 있을 것이다. 아, 이 사람은 결국 모든 OOP 언어가 멀티플 디스패치를 지원해야 한다고 주장하려는 모양이구나! 반쯤은 맞는 얘기다. 하지만 내가 하고 싶은 주장은 조금 다르다.

이상적인 OOP 언어라면 리시버의 갯수를 조절 가능해야 하며, 그에 따라 적절한 표기(notation) 방식을 고를 수 있게 해야 한다.

앞서 리시버가 없는 연산도 존재하며, 따라서 전역 함수는 매우 자연스러운 추상화 방법이라고 얘기한 바 있다. 마찬가지로, 리시버가 하나밖에 없는 연산도 있고, 둘 있는 연산도 있으며, 그 이상 있는 연산들도 있다. 따라서 CLOS처럼 모든 메서드를 제너릭 메서드(generic method)로 정의하게 하고 Java 스타일의 receiver.method(operand) 대신 (method receiver operand) 형태로만 호출 가능하게 하는 것은 Java가 리시버가 없는 연산을 배제하고 무조건 모든 메서드가 클래스에 종속되도록 설계한 것과 비슷하게 잘못된 디자인이라고 본다. Java 스타일의 메서드 호출 표기 방식은 리시버가 하나일 경우 매우 직관적이다. 고전적인 C 스타일의 전역 함수 호출 표기 방식은 리시버가 없을 경우에 매우 직관적이다. 보통 우리가 중위 연산자(infix operator)라고 하는 표기 방식은 리시버가 둘일 때 매우 직관적이다. 자연스러운 표기 방식을 쓸 수 있도록 여러 옵션을 제공할 필요가 있다.

리시버가 없을 경우
f()
f(a, b, c)
리시버가 하나일 경우
a.f()
a.f(b, c)
리시버가 둘일 경우
a f b
리시버가 셋 이상인 경우
f(a, b, c)

즉, 전역 함수도 지원 하고 일반적인 인스턴스 메서드도 지원하며, 멀티메서드도 지원해야 하는 것이다. 아직 내가 언급한 모든 것들을 지원하는 언어는 찾아보기 힘들다. 주류 언어에서는 없으며, 그나마 근접한 것은 Atomo 정도가 있다. 내가 만드려는 언어가 언급한 모든 것들 제공하려고 생각중이긴 한다.

덧붙여서: 많은 사람들의 머릿속에 OOP란 리시버의 갯수와 관계 없이 다형성을 발생시키는 프로그래밍이 아니라, 리시버가 하나로 고정된 프로그래밍을 뜻한다. (내 생각에는 다른 웬만한 OOP 언어보다 진짜 OOP에 가까운) CLOS가 과연 정말 OOP냐는 얘기가 종종 나오는 이유도 다른 게 아니다. 대부분의 사람에게 OOP란 리시버가 하나로 고정된 채 receiver.method(args) 형태로 표기 가능한 것이어야 하기 때문이다. CLOS는 모든 메서드 호출을 일반 함수 호출 표기와 같이 하므로 그러한 생각을 갖고 있는 대부분의 사람에게는 전혀 OOP처럼 보이지 않거나 열화된 버전의 OOP인 것처럼 착각되는 것이다. 나는 CLOS가 OOP를 잘 구현했다고 생각하지만, 그와 상관 없이 좋은 표기법을 제공한다고 생각하지는 않는다.


  1. 사람들은 OOP가 프로그래밍 세상을 완전히 변화시켰다고 생각하는 경향이 강한데, 나는 그런 평가에는 회의적이다. 정보를 다루게 되면 결국 일반화와 특수화를 잘 분리할 필요가 생기며, 근본적으로 OOP에서 말하는 다형성과 동등한 무언가를 발명하게 되어있다.

  2. 반면에 다형성이 없어도 f(a, b, c)라고 쓰던 것을 a->f(b, c)라고 쓸 수만 있으면 OOP가 된다고 착각하는 사람도 상당히 많은데, 그것은 OOP가 아니다.

  3. 엄밀하게 말해 JavaScript가 정말 프로토타입 기반 OOP를 구현하고 있는지는 논란의 여지가 있다. Self나 Io 같은 언어를 보면 제대로 된 프로토타입 기반 OOP가 무엇인지 더 잘 알 수 있다. (그야 물론 프로토타입 기반 OOP를 제시한 언어가 Self고 Io는 Self 영향을 그대로 받았으니까.)

  4. 반면 전역 변수는 좋은 추상화를 거스르는 방법이다. 정확히 말하면, 전역적인 상태가 그렇다.

  5. 혹시나 오버로딩(overloading)을 쓰면 해결할 수 있다고 생각하는 분이 있을지도 모르겠다. 아쉽게도 C++, Java 등의 언어에서 오버로딩은 런타임에 다이나믹 디스패치(dynamic dispatch)가 안된다.