본문 바로가기

Web[웹]/ES6+ 함수형 언어

[JS: ES6+] 제너레이터/이터레이터 프로토콜로 구현하는 지연 평가 (1)

 

#1 range와 느긋한 L.range

 

지연 평가의 정의

 

컴퓨터 프로그래밍에서 느긋한 계산법(Lazy evaluation)은 계산의 결과 값이 필요할 때까지 계산을 늦추는 기법이다. 지연 평가는 필요 할 때까지 계산을 늦추면서 불필요한 계산을 줄일 수 있다. 이러한 지연 평가는 3가지 이점을 가지고 있다.

 

  1. 불필요한 계산을 하지 않으므로 빠른 계산이 가능하다.
  2. 무한 자료 구조를 사용 할 수 있다.
  3. 복잡한 수식에서 오류 상태를 피할 수 있다.

그럼 이제부터 일반적인 평가와 지연평가를 비교해보록 하자.

 


 

range 와 L.range 비교

 

숫자하나를 받고 그 숫자의 크기만한 배열을 리턴 후에 그 값들을 더하는 range함수를 선언해볼 것이다.

 

const add = (a, b) => a + b;

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

var list = range(4);
console.log(list); // [0, 1, 2, 3]
console.log(reduce(add, list)); // 6

 

이번에는 L.range를 선언해볼 것이다.

 

const add = (a, b) => a + b;

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

var list = L.range(4);
console.log(list); /*아래에서 결과 확인*/
console.log(reduce(add, list)); // 6

 

range 와 L.range 둘다 같은 결과인 6을 출력했지만, 콘솔로 list를 출력해보면 그 결과가 다름을 알 수 있다.

 

/* range 에서의 list */
console.log(list); // [0, 1, 2, 3]

/* L.range 에서의 list */
console.log(list)
/*
▼L.range {<suspended>}
   ▶__proto__: Generator
     [[GeneratorLocation]]: hi.html:13
     [[GeneratorStatus]]: "closed"
   ▶[[GeneratorFunction]]: ƒ* (length)
   ▶[[GeneratorReceiver]]: Object
*/

 

즉, L.range에서의 list의 값은 이터레이터임을 알 수 있다. 둘다 같은 결과를 나타낸 이유는 reduce라는 함수가 이터러블을 받기 때문 입니다.

 

그럼 왜 range와 비교했을때 L.range는 느긋하다고 할까?

 

확인을 위해서 range와 L.range 코드 두부분에 while문 밑에 콘솔로 루프문을 하나씩 출력해볼것이다.

 

const add = (a, b) => a + b;

/* range */
const range = length => {
   let i = -1;
   let res = [];
   while (++i < length) {
      console.log(i, 'range');
      res.push(i);
   }
   return res;
};

var list = range(4);


/* L.range */
const L = {};
L.range = function* (length) {
   let i = -1;
   while (++i < length){
      console.log(i, 'L.range');  
      yield i;
  }
};

var list = L.range(4);

 

range와 L.range의 실행 결과를 비교해보자.

 

range에서 기입한 코드만 출력되고, L.range에서 기입한 코드는 전혀 출력되지 않았다.

 

그렇다면, L.range의 코드는 언제 출력이 될까??

 

이터레이터의 내부를 순회할때마다 값이 평가가된다. 즉, next()를 하기 전까지는 함수 안에서 어떠한 코드도 동작하지않는다.

L.range 코드 밑에 next를 찍어서 값을 확인해보면, 다음과 같은 결과가 나온다.

 

console.log(list.next());
console.log(list.next());
console.log(list.next());
console.log(list.next());

 

 

range를 실행했을때 즉시 그 부분의 코드가 배열로 평가가 되어 값이 만들어지고,  L.range는 코드가 평가되지 않고 있다가, reduce와 같이 값이 필요할때 그 값을 꺼내서 값을 만드는 것이다. 

 

다시말해서, L.range는 지정한 범위만큼 배열을 생성하지않고, 평가할 때 동적으로 생성하는 함수이다.

 

 

동작 비교

 

range: array를 만듦→ 이터레이터를 만듦  → next를 만들면서 순회

 

L.range: array를 만들지 않고 실행될때 이터레이터를 만듦  그 이터레이터가 자기 자신을 리턴하는 이터러블이 됨  해당 함수를 실행하면 이미 만들어진 이터레이터를 그냥 리턴만하고 순회

 

 


 

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가 빠른 것을 알 수 있다.

 


 

take 함수를 통한 효율성 비교

 

take함수는 값을 받아서, 원하는 크기 만큼 잘라주는 함수이다.

 

const take = (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]
console.log(take(5, L.range(100))); // [0, 1, 2, 3, 4]

 

위에서 있는 range, L.range 둘다 같은 결과값을 출력하지만,  효율성 측면에서 확연히 다르다.

 

아래코드를 보자.

 

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

 

range같은 경우 100000의 배열을 만든 후 5개를 출력하지만, L.range는 미리 배열을 만들지 않고, 필요한 5개의 값만 만든다.

 

속도를 비교해보면 확연한 차이를 알 수 있을 것이다.

 

console.time('');
console.log(take(5, range(100000))); // [0, 1, 2, 3, 4]
console.timeEnd('');
// 8.6337890625ms

console.time('');
console.log(take(5, L.range(100000))); // [0, 1, 2, 3, 4]
console.timeEnd('');
// 2.4189453125ms

 

즉, L.range의 경우엔 필요한 값만 뽑기 때문에 L.range의 인자에 Infinity값을 집어 넣어도 속도엔 변화가 없을 것이다.

 


 

#2 엄격한 평가와 지연 평가의 비교

 

L.map 과 L.filter

 

먼저, L.map을 볼 것이다.

L.map은 새로운 Array를 만들지 않고, 이터러블을 순회하면서, 각 요소에 대해 함수를 적용한 값을 yield를 통해 게으른 평가를 수행합니다.

 

L.map = function* (f, iter) {
  for(const a of iter) yield f(a);
};
var it = L.map(a => a + 10, [1, 2, 3]);
console.log(it.next()); // {value: 11, done: false}
console.log(it.next()); // {value: 12, done: false}
console.log(it.next()); // {value: 13, done: false}

 

이어서 L.filter를 알아보자.

 

L.map과 거의 유사한데, 이터러블을 순회하면서, 조건결과 값이 참인 경우에만 값을 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]);
console.log(it.next()); // {value: 1, done: false}
console.log(it.next()); // {value: 3, done: false}
console.log(it.next()); // {value: undefined, done: true}

 


엄격한 평가와 지연 평가의 평가 순서

 

다음과 같은 두 함수가 있다고 가정하자.

 

/* 엄격한 평가 */
go(range(10),
map(n => n + 10),
filter(n => n % 2),
take(2),
console.log);

/* 지연 평가 */
go(L.range(10),
L.map(n => n + 10),
L.filter(n => n % 2),
take(2),
console.log);

 

여기서 지연 평가는 필요한 정보만 평가하므로, 일반적인 엄격한 평가보다 훨씬 빠른 것을 나타냄을 보여줄 수 있는데, 아래를 보자.

 

엄격한 평가에서의 계산법을 먼저 봅시다.

range에서 0부터 9를 담은 배열을 먼저 생성한 후에, 그 생성한 배열을 map으로 넘기고, 그 결과 배열을 다시 filter로 넘기는 식으로 진행됩니다. 

 

이제 지연 평가에서의 계산법을 봅시다.

range에서 0을 생성한 후, map에서 이 요소를 10을 더한 후, filter가 그 값을 판별해 false되어 더이상 go함수를 진행하지 않고, 바로 range 1을 생성하고, 계속해서 진행한다.

 

두 가지를 비교해서 설명해보면,  엄격한 평가에서는 range에서 인자로 넣은 값만큼 map에서 전부 배열을 생성한 후, 이어서 모든 인자를 거쳐가는 반면에, 지연 평가에서는 당장 조건에 해당하는 값을, 동적으로 평가하여 그때마다 필요로 하는 값을 평가한다. 

 

정리하자면, 엄격한 평가에선 range에 넣는 인자의 값이 커지면 커질수록, 계산 효율이 떨어지는 반면에,  지연 평가에선 인자의 값이 커져도 계산 효율이 크게 떨어지지 않는다. 

 

 

위 그림을 보면 명확하게 정리가 될 것이다. 지연 평가는 단 3개의 값을 찾기위해 적은 시간이 걸렸지만, 엄격한 평가에서는 range에서 나온 인자의 값인 10의 크기만큼 전부 go 함수를 진행하였다. 즉, 이 인자의 값이 커질수록 엄격한 평가는 계산 효율이 극으로 떨어질 수 있다.

 

 


 

map, filter 계열 함수들이 가지는 특성

 

사용하는 데이터가 무엇이든 상관없이, 그 보조함수가 순수함수라면, 아래 처럼 결합법칙이 성립한다.

 

예를 들자면, 0부터 9까지의 값에 map을 우선 다 적용한 뒤 filter를 적용한 결과나, 0부터 map과 filter를 차례로 적용하여 9까지 반복한 결과나 동일하다는 뜻입니다.