함수형 프로그래밍은 순수 함수와 선언형 프로그래밍 토대 위에 함수 조합과 모나드 조합으로 코드를 설계하고 구현하는 기법이다. 이번 장부터는 타입스크립트 언어로 함수형 프로그래밍을 어떻게 할 수 있는지 알아보자.
함수형 프로그래밍은 다음 세 가지 수한 이론에 기반을 두고 있다.
1. 람다 수학 : 조합 논리와 카테고리 이론의 토대가 되는 논리 수학
2. 조합 논리 : 함수 조합의 이론적 배경
3. 카테고리 이론 : 모나드 조합과 고차 타입의 이론적 배경
함수형 프로그래밍 언어는 정적 타입, 자동 메모리 관리, 계산법, 타입 추론, 일등 함에 기반을 두고, 대수 데이터 타입, 패턴 매칭, 모나드, 고차 타입 등의 고급 기능을 제공한다. 다만, 함수형 언어라고 해서 이러한 기능을 모두 제공하지는 않는다.
함수형 프로그래밍 언어는 인공지능의 언어로 불렸던 LISP에 기반을 두고 있다. LISP는 1958년에 만들어진 언어이며 메타언어로 진화하였고 다시 하스켈 언어로 발전되었다.
하스켈 언어는 스칼라 언어에 의해 개발자들에게 친숙한 C언어와 비슷한 구문을 가지게 되었고, 타입스크립트는 스칼라 언어의 구문을 좀 더 자바스크립트 친화적으로 발전시켰다. 이러한 관점으로 볼 때 타입스크립트는 세상에서 가장 쉬운 함수형 프로그래밍 언어라고 할 수 있다.
타입스크립트는 함수형 언어에서 중요하게 여겨지는 패턴 매칭과 고차 타입이라는 기능을 생략함으로써 구문을 쉽게 만들었다. 하스켈이나 스칼라 같은 고급 함수형 언어들의 구문이 매우 어렵게 느껴지는 이유는 패턴 매칭과 고차 타입 관련 구문들이 매우 복잡하기 때문이다. 이런 이유로 요즘 유행하는 코틀린이나 스위프트도 패턴 매칭 구문 정도만 있을 뿐 고차 타입 구문은 존재하지 않는다.
05장에서 number[], boolean[], string[]과 같은 배열을 T[]로 표현한 적이 있다. number 와 같은 타입을 타입 변수 T
로 표기할 때 이를 제네릭 타입
이라고 한다. 타입스크립트의 함수는 매개변수와 반환값에 타입이 존재하므로, 함수 조합을 구현 할 때는 제네릭 함수 구문을 사용해야만 한다.
타입스크립트에서 제네릭 타입은 함수와 인터페이스, 클래스, 타입 별칭에 적용할 수 있으며, 꺽쇠 괄호<>
로 타입을 감싼 <T>, <T, Q>
처럼 표현한다. 다음은 function 키워드로 만든 함수에 제네릭 타입을 적용한 예이다.
function g1<T>(a: T): void {}
function g2<T, Q>(a: T, b: Q): void {}
이처럼 제네릭 타입으로 함수를 정의하면 어떤 타입에도 대응할 수 있다. g1 함수는 a매개변수가 제네릭 타입으로 지정되었고, g2 함수는 a와 b 매개변수가 각각 다른 제네릭 타입으로 지정되었다.
다음은 화살표 함수에 제네릭 타입을 적용한 예이다.
const g3 = <T,>(a: T): void => {};
const g4 = <T, Q>(a: T, b: Q): void => {};
다음은 타입 별칭(type-alias)에 제네릭 타입을 적용한 예이다.
type Type1Func<T> = (T) => void;
type Type2Func<T, Q> = (T, Q) => void;
type Type3Func<T, Q, R> = (T, Q) => R;
Type3Func 함수의 시그니처는 T와 Q타입 값을 입력 받아 R타입 값을 반환하는 것을 나타낸다.
수학에서 함수는 값 x에 수식을 적용해 또 다른 값 y를 만드는 역할을 하는데, 함수를 f라고 표기하면 값 x,y,f 간의 관계를 다음처럼 표현할 수 있다.
x ~> f ~> y
프로그래밍 언어로 수학의 함수를 구현할 때는 변수 x와 y의 타입을 고려해야 한다. 함수 f가 T 타입의 x값으로 R 타입의 y값으로 만든다고 하면 다음처럼 표현할 수 있다.
(x: T) ~-> f -> (y: R)
수학에서는 이런 관계를 일대일 관계(one-to-one relationship)라고 하고, 이런 동작을 하는 함수 f를 매핑(mapping)
줄여서 맵(map)
이라고 표현한다. 타입스크립트 언어로 일대일 맵 함수를 만든다면 타입 T인 값을 이용해 타입 R인 값을 만들어 주어야 하므로, 함수의 시그니처를 다음처럼 표현할 수 있다.
type MapFunc(T, R) = (T) => R
맵 함수의 가장 단순한 형태는 입력값 x를 가공 없이 그대로 반환하는 것이다. 즉, 입력과 출력 타입이 같다. 함수형 프로그래밍 에서는 이러한 역할을하는 함수 이름에는 보통 identity 혹은 간단히 I라는 단어가 포함된다. 앞에서 예로 든 MapFunc 타입을 사용해 아이덴티티 함수의 시그니처를 다음처럼 표현할 수 있다.
type MapFunc<T, R> = (T) => R;
type IdentityFunc<T> = MapFunc<T, T>;
이렇게 정의한 제네릭 함수 타입 IdentityFunc는 다음과 같은 다양한 함수를 선언할 때 포괄적으로 사용할 수 있다.
const numberIdentity: IdentityFunc<number> = (x: number): number => x;
const stringIdentity: IdentityFunc<string> = (x: string): string => x;
const objectIdentity: IdentityFunc<object> = (x: object): object => x;
const arrayIdentity: IdentityFunc<any[]> = (x: any[]): any[] => x;
함수에서 매개변수의 개수를 애리티(arity)라고 한다. 예를 들어, f()는 애리티가 0인 함수, f(x)는 애리티가 1인 함수, f(x, y)는 애리티가 2인 함수이다. 만약, 함수 f, g, h 모두 애리티가 1이라면 다음처럼 연결해서 사용할 수 있다.
x ~> f ~> g ~> h ~> y
이것을 프로그래밍 언어로 표현하면 다음과 같다.
y = h(g(f(x)));
함수형 프로그래밍 에서는 compose나 pipe라는 이름의 함수를 사용해 compse(h, g, f)또는 Pipe(f, g, h) 형태로 f, g, h 함수들을 조합해 새로운 함수를 만들 수 있다. 그런데 compose 또는 pipe의 동작 원리를 이해하려면 먼저 고차 함수가 무엇인지 알아야 한다.
앞에서 여러 번 언급했지만 타입스크립트에서 함수는 변수에 담긴 함수 표현식이고, 이때 함수 표현식이란 일종의 값이다. 따라서 함수의 반환값으로 함수를 사용 할 수 있다. 이처럼 어떤 함수가 또 다른 함수를 반환할 때 그 함수를 고차 함수
라고 한다.
이러한 관점에서 함수가 아닌 단순히 값을 반환하는 함수를 1차 함수
라고 하며 1차 함수를 반환하면 2차 고차 함수
2차 함수를 반환하면 3차 고차 함수
라고 한다. 이를 함수 시그니처로 표현하면 다음과 같다.
function-signature.ts
export type FirstOrderFunc<T, R> = (T) => R;
export type SecondOrderFunc<T, R> = (T) => FirstOrderFunc<T, R>;
export type ThirdOrderFunc<T, R> = (T) => SecondOrderFunc<T, R>;
이제 이 시그니처를 참조해 실제 함수를 만들어 보자. 다음 코드에서 inc 함수는 단순히 number 타입의 값을 반환하므로 1차 함수이다.
first-order-func.ts
import { FirstOrderFunc } from "/.function-signature";
export const inc: FirstOrderFunc<number, number> = (x: number): number => x + 1;
inc는 함수이므로 03행처럼 호출하는것이 당연하지만, 고차 함수 관점에서는 1차 함수이므로 함수 호출 연산자()를 한번 사용한다고 생각할 수 있다.
first-order-func-test.ts
import { inc } from "./first-order-func";
console.log(inc(1)); // 2
반면의 다음 add 함수는 FirstOrderfunc<number, number>를 반환하므로 2차 고차 함수이다.
import { firstOrderFunc, SecondOrderFunc } from "./function-signature";
export const add: SecondOrderFunc<number, number> =
(x: number) : firstOrderFunc<number, number> =>
(y: number) : number => x + y
}
다음은 2차 고차 함수 add를 사용하는 코드이다.
second-order-func-test.ts
import { add } from "./second-order-func";
console.log(add(1)(2)); // 3
흥미롭게도 2차 고차 함수를 호출할 때는 add(1)(2)처럼 함수 호출 연산자를 두 번 연속해서 사용한다. 함수형 프로그래밍 언어에서는 이를 커리(curry)
라고 한다. 커리의 동작 원리는 잠시후 알아보고 여기서는 고차 함수를 사용하는 방법에 집중하자.
다음 코드에서 add3 함수는 SecondOrderFunc<number, number>를 반환하므로 3차 고차함수 이다.
third-order-func.ts
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;
3차 고차 함수일 때는 add(1)(2)(3)
처럼 함수 호출 연산자를 세 번 이어붙여 호출한다.
third-order-func-test.ts
import { add3 } from "./third-order-func";
console.log(add3(1)(2)(3)); // 6
우리는 앞서 add(1)(2)나 add(1)(2)(3)과 같은 코드를 본 적이 있다. 고차 함수들은 이처럼 자신의 차수만큼 함수 호출 연산자를 연달아 사용한다. 만약, add(1)이나 add3(1), add3(1)(2)처럼 자신의 차수보다 함수 호출 연산자를 덜 사용하면 부분 적용 함수
, 짧게 말하면 부분 함수
라고 한다.
부분 적용 함수에 관해 좀 더 자세히 알아보자, 앞에서 정의한 2차 고차 함수 add의 시그니처는 SecondOrderFunc<number, number>이므로 다음 코드에서 04행처럼 add1함수를 만들 수 있다.
add-partial-function.ts
import { FirstOrderFunc, SecondOrderFunc } from "./function-signature";
import { add } from "./second-order-func";
const add1: FirstOrderFunc<number, number> = add(1); // add1은 부분 함수
console.log(
add1(2), // 3
add(1, 2) // 3
);
add1은 1차함수이므로 06행처럼 함수 호출 연산자를 1개 사용해 일반 함수처럼 호출할 수 있다. 따라서 add1(2)처럼 호출하면 04행에서 정의한 add(1)과 함께 전달돼 3이 출력된다. 그리고 04행에서 add1은 사실 add(1)과 똑같으므로 06행의 add1부분을 add(1)로 대체하면 07행이 된다.
새로운 예제 코드를 하나 더 보자, 다음 코드에서 05행의 add1은 일차 함수이므로 07행처럼 호출할 수 있다.
add3-partial-func.ts
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)
conosle.log(
add1(3), //6
add2(2)(3) // 6
add3(1)(2)(3) // 6
)
그런데 add1은 add2(2)와 똑같으므로 08행처럼 호출할 수도 있다. 마찬가지로 add2는 add3(1) 이므로 09행처럼 호출이 가능하다.
고차 함수의 몸통에서 선언되는 변수들은 클로저(closure)라는 유효 범위를 의미한다.
다음 코드에서 return x + y 부분은 add 함수가 반환하는 함수의 반환문이다. 이 함수는 자신의 바깥쪽 유효 범위(outer scope)에 있는 변수 x를 참조하고 있다.
function add(x: number): (number) => number {
// 바깥쪽 유효 범위 시작
return function (y: number): number {
// 안쪽 유효 범위 시작
return x + y; // 클로저
}; // 안쪽 유효 범위 끝
} // 바깥쪽 유효 범위 끝
그런데 add 가 반환하는 함수의 내부 범위(inner scope)만 놓고 볼 때 x는 이해할 수 없는 변수이다. 이처럼 범위 안에서는 그 의미를 알 수 없는 변수를 자유 변수(free variable)
라고 한다.
타입 스크립트는 이처럼 자유 변수가 있으면 그 변수의 바깥쪽 유효 범위에서 자유 변수의 의미(선언문)를 찾는데, 바깥쪽 유효 범위에서 x의 의미(x: number)를 알 수 있으므로 코드를 정상으로 컴파일 한다.
클로저를 지속되는 유효 범위라고 하는 이유는 다음 처럼 add 함수를 호출하더라도 변수 x가 메모리에서 해제되지 않기 때문이다.
const add1 = add(1); // 변수 x 메모리 유지
자유 변수 x는 다음 코드가 실행되어야 비로소 메모리가 해제된다.
const result = add1(2); // result에 3을 저장 후 변수 x 메모리 해제
이처럼 고차 함수가 부분 함수가 아닌 값
을 발생해야 비로소 자유 변수의 메모리가 해제되는 유효범위를 클로저
라고 한다.
💡고차함수와 클로저
고차 함수를 구현하려면 클로저 기능은 필수이다. 타입스크립트뿐만 아니라 고차 함수 구문이 있는 모든 프로그래밍 언어는 클로저 기능을 제공한다. 따라서 어떤 프로그래밍 언어는 클로저와 고차 함수를 한꺼번에 클로저로 표현하기도 한다.
클로저는 메모리가 해제되지 않고 프로그램이 끝날 때까지 지속될 수도 있다. 다음 makeName 함수는 ()=>string 타입의 함수를 반환하는 2차 고차 함수이다.
closure.ts
const makeNames = (): (() => string) => {
//바깥쪽 유효 범위
const names = ["Jack", "jane", "Smisth"];
let index = 0;
return (): string => {
if (index == names.length) index = 0;
return names[index++];
};
};
const makeName: () => string = makeNames();
console.log([1, 2, 3, 4, 5, 6].map((n) => makeName()));
11행에서 보듯 makeNames 함수를 호출하면 () => string 타입의 함수를 얻는다. 또한, 13행 처럼 1~6까지 숫자가 담긴 배열의 map 메서드를 사용해 makeNames 함수를 호출하면, 배열의 아이템 수만큼 총 여섯 번 호출되어 [ 'Jack', 'jane', 'Smisth', 'Jack', 'jane', 'Smisth' ]
이 출력된다. makeNames 함수에는 내부에 원형리스트(circular list) 방식으로 동작하는 names와 index라는 이름의 자유변수가 있다. index는 names.length와 값이 같아지면 다시 0이 되므로 makeName 함수를 사용하는 한 makeNames 함수에 할당된 클로저는 해제되지 않는다.
함수 조합은 작은 기능을 구현한 함수를 여러 번 조합해 더 의미 있는 함수를 만들어 내는 프로그램 설계 기법이다. 함수 조합을 할 수 있는 언어들은 compose 혹은 pipe라는 이름의 함수를 제공하거나 만들 수 있다.
앞서 함수 f, g, h가 있을 때 이 함수들 모두 애리티가 1이라면 다음처럼 함수를 연결해서 사용 할 수 있다고 하였다.
x ~> f ~> g ~> h ~> y
다음 코드에서 f, g, h는 모두 애리티가 1인 함수이다.
f-g-h.ts
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와 pipe라는 이름의 함수를 만들어보자.
y = h(g(f(x)));
다음 compose 함수는 가변 인수 스타일로 함수들의 배열을 입력받는다. 그 다음 함수들을 조합해 매개변수 x를 입력받는 1차 함수를 반환한다. 여기서는 구현 내용보다는 compose 함수의 사용법을 중심으로 설명한다.
compose.ts
export const compose =
<T, R>(...functions: readonly Function[]): Function =>
(x: T): ((T) => R) => {
const deepCopiedFunctions = [...functions];
return deepCopiedFunctions
.reverse()
.reduce((value, func) => func(value), x);
};
이제 compose함수를 사용하여 앞서 애리티 1인 f, g, h 함수들을 조합해 보자
compose-test.ts
import { f, g, h } from "./f-g-h";
import { compose } from "./compose";
const composedFGH = compose(h, g, f);
console.log(composedFGH("x"); // h(g(f(x)))
코드에서 04행은 compose를 사용해 수학에서 f~> g ~> h를 프로그래밍으로 표현한 것이다. compose(h, g, f)로 만든 composedFGH는 1차 함수이므로 06행처럼 호출할 수 있고, 결과는 h(g(f(x)))라는 문자열이 출력된다.
다음 코드는 03행의 inc 함수를 compose로 세 번 조합한 composed란 함수를 만든다.
compose-test2.ts
import { compose } from "./compose";
const inc = (x) => x + 1;
const composed = compose(inc, inc, inc);
console.log(composed(1));
07행에서 composed(1)처럼 호출하면 4가 출력된다. 그 이유는 compose의 동작으로 1 ~> inc ~> 2 ~> inc ~> 3 ~> inc ~> 4의 과정이 발생했기 때문이다. 즉, 1을 가지고 inc함수가 세 번 연달아 호출되면서 계산식(((1+1)+1)+1)이 되어 4가 출력되었다.
이번엔 compose와 동작 원리는 같지만 조합하는 함수들의 순서만 다른 pipe함수에 대해 알아보자.
pipe 함수는 compose와 매개변수들을 해석하는 순서가 반대이므로, 다음 코드는 compose와 비교해 functions을 revers하는 코드가 없다.
pipe.ts
export const pipe =
<T>(...functions: readonly Function[]): Function =>
(x: T): => T {
return functions.reduce((value, func) => func(value), x);
};
다음 pipe 테스트 코드는 04행에서 변수 x와 가까운 쪽부터 함수가 나열된 pipe(f, g, h)를 통해 새로운 piped 함수를 만든다. pipe는 compose의 역순으로 함수들을 나열하면 compose와 똑같은 결과를 보인다.
pipe-test.ts
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 함수의 구현 순서를 살펴보면서 동작원리를 분석해보자. 먼저, pipe함수는 pipe(f), pipe(f, g), pipe(f, g, h)처럼 가변 인수 방식으로 동작하므로 매개변수를 다음처럼 설정한다.
export const pipe = (...functions)
그런데 가변 인수 functions 타입을 설정하기 어렵다. 예를 들어 함수 f, g, h의 함수 시그니처는 다음처럼 모두 다르다.
- f 함수의 시그니처 : (number) => string
- g 함수의 시그니처 : (string) => string[]
- h 함수의 시그니처 : (string[]) => number
이처럼 각 함수의 시그니처가 모두 다르면 이들을 모두 포함할 수 있는 제네릭 타입을 적용하기 힘들다. 따라서 functions는 자바스크립트 타입 Function들의 배열인 Function[]으로 설정한다.
export const pipe = (...functions: Function[])
pipe 함수는 functions 배열을 조합해 어떤 함수를 반환해야 하므로 반환 타입은 Function으로 설정한다.
export const pipe = (...functions: Function[]): Function
pipe로 조합된 결과 함수는 애리티가 1이다. 따라서 다음처럼 매개변수 x를 입력하는 함수를 작성한다.
그런데 이 내용을 제네릭 타입으로 표현하면 타입 T의 값 x를 입력받아 (T) => R 타입의 함수를 반환하는 것이 된다.
export const pipe = \(...functions: Function[]): Function => (x: T) => T
이제 함수 몸통을 구현할 차례이다. 현재 functions 배열에 [f, g, h]가 있다고 가정할 때 h(g(f(x))) 형태의 함수를 만들어야 한다.
export const pipe = <T, R>(...functions: Function[]): Function => (x: T) => T =>{
// functions의 현재 내용 [f, g, h]
}
배열이 제공하는 reduce 메서드는 이 내용을 구현할 때 요긴하다. 다음처럼 변수 x를 reduce 메서드의 초깃값으로 설정하면 <함수>라고 된 부분만 구현하면 된다.
export const pipe = <T, R>(...functions: Function[]): Function => (x: T) => T =>{
return functions.reduce(<함수>, x)
}
이 <함수> 부분은 (value, func) 형태의 매개변수 구조를 가져야 하는데, reduce 메서드의 두 번째 매개변수(x)는 항상 배열의 아이템이기 때문이다.
앞서 functions의 내용이 [f, g, h]라고 가정했으므로 reduce 메서드의 진행 순서별 매개변수값의 변화는 다음과 같다.
순서 | value | func | 결과값 |
---|---|---|---|
1 | x | f | f(x) |
2 | f(x) | g | g(f(x)) |
3 | g(f(x)) | j | h(g(f(x))) |
즉, functions 배열의 마지막 아이템은 h가 reduce 메서드의 func 매개변수에 입력되면 최종 결과값은 h(g(f(x)))가 되고 해당 값을 반환한다.
compose 함수는 pipe 함수와 매개변수 방향이 반대이다. 즉 pipe(f, g, h)는 compose(h, g, f)와 같다.
따라서 직관적으로는 functions.reverse()를 호출하는 코드를 작성한 뒤, 나머지 코드는 pipe와 같이 작성하면 된다. 하지만 compose는 05장에서 설명한 순수 함수의 모습으로 동작해야 한다. 따라서 다음처럼 functions를 전개 연산자로 전개한 다음, 그 내용을 깊은 복사를 하는 변수를 만든다.
export const compose =
<T, R>(...functions: Function[]): Function =>
(x: T) =>
(T) => {
const deepCopiedFunctions = [...functions];
deepCopiedFunctions.reverse();
};
앞 코드에서 deepCopiedFunctions.reverse()는 이제 pipe 함수의 functions과 내용이 같으므로 pipe때의 구현 내용을 그대로 사용하면 compose 함수 정의가 완성된다.
export const compose =
<T, R>(...functions: Function[]): Function =>
(x: T) =>
(T) => {
const deepCopiedFunctions = [...functions];
deepCopiedFunctions.reverse().reduce((value, func) => func(value), x);
};
고차 함수의 부분 함수는 함수 조합에 사용될 수 있다.
partial-func-composition.ts
import { pipe } from "./pipe";
const add = x=> y=> x+ y;
const inc = add(1);
const add3 = pipe(
inc, add(2)
)
console.log(add3(1)) // 4
코드에서 03행의 add는 2차 고차 함수이므로 04행의 inc 함수는 add의 부분 함수이다. 그리고 06행의 add3은 pipe 함수를 가지고 inc와 add(2) 두 부분 함수를 조합해서 만든 함수이다. add3은 일반함수이므로 11행처럼 호출하면 inc 함수가 호출되어 값 1은 2가되고, 이어 add(2)에 의해 최종 4라는 값이 만들어진다.