[functional JS ES6+] 지연성 1 : L.range, L.map, L.filter, L.take

작성:    

업데이트:

카테고리:

태그: , ,

본 포스트는 인프런의 함수형 프로그래밍과 JavaScript ES6+ 강의(링크)를 듣고 정리한 내용입니다.


range 함수

일반 range 함수

숫자 하나를 받아서, 그 숫자의 크기만한 배열을 만드는 range 함수를 만들어보자

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]

// range 만큼 모두 합을 누적
const list = range(5)
log(list); // [1, 2, 3, 4, 5]
log(reduce(add, list)); // 15


느긋한 range 함수: L.range

  • iterator를 만드는 generator 함수를 이용
  • i가 증가하면서 yield에 추가
const L = {};
L.range = function *(l) {
  let i = -1;
  while (++i < l) {
    yield i;
  }
}

// range 만큼 모두 합을 누적
const list = L.range(5)
log(list); // L.range {<suspended>}
log(reduce(add, list)); // 15


차이점 1. range에 따른 list를 출력할 때

  • range 함수 : 배열 출력
  • L.range 함수 : iterator 출력(next 메서드 사용 가능)


차이점 2. 실행에 대한 평가 시점

  • range 함수 : 실행했을 때, 해당 부분의 표현식이 바로 값(배열)으로 평가
  • L.range 함수
    • 실행했을 때 순회가 모두 실행되지 않는다.
    • iterable이 next가 처음 찍힐 때 함수 내부 평가
    • iterable이 next가 찍힐 때마다 while문 반복 하나씩 평가
L.range = function *(l) {
  log('start');
  let i = -1;
  while (++i < l) {
    log(i, 'L.range');
    yield i;
  }
}

const list = L.range(5)
log(list); // L.range {<suspended>}
log(list.next()); 
// start
// 0 "L.range"
// {value: 0, done: false}
log(list.next()); 
// 1 "L.range"
// {value: 1, done: false}
log(list.next()); 
// 2 "L.range"
// {value: 2, done: false}


이게 왜 필요하죠?

  • range()의 모든 범위를 사용하지 않을 때
  • range()는 그래도 전달된 수만큼의 크기의 배열을 바로 생성
  • 이 배열에서 Symbol.iterator를 통해 iterator를 생성해 next를 돌며 처리

  • L.range()는 필요한 만큼만 그때그때 평가해 값을 꺼내면서 처리
  • 그 자체가 iterator이므로, iterable을 만들고 iterator 생성할 필요 없이 바로 동작
  • 여기에서 효율성의 차이 발생


L.range 함수의 성능 확인

그렇다면 이러한 동작이 어떤 차이를 만드는지 확인해보자

function test(name, time, f) {
  console.time(name);
  while (time--) f();
  console.timeEnd(name);
}

test('L.range', 10, () => reduce(add, L.range(1000000))); // L.range: 296.49...ms
test('range', 10, () => reduce(add, range(1000000))); // range: 488.15...ms

나름 유의미한 효율성의 차이를 볼 수 있다.


지연평가

가장 필요할 때까지 평가를 미루다가 필요할 때 평가하여 값을 만드는 것

  • 게으름과 동시에 똑똑한 방식
  • 필요한 순간에 평가해 값을 만들면, 필요한 만큼만 평가를 실행하므로 불필요한 평가 회피 가능

  • ES6 이전까지는 JS 공식적인 방식이 아닌, 비공식적 연산을 통해서만 구현해야 했음
  • 그런데 이는 다른 라이브러리나 여러 활용 부분에서 호환이 되지 않거나 불안정한 방식
  • ES6 부터 보다 더 공식적인 방식으로 지연평가를 지원
  • 때문에 함수와의 합성이나 라이브러리 등의 사용에서 안정성과 효율성을 취할 수 있게 됨
  • generator 기반, iterator 중심 프로그래밍에서의 지연평가 구현


take

많은 값을 받아서 잘라주는 함수

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

log(take(5, range(100))); // [0, 1, 2, 3, 4]
log(take(5, L.range(100))); // [0, 1, 2, 3, 4]
  • iterable protocol을 따르는 함수
  • 지연성을 가지더라도 iterable protocol을 따른다면 순회해서 배열 생성 가능
  • 여기에서 지연성 함수를 사용하면 최대 개수와 상관없이 5개만 생성해서 취하므로 효율성 차이가 더 극대화


L.map과 L.filter

L.map

L.map = function *(f, iter) {
  for (const a of iter) yield f(a);
}

let it = L.map(a => a + 10, [1, 2, 3]);
log(it.next()); // {value: 11, done: false}
log(it.next()); // {value: 12, done: false}
log(it.next()); // {value: 13, done: false}

log([...it]) // [11, 12, 13]
  • 새로운 array를 만들지 않음.
  • 필요한 범위까지만 iterator의 next를 진행하며 yield를 뽑아냄.
  • 필요하다면 spread를 통해 전체 배열을 순회하며 배열형태로 출력도 가능


L.filter

L.map = function *(f, iter) {
  for (const a of iter) if (f(a)) yield a;
}

let it = L.filter(a => a % 2, [1, 2, 3]);
log(it.next()); // {value: 1, done: false}
log(it.next()); // {value: 3, done: false}
log(it.next()); // {value: undefined, done: true}

log([...it]) // [1, 3]
  • L.map과 거의 유사
  • for문으로 iteration하며 각 요소 a의 함수 반환값 f(a)가 true라면 yield a


평가 순서

즉시 평가와 게으른 평가의 평가 순서와 동작은 어떻게 다른지 확인해보자

go 함수의 내부 함수인자들은 모두 curry로 작성되어 첫 함수 인자의 값(배열)이 다음 함수의 인자로 전달


즉시 평가

go(range(10),
  map(n => n + 10),
  filter(n => n % 2),
  take(2),
  log); // [11, 13]
  1. range(10)이 평가되어 길이가 10인 0부터 9까지의 배열 반환
  2. 이 배열이 map 함수에 전달되어 각각의 값에 10을 더한 배열 반환
  3. 이 배열이 filter 함수에 전달되어 홀수인 값만 남긴 배열 반환
  4. 이 배열이 take 함수에 전달되어 값이 2개만 있는 배열 반환
  5. 이를 log로 출력


핵심은 두 가지이다.

  1. 앞에서부터 순차적으로 진행
  2. 함수마다 모두 평가해서 배열을 준비하고 다음 함수에 전달하며 함수 묶음 진행


게으른 평가(L. 함수 사용)

  • 모양새도, 결과는 같지만 평가 순서는 정반대
go(L.range(10),
  L.map(n => n + 10),
  L.filter(n => n % 2),
  L.take(2),
  log); // [11, 13]
  1. 처음에 L.take 함수를 평가하여 2개의 인자만 뽑는다는 것을 확인
  2. 전달되는 배열 대신 iterator가 위치
  3. 이를 전달할 L.filter 함수 평가
  4. 홀수만 뽑는다는 것을 확인
  5. 이에 iterable을 전달할 L.map 함수 평가
  6. 요소들에 10씩 더한다는 사실 확인
  7. 이 대상이 되는 iterable을 전달하는 L.range 함수 평가
  8. L.range에서 yield를 1개 반환
  9. 이를 L.map 함수에서 실행해 10을 더하고 반환
  10. 이를 L.filter 함수에서 확인하여 필터 여부 판단
  11. 이를 통과한다면 L.take 함수에 전달
  12. 8~11을 반복하며 2개까지 누적
  13. 2개를 만족한다면 log 실행하여 출력


핵심은 두 가지이다.

  1. 뒤에서부터 평가를 역순으로 진행
  2. 하나씩 yield를 반환하지만 결과적으로 필요한만큼만 평가를 진행하여 불필요한 평가 회피 → 시공간적 효율 UP


게으른 평가의 효율성

range()의 범위가 커지게 된다면?

  • 즉시 평가: 매번 큰 크기의 배열을 준비하고 내리므로 연산속도는 급격히 증가, range 인자의 크기가 지배적
  • 게으른 평가: range 크기는 상관 없이 take에서 필요한 개수를 충족하면 평가를 마치므로 take 인자의 크기가 지배적


map, filter 계열 함수들의 결합 법칙

  • 사용하는 데이터가 무엇이든지
  • 사용하는 보조 함수가 순수 함수(ex. 사칙연산)라면 무엇이든지
  • 아래와 같이 결합한다면 모두 결과가 같다.


즉시 평가

  • [[mapping, mapping], [filtering, filtering], [mapping, mapping]]
  • 가로형


게으른 평가

  • [[mapping, filtering, mapping], [mapping, filtering, mapping]]
  • 세로형

댓글남기기