Eternity's Chit-Chat

aeternum.egloos.com



행위 매개변수화(Behavior Parameterization) - 4부[完] Software Design

언어별 행위 매개변수 구현 방식

프로그래밍 언어라는 울창한 숲을 헤매다 보면 언어 간에 의미상으로는 유사하지만 문법상으로는 상이한 여러 가지 표현들과 마주치게 된다. 새로운 프로그래밍 언어의 문법을 익히다 보면 그 언어의 어떤 요소가 다른 언어의 특정 요소와 외관상으로는 다르지만 유사한 방식과 의미로 사용된다는 것을 느낄 때가 있다. 언어 간의 커다란 차이점에도 불구하고 어떤 유사성이 두드러져 보이는 경우가 있다. 그런 유사성의 대표적인 예가 바로 고차 함수다.


모든 언어가 고차 함수의 개념을 직접적으로 지원하는 것은 아니다. 앞서 설명했던 것처럼 고차 함수의 전제조건은 함수(또는 프로시저)가 해당 언어의 일급 시민에 속해야 한다는 것이다. 함수가 일급 시민에 속하지 않는 언어에서 함수를 다른 함수의 매개변수로 전달하는 것은 원칙적으로 불가능하다. 따라서 이런 언어에서 고차 함수의 개념을 모방하는 일반적인 방법은 일급 시민에 속하는 구성 요소로 함수를 감싼 후 해당 구성 요소를 함수의 매개변수로 전달하는 것이다.

여기에서는 몇 가지 비함수형 언어에서의 map() 구현 코드를 살펴 봄으로써 고차 함수를 구현하는 다양한 기법들에 관해 살펴 보기로 한다. 비록 사용하는 언어에 따라 구현 방법이나 문법이 달라진다고 하더라도 이런 기법을 사용하는 이유는 모두 동일하다는 점을 기억하자. 즉, 변하는 부분을 분리시켜 불필요한 부수효과와 중복 코드를 방지하고 재사용성과 유연성을 향상 시키는 것이다.


대부분의 언어는 map()의 기능을 지원하는 표준 라이브러리나 표기법을 제공한다. Ruby 프로그래머라면 map()을 구현하는 대신 collect() 메서드를 이용할 것이다.

    [1, 2, 3, 4].collect {|x| x * x}

Python 프로그래머라면 언어에서 지원하는 리스트 내장(List Comprehension) 표기법을 이용해 동일한 효과를 얻을 수 있을 것이다.

    [x * x for x in [1, 2, 3, 4]]

다양한 언어를 이용해 map()의 구현 방법을 살펴 보는 이유는 언어에서 제공하는 클래스 라이브러리나 표기법을 대체하려는 것이 아니라 언어 간의 문법적인 차이점에도 불구하고 언어들을 관통하는 공통의 설계 원칙이 존재한다는 점을 강조하기 위해서다. 프로그래밍 언어는 정신 모델(mental model)을 코드라는 형태로 표현하기 위한 도구일 뿐이다.



C 구현

C 언어는 행위를 매개변수화 할 수 있는 ‘함수 포인터(function pointer)’를 지원한다. 함수 포인터란 함수의 주소를 값으로 저장할 수 있는 포인터 변수를 의미한다. 따라서 함수 포인터를 함수의 인자로 전달 받은 후 내부적으로 전달된 함수 포인터를 통해 함수를 호출하는 방식으로 고차 함수의 형식을 흉내낼 수 있다.

먼저 리스트의 항목을 제곱해서 반환하는 함수를 구현한다.

int square(int item)
{
    return item * item;
}
map() 함수는 항목 변환에 사용할 함수를 함수 포인터 형태의 인자로 전달 받아 내부적으로 호출한다.
int * map(const int * items, int len, int (*conv)(int item))
{
    int * result = (int *)malloc(len * sizeof(int));
    for(int loop=0; loop < len; loop++)
   
{
         result[loop] = (*conv)(items[loop]);
   
}
    return result;
}
클라이언트는 map() 함수를 호출하면서 앞에서 선언한 square 함수의 주소를 인자로 전달한다.

int items[] = {1, 2, 3, 4};
int * result = map(items, 4, square);

Python 구현
Python 언어는 Lisp과 유사하게 익명 함수를 생성할 수 있는 람다(lambda) 표현식을 제공한다. Python의 람다 표현식은 Lisp과 동일하게 다른 함수의 인자로 전달될 수 있으며 함수의 결과 값으로 반환될 수 있다. Python에서 x의 값을 제곱하는 익명 함수는 다음과 같은 형식으로 선언할 수 있다.
lambda x : x * x
map() 함수는 다음과 같이 리스트(items)와 변환 함수(proc)를 인자로 받는 함수로 정의할 수 있다. map() 함수는 각 리스트의 항목 별로 변환 함수를 호출한 후(proc(each)) 변환된 결과를 리스트에 담아 반환한다.
def map(items, proc):
    result = []
    for each in items:
        result.append(proc(each))
    return result
클라이언트는 리스트와 람다 함수를 인자로 전달해서 map() 함수를 호출할 수 있다
map([1, 2, 3, 4], lambda x : x * x)

Ruby 구현

Ruby 언어에서는 메서드의 인자로 전달 가능한 코드 ‘블록(Block)’을 정의할 수 있다. 블록은 Python의 람다 표현식과 동일하게 익명 함수를 생성하기 위해 사용되지만 Smalltalk의 영향을 받아 Python에 비해 깔끔하고 가독성이 높은 표기법을 제공한다.

다음은 x의 제곱을 구하는 익명 함수를 정의하는 Ruby의 블록 표현식을 나타낸 것이다.

{|x| x * x}
블록을 함수의 인자로 받기 위해서는 인자 앞에 &를 붙이고 함수 내에서 인자로 받은 블록을 호출할 때에는 yield 예약어를 사용한다. 다음은 배열(items)과 리스트 항목을 변환하는 함수(proc)를 인자로 받는 map() 함수의 코드를 나타낸 것이다.
def map(items, &proc)
  result = []
  for each in items
    result << (yield each)
  end
  result
end
배열과 블록을 이용해 map()을 호출하는 코드는 다음과 같다.
map([1, 2, 3, 4]) {|x| x * x}

C# 구현

C# 언어의 경우 고차 함수의 개념을 사용할 수 있도록 람다 표현식을 지원한다. C#의 람다 표현식 역시 Ruby의 블록이나 Python의 lambda처럼 익명 함수를 생성하고 전달하기 위해 사용된다. C#에서 람다 표현식을 이용해 생성된 메서드나 다른 객체의 메서드를 가리키기 위해서는 delegate 키워드를 사용한다.

컬렉션과 변환 기능을 구현하고 있는 함수를 인자로 전달 받는 Map() 함수를 구현하자.
class Mapper
{
    public delegate T Converter<T>(T item);

    public static ICollection<T> Map<T>(ICollection<T> items,
        Converter<T> Converter) {

        ICollection<T> result = new List<T>();
        foreach(T each in items) {
            result.Add(Converter(each));
        }
        return result;
    }
}
이제 람다 표현식을 사용해서 생성된 익명 함수를 Map() 함수에 전달하기만 하면 된다.
Mapper.Map(new List<int>(new int[] { 1, 2, 3, 4 }), (int x) => { return x * x; });

C++ 구현

과거의 C++에서는 Ruby나 Python처럼 고차 함수의 개념을 직접적으로 지원하지 않았다. 즉, C++에서 함수는 일급 시민이 아니었다(최신 C++11 명세는 람다 표현식을 포함하고 있다. 이에 대해서는 뒤에서 설명한다). 과거의 C++과 같이 함수를 일급 시민으로 취급하지 않는 객체지향 언어에서 행위를 매개변수화할 수 있는 유일한 방법은 함수를 일급 시민에 속하는 ‘객체’로 랩핑하는 것이다.

C++에서 객체를 이용해 행위를 매개변수화하기 위해 사용하는 이디엄을 ‘펑크터(Functor)’라고 부른다. 펑크터는 ‘() 연산자’를 오버로딩해서 객체를 함수처럼 보이게 만든다. 따라서 객체를 사용하면서도 실제로 함수를 호출하는 것과 같은 시각적 효과를 얻을 수 있다.

다음은 map() 함수에 전달되어 리스트의 항목들을 변환할 펑크터를 구현한 것이다.

template <typename type>
class square
{
  public:
      const type operator ()(const type &item) const { return item * item; }
};
map() 함수는 두 번째 인자인 conv에 펑크터를 전달 받아 변하는 행위를 매개변수화한다. 펑크터는 ‘() 연산자’를 오버로딩하고 있기 때문에 실제로는 오버로딩된 ‘() 연산자’를 호출하면서도 conv(*pr)과 같이 외부에서는 함수를 호출하는 것처럼 이용할 수 있다.
template <typename type, typename converter>
vector<type> map(const vector<type> & items, const converter & conv)
{
     vector<type> result;
     vector<type>::const_iterator pr;
     for(pr = items.begin(); pr != items.end(); pr++)
     {
         result.push_back(conv(*pr));
     }
     return result;
}
클라이언트는 변환할 배열과 함께 펑크터를 map() 함수의 인자로 전달한다.
int values[] = {1, 2, 3, 4};
vector<int> items(values, values+4);

vector<int> result = map(items, square<int>());
펑크터는 함수를 객체로 가장함으로써 행위를 매개변수화 할 수는 있지만 변형이 필요할 때마다 항상 별도의 클래스를 추가해야 하므로 사용하기가 조금은 불편하다. 사용상의 불편함을 해결하기 위해 C++의 최신 명세인 C++11(C++0x라고도 알려져 있으며 2011년 8월에 공식적으로 승인이 되었다)에서는 독립적인 클래스를 추가하지 않고도 Ruby나 Python 처럼 익명 함수를 정의할 수 있는 람다 표현식이 추가되었다.

람다 표현식을 사용할 경우 템플릿을 이용한 map()의 구현에 변경은 없으며 map()을 호출하는 클라이언트 코드에서 펑크터 객체를 넘기는 대신 직접 익명 함수를 정의해서 넘기도록 수정하기만 하면 된다.
int values[] = {1, 2, 3, 4};
vector<int> items(values, values+4);

vector<int> result = map(items, [](int x) { return x * x; })
원칙상 C++의 펑크터는 클로저(Closure)가 아니다

고차 함수는 다른 함수를 인자로 받거나 내부적으로 함수를 생성해서 반환할 수 있는 함수를 의미한다. 지금까지 살펴 본 것처럼 함수를 다른 함수의 인자로 받을 수 있는 특성은 시스템의 행위를 매개변수화할 수 있는 기반을 제공한다. 그렇다면 함수를 생성 후 반환하는 고차 함수의 또 다른 특성은 언제 사용될까? 일반적으로 함수를 반환하는 고차 함수는 기존에 존재하는 함수를 특정한 방식으로 조합해서 새로운 함수를 정의하기 위해 사용된다.

고차 함수의 이러한 특성은 원래 필요한 인자 보다 더 적은 수의 인자를 이용해 새로운 함수를 생성하는 ‘부분 적용(partial application)’을 가능하게 한다. 부분 적용을 극단적으로 사용하는 것이 n개의 인자를 받는 함수를 1개의 인자만 받는 여러 개의 함수 조합으로 표현하는 ‘커링(currying)’ 기법이다.

예를 들어 두 수를 더하는 add() 함수가 있다고 가정하자.
add(x, y)       => x + y
만약 add() 함수의 두 인자 중 첫 번째 인자인 x를 특정한 값으로 고정시킬 수 있다면 add() 함수를 이용해 인자y만 받는 다양한 종류의 새로운 함수를 생성할 수 있다.
add1  = add(1)         add1(2)     => 3
dec1  = add(-1)        dec1(3)     => 2
add10 = add(10)        add10(2)    => 12
Lisp에서 커링은 다음과 같이 적용할 수 있다.
1: (define (add x) (lambda (y) (+ x y)))      # x 인자의 값을 기억하는 익명 함수 생성
2: (define add1 (add 1))                      # x의 값을 1로 고정하는 새로운 함수 반환
3: (add1 2)                                   # 인자로 전달된 2 를 앞의 1과 더함
1: add() 함수는 하나의 인자인 y를 취하는 익명 함수를 내부적으로 생성한 후 반환한다. 이때 y와 더할 인자 x는 add() 함수를 호출할 때 전달 받는다.
2: add() 함수의 x 인자를 1로 고정시켜 익명 함수를 생성한 후 이 함수에 add1이라는 이름을 붙인다. 따라서 add1() 함수는 (define (add1 y) (+ 1 y))과 동일하다.
3:  add1() 함수에 2를 전달해 익명 함수 생성 시에 전달된 1과 더한다. 따라서 결과는 3이 된다.

여기에서 add() 함수에서 반환되는 익명 함수(lambda (y) (+ x y))가 x의 값을 내부적으로 기억한다는 점에 주목하라. x는 반환된 함수의 별칭인 add1()의 호출 시점에 전달된 것이 아니라 add1()을 정의하는 시점에 전달된 값이다. 이처럼 함수가 실행되는 시점에 동적으로 유효 범위가 결정되지 않고 함수가 정의되는 시점에 유효 범위가 결정되는 것을 ‘어휘적 유효 범위(lexical scope)’라고 한다. 고차 함수가 반환하는 함수가 고차 함수에 속한 어휘적 유효 범위의 변수를 포함할 경우 이를 ‘어휘적 클로저(Lexical Closure)’ 또는 간단히 ‘클로저(Closure)’라고 부른다.

Ruby의 블록과 Python의 lambda 함수는 클로저다. 따라서 Ruby와 Python은 어휘적 유효 범위에 속한 변수를 포함하는 익명 함수를 생성할 수 있다. 다음은 add1() 함수를 Ruby를 이용해 구현한 것이다.
def add(x)
  return Proc.new {|y| x + y}
end

add1 = add(1)
add1.call(2)
이에 반해 앞에서 살펴본 펑크터 이디엄은 어휘적 유효 범위를 지원하지 않는다. 즉, 펑크터는 뒤에서 설명할 Java의 익명 클래스 방식으로 구현이 불가능하기 때문에 펑크터 생성 시점에 어휘적 유효 범위 내의 변수를 포함할 수 있는 방법이 없다(생성자를 통해 전달 받을 수는 있지만 이것은 어휘적 유효 범위의 범주에 속하지 않는다). 따라서 C++의 펑크터는 원칙상 클로저라고 할 수 없다.

이 글에서는 설명을 위해 어휘적 유효 범위의 지원 여부에 따라 펑크터와 클로저를 명확하게 분리하고 있지만 어휘적 유효 범위와 무관하게 단순히 함수를 감싸는 객체를 전달하는 경우에도 클로저라고 부르기도 한다. 클로저의 주된 용도가 콜백이기 때문에 문맥에 따라 명확하게 나누기가 애매한 것도 사실이다. 함수를 객체로 감싸는 패턴은 FUNCTION OBJECT, LEXICAL CLOSURE, FUNCTOR, AGENT, AGENT-OBJECT, FUNCTIONOID, FUNCTOID 등의 다양한 이름으로 불려져 왔다. 자세한 내용은 A Functional Pattern System for Object-Oriented Design을 참고하기 바란다.


예상하겠지만 C++11에 새롭게 추가된 람다 표현식은 클로저로 사용할 수 있다. C++11의 람다 표현식의 경우 어휘적 유효 범위의 변수를 포착하기 위해 명시적으로 람다 표현식 내의 ‘[]’안에 포착할 변수의 목록을 명시할 수 있다(어휘적 유효 범위 내의 모든 변수를 자동으로 포착하는 것도 가능하다).
function<int (int)> add(int x)
{
    return [x](int y) { return x + y; };  // 인자 x의 값을 [x]의 형식으로 포착
}

auto add1 = add(1);
add1(2);
Java 구현
Java에서 함수는 일급 시민에 속하지 않는다. 따라서 언어 차원에서 고차 함수의 개념을 직접적으로 지원하지 않고 있다(Java7에 람다 표현식이 추가될 예정이었으나 Java8으로 연기되었다). 따라서 C++의 펑크터처럼 함수를 객체로 감싼 후 이 객체를 다른 메서드의 인자로 전달함으로써 행위를 매개변수화할 수 있다.

Java는 독립적인 클래스를 구현하지 않고도 함수를 객체로 감쌀 수 있는 익명 클래스(anonymous class)를 지원하기 때문에 C++의 펑크터 보다는 좀 더 간편하게 행위를 전달할 수 있다.익명 클래스를 사용하면 메서드 내부에서 직접 이름이 없는 클래스를 정의하는 동시에 인스턴스를 생성할 수 있다. Java 언어에서 익명 클래스의 주요 용도 중 하나는 행위 매개변수화를 위한 콜백을 간편하게 구현할 수 있도록 하는 것이다(또 다른 용도는 클로저를 구현하는 것이며 뒤에서 설명하기로 한다).

우선 map() 함수에 매개변수로 전달할 행위를 인터페이스로 정의한다.
public interface Converter<T> {
  T to(T item);
}
map() 함수는 변환 기능을 구현할 인터페이스를 인자로 전달 받아 내부적으로 add() 오퍼레이션을 호출한다.
public class Mapper{
    public static <T> Collection<T> map(Collection<T> items,
        Converter<T> converter) {
        List<T> result = new ArrayList<T>();
        for(T each : items) {
            result.add(converter.to(each));
        }
        return result;
    }
}
클라이언트는 Converter 인터페이스의 익명 클래스를 생성해서 행위를 매개변수화한다.
Mapper.map(Arrays.asList(1, 2, 3, 4),
   new Converter<Integer>() {
       @Override
       public int to(int item) {

           return item * item;
       }
   });
Java의 익명 클래스는 클로저(Closure)다
Java의 익명 클래스는 외부에 존재하는 final 변수에 한해서 내부적으로 참조가 가능하다. 즉, 어휘적 유효 범위를 지원한다(실제로는 컴파일러가 final 변수의 복사본을 익명 클래스의 멤버 변수로 추가한다). 따라서 Java에서도 커링을 구현하는 것이 가능하기는 하지만 다른 언어에 비해 상대적으로 조잡하고 코드를 작성하기가 불편하다. Java에서의 커링은 외관상 FACTORY와 유사한 형태를 취한다.
public interface Expression {
   int add(int y);
}

public class AddExpressionFactory {
  public Expression add(final int x) {
    return new Expression() {
        @Override
         public int add(int y) {
            return x + y;
         }
    };
  }
}
위 코드를 이용해 1과 2의 합을 구하는 클라이언트 코드는 다음과 같이 구현할 수 있다.
Expression add1 = new AddExpressionFactory().add(1);
add1.add(2);
앞에서 설명한 것처럼 콜백을 지원하기 위해 함수를 객체로 감싸는 것과 어휘적 유효 범위를 포함하는 클로저의 용도가 명확하게 구분되는 것은 아니다. 콜백과 클로저 두 가지를 동시에 지원하기 위해 익명 클래스를 사용하는 것이 일반적이다.

변하는 것과 변하지 않는 것을 분리하라

변하는 것과 변하지 않는 것을 별도의 구성 요소로 분리하는 것은 가장 기본적인 소프트웨어 설계 원칙이다. 변경의 축을 따라 명확하게 경계를 나눔으로써 코드 중복을 제거할 수 있고, 부수효과를 방지할 수 있으며, 재사용성과 유연성을 향상시킬 수 있다. 고차 함수는 행위를 다른 행위에 대한 매개변수로 전달할 수 있도록 함으로써 이런 목적을 달성할 수 있는 한 가지 기법이다. 함수형 언어를 사용하지 않더라도 고차 함수의 개념을 익히고 언어의 틀 안에서 고차 함수의 개념을 적용하는 것은 소프트웨어의 전반적인 품질을 향상시킬 것이다.

덧글

  • 박성철 2012/06/21 20:16 #

    깔끔한 글 잘 읽었습니다. 함수 수준의 추상화는 정말 유용한 프로그래밍 도구 같습니다.
    그런데 자바 7에 들어갈려다 8으로 미뤄진 것을 클로저라고 하기 보다는 람다식이라고 하는 게 명확할 것 같네요. 혼용해서 쓰기는 하지만 글에서 이미 자바에서 클로저를 지원한다고 하셨으니...
    휴가는 잘 지내고 계신거죠? ㅎㅎ
  • 이터너티 2012/06/21 21:00 #

    안녕하세요. 예 말씀하신 것처럼 의미상 오해의 소지가 있네요. 본문에서 "클로저"를 "람다 표현식"으로 변경했습니다.역시 예리하시네요. ^^
    회사 복직은 이제 일주일 정도 남았습니다. ㅠㅠ 잘 쉬기는 했지만 그래도 아쉽군요. 날씨가 많이 더운데 건강 잘 챙기세요.
  • 리부마 2015/01/20 01:25 # 삭제

    좋은 자료 감사합니다~(..
※ 로그인 사용자만 덧글을 남길 수 있습니다.