함수형 프로그래밍은 순수 함수(pure function)를 이용해 프로그래밍 하는 방식입니다. 순수 함수는 동일한 입력에 대해 항상 동일한 값을 반환하는 함수를 의미합니다. 순수 함수는 함수 내부에서 함수 외부 변수를 참조하거나 변경, 대입하는 것을 지양합니다. 순수 함수를 사용하면 외부 상태에 의존하지 않기 때문에 단위 테스트(Unit test)가 간편해진다는 장점이 있습니다. 왜냐하면 단위 테스트를 하기 전에 따로 외부 상태를 설정하지 않아도 되기 때문입니다.
let a = 1;
function f(b) {
return a + b;
}
f(1); // 2
순수 함수는 위와 같이 함수 내부에서 함수 외부 변수인 a
를 참조하는 방식을 지양합니다.
let a = 1;
function f(a, b) {
return a + b;
}
f(a, 1); // 2
그 대신 함수 외부 변수를 참조하고 싶으면 위와 같이 매개변수로 넘겨주는 방식을 선호합니다. 즉, 순수 함수 내부에선 매개변수로 넘겨준 변수만 참조할 수 있습니다.
const a = {
id: 0,
name: 'kim',
age: 20
}
const b = a;
JavaScript의 객체는 힙에 할당됩니다. C언어에서 malloc
으로 동적할당 후 포인터로 다루는 것과 유사합니다. 그래서 위와 같이 const b = a;
에선 새로운 객체가 생성되는 것이 아니라 b
가 기존 객체의 주소를 가집니다. a
와 b
의 관계에선 어느 한 쪽에서 값을 변경하면 다른 한 쪽에 영향을 끼치는데 프로그램이 복잡해자면 a
와 b
같이 서로 같은 객체를 바라보는 관계를 일일이 기억하기 힘들기 때문에 의도하지 않은 버그가 발생할 수 있습니다.
이런 문제를 해결하기 위해 객체의 상태를 변경할 땐 기존 객체 값을 새로운 메모리 공간에 복사하고 그 공간에서 값을 변경하는 것이 좋습니다. 이와 같이 기존 객체 상태를 일정하게 유지하는 개념을 불변성(Immutability)라고 합니다. 다시 말하자면, 한번 생성된 객체의 값은 변경하지 않고 값을 변경해야 할 때 기존 값을 새로운 메모리 공간에 복사한 후 변경하는 것을 의미합니다.
이 개념을 적용하면 위와 같은 이유로 의도하지 않은 오류가 발생하는 것을 방지할 수 있습니다. 대신 기존 객체의 값을 새로운 메모리 공간에 복사해야 하기 때문에 그에 대한 오버헤드(속도, 공간)가 존재합니다.
함수 내부에서 함수 외부의 값에 영향을 주거나 받는 것을 부수 효과(Side effect)라고 합니다. 즉, 함수 내부에서 매개변수가 아닌 함수 외부의 값을 참조, 대입, 변경, 삭제하는 행위를 말합니다. 각 행위에 대해 구분이 명확한 건 아니지만 이에 대한 간단한 예시는 아래와 같습니다.
// 외부 변수
const a = {
id: 0,
name: 'kim',
arr: [1, 2, 3, 4]
}
function f() {
const local = a.id; // 외부 값을 참조
a.name = 'Park'; // 외부 값에 대입
a.arr.push(5); // 외부 값을 변경
}
이 외에도 console.log()
, 파일 IO, 네트워크 요청/응답, 다른 프로세스 실행, 비 순수 함수 호출 등이 있습니다.
이러한 부수 효과를 함수
일급 객체(First class object)란 변수에 대입할 수 있고, 반환이 가능하고, 매개변수로 불러올 수 있는 것을 의미합니다. 대표적으로 변수가 있는데 JavaScript에선 함수도 위 조건을 만족하기 때문에 first class라고 볼 수 있습니다. 함수가 first class라는 예시는 아래와 같습니다.
// 변수에 대입 가능
const f = () => {
console.log('First class function');
}
// 매개변수, 반환값 가능
function f2(f) {
f();
return f;
}
함수를 인자로 받거나 결과로 반환하는 함수를 고차 함수(Higher Order Function)라고 합니다.
클로저(closure)의 사전적 정의는 닫힘
이다. 개인적으로 생각해봤을 때 클로저는 '외부와 닫힌 독립된 실행 환경을 가지고 있는 함수'라서 그런 이름이 붙은 게 아닐까 싶다.
const a = [];
for (let i = 0; i < 5; i++) {
a[i] = ((n) =>
function foo() {
console.log(n);
})(i);
}
// a = [f, f, f, f, f];
for (let i = 0; i < a.length; i++) {
a[i](); // 0 1 2 3 4
}
위 코드는 아래 코드와 동일합니다.
const a = [];
for (let i = 0; i < 5; i++) {
a[i] = { n: i, f: console.log(i) };
}
// a = [{…}, {…}, {…}, {…}, {…}];
for (let i = 0; i < a.length; i++) {
a[i].f(); // 0 1 2 3 4
}
즉, 클로저는 내부에 상태를 가지고 있는 함수로 볼 수 있습니다. 클로저는 동작 원리가 객체와 비슷합니다.
const arr = [1, 2, 3, 4];
const result = arr.map((element) => element * 2);
console.log(result) // [2, 4, 6, 8]
위 코드는 JavaScript에서 기본적으로 지원하는 Array.prototype.map()
함수다.
const arr = [1, 2, 3, 4];
const result = [];
for (let i = 0; i < arr.length; i++) {
result.push(arr[i] * 2);
}
console.log(result) // [2, 4, 6, 8]
Array.prototype.map()
함수의 내부 코드를 명령형(Imperative) 방식으로 작성하면 위와 비슷할 것이다. 위 코드는 주어진 배열을 처음부터 끝까지 조회하면서 각 원소에 2를 곱한 값을 새로운 배열에 순서대로 넣는 과정을 for문으로 표현했다. 명령형 프로그래밍이란 전통적인 C/Java와 같이 코드를 추상화없이? 순차적으로 실행하는 방식을 의미한다.
// TypeScript
function _map<T>(arr: T[], mapper: (element: T) => T): T[] {
const result = [];
for (let i = 0; i < arr.length; i++) {
result.push(mapper(arr[i]));
}
return result;
}
const arr = [1, 2, 3, 4];
const result = _map(arr, (element) => element * 2);
console.log(result) // [2, 4, 6, 8]
Array.prototype.map()
함수의 내부 코드를 선언형(Declarative) 방식으로 작성하면 위 _map()
함수와 비슷할 것이다. 선언형 프로그래밍이란 코드를 순차적으로 실행하는 대신, 내부 로직을 추상화하고 매개변수로 전달된 함수를 활용해 계산하는 방식이다. 내부 로직의 추상화가 잘 될 수록 재사용성이 높아지는 장점이 있다.
function _map(arrayLike, mapper) {
const result = [];
for (let i = 0; i < arrayLike.length; i++) {
result.push(mapper(arrayLike[i]));
}
return result;
}
const arrayLike = {
0: 1,
1: 2,
2: 3,
3: 4,
length: 4
};
const result = _map(arrayLike, (element) => element * 2);
console.log(result) // [2, 4, 6, 8]
일반 JavaScript와 같이 Type을 없애면 _map()
함수는 ArrayLike 객체를 받을 수 있다. ArrayLike 객체는 내부에 length
가 존재하고 원소를 숫자로 인덱싱할 수 있는 객체를 의미한다.
const arr = [1, 2, 3, 4];
const result = arr.filter((element) => element < 3);
console.log(result) // [1, 2]
위 코드는 JavaScript에서 기본적으로 지원하는 Array.prototype.filter()
함수다.
const arr = [1, 2, 3, 4];
const result = [];
for (let i = 0; i < arr.length; i++) {
if (arr[i] < 3) {
result.push(arr[i] * 2);
}
}
console.log(result) // [1, 2]
Array.prototype.filter()
함수의 내부 코드를 명령형(Imperative) 방식으로 작성하면 위와 비슷할 것이다. 위 코드는 주어진 배열을 처음부터 끝까지 조회하면서 3보다 작은 원소를 새로운 배열에 순서대로 넣는 과정을 for문으로 표현했다.
// TypeScript
function _filter<T>(arr: T[], predicate: (element: T) => boolean): T[] {
const result = [];
for (let i = 0; i < arr.length; i++) {
if (predicate(arr[i])) {
result.push(arr[i]);
}
}
return result;
}
const arr = [1, 2, 3, 4];
const result = _filter(arr, (element) => element < 3);
console.log(result); // [1, 2]
Array.prototype.filter()
함수의 내부 코드를 선언형(Declarative) 방식으로 작성하면 위 _filter()
함수와 비슷할 것이다. 이 함수 또한 위의 _map()
함수와 같이 Type을 없애면 ArrayLike 객체를 받을 수 있다.
위의 map 함수를 활용해서