본문 바로가기

Backend/함수형 자바스크립트

Underscore.js - _.map & _.each

- _.map 구현 및 활용

 

Underscore.js 에서 _.map 함수는, 앞선 포스팅에서 말한 것처럼 {}, [], arguments, ArrayLike 를 인자로 받아 iteratee 함수를 돌려 값을 변형시키고 변형된 값을 배열 형태로 반환합니다.

 

먼저 getLength 와 isArrayLike 함수를 만듭니다.

 

const MAX_ARRAY_INDEX = Math.pow(2, 53) - 1;

const getLength = function(list) {
  return (list === null || list === undefined) ? void 0 : list.length;
};

const isArrayLike = function(list) {
  const length = getLength(list);
  return typeof length === 'number' && length >= 0 && length <= MAX_ARRAY_INDEX;
};

 

위 코드 중 void 0 는 undefined 입니다.

isArrayLike 함수는 인자로 받은 list 의 length property 가 유효한 숫자인지를 판별해 true || false 를 리턴합니다.

이제 isArrayLike 를 활용해 _.map 을 만들어 보겠습니다.

 

const _ = {};

_.map = function(data, iteratee) {
  const newList = [];
  
  if (isArrayLike(data)) {
    for (let i = 0 ; i < data.length ; i++) {
      newList.push(iteratee(data[i], i, data));
    }
  } else {
    for (const key in data) {
      if (data.hasOwnProperty(key)) {
        newList.push(iteratee(data[key], key, data));
      }
    }
  }
  
  return newList;
};

 

코드는 간단합니다.

인자로 들어온 data 가 ArrayLike 면 각 배열의 값에 iteratee 를 돌려 변경된 값을 새로운 배열에 넣어 반환하고,

ArrayLike 가 아니면 객체의 키가 들고 있는 값에 iteratee 를 돌려 변경된 값을 마찬가지로 새로운 배열에 넣어 반환합니다.

 

의도한대로 동작하는지 돌려봅니다.

 

const test1 = _.map([1, 2, 3], function(v) { return v * 2 });
console.info(test1); // [2, 4, 6]

const test2 = _.map({ a: 3, b: 2, c: 1 }, function(v) { return v * 3 });
console.info(test2); // [9, 6, 3]

 

이제 만든 _.map 을 통해 Underscore.js 에 있는 _.identity 와 _.values 를 구현해보겠습니다.

_.identity 는 인자로 들어온 값을 그대로 다시 반환하는 함수고, _.values 는 객체가 가지고 있는 속성의 모든 값을 반환하는 함수입니다.

(_.identity 는 _.map 이랑 상관은 없지만 _.values 를 _.map 과 _.identity 조합으로 만들어봅니다..)

 

_.identity = function(v) { return v; };

_.values = function(list) {
  return _.map(list, _.identity);
};

 

_.identity 는 코드만 봐도 뭘 하는건지 명확하고, _.values 가 의도한대로 동작하는지 확인해봅니다.

 

const test1 = _.values([1, 2, 3]);
console.info(test1); // [1, 2, 3]

const test2 = _.values({id: 1, name: 'A'});
console.info(test2); // [1, 'A']

 

물론 _.identity 를 만들지 않아도, _.values 를 구현할 때 _.map 의 iteratee 로 function(v) { return v; } 를 넘겨도 무방합니다.

하지만 _.identity 를 만들어두면, 이 케이스가 아닌 경우에도 쓰임새가 있습니다.

 

위에서 _.values 를 만들어봤으니, _.keys 도 만들어보겠습니다.

 

_.args1 = function(a, b) { return b; };

_.keys = function(list) {
  return _.map(list, _.args1);
};

 

먼저 만든 _.args1 은 두 개의 인자를 받으면 두번째 인자를 반환하는 함수입니다.

우리는 앞서 _.map 을 구현하는 코드에서 iteratee(data[i], i, data) 의 값을 새로운 배열에 push 하도록 했었습니다.

그럼 여기서 iteratee 에 _.args1 가 들어간다면 배열일 땐 i, 객체일 땐 key 가 새로운 배열로 들어가고 이를 반환하게 됩니다.

동작에 이상이 없는지 확인해보도록 하겠습니다.

 

const test1 = _.keys([1, 2, 3]);
console.info(test1); // [0, 1, 2]

const test2 = _.keys({ id: 1, name: 'A' });
console.info(test2); // ['id', 'name']

 

여기서도 마찬가지로 _.args1 을 만들지 않아도 되지만, 위에서 _.identity 를 만든 것처럼 우선 작은 함수로 만들어 두면 이후 다른 함수를 만들 때 재사용 될 수 있습니다. (함수형으로서의 접근법)

 

- _.each 구현

 

Underscore.js 에서 _.each 함수는 _.map 보다 하는 일은 더 없습니다. _.map 과 동일하게 {}, [], arguments, ArrayLike 를 인자로 받아 루프를 돌면서 iteratee 를 한 번씩 실행하고 받은 인자를 그대로 반환하면 됩니다.

 

_.each = function(data, iteratee) {
  if (isArrayLike(data)) {
    for (let i = 0 ; i < data.length ; i++) {
      iteratee(data[i], i, data);
    }
  } else {
    for (const key in data) {
      if (data.hasOwnProperty(key)) {
        iteratee(data[key], key, data);
      }
    }
  }
  
  return data;
};

const test1 = _.each([1, 2, 3], function(v) { console.info(v * 2); });
// 2
// 4
// 6

 

지금까지의 _.map 과 _.each 의 코드를 보면 굉장히 겹치는 부분이 많습니다. 즉 현재 상태에서 중복을 제거할 수 있을 겁니다.

_.map 과 _.each 는 함수입니다. 따라서 우선 함수를 반환하는 함수를 만들면 될 것 같습니다.

 

function frame(...) {
  return function(data, iteratee) {
  ...
  }
}

_.map = frame(...);
_.each = frame(...);

 

아마 이런 형태가 될 것입니다. 뼈대 라는 느낌으로 frame 이라는 네이밍을 붙여봤습니다.

_.map 과 _.each 가 data, iteratee 두 파라미터를 받게 되어있으니 이 부분은 건드릴 수 없습니다.

내부 구현이 거의 비슷하지만 차이나는 부분은 위 코드상 newList 입니다. _.map 은 iteratee 를 통해 변경된 값을 새로운 배열에 넣어 반환하고, _.each 는 iteratee 를 그저 실행만 하고 끝내죠.

저는 그럼 먼저 frame 의 첫번째 파라미터를 사용해 newList 를 만들거나 기존 값을 그대로 반환하는 함수를 받도록 하겠습니다.

 

function frame(newData, ...) {
  return function(data, iteratee) {
    const newList = newData(data);
    
    ...
    
    return newList;
  }
}

_.map = frame(function() { return []; }, ...);
_.each = frame(function(v) { return v; }, ... };

 

뭔가 newData 라는 네이밍은 맘에 들지 않습니다만.. 이렇게하면 _.map 에선 function() { return []; } 가 newData 에 들어오므로 data 가 어떤 값이던 newList = [] 가 됩니다. _.each 에선 newList = data 가 되겠죠.

그럼 이제 return newList 도 문제없고, _.map 에선 iteratee 를 통해 변경된 값이 newList 에 push 되게끔, _.each 에선 push 를 하지 않으면 됩니다. 이건 frame 의 두번째 인자도 함수로 받아서 처리하면 될 것 같습니다.

 

function frame(newData, doIteratee) {
  return function(data, iteratee) {
    const result = newData(data);
    
    if (isArrayLike(data)) {
      for (let i = 0 ; i < data.length ; i++) {
        doIteratee(result, iteratee(data[i], i, data));
      }
    }
    
    ...
    
    return result;
  }
}

_.map = frame(function(){ return []; }, function(list, value) { list.push(value); });
_.each = frame(function(v) { return v; }, function() {});

 

어느정도 코드가 눈에 들어오셨을거라 생각합니다. _.map 과 _.each 에 각각 두번째 인자로 넣은 함수들이 doIteratee 가 되는데, _.map 에선 list 에 value 를 push 하고 _.each 에선 실행만 하고 끝납니다.

이 상태로 코드를 완성시키고, 의도한대로 동작하는지 확인해보겠습니다.

 

function frame(newData, doIteratee) {
  return function(data, iteratee) {
    const result = newData(data);
    
    if (isArrayLike(data)) {
      for (let i = 0 ; i < data.length ; i++) {
        doIteratee(result, iteratee(data[i], i, data));
      }
    } else {
      for (const key in data) {
        if (data.hasOwnProperty(key)) {
          doIteratee(result, iteratee(data[key], key, data));
        }
      }
    }

    return result;
  }
}

_.map = frame(function(){ return []; }, function(list, value) { list.push(value); });
_.each = frame(function(v) { return v; }, function() {});

const test1 = _.map([1, 2, 3], function(v) { return v * 2; });
console.info(test1); // [2, 4, 6]

const test2 = _.each([1, 2, 3], function(v) { console.info(v * 3); });
// 3
// 6
// 9

 

이런 식으로 해서 frame 안에 _.map 인지 _.each 인지 따로 if 문을 통한 분기 처리 없이 중복을 제거했습니다.

함수를 값으로 다루고, 또 함수가 함수를 반환하는 방식을 통해 가능한 중복 제거였습니다.

 

_.identity, _.args1, frame 등등 함수가 굉장히 많이 나오고 있는데, 이렇게 하는 것이 함수형 프로그래밍 입니다.

함수형 프로그래밍에선 함수의 응용을 중시합니다. 일급 객체인 함수를 바로 정의하기도 하고 인자로 사용하기도 하면서 로직을 만들어 나가는 것입니다. 위 예시에서 _.map 과 _.each 를 만들기 위해 frame 안에 인자로 넣은 함수들 또한 _.identity 처럼 별도의 작은 함수로 정의할 수 있습니다.

 

_.emptyArray = function() { return []; };
_.push = function(list, value) { list.push(value); };
_.noop = function() {};

_.map = frame(_.emptyArray, _.push);
_.each = frame(_.identity, _.noop);

const test1 = _.map([1, 2, 3], function(v) { return v * 3; });
console.info(test1); // [3, 6, 9]

const test2 = _.each([2, 4, 6], function(v) { console.info(v * 4); });
// 8
// 16
// 24

 

이전에 만들었던 _.identity 에 _.emptyArray, _.push, _.noop 을 만들어서 조합해 _.map, _.each 를 만들었습니다.

당연히 기존의 코드와 동일하게 동작합니다.

여기서 _.noop 은 Underscore.js 에도 이미 있는 함수입니다. 처음 볼 때 저게 왜 있는건가 했는데 이런데서 쓰임새가 있네요.

 

- _.keys 에 대해

 

여기서 만들었던 frame 의 성능을 좀 더 향상시켜보겠습니다.

data 가 ArrayLike 일 땐 상관없지만, 그 외의 상황에선 for (const key in data) 로 data 에 있는 모든 key 에 대해 loop 를 돌게 되어있습니다. 어떤 상황일 때 성능에 문제가 있을지 먼저 아래 예시를 보겠습니다.

 

const lodash = require('lodash');

const sample = lodash([1]);

const keys1 = [];
const keys2 = [];

for (const key in sample) {
  keys1.push(key);
  
  if (sample.hasOwnProperty(key)) {
    keys2.push(key);
  }
}

console.info(keys1.length); // 323
console.info(keys2.length); // 5

 

제일 처음에 짠 코드에서도 for loop 에 hasOwnProperty 로 체크하고있었죠. 위 예시는 그와 동일합니다.

위처럼 lodash 객체(sample)의 경우 property 뿐만 아니라 모든 lodash 메서드가 들어있기 때문에 for 문이 323 번을 돌게됩니다.

애초에 5번만 돌면 되는데 말이죠. 323과 5는 숫자만으로도 꽤 차이가 나네요.

 

이를 최적화하기 위해선 Underscore.js 에 있는 _.keys 를 사용하면 됩니다. (왜 Object.keys 가 아니지?)

사실 Underscore.js 의 _.keys 내부 구현은 Native Helper 의 Object.keys 를 통해 이미 성능이 보장되어있습니다.

 

const _ = require('underscore');
const lodash = require('lodash');

const sample = lodash([1]);

console.info(Object.keys(sample).length); // 5
console.info(_.keys(sample).length); // 5

 

동일한 sample 객체에 대해 Object.keys 와 _.keys 의 결과가 동일하죠.

이와 같은 일을 하는 함수 중엔 Native Helper 의 Object.keys 가 가장 빠릅니다. 그럼 뭐 굳이 _.keys 를 써야되나? Object.keys 를 쓰면되지 → 이것도 맞는 말입니다. 다만 Object.keys 는 null 을 넣으면 TypeError 를 뱉는데에 비해 _.keys 는 null 을 넣어도 문제없이 돌고 빈 배열을 반환합니다.

 

만약 Object.keys 를 여러 곳에서 사용하고 있었다면, 그에 맞게 null 체크하는 코드 또한 여기저기 들어가야 할 것입니다.

그렇다면 위에서 말했듯이 이미 내부 구현에 Object.keys 를 통한 성능이 보장되어 있는 Underscore.js 의 _.keys 를 사용하는게 더 낫다는 말이죠. (여담으로 _.keys 는 Object.keys 가 없는 낮은 버전의 브라우저에서도 동작하도록 구현되어 있습니다)

 

그럼 다시 돌아와서 frame 함수의 for loop 부분을 _.keys 로 최적화해보겠습니다.

 

const _ = require('underscore');

function frame(newData, doIteratee) {
  return function(data, iteratee) {
    const result = newData(data);
    
    if (isArrayLike(data)) {
      for (let i = 0 ; i < data.length ; i++) {
        doIteratee(result, iteratee(data[i], i, data));
      }
    } else {
      for (let i = 0, keys = _.keys(data) ; i < keys.length ; i++) {
        doIteratee(result, iteratee(data[keys[i]], keys[i], data));
      }
    }

    return result;
  }
}

 

물론 전통적인 for loop 도 for of 로 깔끔하게 정리할 순 있겠지만, 우선 이정도만 해보겠습니다.

_.keys 를 사용해 성능을 최적화하고, hasOwnProperty 를 체크하던 if 문도 덤으로 제거하게 되었습니다.

frame 함수가 최적화되었다는 것은, 이를 사용하는 _.map 과 _.each 또한 성능이 최적화되었다는 뜻 입니다.

 

지금까지 코드를 작성하면서 본 함수형에 대한 아이디어와 고차함수 활용 등은 저도 쓰면서 가끔 헷갈리긴 합니다만, 충분히 실용적입니다. 함수형을 얘기할 땐 꼭 대척점에 있지 않더라도 OOP(객체지향 프로그래밍)가 항상 비교 대상으로 나오는 것 같은데요.

객체지향 프로그래밍에서는 추상화의 단위가 클래스라면, 함수형 프로그래밍에선 추상화의 단위는 함수입니다. 위에서 만들었던 frame 과 같은 함수가 그렇다고 볼 수 있습니다. 객체지향에선 각 객체간 어떻게 책임을 분리할지, 어떤 메세지를 객체간에 주고 받을지에 대한 설계를 통해 협업을 강조합니다. 함수형에선 함수의 인자와 결과값을 통해 협업합니다.

 

다음 포스팅에서도 이번에 만든 frame 을 활용해 Underscore.js 의 또 다른 함수들을 구현해보도록 하겠습니다.

'Backend > 함수형 자바스크립트' 카테고리의 다른 글

Underscore.js - _.reject & _.find  (0) 2021.08.29
Underscore.js - _.filter  (0) 2021.08.21
Underscore.js - ArrayLike  (0) 2021.08.07
Underscore.js  (0) 2021.08.01
함수형 자바스크립트의 시작  (0) 2021.07.24