*아래에서 사용될 함수(go, curry...)들은 이전 포스트들에서 설명되어 있습니다.
📗지연성이 이루고자 하는 목표
지금까지의 코드를 바탕으로 아래와 같은 코드가 실행된다고 생각해보자.
go(
range(100000),
take(5),
reduce(add),
log);
우선 range 함수를 통해 10만개의 index를 가진 배열이 생성되고 그 중 5개를 take 하여 reduce로 더하는 함수임을 알 수 있다.
문제가 없어 보이지만, 10만개의 index중 단 5개만을 사용하고 나머지는 사용되지 않은 상태여서
나머지 99995개의 배열 인덱스에 값이 평가된 것은 실제로 쓸모없는 일이 되었다.
하지만, 웹에서 돌아가는 js의 특성상 실제 서비스에선 10만개의 index중 5개를 take 할지 10만개 전부를 take 할지는 사용자에 액션에 따라 달라지기 때문에 이건 어쩔 수 없는 부분이 아니냐고 생각 할 수 있다.
만약, 사용하는 값만 평가하고 사용할 수 있다면?
함수형 프로그래밍의 지연성은 위의 아이디어에서 시작한다.
📗range 와 L.range
L.range는 '느긋한' 이라는 말이 붙는 range 함수이다.
여기서의 느긋한은 '함수 내부의 실행을 순회 당시 순서로 미룸' 이라는 말에 좀 더 가깝다.
아래의 함수들을 봐보자
const range = l => {
let i = -1;
let res = [];
while (++i < l) {
res.push(i);
}
return res;
};
L.range = function* (l) {
let i = -1;
while (++i < l) {
yield i;
}
};
range는 l 만큼의 index를 가진 배열을 바로 평가하여 리턴한다.
L.range는 l 까지 순회하는 이터레이터를 리턴한다.
둘의 차이는 값이 평가되는 시점에 있다.
아래와 같이 기존 range는 while의 실행이 다 끝난 이후 return이 되기 때문에 어쩌면 당연하게 콘솔이 1000번이 찍힐 것으로 예상할 것이다.
const range = l => {
let i = -1;
let res = [];
while (++i < l) {
res.push(i);
console.log(i);
}
return res;
};
var list = range(1000);// 1000번의 콘솔이 찍힐 것이다.
log(list); // [0, 1, 2 .... , 999]
하지만 아래 이터레이터는 순회 시점에 값이 평가가 된다.
L.range = function* (l) {
let i = -1;
while (++i < l) {
console.log(i);
yield i;
}
};
var list = L.range(1000); // 아무 콘솔도 찍히지 않는다.
console.log(list); // Object [Generator] {}
list.next(); // 0
list.next(); // 1
list.next(); // 2
list.next(); // 3
그렇다면 이 지연성을 가진 함수는 어떤 이점이 있을까.
맨 처음 실행했던 함수를 L.range로 바꾸어보고 걸리는 시간을 비교해보
console.time('');
go(
range(10000),
take(5),
reduce(add),
log);
console.timeEnd('');
console.time('');
go(
L.range(10000),
take(5),
reduce(add),
log);
console.timeEnd('');
꽤 나 큰 차이가 나는 것을 볼 수 있다.
📗이터러블 중심 프로그래밍에서의 지연 평가 (Lazy Evaluation)
지연평가는 아래와 같은 특성이 있다.
- 제때 계산법
- 느긋한 계산법
- 제너레이터/이터레이터 프로토콜을 기반으로 구현
📗map과 filter에도 적용해보자
기존 로직에서 반복문에 이터레이터를 넣어 yield 하는 제너레이터로 수정했다.
L.map = curry(function* (f, iter) {
iter = iter[Symbol.iterator]();
let cur;
while (!(cur = iter.next()).done) {
const a = cur.value;
yield f(a);
}
});
L.filter = curry(function* (f, iter) {
iter = iter[Symbol.iterator]();
let cur;
while (!(cur = iter.next()).done) {
const a = cur.value;
if (f(a)) {
yield a;
}
}
});
이제 위 함수들을 중첩 사용했을때 어떤 순서로 값이 평가될까.
📗놀랍게도 아래 코드는 take부터 디버깅이 찍힙니다.
go 는 분명 순서대로 값이 평가되어 다음 함수로 넘기는 함수라고 했지만 지연성을 사용한다면 예상한 것과는 약간 다르게 돌아간다.
go(L.range(Infinity), // 1.
L.map(n => n + 10),
L.filter(n => n % 2),
take(10),
log);
만약 위 함수가 기존 range, map, filter 였다면 아래와 같은 순서로 값이 평가 됐을 것이다.
[0, 1, 2, 3, 4, 5, 6, 7, 8...]
[10, 11, 12, ...]
[11, 13, 15 ..]
[11, 13]
그러나 지연성 함수들을 사용한 1. 코드의 경우에는 디버깅을 실행해서 모든 함수의 실행이 언제 되는지를 관찰했을때 take 에서 먼저 실행된다.
이유는 각 함수들이 이터레이터만 생성해서 go를 통해 다음 함수로 넘어갔고, take에서 최초 연산이 실행되기 때문이다.
기존 함수들은 평가를 전부 하고 넘기는 방식이었다면 L 함수들은 이터레이터들의 평가를 세로로 계속 왔다 갔다 하면서 평가를 미룬다. 이때, filter에서 걸려서 yield 하지 않게 되면 take에 전달이 되지도 않는다.
평가 순서는 아래와 같은 모습이다.
[0 [1 // 아까는 평가의 순서가 수평적으로 이루어지고 전달되었다면, 지연함수는 수직적으로 이루어지는 것 처럼 보인다.
10 11
false] true]
📗map, filter 계열 함수들이 가지는 결합 법칙
위 함수들에서 map, filter 계열의 함수들만 사용시에는 결합법칙을 가진다.
사용하는 데이터가 무엇이든지, 사용하는 보조 함수가 순수 함수(기존 데이터를 변경하지 않는 함수)라면 무엇이든지 아래와 같이 결합한다면 전부 결과가 같다.
[[mapping, mapping], [filtering, filtering], [mapping, mapping]]
=
[[mapping, filtering, mapping], [mapping, filtering, mapping]]
📗ES6의 기본 규악을 통해 구현하는 지연 평가의 장점
- 평가를 원하는 시점을 통해 할 수 있게 하여 효율성을 높일 수 있다.
- ES6에서 약속된 자료구조인 이터레이터를 통해 개발한다면 다른 라이브러리 혹은 함수등의 자구조와 합성이 용이하다.
자바스크립트의 규약인 ES6에서 사용하는 자료구조들을 기반으로 지연평가를 구현한다면, 해당 함수들의 다형성은 매우 높다.
'함수형 프로그래밍과 ES6+' 카테고리의 다른 글
[함수형 프로그래밍과 ES6+] 동시성 - 1 (2) | 2024.06.08 |
---|---|
[함수형 프로그래밍과 ES6+] 지연성 - 2 (0) | 2024.05.26 |
[함수형 프로그래밍과 ES6+] 코드를 값으로 바꾸어 표현력 높이기 (0) | 2024.05.21 |
[함수형 프로그래밍과 ES6+] 함수형 프로그래밍을 위한 기본 지식 (0) | 2024.05.19 |