지난번 글에서 함수형 프로그래밍의 장점을 아래와 같이 한마디로 요약했었다:

(훨씬) 더 짧고 (훨씬) 더 이해하기 쉽고 (훨씬) 더 버그가 적은 코드를 (훨씬) 더 짧은 시간 안에 만들 수 있다.

그런데 실제로 인터넷을 검색해서 찾을 수 있는 함수형 프로그래밍에 관한 글과 유튜브 동영상 같은 걸 보면 무슨 얘긴지 이해하기 힘든 것들이 대부분인 것 같다. 만약 함수형 프로그래밍이 (훨씬) 더 짧고 (훨씬) 더 이해하기 쉬운 코드를 만드는데 기여한다면, 배우기도 (훨씬) 더 쉬워야 하지 않을까? 왜 함수형 프로그래밍은 어려운 것인지 그 이유를 두가지로 생각해 보았다:

  1. 함수형 프로그래밍에 적합하지 않은 언어로 함수형 프로그래밍을 하기 때문에
  2. 사실은 어려운 것이 아니고 아직 익숙해지지 않은 것뿐

여기서는 간단한 로또 번호 생성기를 만들면서 이 두가지 이유에 관해 생각해 보기로 한다. 일단 로또 번호 생성에는 세가지 조건이 필요하다:

  1. 숫자의 범위는 1부터 45까지다.
  2. 총 6개의 숫자가 필요하다(편의상 보너스 숫자는 생각않기로 함).
  3. 숫자들끼리 서로 겹치지 않는다.

명령형 스타일로 만든 C# 로또 번호 생성기

함수형 프로그래밍과 반대되는 기존의 프로그래밍 스타일을 명령형(imperative)이라고 한다. 첫번째로 가장 흔하게 생각할 수 있는 코드는 다음과 같다:

int[] LottoNumbers_UsingArrayV1(int minValue, int maxValue, int count) {
    var rand = new Random((int)DateTime.Now.Ticks);
    var numbers = new int[count];
    // XXX 아래 코드는 버그 있음
    for (int i = 0; i < count; i++) {
        var num = rand.Next(minValue, maxValue + 1);
        numbers[i] = num;
    }
    return numbers;
}

여섯개의 숫자를 담아야 하기에 크기 6 짜리 배열을 만들고 루프를 돌면서 1부터 45까지의 난수를 배열의 매 칸에 차례대로 담는 방식이다. 언뜻 정상적인 코드처럼 보이고 실행도 잘 되지만 계속해서 돌리다 보면 이내 버그를 발견할 수 있다(Visual Studio 내의 C# Interactive에서 실행함):

> LottoNumbers_UsingArrayV1(1, 45, 6)
int[6] { 25, 9, 30, 1, 5, 23 }
> LottoNumbers_UsingArrayV1(1, 45, 6)
int[6] { 35, 35, 29, 9, 7, 9 }

두번째 실행에서 9란 숫자가 중복 생성된 것이 보인다. 이를 해결하려면 루프 중간에 중복을 걸러내는 코드를 삽입한다:

int[] LottoNumbers_UsingArrayV2(int minValue, int maxValue, int count) {
    var rand = new Random((int)DateTime.Now.Ticks);
    var numbers = new int[count];
    // XXX 아래 코드는 버그 있음
    for (int i = 0; i < count; i++) {
        var num = rand.Next(minValue, maxValue + 1);
        if (!numbers.Contains(num)) {
            numbers[i] = num;
        }
    }
    return numbers;
}

이렇게 하면 중복은 제거되지만 이번에는 다른 문제가 발생한다:

> LottoNumbers_UsingArrayV2(1, 45, 6)
int[6] { 24, 38, 22, 4, 0, 20 }

중복을 제거하는 바람에 번호가 5개만 생성되면서 빈 자리가 0으로 남겨진 것이다. 이를 해결하기 위해 루프 카운트 증가의 위치를 루프 내부로 옮긴다:

int[] LottoNumbers_UsingArrayV3(int minValue, int maxValue, int count) {
    var rand = new Random((int)DateTime.Now.Ticks);
    var numbers = new int[count];
    for (int i = 0; i < count;) {
        var num = rand.Next(minValue, maxValue + 1);
        if (!numbers.Contains(num)) {
            numbers[i] = num;
            i++;
        }
    }
    return numbers;
}

이렇게 수정하고 나면 비로소 처음의 세가지 조건을 모두 만족시키는 로또 번호 생성기가 된다. 그런데 이 코드는 루프 카운트를 변칙적으로 사용함으로써 앞의 두 코드보다 훨씬 지저분해 보인다. 이를 해결하기 위해 for 루프를 while 루프로 대체한다:

int[] LottoNumbers_UsingArrayV4(int minValue, int maxValue, int count) {
    var rand = new Random((int)DateTime.Now.Ticks);
    var numbers = new int[count];
    int i = 0;
    while (i < count) {
        var num = rand.Next(minValue, maxValue + 1);
        if (!numbers.Contains(num)) {
            numbers[i] = num;
            i++;
        }
    }
    return numbers;
}

이전 버전보다는 깔끔해졌지만 이번에는 루프 카운트가 루프 밖을 벗어나 선언되어 있는 모양이 별로 좋지 못하다. 더 나은 방법은 아예 배열을 List로 대체하는 것이다:

List<int> LottoNumbers_UsingList(int minValue, int maxValue, int count) {
    var rand = new Random((int)DateTime.Now.Ticks);
    var numbers = new List<int>();
    while (numbers.Count != count) {
        var num = rand.Next(minValue, maxValue + 1);
        if (!numbers.Contains(num)) {
            numbers.Add(num);
        }
    }
    return numbers;
}

이것이 명령형 스타일의 최종 버전이 되겠다.

여기서 눈여겨 볼 점은 루프라는 구문 자체가 프로그램을 지저분하게 만들기 쉽다는 것이다(특히 오래된 C 스타일 for 루프는 더더욱). 거기다 배열은 본질적으로 원소의 값을 계속해서 수정하는 가변(mutable) 타입이기 때문에 함수형 프로그래밍의 핵심 개념인 불변성과는 상극이다. 따라서 이러한 루프와 배열을 중심으로 프로그래밍이 이루어지는 언어로는 함수형 프로그래밍을 제대로 하기가 사실상 불가능하다.

함수형 스타일로 만든 C# 로또 번호 생성기

이번에는 함수형 스타일로 만든 버전이다:

IEnumerable<TResult> RunInfinite<TResult>(Func<TResult> func) {
    while (true)
        yield return func();
}

IEnumerable<int> LottoNumbers_Functional(int minValue, int maxValue, int count) {
    var rand = new Random((int)DateTime.Now.Ticks);
    return RunInfinite(() => rand.Next(minValue, maxValue + 1))
           .Distinct()
           .Take(count);
}

코드를 그대로 읽으면: RunInfinite 메쏘드로 난수 발생 함수(() => rand.Next(minValue, maxValue + 1)를 반복 실행하면서 난수를 무한대로 생성해내고, 겹치지 않는 값만 골라서(.Distinct()), 주어진 개수 만큼 취한다(.Take(count)).

여기서 난수를 무한 생성하는 동안 메모리 부족 문제가 일어나지 않는 이유는 전체 문장의 제어권을 마지막에 실행되는 Take(count)가 가지기 때문이다. 따라서 실제로 RunInfinite()는 무한개가 아니라 정확히 count개의 난수만 생성하게 된다.

그리고 이렇게 생성한 숫자들을 배열에 담을지 리스트에 담을지는 받는 쪽에서만 신경쓰면 된다:

> LottoNumbers_Functional(1, 45, 6)
TakeIterator { 13, 4, 25, 16, 29, 15 }
> LottoNumbers_Functional(1, 45, 6).ToArray()
int[6] { 9, 20, 40, 11, 4, 10 }
> LottoNumbers_Functional(1, 45, 6).ToList()
List<int>(6) { 24, 34, 3, 44, 17, 5 }

지금까지 살펴본 코드를 통해 알 수 있는 함수형과 명령형 프로그래밍의 차이는 다음과 같다:

  • 함수형 프로그래밍은 루프가 없다.
  • 함수형 프로그래밍은 값이 변하는 변수가 없다.
  • 함수형 프로그래밍은 추상화 단계가 높다.
  • 함수형 프로그래밍은 컴퓨터보다 사람의 사고 체계에 더 가깝다.

만약 프로그래밍을 처음 배울 때 함수형 프로그래밍으로 시작했더라면 오히려 앞서 소개한 배열과 루프를 기반으로 한 명령형 방식이 훨씬 어렵고 이해하기 힘들지 않을까?

함수형 스타일로 만든 F# 로또 번호 생성기

이번에는 F#으로도 같은 코드를 만들어 보았다:

open System

let lottoNumbers minValue maxValue count =
    let rand = Random(int DateTime.Now.Ticks)
    Seq.initInfinite (fun _ -> rand.Next(minValue, maxValue + 1))
    |> Seq.distinct
    |> Seq.take count

구조 자체는 함수형 스타일 C# 버전과 거의 똑같고 결과도 완전히 같지만 {}, ,, ; 같은 군더더기가 싹 제거되어 코드가 훨씬 깔끔해 보인다. 심지어 F#에서는 대부분의 경우 타입 지정을 생략할 수 있다. minValue, maxValue, count 등은 int같은 타입 지정이 없음에도 불구하고 전부 문맥에 맞는 적당한 타입으로 유추되어 컴파일된다.

다음 글에서는

요새는 C++, C#, 자바 같은 기존 언어에도 함수형 프로그래밍 구문이 언어 차원에서 제공되고 있고, 코어 라이브러리에도 함수형 프로그래밍에 필요한 API가 지원된다. 그렇지만 원래부터 함수형 프로그래밍을 위해 설계된 F#같은 언어보다는 아무래도 프로그램하기가 힘든 것은 어쩔 수 없다. 다음 글에서는 F# 언어 자체에 관해 더 자세히 소개하기로 한다.