map, filter, reduce

2024. 3. 11. 14:21함수형 프로그래밍

map

아래와 같이 상품 정보들이 주어진다.

const products = [
  { name: '반팔티', price: 15000 },
  { name: '긴팔티', price: 20000 },
  { name: '핸드폰케이스', price: 15000 },
  { name: '후드티', price: 30000 },
  { name: '바지', price: 25000 },
];

 

 

name끼리 price끼리 각 속성을 분리해서 따로 모으고 싶다. for ... of 구문을 사용해서 다음과 같이 구현할 수 있다.

const products = [
  { name: '반팔티', price: 15000 },
  { name: '긴팔티', price: 20000 },
  { name: '핸드폰케이스', price: 15000 },
  { name: '후드티', price: 30000 },
  { name: '바지', price: 25000 },
];

let names = [];
for (const p of products) {
  names.push(p.name);
}

let prices = [];
for (const p of products) {
  prices.push(p.price);
}

 

 

하지만 객체의 키값마다 일일히 for ... of 구문을 사용해야하는 번거로움이 있다.

기능을 담당하는 함수를 하나 구현하면 일일히 for ... of 구문을 적을 필요가 없다.

const products = [
  { name: '반팔티', price: 15000 },
  { name: '긴팔티', price: 20000 },
  { name: '핸드폰케이스', price: 15000 },
  { name: '후드티', price: 30000 },
  { name: '바지', price: 25000 },
];

const map = (k, products) => {
  let ret = [];
  for (const p of products) {
    ret.push(p[k]);
  }
  return ret;
};
console.log(map('name', products));
console.log(map('price', products));

 

 

조금 더 함수형 프로그래밍답게 작성하기 위해서는 보조함수를 전달해야한다.

map의 인자에 수집할 키값을 직접적으로 전달하는 것이 아니라, 보조함수를 전달해서 map함수를 사용하는 쪽에게 제어권을 위임하는 것이 좋다. 

이렇게 하면 맵함수는 고차함수의 조건을 만족한다. 함수를 값으로 다루면서 내가 원하는 시점에 인자를 적용할 수 있다.

const products = [
  { name: '반팔티', price: 15000 },
  { name: '긴팔티', price: 20000 },
  { name: '핸드폰케이스', price: 15000 },
  { name: '후드티', price: 30000 },
  { name: '바지', price: 25000 },
];

const map = (f, iter) => {
  let ret = [];
  for (const a of iter) {
    ret.push(f(a));
  }
  return ret;
};
console.log(map((p) => p.name, products));
console.log(map((p) => p.price, products));

 

이터러블 프로토콜을 따른 map 함수의 다형성

위에서 구현한 map 함수의 내부구조는 Array 프로토타입에 정의된 map 함수와 유사하다.

그렇다면 왜 이터러블 프로토콜을 따르는 map 함수를 정의해서 사용하는걸까?

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      document.querySelectorAll('*').map((e) => console.log(e));
    </script>
  </body>
</html>

 

 

위의 코드가 에러가 생기는 이유는 document.querySelectorAll이 반환하는 값은 Array가 아니라 NodeList이기 때문이다.

NodeList는 유사배열객체이기 때문에 Array에서 사용하는 map을 사용할 수 없다.

하지만 NodeList는 이터러블 프로토콜을 준수한다. 따라서 다음과 같이 직접 구현한 map 함수는 사용이 가능하다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      const map = (f, iter) => {
        const ret = [];
        for (const a of iter) {
          ret.push(f(a));
        }
        return ret;
      };
      console.log(map((el) => el.nodeName, document.querySelectorAll('*')));
    </script>
  </body>
</html>

 

이터러블 프로토콜을 준수한다는 증거

const it = document.querySelectorAll('*')[Symbol.iterator]();
console.log(it.next());
console.log(it.next());
console.log(it.next());
console.log(it.next());

 

 

또한 제너레이터 함수에도 map 함수를 적용할 수 있다.

즉, 이터러블 프로토콜을 준수하는 모든 것에 함수를 적용할 수 있으므로 다형성이 높아진다는 장점이 있다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      const map = (f, iter) => {
        const ret = [];
        for (const a of iter) {
          ret.push(f(a));
        }
        return ret;
      };

      function* genFunc() {
        yield 2;
        yield 3;
        yield 4;
      }
      console.log(map((a) => a * a, genFunc())); // [4, 9, 16]
    </script>
  </body>
</html>

 

 

또한 Map 자료형도 이터러블 프로토콜을 준수하므로 map 함수를 활용해서 새로운 Map 자료형을 생성할수도 있다.

이 코드를 보니 map 함수의 인자로 들어가는 헬퍼함수의 역할이 조금 눈에 보인다.

직접 map 함수에 인자를 전달하는 것이 아니라 헬퍼함수를 통해 map 함수를 사용하는 쪽에서 자율성이 높아지고 다양한 작업을 추가적으로 할 수 있다는 것을 느꼈다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      const map = (f, iter) => {
        const ret = [];
        for (const a of iter) {
          ret.push(f(a));
        }
        return ret;
      };

      const m = new Map();
      m.set('a', 10);
      m.set('b', 20);
      console.log(new Map(map(([k, v]) => [k, v * 2], m)));
    </script>
  </body>
</html>

filter

map을 구현하는 것과 유사하다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      const products = [
        { name: '반팔티', price: 15000 },
        { name: '긴팔티', price: 20000 },
        { name: '핸드폰케이스', price: 15000 },
        { name: '후드티', price: 30000 },
        { name: '바지', price: 25000 },
      ];
      const filter = (f, iter) => {
        const ret = [];
        for (const a of iter) {
          if (f(a)) ret.push(a);
        }
        return ret;
      };
      console.log(...filter((p) => p.price > 15000, products));
      console.log(filter((n) => n % 2, [1, 2, 3, 4]));
      console.log(
        filter(
          (n) => n % 2,
          (function* () {
            yield 1;
            yield 2;
            yield 3;
            yield 4;
            yield 5;
          })()
        )
      );
    </script>
  </body>
</html>

 

 

여기서 살펴볼 점은 이터러블 대상이 다양하게 올 수 있다는 것이다.

헬퍼함수에서 필터링의 조건을 보조하고 이터러블 프로토콜을 준수하는 객체라면 어떤 값이든 filter 함수의 인자로 전달할 수 있기 때문에 다양한 이터러블 객체의 필터링을 적용할 수 있다.

      console.log(filter((n) => n % 2, [1, 2, 3, 4]));
      console.log(
        filter(
          (n) => n % 2,
          (function* () {
            yield 1;
            yield 2;
            yield 3;
            yield 4;
            yield 5;
          })()
        )
      );

reduce

Array 프로토타입의 reduce 메서드는 다음과 같은 구조를 갖는다.

[1, 2, 3, 4, 5].reduce((acc, cur) => acc + cur, 0);

순회할 대상과 누적값 그리고 어떻게 누적할 것인지에 대한 로직을 헬퍼함수로 받는다.

이 구조를 기반으로 모든 이터러블 객체에 적용할 수 있는 reduce 함수를 정의해보자.

const reduce = (f, acc, iter) => {
  for (const a of iter) {
    acc = f(acc, a);
  }
  return acc;
};
const add = (a, b) => a + b;

console.log(reduce(add, 0, [1, 2, 3, 4, 5]));
console.log(add(add(add(add(add(0, 1), 2), 3), 4), 5));

순회 대상을 순회하면서 반복적으로 헬퍼함수를 호출한 결과값을 누적값에 누적하는 것이 reduce 내부 로직의 핵심이다.

조금 더 조건을 추가해야한다.

console.log([1, 2, 3, 4, 5].reduce((acc, cur) => acc + cur)); // 15

reduce 메서드에 초기값을 전달하지 않은 경우 순회할 대상의 첫번째 요소가 대신 들어간다. 이 동작을 추가해야한다.

const reduce = (f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator]();
    acc = iter.next().value;
  }
  for (const a of iter) {
    acc = f(acc, a);
  }
  return acc;
};
const add = (a, b) => a + b;
console.log(reduce(add, [1, 2, 3, 4, 5])); // 15

 

 

reduce 함수도 데이터를 누적하는 로직을 헬퍼함수로 완전히 위임하기 때문에, 어떤 헬퍼함수를 전달하느냐에 따라 누적 로직이 달라진다. 따라서 이터러블 프로토콜을 준수하는 다양한 객체에 사용할 수 있다.

const products = [
  { name: '반팔티', price: 15000 },
  { name: '긴팔티', price: 20000 },
  { name: '핸드폰케이스', price: 15000 },
  { name: '후드티', price: 30000 },
  { name: '바지', price: 25000 },
];

const reduce = (f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator]();
    acc = iter.next().value;
  }
  for (const a of iter) {
    acc = f(acc, a);
  }
  return acc;
};

console.log(reduce((totalPrice, p) => totalPrice + p.price, 0, products));

이렇게 단순한 배열 데이터가 아닌 이터러블 객체 또한 reduce를 사용하고 헬퍼함수로 어떤 값을 누적할 지 결정할 수 있다. 


map, filter, reduce의 조합

다음과 같이 데이터가 주어졌을 때, 20000원 미만의 상품 가격의 총합을 함수를 조립해가며 구해보자.

const products = [
  { name: '반팔티', price: 15000 },
  { name: '긴팔티', price: 20000 },
  { name: '핸드폰케이스', price: 15000 },
  { name: '후드티', price: 30000 },
  { name: '바지', price: 25000 },
];

 

 

상품 정보에 가격만을 매핑한 후, 20000원 미만의 가격만 필터링을 하고 reduce를 통해 누적값을 구하면 된다.

const products = [
  { name: '반팔티', price: 15000 },
  { name: '긴팔티', price: 20000 },
  { name: '핸드폰케이스', price: 15000 },
  { name: '후드티', price: 30000 },
  { name: '바지', price: 25000 },
];

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

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

const reduce = (f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator]();
    acc = iter.next().value;
  }
  for (const a of iter) {
    acc = f(acc, a);
  }
  return acc;
};

const add = (totalPrice, price) => totalPrice + price;
console.log(
  reduce(
    add,
    filter(
      (price) => price < 20000,
      map((p) => p.price, products)
    )
  )
);

 

 

 

함수형 프로그래밍을 할 때는 함수가 평가되어 값으로 표현될 때 어떤 값으로 될지 기대하며 코딩한다.

예시를 들어보자.

console.log(reduce(add, [15000, 15000]));

reduce 함수를 사용할 때 이터러블 객체로 들어올 것이라 기대한 값은 다음과 같다.

함수 코드가 평가될 때, 20000원 미만의 가격만 따로 구해진 배열이 들어올 것이라 기대할 수 있다.

 

console.log(
  reduce(
    add,
    filter((price) => price < 20000, [15000, 20000, 15000, 30000, 25000])
  )
);

마찬가지로 필터링할 대상은 상품의 가격정보만 모아둔 배열이다.

함수 코드가 평가될 때 상품의 가격만 따로 모아둔 배열이 된다는 것을 기대할 수 있다.

 

console.log(
  reduce(
    add,
    filter(
      (price) => price < 20000,
      map((p) => p.price, products)
    )
  )
);

따라서 위의 코드와 같이 함수를 작성하면 원하는 기대값으로 평가되어진다는 것을 알 수 있다.

 

정리하자면 함수형 프로그래밍은 함수를 값처럼 사용하고 전달한 함수가 평가되는 시점에 개발자가 원하는 로직을 구현할 수 있게끔 도와준다. 위의 예시를 들어 가격을 필터링하고 싶다면 가격을 필터링한다는 로직을 헬퍼함수에 구현하고, 이름을 필터링하고 싶다면 이름을 필터링하는 로직을 헬퍼함수에 구현한다. 코드의 표현력을 높혀주고 다양한 아이디어를 떠올릴 수 있게끔 도와준다.

'함수형 프로그래밍' 카테고리의 다른 글

코드를 값으로 다루기  (0) 2024.03.19
제너레이터  (0) 2024.03.10
이터러블/이터레이터 프로토콜  (0) 2024.03.10
일급 함수와 고차 함수  (0) 2024.03.10