함수 조합은 작은 기능을 하는 여러 함수를 pipe나 compose라는 이름의 함수로 조합하여 조금씩 더 의미 있는 함수로 만들어 가는 코드 설계 기법이다.
이번 장은 함수 조합의 토대가 되는 고차 함수와 커리, 그리고 이들을 이용한 함수 조합에 간해 살펴보자.
8-1 함수형 프로그래밍이란?
함수형 프로그래밍은 순수 함수와 선언형 프로그래밍의 토대 위에 함수 조합과 모나드 조합으로 코드를 설계하고 구현하는 기법이다.
함수형 프로그래밍은 다음 세 가지 수학 이론에 기반을 두고 있다.
- 람다 수학(ramda calculus) : 조합 논리와 카테고리 이론의 토대가 되는 논리 수학
- 조합 논리(combinatory logic) : 함수 조합의 이론적 배경
- 카테고리 이론(category theory) : 모나드 조합과 고차 타입의 이론적 배경
함수형 프로그래밍 언어는 정적 타입, 자동 메모리 관리, 계산법, 타입 추론, 일등 함수에 기반을 두고, 대수 데이터 타입, 패턴 매칭, 모나드, 고차 타입 등의 고급 기능을 제공한다. 하지만, 함수형이라고 해서 이러한 기능을 모두 제공하지는 않는다.
타입스크립트는 함수형 언어에서 중요하게 여겨지는 패턴 매칭과 고차 타입이라는 기능을 생략함으로써 구문을 쉽게 만들었다. 하스켈이나 스칼라 같은 고급 함수형 언어들이 어렵게 느껴지는 이유는 패턴 매칭과 고차 타입 관련 구문들이 매우 복잡하기 때문이다. 이런 이유로 요즘 유행하는 코틀린이나 스위프트도 패턴 매칭 구문 정도만 있을 뿐 고차 타입 구문은 존재하지 않는다.
8-2 제네릭 함수
타입을 타입 변수 T로 표기할 때 이를 제네릭 타입이라고 한다. 타입스크립트의 함수는 매개변수와 반환값에 타입이 존재하므로, 함수 조합을 구현할 때는 제네릭 함수 구문을 사용해야 한다.
타입스크립트의 제네릭 함수 구문
제네릭 타입은 함수와 인터페이스, 클래스, 타입 별칭에 적용할 수 있으며, 꺾쇠 괄호 <>로 타입을 감싼 <T>, <T, Q>처럼 표현한다.
function g1<T>(a:T): void{}
function g2<T,Q>(a: T, b: Q): void{}
이처럼 제네릭 타입으로 함수를 정의하면 어떤 타입에도 대응할 수 있다.
다음은 화살표 함수에 적용한 예다
const g3 = <T>(a: T): void => {}
const g4 = <T,Q>(a: T, b: Q): void => {}
타입 별칭에 제네릭 타입을 적용해 보자
type Type1Func<T> = (T) => void
type Type2Func<T,Q> = (T,Q) => void
type Type3Func<T,Q,R> = (T,Q) => R
Type3Func 함수의 시그니처는 T와 Q 타입 값을 입력 받아 R 타입 값을 반환한다.
8-3 고차 함수와 커리
함수에서 매개변수의 개수를 애리티 (arity)라고 한다. 예를 들어, f()는 애리티가 0인 함수, f(x)는 애리티가 1개인 함수이다. 만약 함수 f,g,h 모두 애리티가 1이라면
x -> f -> g -> h -> y
이처럼 연결해서 사용할 수 있다. 이것을 프로그래밍 언어로 표현하면
y = h(g(f(x)))
고차 함수란?
타입스크립트에서 함수는 변수에 담긴 함수 표현식이고, 이때 함수 표현식이란 일종의 값이다. 따라서 함수의 반환값으로 함수를 사용할 수 있다. 이처럼 어떤 함수가 또 다른 함수를 반환할 때 그 함수를 고차 함수라고 한다.
단순히 값을 반환하는 함수를 1차 함수, 1차 함수를 반환하면 2차 고차 함수, 2차 함수를 반환하면 3차 고차 함수라고 한다.
export type FirstOrderFunc<T, R> = (a: T) => R
export type SecondOrderFunc<T, R> = (b: T) => FirstOrderFunc<T, R>
export type ThirdOrderFunc<T, R> = (c: T) => SecondOrderFunc<T, R>
다음 코드 inc 함수는 단순 number 타입의 값을 반환하는 1차 함수이다
import {FirstOrderFunc} from './function-signature'
export const inc: FirstOrderFunc<number, number> = (x: number): number => x + 1
고차 함수 관점에서는 1차 함수이므로 함수 호출 연산 ()를 한 번 사용한다고 생각할 수 있다
import {inc} from './first-order-func'
console.log(
inc(1) // 2
)
반면 다음의 add 함수는 2차 고차 함수이다
import {FirstOrderFunc, SecondOrderFunc} from './function-signature'
export const add: SecondOrderFunc<number, number> =
(x: number): FirstOrderFunc<number, number> =>
(y: number): number => x + y
import {add} from './second-order-func'
console.log(
add(1)(2) // 3
)
2차 고차 함수를 호출할 때는 함수 호출 연산자를 두 번 연속해서 사용한다. 함수형 프로그래밍에서 이를 커리(curry)라고 한다.
다음은 3차 고차 함수이다
import {FirstOrderFunc, SecondOrderFunc, ThirdOrderFunc} from './function-signature'
export const add3: ThirdOrderFunc<number, number> =
(x: number): SecondOrderFunc<number, number> =>
(y: number): FirstOrderFunc<number, number> =>
(z: number): number => x + y + z
import {add3} from './third-order-func'
console.log(
add3(1)(2)(3) // 6
)
부분 적용 함수와 커리
고차 함수들은 자신의 차수만큼 함수 호출 연산자를 연달아 사용한다. 만약 add(1)이나 add3(1), add3(1)(2)처럼 자신의 차수보다 함수 호출 연산자를 덜 사용하면 부분 적용 함수 즉, 부분 함수라고 한다.
import {FirstOrderFunc, SecondOrderFunc} from './function-signature'
import {add} from './second-order-func'
const add1: FirstOrderFunc<number, number> = add(1)
console.log(
add1(2), // 3
add(1)(2) // 3
)
add1은 1차 함수이므로 함수 호출 연산자를 1개 사용해 일반 함수처럼 호출할 수 있다. add1(2)를 호출하면 정의한 add(1)과 함께 전달 돼 3이 출력된다.
import {FirstOrderFunc, SecondOrderFunc} from './function-signature'
import {add3} from './third-order-func'
const add2: SecondOrderFunc<number, number> = add3(1)
const add1: FirstOrderFunc<number, number> = add2(2)
console.log(
add1(3), // 6
add2(2)(3), // 6
add3(1)(2)(3) // 6
)
클로저
고차 함수의 몸통에서 선언되는 변수들은 클로저라는 유효 범위를 가진다. 즉, 클로저는 지속되는 유효 범위를 의미한다.
다음 코드에서 return x + y 부분은 add 함수가 반환하는 함수의 반환문인데, 자신의 바깥쪽 유효 범위에 있는 변수 x를 참조하고 있다.
function add(x: number): (number) => number { // 바깥쪽 유효 범위 시작
return function(y:number): number { // 안쪽 유효 범위 시작
return x + y // 클로저
} // 안쪽 유효 범위 끝
} // 바깥쪽 유효 범위 끝
하지만 add가 반환하는 함수의 내부 범위만 놓고 볼 때 x는 이해할 수 없는 변수이다. 이러한 의미를 알 수 없는 변수를 자유 변수라고 한다.
타입스크립트는 이처럼 자유 변수가 있으면 그 변수의 바깥쪽 유효 범위에서 자유 변수의 의미를 찾는데, x의 의미 즉, (x:number)를 알 수 있으므로 코드를 정상적으로 컴파일한다.
클로저를 지속되는 유효 범위라고 하는 이유는 다음 add 함수를 호출해도 변수 x가 메모리에서 해제되지 않기 때문이다.
const add1 = add(1) // 변수 x 메모리 유지
const result = add1(2) // result에 3을 저장하고 메모리를 해제
이처럼 고차 함수가 부분 함수가 아닌 값을 발생해야 자유 변수의 메모리가 해제되는 유효 범위를 클로저라고 한다.
8-4 함수 조합
함수 조합은 작은 기능을 구현한 함수를 여러 번 조합해 더 의미 있는 함수를 만들어 내는 프로그램 설계 기법이다.
함수 조합을 할 수 있는 언어들은 compose 혹은 pipe라는 이름의 함수를 제공하거나 만들 수 있다.
다음 코드에서 f,g,h는 모두 애리티가 1인 함수이다
export const f = <T>(x: T): string => `f(${x})`
export const g = <T>(x: T): string => `g(${x})`
export const h = <T>(x: T): string => `h(${x})`
compose 함수
다음 compose 함수는 가변 인수 스타일로 함수들의 배열을 입력받은 뒤, 함수들을 조합해 매개변수 x를 입력받는 1차 함수를 반환한다.
export const compose = <T>(...functions: readonly Function[]): Function => (x: T): T => {
const deepCopiedFunctions = [...functions]
return deepCopiedFunctions.reverse().reduce((value, func) => func(value), x)
}
이 함수를 이용해 애리티 1인 f,g,h 함수들을 조합해 보자
import {f, g, h} from './f-g-h'
import {compose} from './compose'
const composedHGF = compose(h, g, f)
console.log(
composedHGF('x') // h(g(f(x)))
)
compose(h,g,f)로 만든 composedFGH는 1차 함수이므로 결과는 h(g(f(x)))라는 문자열이 출력된다.
다음은 inc 함수를 compose로 세 번 조합한 composed란 함수를 만든다
import { compose } from './compose'
// const inc = x => x + 1
const inc = (x: number) => x + 1
const composed = compose(inc, inc, inc)
console.log(
composed(1) // 4
)
4가 출력되는데, 그 이유는 compose의 동작으로 1→inc→2→inc→3→inc→4의 과정이 발생했기 때문이다. 즉, 1을 가지고 inc 함수가 세 번 연달아 호출되면서 결과값이 4가 출력되었다.
이번에는 compose와 동작 원리는 같은데 조합하는 함수들의 순서만 다른 pipe 함수를 알아보자
pipe 함수
pipe 함수는 compose와 매개변수들을 해석하는 순서가 반대이므로, 함수를 reverse하는 코드가 없다.
export const pipe = <T>(...functions: readonly Function[]): Function => (x: T): T => {
return functions.reduce((value, func) => func(value), x)
}
다음 pipe 코드는 변수 x와 가까운 쪽부터 함수가 나열된 pipe(f,g,h)를 통해 새로운 piped 함수를 만든다. pipe는 compose의 역순으로 함수들을 나열하면 compose와 똑같은 결과가 나온다.
import {f, g, h} from './f-g-h'
import {pipe} from './pipe'
const piped = pipe(f, g, h)
console.log(
piped('x') // h(g(f(x)))
)
pipe와 compose 함수 분석
pipe 함수의 구현 순서를 살펴보면 pipe 함수는 pipe(f), pipe(f,g), pipe(f,g,h) 처럼 가변 인수 방식으로 동작한다. 하지만 각 함수의 시그니처가 모두 다르면 이들을 모두 포함할 수 있는 제네릭 타입을 적용하기가 힘들다. 따라서 functions은 자바스크립트 타입 Function들의 배열인 Function[]으로 설정한다.
pipe 함수는 functions 배열을 조합해 어떤 함수를 반환해야 하므로 반환 타입은 Function으로 설정한다.
compose 함수는 pipe 함수와 매개변수 방향이 반대이다. 따라서 직관적으로는 functions.reverse()를 호출하는 코드를 작성한뒤, 나머지 코드는 pipe와 똑같이 작성하면 된다. 하지만 compose는 순수 함수의 모습으로 동작해야 한다. 따라서 functions을 전개 연산자로 전개한 다음, 깊은 복사를 하는 변수를 만든다.
export const compse = <T>(...functions: readonly Function[]): Function => (x:T): T
=> {
const deepCopiedFunctions = [...functions]
deepCopiedFunctions.reverse()
}
pipe 함수의 functions과 내용이 같으므로 pipe 때의 구현 내용을 사용하면 compose 함수 정의가 완성된다
export const compse = <T>(...functions: readonly Function[]): Function => (x:T): T
=> {
const deepCopiedFunctions = [...functions]
deepCopiedFunctions.reverse().reduce((value,func) => func(value),x)
}
부분 함수와 함수 조합
import { pipe } from './pipe'
// const add = x => y => x + y
const add = (x: number) => (y: number) => x + y
const inc = add(1)
const add3 = pipe(inc, add(2))
console.log(
add3(1) // 4
)
add는 2차 고차 함수이므로 inc 함수는 add의 부분 함수이다. add3은 pipe 함수를 가지고 inc와 add(2) 두 부분 함수를 조합해서 만든 함수이다. add3은 일반 함수이므로 값 1은 2가 되고 add(2)에 의해 최종 4라는 값이 만들어진다.
포인트가 없는 함수
map 함수는 함수 조합을 고려해 설계한 것으로, map(f) 형태의 부분 함수를 만들면 compose나 pipe에 사용할 수 있다. 이처럼 함수 조합을 고려해 설계한 함수를 포인트가 없는 함수라고 한다.
export const map = f => a => a.map(f)
import { map } from './map2'
// const square = value => value * value
const square = (value: number): number => value * value
export const squaredMap = map(square)
'Books > Do It 타입스크립트 프로그래밍' 카테고리의 다른 글
8장 람다 라이브러리 (0) | 2023.02.12 |
---|---|
7장 비동기 콜백 함수 (0) | 2022.12.30 |
6장 반복기와 생성기 (0) | 2022.12.12 |
5장 배열과 튜플 (0) | 2022.12.11 |
4장 함수와 메서드 (0) | 2022.11.29 |