'프로그래밍 언어'에 해당되는 글 3건

  1. 2011.05.14 기본 타입 간 (무분별한) 변환은 자제합시다 (3)
  2. 2011.02.03 프로그래밍 언어 만들기 中 (3)
  3. 2010.12.05 프로그래밍 언어 만들기 上

기본 타입 간 (무분별한) 변환은 자제합시다

개발 2011.05.14 01:59

 요새 하는 일 중 상당 부분이 대개 남이 짜놓은 코드에서 발생하는 버그들을 잡는 것인데, 이 버그들을 보다보면 여러 가지 생각을 하게 된다. 전생에 내가 뭘 잘못했길래 ... 같은 부류의. 내가 폭력적인 싸이코패스였다면 좀 달라졌을까? (...)


 이 중 상당수는 아주 기초적인 부분들을 신경쓰지 않아서 발생하는 버그다. 코드를 작성하기 시작할 때 최소한의 원칙을 세우고 거기에 대해 약간만 신경을 써주면 애초에 발생하지 않았을 버그들이지만, 그 시점에 치르는 약간의 비용이 아까워서/혹은 아예 그런 생각 자체를 못해서 차후에 1주~1달 가까이를 무의미하게 소모하게 된 것이다. 특히나 해당 코드를 작성한 사람이 아니라 대개 다른 사람이 삽질을 해야 한다는 점에서 질이 아주 안 좋다고 볼 수 있다. -_-


 아래에서 드는 몇 가지 사례는 프로그래밍에 있어서 기본 중 기본이라고 할 수 있는 기본 형 변환조차 제대로 다루지 못하여 발생한 버그 혹은 문제들이다.



  • signed/unsigned 정수형 간의 경계 없는 사용

 C/C++은 signed/unsigned 형을 구분하여 사용하며, 표준 라이브러리에서도 size_t, ptrdiff_t 등의 형태로 이 둘 모두를 사용하기 때문에 언어를 사용하는 입장에서 "signed 형만 골라 쓴다" 식의 고유한 정책을 취하기는 쉽지 않다. 게다가 이에 관련된 형 변환 정책은 직관적이지 못하고[각주:1], 자칫 잘못하면 off-by-one 에러와 함께 엮여 무한 루프를 만들어 내는 지옥도를 보기 십상이라 이는 언어 설계의 미스로 자주 지적된다.


 signed/unsigned 정수형 간의 비교 연산으로 인한 버그는 좀 식상한 이야기이니 다른 케이스를 이야기해보자. unsigned 정수형은 추상대수학에서 말하는 기본적인 환 ring 의 일종으로 덧셈, 곱셈이 수학적으로 잘 정의 well-define 된다. 이러한 수학적인 우아함 때문에 현존하는 거의 모든 아키텍쳐에서는 연산 과정에 오버플로가 일어나도 해당 결과에 UINT_MAX로 나머지 연산을 한 값이 나오도록 정의하며, C 표준도 그러하다.


 다만 signed 정수형에서 발생하는 overflow의 경우 C 언어 표준에 따르면 그 처리 방법은 미정의이다. 허나 2의 보수 체계를 택하는 대개의 아키텍쳐에서 signed 정수형과 unsigned 정수형의 차이란 비트값을 정수로 변환할 때 사용하는 다항식의 최상단 계수가 음수냐 양수냐 밖에 없으므로 회로를 간단하게 만들기 위해 이 둘 사이의 덧셈과 곱셈은 호환되게 만드는 경우가 많다. 그런 관계로 컴퓨터 구조를 어설프게 알고 있는 경우 이 둘 사이를 마음대로 오가도 문제 없다고 생각하는 경우가 있다.


 헌데 나눗셈과 나머지 연산이 나오면 이야기가 좀 다르다. signed와 unsigned에 적용되는 연산이 완전히 다른 것이다. 여기에 동일한 크기의 정수형 사이의 signedness 캐스팅에는 signed->unsigned 캐스팅이 이루어진다는 기본적인 캐스팅 원칙이 함께 적용되면 프로그래머 입장에서 어떤 연산이 적용될지 한 눈에 파악하기가 어려워진다.



 일례를 들어보겠다. 어떤 코드에서는 시점을 나타내기 위한 타입으로 아래와 같이 unsigned int를 사용한다고 치자.


typedef unsigned int TimeType;

 일반적으로 시점은 음의 값을 가질 이유가 없으며, timeGetTime 등을 사용하여 시간 값을 가져오는 경우 표현 가능한 기간에 현실적인 수준의 제약이 있는 점 등을 감안하면 이는 어느 정도 합리적인 선택이다. 그런데 여기에서 모종의 이유로 인해 시차를 표현하기 위한 용도로도 저 타입을 함께 사용하기로 했다. 게임으로 치자면 쿨다운이라거나 마법의 시전 시간 등도 포함될 것이다. 일반적인 경우라면 이 역시 대개 양의 값을 가질테니 큰 문제는 없을터다.


const TimeType Cooldown::getAppliedCooldownTime()
{
	return baseCooldown_ - cooldownBonus_/divisionFactor_;
}

 원래는 쿨다운 보너스는 양수 값만 가지고, 나누기 계수는 그 때 그 때 다른 값을 가진다고 치자. 그런데 어느 날 이걸 페널티로도 써보겠다는 멋진 생각 아래 기획에서 보너스 값에 음수 값을 넣기로 하였다고 치자. 코드에 대해 잘 모르는 경우라면 충분히 있을 법 한 일이다. 여기에 나누기 계수로 2를 넣는다면 저 함수의 반환 값은 대략 21억 가량이 나올 것이다. 대충 짠 코드 하나 덕분에 스킬 하나의 쿨다운 시간이 몇 개월이 되어 버린 멋진 결과다.


 unsigned와 signed를 섞어 쓰는 코드의 악랄함은 unsigned 정수형의 수학적인 우아함과 더불어 이에 완전히 호환되는 2의 보수 모델 덕분에 어지간하면 큰 문제 없이 잘 돌아 간다는 것에 있다. 그래서 C4018 같은 경고가 나와도 그냥 무시하는 코드도 많고, 경고조차 안 나오는 연산 코드에 대해서는 그 위험성조차 인지하지 못하는 사람들이 대부분이다. 이런 걸 당연하게 여기기 시작하면 이런 문제가 발생해도 잡아내기가 무척 힘들다.



  • 부정확한 부동 소수점을 정수형으로 캐스팅함으로 생기는 오차

 정수와 부동 소수점 사이를 오가는 코드 역시 주의 대상인데, 이런 코드에는 세 가지 문제점이 있다.



 하나는 정밀도 문제다. 부동 소수점은 정확한 수가 아닌 근사값이다. 특히나 십진법을 사용하는 입장에서 자주 사용하게 되는 수 대부분은 부동 소수점으로 정확한 값을 표현할 수 없다. 이런 근본적인 문제와 더불어 부동소수점을 정수형으로 캐스팅하는 연산이 단순한 절삭 연산이라는 점 때문에 0.00001 가량의 작은 오차가 1로 확 벌어지는 경우가 발생할 수 있다. 이를테면,


int value = 1000 * 1.7 * 1.14;

 정상적인 계산이라면 위의 값은 당연히 1938이 나와야 한다. 그런데 내 컴퓨터에서 돌리면 1937이 나온다. 사실 이건 양반이다. 심지어는 비트 레벨에서 완전히 동일한 double 두 값을 뺐는데도 0이 안 나오는 경우도 있을 수 있다. 대부분의 컴파일러에는 이런 비일관성을 줄이는 옵션도 있지만, 이는 프로그램의 속도에 악영향을 준다.



 더 큰 문제는 다른 컴퓨터/플랫폼에서도 항상 똑같은 결과가 나온다는 보장이 없다는 것이다. 이는 부동소수점 연산에는 여러 가지 방식이 있기 때문인데, 이로 인해 부동소수점 연산은 비결정적인 non-deterministic 특성을 띄게 된다. lockstep 류의 네트워크 동기화를 사용하거나 로직이 재연 가능해야 하는 경우에는 이렇게 작은 차이도 치명적이라 floating point determinism을 확보하기 위해 벼라별 삽질을 다 하는 마당에, 이런 작은 오차가 정수형 연산까지 번져 버리면 곤란하다.



  • 타입 간 표현 가능한 수치 범위의 차이로 인한 오버플로 문제

 또 하나의 문제는 타입 간 캐스팅으로 인해 생기는 오버플로다. 정수형끼리의 캐스팅을 다루는 경우는 대개 해당 타입들의 범위를 잘 알고 있다. 그렇기에 오버플로가 일어날 가능성이 아무래도 더 명백하게 드러나서 대부분 크게 문제가 되지는 않는다. 보통 이 경우는 연산으로 인한 오버플로가 더 문제다.


 그런데 부동소수점 실수형의 범위는 대부분 아주 큰 값, 심지어는 무한대로도 생각하는 경향이 있어서 오버플로가 발생할 가능성에 대해서는 아무래도 둔감하다. 실제로 발생하는 경우도 극히 드믈다. 그런데 유의해야 하는 경우가 있으니 바로 실수가 정수형 변수로 캐스팅되는 경우다. 예를 들어 소수점 이하 2자리를 정수형으로 반환하려는 의도의 아래 코드를 보자. (양수만 입력된다고 가정하자.)


const int32_t getFractional2(const float positiveInput)
{
    return (positiveInput - static_cast<int32_t>(positiveInput)) * 100.0f;
}

 아주 간단한 코드지만 버그를 가지고 있다. positiveInput이 만약 int32_t의 범위를 넘어선다면 이 코드의 결과는 미정의다. 해당 캐스팅이 어떤 결과를 가져올지는 플랫폼마다 다르겠지만, 내가 사용하는 플랫폼에서는 0x80000000가 나온다. 이 문제를 해결하려면 내림을 위해 int 캐스팅을 사용할 게 아니라 아래와 같이 floor 함수를 사용해야 한다. 이 경우 괄호 안의 표현식은 0.0 ~ 1.0 사이의 값을 가지게 되므로 오버플로가 일어나지 않는다.


const int32_t getFractional2(const float positiveInput)
{
    return (positiveInput - std::floor(positiveInput)) * 100.0f;
}

 사실 이 코드는 정밀도 한계 때문에 입력이 천만만 넘어가도 0만 반환하므로 별 의미 없는 문제일지도 모른다. 하지만 함수의 스펙대로 0~100 사이의 값이 반환되는 것과 예측 불가의 미정의된 값이 반환되는 것은 완전히 다른 이야기다. 입력의 범위를 확신할 수 있는 상황이 아니라면 가급적 오버플로가 일어나지 않게 방어적으로 코드를 짜는게 맞다. 산술 연산으로 인해 생기는 오버플로는 잡아내기 힘들지만, 캐스팅으로 인해 생기는 오버플로는 비교적 잡아내기 쉬우니 그리 어려운 일도 아니다. 단지 귀찮을 뿐.



  • 정수 - 실수 간 캐스팅 오버헤드

 이건 버그는 아니지만 한번 짚고 넘어갈 만한 부분이다. 정수형/실수형을 택할 수 있는 상황에서 속도 문제 때문에 정수형을 고집하는 사람들이 의외로 많다. 정수형 계산이 실수형 계산에 비해 빠른 것은 사실이다. 하지만 여러 분야에 있어 부동 소수점 연산 속도는 매우 중요하기 때문에 구조적으로 많은 최적화가 이루어졌고, 대부분의 프로세서는 부동 소수점 연산만을 위해 물리적/논리적으로 많은 자원을 할당해둔다. 이런 배려 덕분에 나눗셈 연산 정도를 제외하면 정수 연산과 부동소수점 연산의 속도 차이는 생각만큼 크지 않다.


 이를 위해 희생한 부분 중 하나가 있으니 바로 정수/실수 사이의 캐스팅에서 발생하는 오버헤드다. x86을 포함한 상당수의 아키텍쳐에서는 간결한 구조를 위해 FPU를 독립적으로 다루며, 레지스터 역시 정수형과 부동소수점형이 따로 분리되어 관리된다. 즉, 정수형과 실수형 사이의 캐스팅이 이루어진다는 것은 레지스터에 있는 정수형 레지스터에 저장된 값을 메모리로 빼놨다가 다시 실수형 레지스터로 옮겨가는 과정을 수반한다는 이야기다. 캐스팅 연산 자체의 비용은 그리 크지 않더라도 이런 메모리 연산 과정이 수반되기 때문에 가급적 피하는게 좋다.


 이런 구조를 모른다면 최적화라는 명분 하에 멀쩡한 float형 변수들을 전부 int로 바꾸고 여기에다 실수 상수들을 곱하고 나누는 삽질을 하는 것도 충분히 가능한 이야기이고 또 자주 일어나는 일이다. 어설픈 프로그래머보단 컴파일러가 미세 최적화는 훨씬 잘하니 아예 손을 안 대는게 낫다.



 이론은 이러한데, 아래의 코드를 가지고 실제로도 그런가를 확인해보았다.


const int ArraySize = 10000000;
const int IterationCount = 20;

int    cast[ArraySize];
float pureFloat[ArraySize];
int    pureInt[ArraySize];

#pragma optimize("", off)
void integerCast()
{
    for (int j = 0; j < IterationCount; j++) {
        for (int i = 0; i < ArraySize; i++) {
            cast[i] *= 1.1f;
        }
    }
}

void pureInteger()
{
    for (int j = 0; j < IterationCount; j++) {
        for (int i = 0; i < ArraySize; i++) {
            pureInt[i] *= 11;
        }
    }
}

void pureFloatingPoint()
{
    for (int j = 0; j < IterationCount; j++) {
        for (int i = 0; i < ArraySize; i++) {
            pureFloat[i] *= 1.1f;
        }
    }
}
#pragma optimize("", on)

 컴퓨터마다 그 결과가 다르겠지만, 내 경우는 순수 실수 사이의 연산이 정수와 실수를 섞은 경우보다 1.7 ~ 1.8배 가량 빨랐고, 순수 실수와 순수 정수 연산 사이에서도 큰 수준의 차이는 없었다. 루프에 들어가는 오버헤드나 pragma 때문에 놓친 공격적인 최적화의 기회까지 감안하면 실제론 이보다 더 크게 차이난다고 봐도 될 것이다. 물론 저렇게 무식하게 곱연산만 하는 경우는 없겠지만, 적어도 성능 때문에 정수형을 쓸 필요는 없다는 근거 정도는 되리라 생각한다. 부동소수점을 써선 안 되는 경우는 정밀도 문제가 엮여 있거나 연산의 일관성이 필요한 경우지, 적어도 높은 성능이 필요한 경우는 아니다.



 마지막으로 이런 문제점들이 모이고 모여 총체적 난국을 만들어내는 코드 하나를 살펴보도록 하자. 위에 나온 예시와 같이 시간을 표현하기 위해 unsigned 정수형을 사용하는 코드다.


...

    TimeType baseCastingTime_;
    TimeType castingTimePerLevel_;
    float castingTimeFactor_;

...

const TimeType Casting::getCastingTime(const int32_t skillLevel)
{
    return baseCastingTime_ * castingTimeFactor_ +
           castingTimePerLevel_ * skillLevel;
}

...

TimeType castingTime = casting.getCastingTime(skillLevel);

...

 baseCastingTime_이 300, castingTimePerLevel_이 -50, castingTimeFactor_가 1.0f, skillLevel이 3이라고 치자. 그럼 어떤 값이 나올까? signedness를 떠나 상식적으로 생각해보면 당연히 150이 나와야 한다. 그리고 실제로 돌려보니 150이 나왔다. 문제 해결! ... 이면 여기에 이렇게 글을 썼을리가 없다. -_-


 x86, 윈도우즈 플랫폼에서 위의 코드를 수행하면 확실히 150이 나온다. 그런데 x64로 컴파일해서 돌리면 0이 나온다! 어셈도 안 쓰고 플랫폼 의존적인 함수도 없이 순수 C++ 표현식만 썼음에도 플랫폼마다 다른 결과가 나오는 대단한 코드를 만들어 버린 것이다. -_-



 이런 결과가 나오는 까닭은 이렇다. (1) castingTimeFactor와 baseCastingTime을 곱하면 암묵적으로 float 캐스팅이 이루어진다. (2) 그리고 castingTimePerLevel과 skillLevel을 곱하면 역시 암묵적으로 TimeType으로 캐스팅된다. (3) 최종적으로 이 둘을 더한 결과는 float이고, 함수는 이를 다시 TimeType으로 캐스팅하여 반환한다.


 문제는 2번과 3번이다. 2번에서는 약 42억 가량의 unsigned 값이 들어가고, 3번에서 둘을 더한 float 값 역시도 42억 가량의 값이 들어가게 된다. 이 때 부동소수점 정밀도 문제로 인해 아래 9비트 만큼의 정보가 잘려 나가면서 정확히 2의 32승을 가리키는 값이 되어버리고, 이 값이 unsigned 정수로 캐스팅되면 0이 된다. 음수가 unsigned 정수형에 들어갈 때 내부적인 표현이 부동소수점 정밀도 문제와 겹치면서 문제가 발생한 것이다.



 그렇다면 의문점이 남는다. 왜 32비트에서는 제대로 된 값이 나왔나? 생성된 어셈 코드를 보면 32비트에서는 계산된 FPU 레지스터의 결과값(3)을 바로 정수형으로 치환해서 메모리에 저장하는 코드가 생성된다. 여기에서 한 가지 알아둬야 할 점은 x86 아키텍쳐의 FPU 내부에서 이루어지는 연산은 기본적으로 80비트 부동소수점을 다룬다는 점이고, 이 레지스터에 저장되는 값은 32비트 정수를 손실 없이 저장할 수 있다. 여기에 80비트->32비트 부동소수점 변환 과정이 생략되고 바로 정수형으로 캐스팅되므로 저렇게 올바른 값이 나오는 것이다. 실제로 float 형으로 한 번 지역 변수에 저장했다가 반환하면 64비트처럼 0이 반환된다.


 그런데 64비트는 MMX 명령어를 이용하여 부동소수점 연산을 수행하는 코드를 만들어 냈다. 이게 내부적으로 어떤 과정을 거쳐 수행되는지는 모르겠으나, 해당 명령어들이 수행되는 과정들을 관찰해보면 32비트와는 다르게 별다른 변환 과정 없이 32비트 부동소수점만을 이용하여 계산이 이루어지는 것을 볼 수 있다. 어떻게 보자면 이 쪽이 좀 더 일관성 있는 동작을 한다고 볼 수도 있다.



 결론은 간단하다.


  • 기본형 간의 불필요한 캐스팅이 일어나는 상황은 최대한 줄이도록 한다.
  • 연산은 가급적 같은 타입의 변수끼리 하도록 한다. 즉, 암묵적인 캐스팅을 자제한다.
  • 타입 간의 경계에서 일어나는 캐스팅은 정밀도나 범위등을 고려하여 주의 깊게 수행하도록 한다.

 위의 사례를 보면 알 수 있겠지만 unsigned형과 더불어 정수와 실수형을 마구 섞어쓰면 아주 사소해보이는 표현식 수행을 이해하는 일도 아주 피곤한 일이 되어버린다. 생각해보면 해결해야 할 문제의 본질과 전혀 상관 없는 저런 지식들을 프로그래머가 일일히 알아야 할 이유는 전혀 없다. 하지만 C/C++가 애초부터 잘못 설계된 언어인 관계로 어쩔 수 없다. 가급적이면 저런 상황 자체를 안 만드는 게 차선책이다.

  1. 심플하긴 하다. 크기가 같으면 signed를 unsigned로 캐스팅하며 다르면 더 큰 signed 형으로 캐스팅한다. 다만 언제는 signed로, 언제는 unsigned로 캐스팅하는 비일관성이 문제다. [본문으로]

'개발' 카테고리의 다른 글

NDC 간략한 참관기 #2  (1) 2011.06.12
NDC 간략한 참관기 #1  (0) 2011.06.04
기본 타입 간 (무분별한) 변환은 자제합시다  (3) 2011.05.14
Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
Trackback 0 : Comments 3

프로그래밍 언어 만들기 中

컴퓨터 과학 2011.02.03 14:27

 원래는 상/하 편으로 끝낼 생각이었는데 견적을 잡아보니 두 편으로는 무리인 것 같다. 게다가 쓰다보니 예상보다 3배 가량 길어졌다! 분량 조절 실패의 향기가... orz



 - 일급 객체인 함수 First-class function


 사실 First-class object의 우리 말을 일급 객체로 두는게 적당한지는 의문이나 보통 이렇게들 쓰는 듯 하니 여기에서는 이 단어를 쓴다.


 위키백과에 따르면 일급 객체란 아래와 같은 특성을 가지는 객체다.

  • 변수 혹은 자료 구조에 저장이 가능하다.
  • 서브루틴의 인자로 전달되거나 그 결과값으로 반환될 수 있다.
  • 런타임에 생성이 가능하다.
  • 주어진 이름과는 무관하게 객체 그 자체에 내재된 정체성 Identity을 가지고 있어야 한다.

  이 특성에 따르면 우리가 프로그래밍에 일상적으로 사용하는 객체들은 대부분 일급 객체다. 하지만 C/C++의 함수는 위의 조건들을 만족시키지 못한다. 함수를 런타임에 생성할 수도 없고, 모든 함수들은 주어진 이름에 묶여 있다. 그렇기 때문에 함수 자체를 객체로 사용하지 못하고 포인터를 이용하여 함수를 다룬다.


 함수를 일급 객체로 다룬다는 것은 코드를 다른 코드들 사이에서 주고 받는 데이터처럼 다룰 수 있다는 이야기다. 조금 더 세련되게 표현하자면 함수형 프로그래밍의 핵심적인 특징 중 하나인 고차 함수 Higher-order function를 사용할 수 있게 된다. 그렇다면 함수가 일급 객체라는 것/고차 함수를 사용할 수 있다는 것은 프로그래밍에 있어 어떤 의미를 가지는가? 내 생각엔 언어적인 차원에서 간결하게 로직을 결정하는 지점과 수행할 지점 사이의 결합을 분리해낸다는게 포인트다.


 멀리 갈 것 없이 배열을 정렬하는 퀵소트 루틴을 생각해보자. 정수 배열을 정렬하는 퀵소트 루틴은 그리 어렵지 않게 구현할 수 있다. 생각해보니 아닐지도...이 루틴은 입력과 출력이 명확하게 정의되어 있고 그 외의 부가 효과는 없으므로 정수 배열을 정렬할 필요가 있는 상황 어디에서도 사용할 수 있다. 퀵소트 루틴의 코드가 이 루틴을 사용하는 상단 모듈들과 독립적이기에 가능한 일이다.


// 아래는 예시용으로 대충 작성한 가짜 코드로 아마 버그가 있을 것 같다 -_-;
int[] QuickSort(int array[])
{
	if (array.size() < 2)
		return array;
	int pivot = SelectPivot(array);
	int[] less, greater;
	foreach(int i in array)
		(pivot < i ? less : greater).append(i);
	return concatenate(QuickSort(less), QuickSort(greater));
}

 그런데 정수가 아닌 다른 타입의 배열을 정렬해야 한다면? 아니면 정수를 역순으로 정렬하고 싶다면? 보통은 대소 비교 로직만 변경하면 된다고 생각할 것이다. 헌데 퀵소트 루틴이 하단부에 위치한 대소 비교 로직에 의존하고 있어서 그게 쉽지 않다. 따라서 이 하단부 로직을 퀵소트를 호출하는 상단부로 보내 의존성을 역전시켜야 할 필요가 있는데, 이 때 함수가 일급 객체라면 사용하고자 하는 하단부 로직을 함수 인자로 받아 쉽게 해결할 수 있다.


 이렇게 로직을 결정하는 지점과 수행하는 지점을 분리할 수 있으면 하부의 구체적인 구현을 대소 비교 함수라는 추상적인 개념 뒤로 쉽게 감출 수 있다. 이로 인해 상하 간의 의존성이 줄어 들고 코드의 가능한 호출 경로가 함수 숫자의 곱에 비례하여 증가하여 적은 코드로도 다양한 일을 할 수 있게 된다. 함수형 언어에서 단골로 나오는 map, reduce, filter 같은 고차 함수들이 가지는 능력을 생각해보면 될 것이다. 사실 이에 대해서는 내가 든 예시보다는 조엘이 쓴 글이 훨씬 재밌고 유익하니 이 쪽을 보는 게 나을 것 같다.


// 위의 예시에 일급 객체 함수를 적용, 비교 로직을 호출부로 떠넘기도록
// 살짝 수정한 코드이다.
int[] QuickSort(T array[], function boolean(T, T) compare)
{
	if (array.size() < 2)
		return array;
	T pivot = SelectPivot(array);
	T[] less, greater;
	foreach(T i in array)
		(compare(pivot, i) ? less : greater).append(i);
	return concatenate(QuickSort(less), QuickSort(greater));
}

  물론 여타 언어들에서도 함수 포인터, 함수 객체, 전략 패턴 등을 이용하여 이를 모사할 수 있긴 하지만 문제의 본질과는 무관한 다른 개념을 한 단계 거치기 때문에 이용하기 번잡한 면이 있다. C++의 algorithm 헤더를 쓰기 위해서 함수 객체를 일일히 만들어야 하는 삽질을 - 결과적으로는 안 쓰게 되는 - 생각해보면 쉽게 이해가 갈 것이다. 거기에 아래에 기술될 클로저나 람다 같은 개념들과 타이트하게 엮어서 사용하려면 일급 객체로써의 함수라는 개념을 언어 차원에서 지원하는 편이 아무래도 좀 더 유리할 것 같다.


  함수를 일등급 객체로 만드는 것은 생각만큼 어려운 일이 아니다. 당장 C++0x에서도 라이브러리 차원에서 템플릿을 이용, 함수를 추상화한 클래스인 std::function이 추가되는데, 크고 아름다운 템플릿 메타 프로그래밍 덕분에 이해하기가 까다로워 그렇지, 내부를 살펴보면 함수 객체를 쓰기 쉽게 포장한 것에 불과하다. 런타임 오버헤드도 딱 가상 함수 호출 수준으로 꽤나 효율적인 구현이 되어 있다. C++의 연산자 오버로딩과 템플릿의 지원이 있긴 했지만, 일개 라이브러리의 구현이 이 정도 수준이니 언어 차원에서 지원된다면 훨씬 더 쉽게 지원할 수 있을 것 ... 은 순진한 희망 사항이고, 클로저를 고려하려면 문제가 좀 복잡해진다. 이게 바로 언어 차원에서 일등급 함수를 지원하는 것은 지지하는 이유다.




 - 클로저 Closure / 람다 Lambda


  일급 객체로써의 함수를 통해 코드를 데이터처럼 다룰 수 있으면 표현력이 증대된다. Lisp 계열의 Homoiconicity[각주:1] 같은 수준은 아니라도 특정 지점에서 수행되는 로직을 상황에 따라 간단하게 수정/변경할 수 있다는 것은 분명히 매력적이다.


  하지만 함수 인자로 딱 한 번 쓰기 위해 매 번 함수를 새롭게 정의하고 이름을 지정하는 것은 꽤 번거롭다. 전달하고자 하는 로직은 딱 한 줄에 그나마도 한 번만 쓰이는 상황에도 함수라는 형식을 따르기 위해 실제 로직과는 전혀 상관 없는 위치에 정의를 하고 함수 정의 문법에 따라 함수를 작성한 뒤 구분에 쓰일 고유 식별자도 붙여야 한다. C++이나 자바처럼 한번 쓰자고 클래스를 새로 선언하는 삽질보단야 낫지만 아무래도 최선책은 아니다. 아예 아래 같이 간단하게 함수를 넘길 수 있으면 좋을 것이다.


// 로직 한 줄 바꾸자고 함수를 새로 만들어야 하나?
boolean compareInteger(int lhs, int rhs)
{
	return lhs < rhs;
}

...

QuickSort(array, compareInteger);

// 하지만 익명 함수가 출동하면 어떨까?
QuickSort(array, function boolean (int lhs, int rhs){ return lhs < rhs; } );

// 함수 인자를 토대로 간단하게 타입 추론을 할 수 있다면 이런 코드도 가능할 것이다.
QuickSort(array, function (lhs, rhs){ return lhs < rhs; } );

  익명 함수(혹은 람다)의 도입으로 훨씬 응집성 있고 깨끗한 코드가 작성되었다. 이 정도만 해도 장족의 발전이다. 게다가 이 기능은 단순한 syntax sugar이니 구현도 간단하다. 문제를 찾자면 기술적인 부분보다는 익명 함수 문법을 간결하고 깔끔하게 정의하는게 꽤 까다롭다는 것이다. 이를테면 기존 함수 문법과의 일관성을 택할 것이냐 간결함을 택할 것이냐 같은 선택부터 여타 문법들과의 모호성 문제를 어떻게 해결할 것인가 등등.


  어쨌든 익명 함수 문법으로 문제를 상당 부분 완화하는데 성공했다 치자. 그런데 추가된 문법이 단순한 syntax sugar 수준이라 또 문제가 생긴다. 익명 함수 내부의 코드는 해당 함수가 위치하는 스코프 외부의 이름들에 접근할 수 없다. 원래 외부 스코프에 위치했어야 할 함수를 안으로 들여오기만 한 것이니 당연한 일이다. 이로 인해 생기는 문제로는 스코프 규칙[각주:2]이 깨짐으로 인한 비일관성의 문제도 있지만, 이건 아래의 코드에서 드러나는 것 처럼 외부 이름들에 접근하지 못함으로 인한 사용성의 저하에 비할 바는 아니다.


int sum = 0;
for (int x in array) {
	// 아래 코드에서 컴파일 에러가 뜬다면?
	sum += x;
}

...

int sum = 0;
// 아래 코드에서 컴파일 에러가 뜬다면?
foreach(array, function(x) { sum += x; });

  이를 해결하려면 함수가 생성되는 시점에 외부 스코프에 위치한 이름들과 그 이름이 문맥적으로 가지는 정보들을 포착 close 하여 함수에 저장해놨다가 수행 시점에 이를 참조해야 할 필요가 있다. 이를 위해 클로저 closure라는 개념을 도입한다. 클로저란 일급 객체로써의 함수이되, 함수가 생성될 당시의 환경을 호출시 사용할 수 있도록 함수에 함께 묶어둔 것이라고 보면 크게 틀리지 않을 것이다. 대부분의 경우 코드의 정적인 스코프 Lexical scope를 기준으로 주변 환경을 가져온다.


// 퀵소트의 성능이 궁금해졌다.
int compareCount = 0;
Quicksort(array, function (x, y) {
                 	compareCount++;
                 	return x < y;
                 } );
print(compareCount);

 클로저를 구현할 때 까다로운 점 하나가 일반적으로 사용하는 스택 기반 언어와 클로저는 서로 궁합이 안 맞는다는 것이다. 주류 명령형 언어들은 대부분 콜스택에 사용하는 지역 변수와 더불어 함수 호출 정보를 저장해둔다. 이는 지역 변수의 수명이 함수가 수행되는 기간을 벗어나지 않기 때문에 가능한 것이다. 즉, 함수가 수행 종료되면 함수 내의 지역 변수가 다시 참조될 일은 절대로 없다. 그래서 서로 다른 함수 호출들도 모두 콜스택 자료 구조 하나에 깔끔하게 정리될 수 있다. 그런데 클로저가 도입되면 함수 수행이 종료된 이후에도 함수 내의 지역 변수가 참조될 가능성이 생긴다. 즉, 지역 변수의 수명이 함수 수행 기간과 따로 논다는 이야기다.


// 이제 x의 수명은 generateAdder에 의해 반환된 함수의 수명와도 연관되게 되었다.
function int(int) generateAdder(int x)
{
	return function int(int y) { return x + y; };
}

...

function int(int) add3 = generateAdder(3);
print(add3(2)); // 5 출력

  따라서 콜스택에 지역 변수 정보를 쌓아 놓는 기존 모델을 그대로 써먹을 수는 없다. 고로 원칙적으로는 문맥을 표현하기 위해서 콜스택과는 좀 다른 구조의 Continuation을 쓰거나 아예 힙으로 넘겨 버리는 식의 꼼수를 써서 우회해야 한다. 구현하는 방법은 언어마다 다르고 가상 머신마다 다르겠지만 핵심은 함수 종료 이후에도 사용될 수 있는 외곽 지역 변수들을 어떻게 유효한 상태로 유지시키는가이다. 해당 변수의 참조가 끝난 뒤 뒷정리는 가비지 컬렉터가 알아서 해줄테니 걱정할 필요가 없겠다. (이런 이유로 가비지 컬렉터나 유사품 없이는 사용 가능한 수준의 클로저를 구현하는게 어렵다고 알려져 있다.)


  클로저를 구현하는 방법에 대해 내가 아는 건 lua와 C# 두 가지다. lua 같은 경우는 가상 머신의 지원을 받아 구현한 사례인데, 함수 외부 지역 변수들은 up value라는 참조값을 통해 간접적으로 접근하는 것이 핵심이다. 클로저를 만들때 이 클로저가 참조하는 외부 지역 변수에 대한 중간 코드를 토대로 up value의 배열도 함께 만드는데, 이 up value들은 처음엔 콜스택에 위치한 지역 변수들을 참조한다. 그 뒤 함수가 반환되어 지역 변수가 무효화될 시점이 되면 지역 변수 값을 힙 영역으로 옮긴 뒤 up value가 가리키는 위치를 이 쪽으로 바꾸는 것이 기본 아이디어다. 구체적인 구현은 좀 복잡한데, 이 문서를 본 뒤 lua의 구현 코드를 보면 대강 감을 잡을 수 있을 것이다.


  C#은 가상 머신은 건드리지 않고 최대한 컴파일러만으로 해결을 보는 방향을 택했다. C# 컴파일러는 현재 함수 이외의 다른 함수에서도 참조되는 지역 변수를 발견하면 해당 지역 변수와 이를 참조하는 클로저의 코드를 멤버로 가지는 새로운 익명 클래스를 하나 만든 뒤 이 클래스로 기존의 지역 변수를, 멤버 함수로 기존의 delegate들을 대체해버린다. 이러면 지역 변수가 힙으로 넘어가면서 수명 관리의 책임 역시 자동으로 가비지 컬렉터에게 넘어가니 문제가 간단히 해결된다. (물론 컴파일러 구현까지 간단한 건 아닐 것이다.)




 - 다중 디스패치 Multiple dispatch

 OOP의 핵심은 다형성이라고들 한다. 어떤 블로그에서 봤던 인상깊은 구절 중 하나가 "OOP란 조건문을 줄이는 것"이었는데, 이는 틀린 말이 아니다. 코드들을 잘 살펴보면 상당 부분이 객체(프로그래밍 언어에서 말하는 그것이 아닌 모델링하고자 하는 영역의)의 유형을 구분하고 그에 따른 세부 로직으로 진입하는 것인데, 이 중 상당수는 서브 타이핑을 통한 다형성을 이용한 코드로 쉽지않게 리팩터링할 수 있다.[각주:3]


// 크고 아름다운 switch문을 가진 몬스터 메소드
void Actor::onSkillHit(Skill& skill)
{
	...

	switch(skill.getType()) {
	case Skill::PHYSICAL_DAMAGE:
		onPhysicalDamageSkill(skill, this);
		break;
	case Skill::MAGICAL_DAMAGE:
		onMagicalDamageSkill(skill, this);
		break;
	case Skill::HEAL:
		onHealSkill(skill, this);
		break;

	...

	default:
		assert(false);
	}

	...
}

// 우리 몬스터 메소드가 달라졌어요

void Actor::onSkillHit(Skill& skill)
{
	...

	skill.process(this);

	...
}

void PhysicalDamageSkill::process(Actor& actor)
{
	...
}

void MagicalDamageSkill::process(Actor& actor)
{
	...
}

void HealSkill::process(Actor& actor)
{
	...
}

 그런데 가끔 객체의 유형에 의존하는 로직은 맞는데 리팩터링을 하려고 보면 관련 로직이 해당 객체가 직접 져야 할 책임과는 거리가 좀 있는 경우가 있다. 비즈니스 로직을 다루는 객체를 가지고 서브시스템이 특정 작업을 하거나 (객체를 직렬화해서 네트워크로 보내거나 DB에 저장한다거나 등등) 메시지 객체의 종류를 보고 적당히 핸들링을 해주는 경우 등등... 원 객체의 구조에 손을 대지 않으면서도 다형성을 필요로 하는, 다시 말해 비침입적인 다형성이 요구되는 경우다.


 디자인 패턴을 공부한 사람이라면 대부분 Visitor 패턴에 대해 잘 알 것이다. OOP의 다형성은 보통 상속을 통해 이루어지는데, 상속 기반의 다형성을 이용하면 기존 코드 구조의 수정 없이 새로운 타입을 추가하기는 쉽지만 연산을 추가하기는 어렵다, 뭐 이래서 Visitor 패턴을 통해 비침입적으로 연산을 쉽게 추가할 수 있도록 하려는 의도라는 이야기다. 주로 위에서 기술한 것과 같은 상황에 쓰는게 목적이다.


 그런데 막상 Visitor를 쓰려고 하면 꽤 번거롭다. 일단 방문 대상이 되는 객체들에 일일히 accept 함수를 넣어주는 것부터가 다분히 침입적이다. 거기에 Visitor들 역시 Visitor 인터페이스를 상속해줘야 하니 (방문 대상들이 사용하는 Visitor가 하나 뿐이면 안 해줘도 되지만) 필연적으로 상속 구조가 복잡해진다. 클래스 계통에 자식 클래스라도 새로 추가하려면 Visitor 인터페이스도 같이 수정해야 하는 부담도 있다. 이렇게 방문 대상과 Visitor가 서로에 의존하기에 타입 정보+switch 문을 쓰는 것에 비해 설계가 아주 깨끗하게 떨어지지 않는 이상 Visitor를 사용하는 데에 있어 심리적인 장벽이 있는건 사실이다.


 생각해보면 이런 일은 컴파일러가 알아서 더 잘해 줄 수 있는 일이고, 가능하다면 컴파일러에게 떠넘기는게 맞다. 예전부터 비슷한 생각을 하신 언어 개발자들 덕분에 몇몇 프로그래밍 언어들에서 다중 디스패치라는 개념을 볼 수 있다. 다중 디스패치의 개념을 설명하자면 수행 함수의 결정이 실행 시간에 동적으로 이루어지는 함수 중복 적재 Function overloading 인데, 우리가 사용하는 가상 함수는 다중 디스패치의 특수한 케이스이다. [각주:4] 아래는 다중 디스패치를 사용한 예시 코드이다.


Object::handle(Message& message)
{
	// 메시지에 대한 일반 처리
	...
}


// Actor는 Object를 상속한 객체
Actor::handle(ActorNotification& message)
{
	// 다른 Actor가 가시 범위 내로 출현
	...
}

Actor::handle(Interaction& message)
{
	// 다른 Actor가 상호 작용을 시도함
	...
}


// Player는 Actor를 상속한 객체
Player::handle(Interaction& message)
{
	// Player 고유의 Interaction 메시지 핸들링
	...
}


// 겉모습만 봐선 함수 중복 적재를 하는 보통의 코드와 다를 게 없다!

 언어 차원에서 다중 디스패치를 지원하는 것의 의미는 정적으로 이루어지는 함수 중복 적재와 동적으로 이루어지는 단일 디스패치 두 기능을 직교적/상호 보완적으로 동작할 수 있도록 하나로 엮는다는 것에 있다. 언어 자체의 완결성이 높아지는 것도 그렇지만, 다른 이득도 많다. 이를테면기존 언어들에서는 컴파일 시점에는 불확실한 동적 타입의 객체를 정적으로 다운 캐스팅하다가 타입 안전성이 깨지는 상황이 많았는데, 다중 디스패치를 지원하면 이런 구멍을 쉽고 안전하게 메꿀 수 있다. 여기에 임의의 타입에 대해서도 비침입적으로 다형성을 이용할 수 있게 되면서 얻는 설계의 유연성도 무시할 수 없다.


 그렇다면 이렇게나 좋은 기능을 구현하는 언어가 왜 이렇게 적은가? 당연히 구현이 까다롭기 때문이다. C++만 하더라도 스트로스트룹 아저씨가 예전부터 이 기능을 넣고 싶어했고 그래서 Open method라는 확장된 개념을 제안했지만 차기 표준에도 안 들어간 모양이다. (사실 그럴 법 한게 내용을 읽어보면 현재의 컴파일러-링커 모델을 꽤나 고치고 확장해야 구현이 가능한 것 같다. 이게 가능하면 무려 템플릿 가상 멤버 함수도 구현된다! export까지 지원될지도 몰라!)


 다중 디스패치를 구현하기 어려운 이유가 이를 이용하는 클래스/함수를 컴파일하는 시점에 인자로 주어지는 클래스 계통에 대한 완전한 정보가 필요한데, 이 정보를 확보하려면 프로그램 전체에 대한 분석이 필요하기 때문이다. 실시간으로 인자를 타입 메타 데이터들과 비교하면서 함수를 고르는 O(n) 알고리즘을 쓰면 해결이야 되겠지만, 언어의 기반으로 써먹을 수준은 못 된다. 고로 디스패치 알고리즘이 상수 시간이거나 최소한 인자 수에 선형 비례해야 함은 물론이고 호출 부하 역시 최소화해야 한다. 이러려면 디스패치를 위한 함수 테이블을 만들어 쓸 수 밖에 없는데, 이러려면 클래스 계통을 알아야 하니 프로그램 전체에 대한 분석이 필요함은 자명하다.


이 문제를 어찌 해결했다 칠 때, 이후의 구현 방법은 Modern C++ Design 같이 기존 언어 위에 라이브러리로 올리는 방법부터 위의 Open method를 비롯한 Most Specific Applicable, Multiple Row Displacement, Single Receiver Projections 등등 다양한 기법이 있는 듯 하나 솔직히 잘 모르므로 -_-; 차후를 기약해보자.





 새로운 언어를 디자인할 때 집중해야 할 점 중 하나는 언어 자체에 기인한 불필요한 복잡성은 가급적 제거하여 프로그래머로 하여금 문제에 내재된 본질적인 복잡성만을 다룰 수 있도록 하는 것이 아닌가 싶다. 그래서 이번에 다룬 요소들은 주로 언어 자체의 한계로 인해 발생하는 부수적인 복잡성을 줄이고 언어의 규칙을 조금 더 직관적/일관적으로 만드는데 초점을 맞춘다.


 어떻게 보자면 이런 개선에는 옛날의 객체 지향 프로그래밍이나 요즈음의 함수형 프로그래밍, 병행 프로그래밍 같이 생각의 틀 자체를 바꾸는 (혹은 바꾸도록 만드는 -_-;) 그런 힘은 없다. 그냥 프로그래밍을 조금 더 편하게 할 수 있도록 도와주는, 높은 곳에서 보자면 소소한 수준의 의미를 가지는 기능들이다. 근데 관점을 바꿔보자면 인간의 사고 회로는 무척이나 직렬적이기 때문에 상태라는 것을 가지는 명령형 언어가 가장 친숙한게 사실이고, 수학적인 사고 회로에 기반하는 함수형 언어는 기본적으로 꽤나 높은 허들을 가진다. 그렇다면 현재의 친숙한 모델을 적정선에서 유지 발전시키면서 고도로 발전하는 옆 동네를 주시하다가필요한 것만 적절하게 빼먹는게 언어 설계를 위한 가장 현명한 방법이 아닐까 싶기도 하다. (...)


 사실 그런 의미에서 여타 현대 언어들이 동시성을 지원하는 전략들에 대해서도 같이 정리해보려고 했는데 쉽게 정리하기도 까다롭고 또 이미 쓴 내용만 가지고도 너무 길어서 -_-; 근데 이런 건 사실 알 사람들은 이미 다 아는 내용이고 관심 없을 사람들은 평생 관심 없을 내용이라 괜히 쓰는 것 같다는 생각도 든다. orz

  1. 프로그램의 코드가 언어에서 지원되는 자료 구조로 표현 가능한 특징을 가리키는 용어다. 이런 특성을 지니는 언어에서는 수행되는 코드가 실시간으로 변경될 수 있기 때문에 아주 강력한 수준의 메타 프로그래밍이 가능해진다. [본문으로]
  2. 내부 스코프에서는 외부 스코프에서 선언된 이름들에 접근 가능하다는 기본적인 규칙을 의미한다. [본문으로]
  3. 물론 이게 항상 좋다고만 볼 수는 없다. 다형성은 로직을 "감추기" 때문인데, 따라서 프로그램 구조와 로직을 잘 구분하여 구조에 해당되는 부분에만 적절하게 적용할 필요가 있다. [본문으로]
  4. 객체들이 가지는 멤버 함수들의 정체는 첫 인자를 this로 받는 함수이다. 즉, 가상 함수 호출은 첫 인자의 타입만을 가지고 동적으로 디스패치되는 다중 디스패치의 특수한 경우라고 할 수 있다. [본문으로]
Trackback 0 : Comments 3

프로그래밍 언어 만들기 上

컴퓨터 과학 2010.12.05 14:11
소프트웨어 엔지니어들에게 있어서 로망이 있다고 하면 OS, DB, 컴파일러, 프로그래밍 언어 같이 컴퓨터 과학의 정수를 담은 물건을 직접 만들어 보는 게 하나다. 특히나 프로그래머들이 직접 사용하는 도구인 프로그래밍 언어가 그런 경향이 좀 있다. 좀 쓰다보면 다른 언어와 비교되는 아쉬운 점이 계속 보이는데, 진행 중인 프로젝트에서 사용하는 언어를 갈아 타는 일은 대개 불가능해서 입맛만 다신다.

나는 C++을 주로 사용하는데, 이 언어는 여러 가지 문제점을 가지고 있는 언어다. 그런데 표준화 위원회에 의해 개발되기 때문에 발전 속도도 무척 느리다. [각주:1] 다른 언어들에서는 언어 차원에서 기본적으로 지원되는 기능이 C++에서는 차기 표준은 커녕 검토 목록에도 아직 올라오지 않은 것을 보면 배가 아프다. 이런 문제에 대한 가장 근본적인 해법은 그냥 새로 언어를 만드는 것이다. 그런데 그게 쉬운 일은 아니니 공상부터 하게 된다.

서두가 쓸데 없이 길었다. 제목에 낚시성이 농후한데, 조금 더 정확한 제목은 "프로그래밍 언어 상상해보기" 정도일 것이다. 쓰는 언어의 근본이 C++이라 학계의 최신의 연구 결과를 반영하는 것까진 감히 상상하지 못하고, 그저 C++을 내 취향대로 다시 reengineer한다면 이런 모양이 아닐까 이런 느낌이다.


1. 기본적인 문법

언어의 기본적인 성격과 문법들, 이를테면 절차 지향 언어라는 점 혹은 사용하는 표현식 문법이나 변수 선언, 블록, 상수성 등에 있어서는 C 계열의 언어로 분류될 수 있을 것이다. C/C++이 복잡한 문법을 가지는 언어로 지탄받지만, 이런 부분은 의외로 직관적이고 괜찮은 편이다. 다만 함수 문법은 약간 수정되어야 할 필요가 있다고 생각한다. 이를테면 아래와 같은 코드가 있다고 하면

someclass someobject(someargument);

현재의 C++과 같은 문법으로는 이게 someclass 타입의 실체화인지 아니면 someclass라는 타입을 반환하는 함수의 선언인지 문맥을 파악하기 전까지는 알 수 없다. 다른 사례를 들자면

// ScopedLock은 operator bool 함수를 오버 로딩하고 있으므로
// 필요한 경우 암묵적으로 false로 캐스팅이 된다.

// 컴파일 에러
#define synchronized(lock) \
    if (ScopedLock<decltype(lock)> __scope(lock)) {} else
// 컴파일 됨
#define synchronized(lock) \
    if (ScopedLock<decltype(lock)> __scope = lock) {} else

이건 자바의 synchronized 키워드를 에뮬레이션하기 위해 만들어 본 매크로인데, 안타깝게도 전자는 함수 선언을 if 문에 넣은 것으로 간주하여 컴파일이 되지 않는다. 왜 이렇게 되는지가 쉽게 이해가질 않는데, 어쨌든 직관성이라는 부분에 있어서 이 부분은 빵점이다. 애초에 전역 함수가 없거나 선언과 정의라는 개념을 분리하지 않는다면 간단하게 해결될 문제긴 하지만.


2. 정적 강타입 언어

나는 코딩할 때 실수가 잦은 편이라 정적 강타입 언어를 선호한다. IDE의 도움을 받지 않으면 코드를 살짝만 수정해도 에러가 5~6개씩 뜨는건 기본이다. 그런데 만약 여기에서 타입 안전성 체크가 없었다면 어떤 재앙이 일어났을지 생각해보면 좀 무섭다. 사실 이게 lua를 쓸 때 가장 난감했던 점이기도 하다. 변수나 멤버 이름 하나를 오타 내도 런타임에 nil을 꺼내 쓸 뿐 컴파일 시점에서 아무런 경고가 뜨지 않는다. 이런 걸 디버깅하는 것은 무척 괴로운 일이다. 변수를 명시적으로 선언해서 쓰는 언어에서는 이런 문제가 덜하지만, 타입 정보가 없음으로 인해 프로그래머가 가정할 수 있는 내용이 적다는 것은 여전히 약점이다.

정적 타입 언어의 강점은 또 하나 있다. 타입 정보가 컴파일 시점에 완전히 알려지기 때문에 최종적으로 수행되는 코드의 효율성은 동적 타입 언어의 그 것과 비교가 되질 않는다는 것이다. 아래와 같은 코드가 있다고 치자.

var a = 10, b = 2.0f, c = "haha";
var d = a+b+c;

동적 타입 언어에서는 우선 a, b의 타입을 체크해서 어떻게 타입 캐스팅을 하고 덧셈 연산을 수행할 지 판단한 뒤에야 실제 덧셈 연산이 수행될 수 있다. 위 같은 코드는 그나마 컴파일 시점에 최적화를 해볼 여지가 있지만, 함수 인자 등으로 넘어오는 경우는 얄짤 없다. 하지만 정적 타입 언어는 모든 타입 정보를 컴파일 시점에 가지고 있기 때문에 타입 캐스팅과 수행할 덧셈 연산이 컴파일 시점에 전부 결정된다. 분기문이 완전히 사라지므로 CPU 파이프라인을 훨씬 효율적으로 사용할 수 있다. 당연히 수행 효율은 동적 타입 언어가 범접할 수 없는 수준이다. 물론 요즘 컴파일러/가상머신 기술들이 좋아져서 그 격차가 많이 줄어들긴 했지만, 이건 내가 직접 만든다는 가정하에 공상을 하는 것이므로 내가 구현할 수 없는 내용엔 큰 의미를 두지 않겠다. (...)

물론 강타입 언어의 사용성은 동적 타입 언어에 비해 현저히 떨어진다. C++의 iterator 삽질만 해도 그렇다. 좀 오버스러운 사례지만, std::vector<std::list<std::pair<someclass::key, someclass::value> >::iterator >::iterator 이런 것을 일일히 쳐주다 보면 내가 무슨 일을 하는건지 알 수 없는 경지에 도달하게 된다. 하지만 기본적인 수준의 타입 유추 기능이라도 도입하면 이런 문제는 많이 완화시킬 수 있다. 사실 템플릿을 지원 안 하는 것도 좋은 방법이다. 취미 수준으로 만들기에 C++ 같은 템플릿은 너무 방대하고 복잡하다.


3. 구조적 타이핑 Structural typing

객체 지향 프로그래밍을 한다고 할 때 보통 각각의 객체에는 부여된 책임이 있을 것이고, 이를 수행하기 위해 필요한 연산의 집합이 있을 것이다. 전통적인 OOP 언어에서는 해당되는 연산의 집합을 정의하는 상위 클래스를 만든 뒤 이를 상속받아서 다형성을 이용한다.
class Duck {
public:
    virtual void walk() = 0;
    virtual void swim() = 0;
    virtual void quack() = 0;
};
// 새끼 오리
class BabyDuck : public Duck {
    ...
};
// 나는 오리
class FlyingDuck : public Duck {
    ...
};
Duck* duck = new FlyingDuck;

반면 덕 타이핑을 지원하는 언어에서 조금 다른 방식의 다형성이 지원된다. 이는 "오리처럼 걷고, 오리처럼 수영하고, 오리처럼 꽥꽥거리는 새를 본다면 이를 오리라고 한다."라는 설명에서 유래한 용어다. 동일한 메타포를 이용하여 상속 기반 다형성을 표현하자면 "오리란 오리처럼 걷고 오리처럼 수영하고 오리처럼 꽥꽥거리는 새이다" 정도가 될 수 있다.

이 개념을 지원하는 언어에서는 상속과 같이 코드에 선언된 타입 정보와는 무관하게 런타임에 객체가 지원하는 연산 집합을 판단, 사용하려는 문맥에 의미적으로 호환되는지를 확인한다. 따라서 타입 호환 여부를 런타임 환경에서 직접 검증해야 하는 대부분의 동적 타입 언어에서는 덕타이핑을 어렵지 않게 지원할 수 있다. 하지만 정적 타입 언어에서는 이야기가 다르다. 컴파일되고 나면 대부분 심볼 및 타입 정보가 날아가 버리며, 설령 타입 관련 메타 데이터들이 남아 있다고 해도 이를 실시간으로 찾아서 디스패칭해주는 오버헤드를 감수하는 것은 있을 수 없는 일이다. 게다가 타입 안전성을 런타임에 체크하려면 굳이 정적 타입 언어를 쓸 이유가 없다.

구조적 타이핑은 그 사이의 중간점을 취한다. 상속과 같은 타입 구조가 아닌 지원하는 연산에 따라 타입을 판별하는 것은 덕 타이핑과 똑같다. 하지만 타입 간의 호환성은 컴파일 시점에 검증함으로써 타입 안전성과 더불어 수행 효율을 증대시키는 방법이다.
interface Duck {
public:
    void walk();
    void swim();
    void quack();
};
// 새끼 오리
class BabyDuck {
    ...
};
// 나는 오리
class FlyingDuck {
    ...
};
Duck duck = new FlyingDuck;

구조적 타이핑을 지원하면 얻을 수 있는 이점으로는 여러 가지가 있겠지만 가장 큰 것으로는 각각의 객체들이 타입 정의에 종속되지 않기 때문에 프로그램 내부의 결합도가 크게 낮아진다는 강점이 있다. 요구하는 인터페이스와 구현 사이의 관계가 칼 같이 분리되기 때문이다. 다형성을 활용하기 위해 상속 구조를 고민할 필요가 없어지고, 그저 인터페이스에서 요구하는 연산을 구현해주기만 하면 된다. 그리고 타입에 대해 필요한 가정이 객체가 지원하는 연산 집합으로만 한정되므로 C++의 템플릿을 이용하는 것처럼 일반화 프로그래밍 Generic programming이 가능해지는 것도 한 가지 장점이다.

이렇게 좋은 개념이 있음에도 불구하고 그간의 주류 OOP 언어에서 지원하지 않은 이유는 이를 효율적으로 구현하는 데에 있어 난점이 있었기 때문이다. 상속 기반 다형성을 구현하기 위해서 보통 가상 함수 테이블을 사용하는데, 이는 상속이라는 개념을 통해 해당 클래스가 어떤 인터페이스에서만 사용될지를 예측할 수 있어서 가능한 것이다. 하지만 구조적 타이핑 언어에서는 비슷한 방법을 쓰려면 프로그램 전체에 대한 분석을 수행하던지 모든 클래스와 모든 인터페이스에 대해 필요한 디스패치 테이블을 만들어줘야 한다. 이런 이유로 구조적 타이핑을 지원하는 언어가 흔치 않았는데 비교적 최신 언어인 haskell이나 구글의 go 등에서는 타입의 메타 데이터를 이용, 실제로 사용되는 클래스-인터페이스 조합에 대해서만 런타임에 딱 한번 디스패치 테이블을 만들어 사용하고 컴파일 시점에는 타입 안전성만을 체크하는 방법을 통해 효율적인 구현을 해냈다.


4. 가비지 컬렉션 Garbage collection

C++ 프로그래머들에게 Java에서 가장 부러운 것을 들라고 하면 아마도 첫 번째로는 방대한 표준 라이브러리가 나올 것이고, 두 번째로는 가비지 컬렉션이 나올 거라고 생각한다. 적어도 난 그렇다. 그래도 전자는 boost라는 괴물이 있기 때문에 많은 부분 상쇄될 여지가 있지만, 후자는 그 구현의 난이도 때문에 당분간은 가망이 보이질 않는다. [각주:2]

다행스럽게도 하위 호환성 신경 쓸 필요 없는 완전히 새로운 언어를 만드는 경우의 가비지 컬렉션 지원은 "비교적" 간단하다. 그런데 멀티 쓰레드 환경에 들어가면 그 비교적이라는 단어의 의미가 좀 무색해지긴 한다. orz

가비지 컬렉션의 가장 분명한 강점은 당연히 메모리 관리가 수월해진다는 점에 있다. 하지만 그 외에 얻을 수 있는 것으로는 추가적으로 메모리 압축 memory compaction을 지원함으로써 얻는 성능적인 이점이다. 메모리 압축이 이루어지면서 단편화 문제도 원천적으로 사라지고, 캐쉬 지역성을 조금 더 잘 활용할 수 있다는 점 등으로 인해 가비지 컬렉션으로 인한 성능적인 약점이 어느 정도 상쇄된다. 또한 메모리를 어떻게 할당해도 최종적으로는 공간을 최적으로 활용한다는 보장이 있기 때문에 새로운 메모리를 할당하는 속도가 일반적인 메모리 할당자와는 비교도 안 될 정도로 빨라진다는 점도 있다. [각주:3]

허나 가비지 컬렉션이 아무리 빨라져도 수동으로 해제하는 것에 비해서는 빠르기가 어렵다. 이게 단순히 프로그램 전반의 throughput이 떨어지는 정도라면 큰 문제가 아니겠지만, 그 부하가 한 순간에 집중됨으로써 반응 속도가 느려지는 것이 문제다. 이를 해결하기 위해 사용되는 다양한 알고리즘들이 있지만 모든 경우에 대해 완벽하게 적용 가능한 일반적인 알고리즘은 없다. 또한 가비지 컬렉션을 사용하는 경우 프로그래머가 객체의 수명을 직접 컨트롤할 수 없다는 것 역시 큰 문제다. 따라서 가비지 컬렉션에 사용되는 정책을 필요에 따라 적절하게 조절할 수 있는 기능과 더불어 소유권이 명백한 객체에 대해서는 사용자가 수동으로 관리할 수 있도록 하는 방법을 공존시킬 방안을 모색해봐야 한다.


어쩐지 너무 길어지고 있다. 다음에 꼐속.
  1. C++0x가 2012년에 공표된다는 이야기를 보면 마이너 패치였던 C++03을 제하면 첫 표준 이후로 다음 표준까지의 기간이 14년이나 걸리는 셈이다. [본문으로]
  2. 소멸자가 호출되는 시점이 결정적(deterministic)이라는 C++의 특성과 연관된 부분들에서 많은 문제점들이 발생한다. 포인터를 값으로 직접 다룰 수 있는 언어의 특성도 한 몫한다. [본문으로]
  3. 오라클의 JVM 구현에서는 메모리 할당이 10사이클 내외에 이루어진다. [본문으로]
Trackback 1 : Comment 0

티스토리 툴바