본문 바로가기
개발자 도전기/[STUDY] JS || TS

JavaScript | FP && ES6+ | 지연성

by 답수 2022. 2. 16.
728x90
SMALL

 

 

| 지연 평가(Lazy Evalutaion)

지연평가는 값이 필요할 때까지 평가를 미루는 것을 말한다. 즉 필요할 때, 필요한 것만 평가하여 연산을 효율적으로 하는 방식이다. 이러한 방식을 지연성이라고 하고, 자바스크립트는 ES6부터 공식적인 프로토콜로 사용이 가능하다. 자바스크립트의 지연 평가는 이터러블을 중심으로 이루어진다.

 

이제 range, take 등의 함수를 뜯어보면서 지연 평가에 대해 더 자세하게 정리해보자.

 

 

range

파이썬할 때 사용하던 그 range 맞다. 0부터 시작해서 주어진 제한 길이까지 값을 배열로 리턴하는 메소드. 

const range = l => {
    let res = [];
    let i = -1;
    while (++i < l) {
        res.push(i);
    }
    return res;
};

console.log(range(5));  // [0, 1, 2, 3, 4]

 

 

제너레이터로 구현하면 다음과 같다.

const L = {};
L.range = function* (l) {
    let i = -1;
    while (++i < l) {
        yield i;
    }
}

const list = L.range(4);
console.log(list);  // 이터레이터가 리턴됨: L.range { <suspended> }
console.log(list.next());   // { value: 0, done: false }
console.log(list.next());
console.log(list.next());
console.log(list.next());
console.log(list.next());   // { value: undefined, done: true }

 

range는 반복문 내에 있는 코드를 순차적으로 실행한다. 반면에 L.range는 내부에 있는 코드를 바로 실행하지 않고, next가 있을 때 하나씩 실행한다. 

range는 배열을 만들고 그 배열을 이터레이터로 만든 후 next를 통해 순회한다. L.range는 range와는 다르게 실행하면서 이터레이터를 만들고, 그 이터레이터는 자신을 리턴하는 이터러블이다. 즉 해당하는 함수를 실행하면 이미 만들어진 이터러블을 리턴하고 그 이후에 순회한다. 

 

 

강의에서 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)));

/*결과
L.range: 233.51611328125 ms
range: 325.8740234375 ms
*/

range에 비해 L.range의 효율성이 더 좋다는 것을 알 수 있다.

 

 

take

take는 값들을 받고, 원하는 부분까지만 잘라주는 함수다.

const take = curry((l, iter) => {
  let res = [];
  for (const a of iter) {
    res.push(a);
    if (res.length == l) return res;
  }
  return res;
});

console.log(take(5,range(100)));    // [0,1,2,3,4]  하지만 range는 100까지 순회 다 함
console.log(take(5,L.range(100)));  // [0,1,2,3,4]  100까지 순회하지 않음

여기서 range와 L.range의 효율성에 대해서 한 번 더 언급할 수 있는 포인트가 있는데, range는 위에서 얘기했던 것처럼 배열을 생성하기 때문에 take로 5개의 원소만 출력한다고 하더라고 크기 100까지 순회를 실행한다. 반면에 L.range는 이터러블을 통해 next를 통해서만 평가가 되기 때문에 5개 원소까지만 순회를 실시한다. 즉 시간 효율성 면에서 봤을 때 훨씬 효율적이라는 것이다.

 

take에서 받는 iter의 값을 이터러블로 바꿔서 함수를 성능적으로 보완할 수 있다.

const take = curry((l, iter) => { // 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;
});

 

 

L.map

map 구현을 한 번 더 보자.

const map = curry((f, iter) => {
  let res = [];
  for (const a of iter) {
    res.push(f(a));
  }
  return res;
});

 

 

map 역시 배열을 받아서 그 배열 안의 원소들을 원하는 함수를 통해 값을 평가한다.  L.map은 배열을 만들지 않고 이터러블 순회를 통해 yield로 지연 평가를 한다.

L.map = function* (f, iter) {
    for (const a of iter) yield f(a);
}
let it = L.map(a => a+10, [1,2,3]);
console.log(it.next());
console.log(it.next());
console.log(it.next());
console.log(it.next());
console.log([...it]);   // 전개 연산을 통해 출력 가능 
console.log([it.next().value]);

 

 

L.filter

const filter = curry((f, iter) => {
  let res = [];
  for (const a of iter) {
    if (f(a)) res.push(a);
  }
  return res;
});

 

L.filter = function* (f, iter) {
    for (const a of iter) if (f(a)) yield a;
};
let it = filter(a => a % 2, [1,2,3,4]);
console.log([...it]);   // [1, 3]

 

 

range, map, filter, take, reduce 중첩 사용

 기존에 구현했던 map, filter, take, reduce를 이터레이터를 반환하도록 보완하면 다음과 같다.
  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;
  });
 
 
위의 함수들을 중첩해서 사용할 수 있음!
console.time('');
go(range(100000),
    map(n => n + 10),
    filter(n => n % 2),
    take(10),
    console.log);
console.timeEnd('');

 

여기서 map, filter 계열 함수들이 가지는 결합 법칙이 있다.

  • 사용하는 데이터가 무엇이든지,
  • 사용하는 보조 함수가 순수 함수라면 무엇이든지,

아래와 같이 결합한다면 둘 다 결과가 같다.

[[mapping, mapping], [filtering, filtering], [mapping, mapping]]
= [[mapping, filtering, mapping], [mapping, filtering, mapping]]

즉 가로로 결합하던 것들을 새로로 결합해도 결과는 같다.

 

 

ES6의 기본 규약을 통해 구현하는 지연 평가의 장점

  • 함수와 함수 리턴 값을 통해서 원하는 시점에 지연할 수 있음
  • JS의 고유한 규약을 통해 안전하게 합성 가능
  • 서로 다른 라이브러리, 서로 다른 사람이 작성한 코드라도 JS의 기본 값을 토대로 소통되기 때문에 조합성이 높고 안전하게 합성 가능

 

 

결과를 만드는 함수 reduce, take

map, filter는 지연성을 가질 수 있는 함수인 반면, reduce, take는 실제로 연산을 시작하여 최종적으로 결과를 만드는 함수다. A로부터 B라는 값을 만든다고 가정할 때, map 등을 거친 후 reduce를 통해 리턴하겠다! 라는 사고로 접근하면 더 수월할 것이라고 한다.

 

 

 

 

 

 

* ref

728x90
LIST

댓글