초반부 강의를 듣다가 글을 쓰러 올라왔다.
이 세션의 마지막 강의를 듣고 결론을 보면 무엇을 위해 이 함수들을 구현하고 배우는지 알 수 있겠지만
평소 내가 지금 무엇을 공부하고 있는지 알아야 효율이 좋은 나는 "대체 자꾸 뭐가 느긋한데..."라는 의문이 있는채로 계속 강의를 들을 수는 없었다.
'느긋한' 이라는 단어가 억지로 번역하는 데에서 나온 단어라고 생각하고 이에 맞는 영어 단어 'lazy'를 찾고, 구글링하며 공부했다.
자바스크립트에는 'lazy'라는 개념이 있다.
보통 'lazy loading(지연 로딩)', 'lazy evaluation(지연 평가)' 에서 쓰이는데, 생각하는 느낌대로 필요한 시점까지 로딩을 미루고 계산을 미루는거다.
모든 계산을 먼저 수행하고 원하는 부분을 찾으면 그 계산이 모두 수행될 때까지 시간이 엄청 걸리지 않겠는가?
웹페이지 로딩을 하는데 내가 지금 보고 있는 페이지뿐만 아니라 전체 모든 부분이 로딩된 후 읽으면 처음 로딩하는 시간이 오래걸릴 것이다.
이런건 매우 비효율적인 동작이다.
그래서 우리는 지연평가(Lazy Evaluation)를 한다.
구글링하며 도움을 받은 블로그 링크를 하나 첨부하겠다.
정리가 잘 되어있어 한번 읽어보는걸 추천한다.
Understanding lazy loading in JavaScript - LogRocket Blog
In this post, we will look at how lazy loading works in JavaScript and explore the importance and advantages of lazy loading.
blog.logrocket.com
숫자 하나를 받아 그 숫자만큼의 크기인 배열을 reuturn하는 range를 구현하고,
range에 있는 배열을 모두 더해보자.
(fx.js파일은 map, filter, reduce 등이 구현되어있는 파일이다. #3에 전체 파일이 첨부되어있다.)
<script src="fx.js"></script>
<script>
const add = (a, b) => a + b;
const range = (l) => {
let i = -1;
let res = [];
while (++i < l) {
res.push(i);
}
return res;
};
log(range(5)); //[0, 1, 2, 3, 4]
log(range(2)); //[0, 1]
var list = range(4);
log(reduce(add, list)); //6
</script>
line10) 배열에 값을 push해 range를 구현했다.
line19) reduce(add)로 값을 더했다.
이번에는 위와 똑같은 일을 하는 코드를 '느긋한 range'로 만들어보겠다.
const L = {};
L.range = function* (l) {
let i = -1;
while (++i < l) {
yield i;
}
};
var list = L.range(4);
log(list); //L.range {<suspended>}
log(reduce(add, L.range(4))); //6
이처럼 제너레이터 함수로 만들어도 결과는 동일하게 6이 출력된다.
위 코드와 다른 점은 list를 출력했을 때 보이는데, 위 코드에서는 list 안에 [0, 1, 2, 3] 배열이 출력되었고,
이 코드에서는 이터레이터가 출력되었다.
list가 다르게 출력되는데도 reduce를 출력했을 때 같은 값이 나오는 이유는
배열과 이터레이터 모두 이터러블이기 때문이다.
이러한 실행 결과를 통해 그냥 range와 느긋한 range의 차이에 대해 유추할 수 있다.
그냥 range는 reduce(위 코드 라인18) 전부터 이미 list에 담긴 값이 배열이다.
즉, 실행 즉시 list가 배열로 평가되었다는 뜻이다.
이와달리 느긋한 range는 list를 순회하기 전까지 함수 안의 어떤 코드도 동작하지 않고,
이터레이터의 내부를 순회할 때마다 값이 하나씩 평가된다.
밑의 코드를 실행시켜보면 더 쉽게 이해할 수 있는데,
내부의 값을 순회하는 코드를 주석처리 하면 4라인은 실행되지 않는다.
const L = {};
L.range = function* (l) {
log("i'm L.range");
let i = -1;
while (++i < l) {
yield i;
}
};
var list = L.range(4);
log(list); //L.range {<suspended>}
// log(list.next()); //{value: 0, done: false}
// log(list.next()); //{value: 1, done: false}
// log(list.next()); //{value: 2, done: false}
// log(list.next()); //{value: 3, done: false}
// log(list.next()); //{value: undefined, done: true}
// log(reduce(add, L.range(4))); //6
이처럼 L.range는 평가가 완벽히 되지 않은 상태로 있다가 해당 값이 필요한 그 시점에 값을 꺼내서 평가한다.
마지막으로 정리하면,
첫번째 코드(배열)는 Array가 되고 - > 그 Array가 iterator가 되고 - > next하면서 순회
두번째 코드(L.range)는 실행됐을 때 iterator를 만들고 - > 이미 만들어진 iterator를 순회
다음은 range와 L.range의 효율성을 확인할 간단한 코드이다.
실행 속도 차이를 비교해보자.
function test(name, time, f) {
console.time(name);
while (time--) f();
console.timeEnd(name);
}
test("range", 10, () => reduce(add, range(1000000)));
test("L.range", 10, () => reduce(add, L.range(1000000)));
take함수는 많은 값을 받아서 특정 길이 이후로는 없애는 함수이고,
10line, 11line에서는 take함수를 통해서 앞에서 다섯 개만 출력하고 있다.
const take = (l, iter) => {
let res = [];
for (const a of iter) {
res.push(a);
if (res.length == l) return res;
}
return res;
};
log(take(5, range(100000)));
log(take(5, L.range(100000)));
10line) 100000크기의 array를 만들고 - > 5개를 뽑는다
11line) 최대 100000의 값을 뽑을거긴하지만 array를 만들지 않고, 딱 5개의 값만 만든다.
L.range가 딱 5개의 값만 만들기 때문에 훨씬 효율적이다.
이러한 지연성은 take나 range같은 함수를 만났을 때 연산이 시작된다.
앞으로는 이터러블 중심 프로그래밍에서의 지연 평가(Lazy Evaluation)에 대해 공부해보자.
앞서 만들었던 map, filter 함수에 지연 평가를 적용해보자.
제너레이터/이터레이터 프로토콜을 기반으로 구현할 것이다.
L.map = function* (f, iter) {
for (const a of iter) yield f(a);
};
var it = L.map((a) => a + 10, [1, 2, 3]);
// log(it.next());
// log(it.next());
// log(it.next());
4라인까지는 아무것도 진행되지 않고, next를 통해 평가하는 만큼의 값만 얻어올 수 있다.
L.map 자체에서는 새로운 array를 만들지 않고, 값 하나하나 순회하며 yield를 통해 함수가 적용된 값을 iterator의 next를 실행할 때마다 하나씩 전달하게되고, 그런 iterator 객체를 원하는 방법으로 평가한다.
이제 L.fiter 함수이다.
L.map 함수와 비슷하지만 f(a)가 true일 때만 yield가 된다.
L.filter = function* (f, iter) {
for (const a of iter) if (f(a)) yield a;
};
var it = L.filter((a) => a % 2, [1, 2, 3, 4]);
log(it.next());
log(it.next());
log(it.next());
breaking point로 확인하기 쉽게 하기 위해 map, filter, reduce의 각 함수의 for of 문을
모든 값을 순회하는 (iterator가 done이 되기 전까지 배열에 push) while문으로 수정 후,
여태까지 만들었던 모든 함수를 html 파일로 옮겨주겠다.
<script>
const log = console.log;
const curry =
(f) =>
(a, ..._) =>
_.length ? f(a, ..._) : (..._) => f(a, ..._);
const go = (...args) => reduce((a, f) => f(a), args);
const pipe =
(f, ...fs) =>
(...as) =>
go(f(...as), ...fs);
const range = (l) => {
let i = -1;
let res = [];
while (++i < l) {
res.push(i);
}
return res;
};
const map = curry((f, iter) => {
let res = [];
iter = iter[Symbol.iterator]();
let cur;
while (!(cur = iter.next()).done) {
const a = cur.value;
res.push(f(a));
}
return res;
});
const filter = curry((f, iter) => {
let res = [];
iter = iter[Symbol.iterator]();
let cur;
while (!(cur = iter.next()).done) {
const a = cur.value;
if (f(a)) res.push(a);
}
return res;
});
const take = curry((l, iter) => {
let res = [];
iter = iter[Symbol.iterator]();
let cur;
while (!(cur = iter.next()).done) {
const a = cur.value;
res.push(a);
if (res.length == l) return res;
}
return res;
});
const reduce = curry((f, acc, iter) => {
if (!iter) {
iter = acc[Symbol.iterator]();
acc = iter.next().value;
} else {
iter = iter[Symbol.iterator]();
}
let cur;
while (!(cur = iter.next()).done) {
const a = cur.value;
acc = f(acc, a);
}
return acc;
});
</script>
<script>
const L = {};
L.range = function* (l) {
let i = -1;
while (++i < l) {
yield i;
}
};
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;
}
}
});
</script>
//그냥 함수 확인 코드
<script>
go(
range(10),
map((n) => n + 10),
filter((n) => n % 2),
take(2),
log
);
</script>
//lazy함수 확인 코드
<script>
go(
L.range(10),
L.map((n) => n + 10),
L.filter((n) => n % 2),
take(2),
log
);
</script>
각 함수의 인자가 들어오는 부분, 반복하는 부분, return값 등에 breakpoint를 찍고 디버깅해보면 값이 어떻게 변해가는지 확인할 수 있다.
이렇게 그냥 함수들과 lazy 함수들의 차이를 스스로 확인해보자.
lazy함수에서 breakpoint를 설정하고 실행하면, 가장 먼저 어떤 함수로 들어갈까?
함수 실행 순서는 range - > map - > filter - > take 순이지만,
놀랍게도 take함수로 먼저 들어가는 것을 볼 수 있다.
디버깅해보면
가장 먼저 take로 들어간 후 while문 안의 iter.next()를 실행할 차례인데(50line)
filter로 넘어간다.
이유는 무엇일까?
평가를 미뤄둔 L.range의 generator가 L.map으로 들어가고
L.map 역시 평가를 미뤄둔 iterator를 return하기 때문에
L.filter에게도 연속적으로 평가를 미뤄둔 iterator가 들어간다.
take에서는 받은 iterator를 찾아야하기 때문에 디버깅 시 take - > filter - > map - > range 순으로 넘어간다.
정리하자면
첫번째 예시(lazy가 아닌)에서는
1) [0, 1, 2, 3, 4, 5...] range로 숫자를 9까지 만들고
2) [10, 11, 12, 13, 14, 15...] 만들어진 숫자에 map으로 10을 더하고
3) [11, 13, 15...] filter로 거르고
4) [11, 13] take로 최종 2개를 자르고
두번째 예시(lazy인)에서는
1) range의 0이 map에서 10이 되고 filter에서 false가 되고
2) range의 1이 map에서 11이 되고 filter에서 true가 되고
...
이러한 실행 순서를 볼 때,
첫번째 예시에서는 가로로 실행되고
두번째 예시에서는 세로로 실행된다는 것을 확인할 수 있다.
이처럼 첫번째 예시가 모든 수들을 다 평가하는 것과 달리,
두번째 예시에서는 필요한 값만 평가하므로 느긋한 계산(lazy evaluation)이 더 효율적인 것을 알 수 있다.
(lazy에서는 range가 1000이든 10000이든 take하는 수만큼의 시간만 걸리게 될 것이다.)
map, filter 계열 함수들의 결합 법칙
사용하는 데이터가 무엇이든지
사용하는 보조 함수가 순수 함수라면
다음과 같이 결합한다면 둘 다 결과가 같다.
[[mapping, mapping], [filtering, filtering], [mapping, mapping]]
=
[[mapping, filtering, maping], [mapping, filtering, mapping]]
(가로로 계산하던걸 세로로 결합해도 결과가 같다는 뜻.
조건 하에서 즉시 평가하던걸 지연 평가해도 결과가 같다.)