본문 바로가기

Backend/함수형 자바스크립트

Underscore.js - _.filter

- _.filter 구현

 

Underscore.js 의 _.filter 는 특정 조건을 만족하는 값들만 모은 새로운 배열을 만드는 함수입니다.

예전 포스팅에서 이미 _.filter 를 아래처럼 구현해보긴 했었습니다.

 

_.filter = (list, predicate) => {
  const newList = [];
  for (let i = 0 ; i < list.length ; i++) {
    if (predicate(list[i], i, list)) newList.push(list[i]);
  }
  return newList;
};

 

위 구현에서 list 는 Array 만 가능합니다. 따라서 {}, [], arguments, ArrayLike 를 처리할 수 있도록 for loop 부분만 이전에 만들었던 _.each 로 대체해보겠습니다.

 

_.filter = function(data, predicate) {
  const result = [];
  
  _.each(data, function(val, idx, data) {
    if (predicate(val, idx, data)) {
      result.push(val);
    }
  });
  
  return result;
};

const test1 = _.filter([1, 2, 3, 4], function(val) { return val >= 2; });
console.info(test1); // [2, 3, 4]

 

_.each 는 바로 이전 포스팅에서 만들어뒀던 함수를 사용하였고, Array 만 처리할 수 있는 함수가 아니기 때문에 list 라는 파라미터명을 data 로 변경했습니다. (단순 네이밍만 변한게 아닌 다형성이 높아졌습니다)

위 코드대로라면 _.map 을 사용해 _.each 를 대신할 수도 있지만, _.each 와 달리 _.map 은 새로운 배열을 만들어내는 로직이 있으므로 _.filter 에선 불필요한 로직이 추가되게 됩니다. 이처럼 함수형 프로그래밍에선 상황에 맞는 함수를 선택해야 합니다.

 

이렇게 둬도 되지만, 지난 포스팅에선 frame 이라는 뼈대를 만들어 두고 _.map 과 _.each 를 만들었습니다.

이번에도 frame 을 사용해 _.filter 를 만들어 보겠습니다.

 

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;
  }
}

 

지난번에 만들었던 frame 함수입니다. keys 부분만 최적화하기 위해 Underscore.js 의 keys 함수를 사용했습니다. (물론 만든 keys 를 사용해도 되지만..) _.filter 를 만들 수 있으면서 기존 _.map 과 _.each 에는 영향이 가지 않도록 frame 함수를 수정해야 합니다.

 

이전에 작은 함수를 만들어 조합했던 _.map 함수를 다시 가져와보겠습니다.

 

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

 

_.filter 의 리턴값은 새로운 객체여야 합니다. 그럼 _.map 에서 사용한 첫번째 인자(_.emptyArray) 를 동일하게 사용하면 될 것 같습니다.

_.map 에서 두번째 인자는 value 를 무조건 list 에 push 하고 있습니다. _.filter 에서는 특정 조건을 만족(true) 할 때 push 해야 합니다.

그럼 function(list, isTrue, value) { if (isTrue) { list.push(value); } } 정도가 될 것 같습니다.

그렇다면 아직 frame 을 수정하진 않았지만, _.filter 는 아래와 같은 형태가 될 것입니다.

 

_.filter = frame(
  function(){ return []; },
  function(list, isTrue, val) {
    if (isTrue) {
      list.push(val);
    }
  });

 

이 형태를 맞추기 위해선 기존 frame 함수에서 doIteratee 가 2개의 파라미터만 받을 수 있던 구조를 3개로 변경해야겠습니다.

약간 어색하게 isTrue 파라미터가 두번째에 정의되어 있는 이유는 기존 _.map, _.each 등을 수정하지 않기 위함입니다.

설명이 조금 난해한데, _.map 에서 두번째 인자로 function(list, value) 가 들어가고 있죠. 여기서 value 는 iteratee(...) 의 결과값입니다. 즉 iteratee 에 의해 변경된 value 가 list 에 push 된다 라는 의미죠.

_.filter 에선 iteratee 에 predicate 가 들어가게 됩니다. predicate 를 통해 isTrue 가 나오게 되는데, 기존 순서에도 영향을 주지 않으려면 이 isTrue 가 두번째로 와야겠죠. 그럼 저기서 val 은 배열 혹은 객체의 원본 값이 될 겁니다.

 

아래 변경된 frame 을 보면 이해가 더 되실거라 생각합니다.

 

const _ = require('underscore');

function frame(newData, doIteratee) {
  return function(data, iterOrPredi) {
    const result = newData(data);
    
    if (isArrayLike(data)) {
      for (let i = 0 ; i < data.length ; i++) {
        doIteratee(result, iterOrPredi(data[i], i, data), data[i]); // data[i] -> 원본 추가
      }
    } else {
      for (let i = 0, keys = _.keys(data) ; i < keys.length ; i++) {
        doIteratee(result, iterOrPredi(data[keys[i]], keys[i], data), data[keys[i]]);
      }
    }

    return result;
  }
}

 

변경된 건 거의 없습니다. 기존 iteratee 라는 변수명은 _.filter 의 경우 predicate 가 들어오게 되므로 iterOrPredi 라고 변경했습니다. (맘에 들지않는 네이밍.. 참 어렵네요) 그리고 위 코드 중 function(list, isTrue, val) 부분에서 val 에 들어갈 원본이 필요하기 때문에 data[i] 혹은 data[keys[i]] 를 doIteratee 의 세번째 인자로 추가했습니다.

 

기존 _.map, _.each 에 영향이 없으면서 _.filter 에도 문제가 없는지 확인해보겠습니다.

 

_.map = frame(_.emptyArray, _.push);
_.each = frame(_.identity, _.noop);
_.filter = frame(_.emptyArray, function(result, isTrue, val) {
  if (isTrue) {
    result.push(val);
  }
});

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

_.each([1, 2, 3, 4], function(val) { console.info(val * 4); });
// 4
// 8
// 12
// 16

const test2 = _.filter([1, 2, 3, 4], function(val) { return val >= 3; });
console.info(test2); // [3, 4]

 

이전 포스팅에도 나와있지만 _.map 과 _.each 는 그대로입니다.

_.filter 엔 _.map 에 첫번째 인자로 들어가는 _.emptyArray 를 그대로 넣고 두번째 인자에 새로 만든 함수를 넣었습니다.

(과정에서 list 변수명이 Array 만 처리 가능한 변수처럼 느껴져 result 로 변경했습니다)

이렇게 _.filter 도 frame 을 통해 만들었습니다. function(result, isTrue, val) { ... } 이 익명함수도 작은 함수로 하나 빼면 좋겠네요.

 

- 더 작게 쪼개기

 

위에서 말했듯이 function(result, isTrue, val) ← 이 익명함수를 별도의 함수로 만들어도 되긴 합니다만, 좀 더 함수형스럽게 접근해보고자 합니다. 작은 함수들을 만들어서 조합하는 식으로 말이죠.

_.filter 에서 사용하는 익명함수는 isTrue 이면 result 에 push 한다 라는 동작을 하고 있습니다.

이런 느낌으로 _.if 를 만들어보겠습니다. validation 할 수 있는 함수를 첫번째 인자로 받아 true 면 두번째인자를 실행하고, false 면 세번째 인자를 실행하도록 하면 될 것 같습니다.

 

_.if = function(validator, trueFunc, falseFunc) {
  ...
};

 

이런 느낌입니다. 여기선 위에서 말한대로 동작하는 함수를 리턴하도록 만들고 그 과정에서 Function.prototype.apply 를 활용하겠습니다. 함수로 만들어야 다른 곳에서의 재사용도 도모할 수 있을 것입니다.

 

_.if = function(validator, trueFunc, falseFunc) {
  return function() {
    return validator.apply(null, arguments) ?
      trueFunc.apply(null, arguments) : falseFunc.apply(null, arguments);
  }
};

 

apply 나 call 과 같은 함수는 따로 적은 포스팅이 있으니 별도로 확인하면 좋을 것 같습니다. (개인적으론 이 함수들을 최대한 쓰지 않으려고는 합니다만 여기선 불가피하네요)

validator 함수를 arguments 로 호출해 true 면 trueFunc, false 면 falseFunc 를 호출하는 구조입니다. 역시나 네이밍은 좀 걸리네요..

근데 이런 형태로 만들면 falseFunc 도 무조건 넣어줘야 하니, falseFunc 는 생략 가능하도록 수정해보겠습니다.

 

_.if = function(validator, trueFunc, falseFunc) {
  return function() {
    return validator.apply(null, arguments) ?
      trueFunc.apply(null, arguments) : falseFunc && falseFunc.apply(null, arguments);
  }
};

 

falseFunc && ← 부분만 추가되었습니다. 이 값을 전달하지 않는다면 false 일 때 _.if 를 통해 만들어진 함수는 아무것도 하지 않을 것입니다. 이렇게 만든 함수는 이런식으로 사용할 수 있습니다.

 

const diff = _.if(
  function(a, b) { return a >= b; },
  function(a, b) { return a - b; },
  function(a, b) { return b - a; }
);

const test1 = diff(2, 5);
console.info(test1); // 3

_.toArray = _.if(Array.isArray, _.identity, _.values);

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

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

 

만들었던 _.if 가 어떻게 동작하는지 보이실 거라 생각합니다.

그럼 이 형태를 그대로 응용해서 _.filter 를 frame 과 _.if 로 만들어 보겠습니다.

 

_.filter = frame(_.emptyArray, _.if(_.identity, _.push));

const test1 = _.filter([1, 2, 3, 4], function(val) { return val >= 3; });
console.info(test1); // [false, false, true, true]

 

_.if 에 falseFunc 는 따로 전달하지 않고 trueFunc 에 _.push 를 넣어봤는데, 뭔가 의도한대로 동작하지 않았습니다.

우선 기존 frame 에서 수정되어야 할 부분이 있습니다.

 

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

    return result;
  }
}

 

기존 doIteratee 를 실행하는 부분에서 result 와 iterOrPredi 의 순서를 변경했습니다.

result 가 첫번째 인자로 오게 되면, _.if 에서 for loop 가 반복될때 arguments 가 아래처럼 들어오게 됩니다.

 

[Arguments] { '0': [], '1': false, '2': 1 }
[Arguments] { '0': [ false ], '1': false, '2': 2 }
[Arguments] { '0': [ false, false ], '1': true, '2': 3 }
[Arguments] { '0': [ false, false, true ], '1': true, '2': 4 }

 

그럼 validator 로 동작하는 _.identity 는 첫번째 인자를 갖고 true || false 를 판별해야되는데 그 첫번째 인자로 [], [false], ... 이런 형태가 들어오니 무조건 true 로 동작해서 [] → [false] -> [false, false] → ... 이렇게 계속 두번째 인자가 _.push 를 타게 되는 것입니다.

따라서 frame 함수에서의 doIteratee 호출 시 인자 순서를 바꿔줍니다. 그러면 기존 구현해놨던 _.push 도 list, value 순서를 변경해줘야 합니다. _.filter 에서 function(result, isTrue, val) 의 순서도 변경은 지금 무의미하므로 패스하겠습니다.

 

변경 후 코드를 실행해서 arguments 도 함께 확인해보겠습니다.

 

_.filter = frame(_.emptyArray, _.if(_.identity, _.push));

const test1 = _.filter([1, 2, 3, 4], function(val) { return val >= 3; });
console.info(test1); // [true, true]

 

[Arguments] { '0': false, '1': [], '2': 1 }
[Arguments] { '0': false, '1': [], '2': 2 }
[Arguments] { '0': true, '1': [], '2': 3 }
[Arguments] { '0': true, '1': [ true ], '2': 4 }

 

수정한대로 다시 돌려보면 뭔가 의도한대로 되는 것 같으면서 반환값이 잘못되었네요.

지금의 변경된 _.push 는 function(value, list) { list.push(value); } 입니다. 첫번째 인자를 list 에 push 하도록 되어있습니다.

우선은 간단하게 _.pushOrigin 이라는 함수를 만들어 대체해보겠습니다.

 

_.pushOrigin = function(value, list, origin) { list.push(origin); };

_.filter = frame(_.emptyArray, _.if(_.identity, _.pushOrigin));

const test1 = _.filter([1, 2, 3, 4], function(val) { return val >= 3; });
console.info(test1); // [3, 4]

 

이렇게하면 의도한대로 동작하는 _.filter 를 함수의 조합으로 만들었습니다. frame 과 _.push 가 수정되긴 했지만 기존 만들었던 _.map, _.each 도 테스트해보면 동일하게 동작합니다.

_.filter 를 위해 _.pushOrigin 을 만들긴 했지만, 개인적으로 쓰지도 않을 value 를 그대로 둬야 한다는게 마음에 들진 않습니다.

기존 _.push 를 사용할 순 없을까.. arguments 에서 첫번째 인덱스를 날려버리고 _.push 를 하는 함수를 만들면 될 것 같습니다.

 

_.applyRest = function(func) {
  return function() {
    return func.apply(null, _.toArray(arguments).slice(1));
  }
}

function sum(a, b, c, d) {
  return (a || 0) + (b || 0) + (c || 0) + (d || 0);
}

const test1 = _.applyRest(sum)(1, 2, 3, 4);
console.info(test1); // 9

const test2 = _.applyRest(sum)(3, 10, 2);
console.info(test2); // 12

 

arguments 에서 slice 한 나머지를 함수에 적용한다는 뜻으로 _.applyRest 라고 만들어봤습니다.

구현부는 arguments 를 Array 로 만들어 첫번째 인덱스를 날려버린 arguments 로 func 을 호출합니다.

(여기선 slice(1) 을 강제했는데, 이 부분도 함수 파라미터로 받아서 slice(x || 1) 와 같이 처리하면 좀 더 범용적으로 쓸 수 있겠습니다)

임시로 만들어본 sum 과 조합해 사용하면, (1, 2, 3, 4) 에선 1 을 제외한 (2, 3, 4) 의 합이 나왔고 (3, 10, 2) 에선 3을 제외한 합이 결과값으로 나왔습니다. 이제 만든 _.applyRest 와 _.push 를 조합해 _.filter 를 만들어 보겠습니다.

 

_.filter = frame(_.emptyArray, _.if(_.identity, _.applyRest(_.push)));

const test1 = _.filter([1, 2, 3, 4], function(val) { return val >= 3; });
console.info(test1); // TypeError: list.push is not a function

 

음.. 생각이 약간 잘못되었습니다. 현재 _.push 는 (value, list) 의 순서로 인자를 받게 되어있습니다. 따라서 arguments 에서 첫번째 인덱스를 날려버리고 들어온 인자는 ([], 1) 이런 형태입니다. 순서가 반대로 되었네요.

우선 구현을 위해 아까 수정했던 _.push 의 (value, list) 순서를 다시 (list, value) 로 변경합니다.

 

_.push = function(list, value) { return list.push(value); };
_.filter = frame(_.emptyArray, _.if(_.identity, _.applyRest(_.push)));

const test1 = _.filter([1, 2, 3, 4], function(val) { return val >= 3; });
console.info(test1); // [3, 4]

 

이제 제대로 동작합니다.. 네 물론 이 상태로 _.map 은 _.push 의 인자 순서가 바뀌었기 때문에 제대로 동작하지 않습니다.

그렇다고 아까 변경한 frame 에서 doIteratee 호출 시 들어가는 인자 순서를 바꾸면 이번엔 _.filter 가 제대로 동작하지 않을 것입니다.

그럼 역시나 _.push 를 _.map 과 _.filter 용도로 분리하거나, _.pushOrigin 을 쓰거나 해야겠습니다.

 

물론 지금은 frame 이라는 함수를 만들어 놓고 이 함수로 모든 케이스를 처리하려다 보니 이런 상황이 발생했습니다.

이 방식엔 장단점이 있고, 재사용도 중요하겠지만 오히려 그걸 고집하다가는 이런식으로 꼬이거나 코드가 더 복잡해질 가능성이 있습니다. 지금 만들고있는 일종의 라이브러리에서 주요 기능은 성능상의 이점때문에 오히려 단일 함수로 구현하는 것이 더 적합할 수 있습니다.

이런 작업 방식은 오히려 어플리케이션 개발시 효율을 높여줄 수 있습니다. 어떤 방식이던 '적절' 하게 사용하는 것이 중요합니다.

 

_.filter 하나를 함수형 프로그래밍 접근법을 통해 변경해나가며 좀 길긴했지만, 위에서 종종 나온 것처럼 일반적인 값을 함수로 대체하면 다른 함수와의 협업이 가능해지며 확장성 높은 함수를 만들 수 있습니다.

 

누군가가 보기엔 _.filter = frame(_.emptyArray, _.if(_.identity, _.applyRest(_.push))) 이런 형태가 더 복잡해 보일 수 있습니다.

함수형에선 이런 함수 선언을 아름답다고 하는 것 같은데, 저는 아직은 모르겠네요. 😅

실무에서 프로그래밍은 혼자 하는게 아닌 만큼 내가 함수형에 이만큼의 지식을 갖고 이렇게 작업했을 때 얻는 것도 있겠지만, 그만큼 이 패러다임과 거리가 먼 팀원과의 협업은 어려울 수도 있겠다는 생각이 듭니다. 디버깅도 좀 더 까다로워질 가능성이 있구요.

하지만 이런 패러다임은 OOP 도 마찬가지라 생각하고, 아름다운지는 모르겠지만 나름의 매력을 느꼈기 때문에 좀 더 파고들 생각입니다. (여담이 좀 길었네요)

 

- 익명함수, 람다, 화살표 함수

 

지금까지 예제 코드를 작성하면서 사용한 익명 함수들은, 화살표 함수로도 거의 모든 표현이 가능합니다.

(저도 화살표 함수를 거의 모든 곳에 사용합니다)

함수형 프로그래밍에선 람다를 많이 사용합니다. 하지만 함수형 프로그래밍이 곧 람다는 아닙니다.

람다 표현식은 함수형 프로그래밍의 일부이며, 클로저 혹은 고차함수를 사용하기 위한 기법 중 하나일 뿐입니다.

 

함수를 적극적으로 사용하고, 함수를 추상화의 단위로 사용하고, 상태 변경을 최소화하고, 로직을 함수로 고르고, 기본 객체를 많이 사용하면서 함수의 응용을 중시하는 프로그래밍이 함수형 프로그래밍 입니다.

람다 표현식도 유용하고 강력하지만, 만들어 둔 함수들을 최대한 많이 사용하는 방법도 좋고 표현력을 끌어 올려 줍니다.

이 둘도 적당히 잘 조합해서 다양하게 프로그래밍 하는 게 시야를 넓히는 데 좋다고 생각합니다. 

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

Underscore.js - _.reduce  (0) 2021.09.05
Underscore.js - _.reject & _.find  (0) 2021.08.29
Underscore.js - _.map & _.each  (0) 2021.08.14
Underscore.js - ArrayLike  (0) 2021.08.07
Underscore.js  (0) 2021.08.01