Eternity's Chit-Chat

aeternum.egloos.com



행위 매개변수화(Behavior Parameterization) - 2부 Software Design

고차 함수(Higher-Order Function)를 이용한 설계 개선

문제를 해결할 수 있는 방법은 ‘변하는 부분을 변하지 않는 부분으로부터 분리’하는 것이다. 일반적으로 ‘변하지 않는 부분’은 중복 코드의 형태를 띠며 두 코드에서 중복 코드를 제외한 나머지 부분이 ‘변하는 부분’이 된다. 기본적인 리팩토링 원칙은 변하는 부분을 변하지 않는 부분으로부터 분리하되 변하는 부분들의 프로토콜을 일치시킴으로써 동일한 방식으로 조합할 수 있도록 하는 것이다.


map-square()와 map-succ() 함수 간에 리스트를 순회하는 로직은 중복되는데 비해 리스트의 각 항목을 변환하는 lambda 식은 달라진다는 것을 알 수 있다. 즉, 변하지 않는 부분은 리스트 순회 로직이고 변하는 부분은 리스트 변환 로직이다. 따라서 ‘변하는 부분을 변하지 않는 부분으로부터 분리’하라는 원칙에 따라 두 map 함수의 문제 해결 방법은 ‘항목을 변환하는 로직을 리스트를 순회하는 로직으로부터 분리’하는 것으로 요약할 수 있다.


하나의 통합된 기능 단위로 작동하던 부분을 두 개의 독립된 부분으로 분리한다는 것은, 다시 말해 실행 시에 하나의 기능 단위가 될 수 있도록 두 부분을 조합할 수 있는 메커니즘이 필요하다는 것을 의미한다. 두 map 함수를 살펴 보면 lambda 식과 순회 로직 모두 독립적인 함수의 형태를 취한다는 것을 알 수 있다. 따라서 lambda 식과 순회 로직을 독립적인 두 개의 함수로 구현하고 이 두 개의 함수를 필요에 따라 결합할 수만 있다면 변경으로 인해 파생되는 다양한 문제들을 해결할 수 있을 것이다.


함수형 언어에서는 함수와 함수를 결합할 수 있도록 ‘고차 함수(Higher-Order Function)’라는 개념을 지원한다. 고차 함수의 개념을 이해하기 위해서는 먼저 ‘일급 시민(first-class citizen)’ 또는 ‘일급 객체(first-class object)’의 개념부터 이해해야 한다. 모든 프로그래밍 언어는 계산 과정에서 사용 가능한 대상에 대해 특정한 방식의 제약을 부과한다. 이때 해당 언어에서 가장 제약이 적은 언어 요소를 일급 시민에 속한다고 말한다.


일급 시민의 특성은 다음과 같다.

  • 변수의 값이 될 수 있다. 즉, 이름을 붙일 수 있다.
  • 프로시저의 인자로 전달할 수 있다.
  • 프로시저의 반환 값이 될 수 있다.
  • 자료 구조의 구성 요소가 될 수 있다.
함수를 중심으로 프로그램을 구성하는 함수형 언어에서 함수는 일급 시민에 속한다(함수형 언어에서 함수가 일급 시민인 것처럼 객체지향 언어에서는 객체가 일급 시민에 속한다. 일부 객체지향 언어의 경우 함수-또는 프로시저-를 일급 시민으로 다룰 수 있는 언어적인 메커니즘을 지원한다). 따라서 함수를 변수에 할당할 수 있고, 함수의 매개변수로 함수를 전달하거나 함수를 반환하는 함수를 만들 수도 있으며, 다른 자료 구조 안에 함수를 포함할 수 있다.

위 4가지 특성 중 고차 함수는 함수의 매개변수나 반환 값으로 함수를 사용할 수 있다는 특성을 이용한다. 간단히 말해 ‘고차 함수(higher-order function)’란 함수형 언어에서 일급 시민으로 취급되는 함수를 인자로 전달 받거나(downward funarg라고 한다) 결과 값으로 반환하는(upward funarg라고 한다) 함수를 말한다. 즉, 고차함수란 함수를 값으로 이용할 수 있는 함수다.


고차 함수를 이용하면 변하는 부분과 변하지 않는 부분을 별도의 함수로 분리하고 두 함수를 실행 시간에 조합할 수 있다. 기본적인 메커니즘은 변하지 않는 부분의 함수 호출 시 변하는 부분의 함수를 인자로 전달하고 내부적으로 변하지 않는 부분에서 변하는 부분의 함수를 호출하는 것이다.


앞에서 살펴 본 두 가지 map 함수에서 변하는 부분은 리스트의 각 항목을 변환하는 로직이고 변하지 않는 부분은 리스트를 순회하는 로직이다. 따라서 순회 로직을 구현하는 함수가 변환 로직을 구현하는 함수를 인자로 전달 받아 내부적으로 호출하도록 코드를 개선하면 앞에서 살펴 본 설계 문제를 해결할 수 있다. 먼저 변하지 않는 순회 로직을 map() 함수로 추출하자.


map() 함수는 리스트와 함께 변환 로직을 담고 있는 함수를 인자로 받는다.


<List 3> 함수를 인자로 받는 고차함수 map()

1: 
(define (map proc items)               
2:      (if (null? items)
3:          '
()
4:          (cons (proc (car items))           
5:                    (map proc (cdr items)))))       

1: 
map() 함수의 proc 인자로 변환 함수를, items 인자로 대상 리스트를 전달 받는다.
4:  전달된 proc 함수를 리스트의 첫 번째 항목에 적용해서 변환한다.
5:  cdr 함수를 이용해 리스트의 두 번째 이후의 전 항목을 포함하는 서브 리스트를 구한다(cdr items). 서브 리스트에 포함된 각 항목을 변환하기 위해 서브 리스트와 proc 함수를 인자로 사용해서 다시 한 번 map() 함수를 재귀 호출한다.

리스트를 순회하고 변환 로직을 결합할 수 있는 map() 함수를 구현했으므로 이를 기반으로 map-square()와 map-succ() 함수를 구현하자. 제곱 값을 구하는 익명 함수를 map() 함수에 전달하면 간단하게 map-square() 함수를 구현할 수 있다.


<List 4> map() 함수를 이용한 map-square() 함수의 구현

1:  (define (map-square items)
2:      (map (lambda (x) (* x x)) items))

map-succ() 함수는 map() 함수에 값을 1 증가시키는 익명 함수를 전달한다.


<List 5> map() 함수를 이용한 map-succ() 함수의 구현

1: 
(define (map-succ items)
2:      (map (lambda (x) (+ x 1)) items))

변하는 변환 로직과 변하지 않는 순회 로직을 분리하고 고차 함수를 이용해 실행 시에 결합 가능하도록 개선함으로써 앞에서 살펴 본 세가지 문제를 해결할 수 있다.

  • 코드 중복(code duplication) 제거: 중복되었던 리스트 순회 로직은 이제 map() 함수 한 곳에만 존재한다. 따라서 리스트의 순회 방법을 꼬리 재귀(tail recursion)로 변경하더라도 map() 함수만 수정해 주면 된다.
  • 원하지 않는 부수 효과(side-effect) 방지: 리스트 순회 로직과 변환 로직이 별도의 함수 안에 존재하기 때문에 한쪽을 수정하더라도 다른 코드가 영향 받을 가능성을 낮출 수 있다. 변경 지점의 분리와 고립은 전체적인 시스템의 안전성을 향상시킨다.
  • 재사용성(reusability)과 유연성(flexibility) 향상: 분리된 리스트 순회 로직은 특정한 변환 로직에 독립적이기 때문에 다양한 상황에서 재사용 가능하다. 또한 다양한 변환 로직과 쉽게 조합 가능하기 때문에 유연성이 향상된다. 일반적으로 시스템을 구성하는 각 부분이 서로에 대해 더 적게 알수록 재사용성과 유연성이 향상된다.