JavaScript 기본

Array 함수 #4. reduce vs.forEach

밀하우스 마나스톰 2022. 3. 2. 17:18

1. reduce

 

reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
reduce<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;

reduce 함수는 배열을 순회하면서 콜백 함수를 반복 실행하고, 모든 반복의 결과를 하나의 값으로 줄이는(reduce) 함수다.

  • callbackfn : 배열을 순회하면서 실행할 함수. 다음 네 가지 인수를 가짐.
    • previousValue (=accumulator)

             이전 콜백의 반환 값(=콜백의 반환 값 누적). 콜백의 첫 번째 호출이면서 initialValue를 제공한 경우에는 initialValue의 값임.

    • currentValue

             현재 원소의 값

    • currentIndex

             현재 원소의 인덱스. initialValue를 제공한 경우 0, 아니면 1부터 시작

    • array

             현재 순회하고 있는 배열 전체. reduce를 실행한 배열

  • initialValue : 콜백의 최초 실행에서 첫 번째 인수 previousValue에 제공하는 값
    • 이 값을 제공하지 않으면 배열의 첫 번째 원소의 값을 초기값으로 사용하고, 0번 원소의 순회를 건너뛰고 1번 원소부터 콜백 함수를 실행함
    • initialValue, previousValue, reduce의 최종 리턴 값. 세 값의 타입은 모두 같음
    • 빈 배열에서 초기값 없이 reduce를 실행하면 에러 발생

 

 

 

2. reduce를 쓰는 이유

 

filter, some, every 등의 기존 Array 함수는 사용할 수 있는 범위가 정해져 있다.

 

reduce는 이 함수들로 구현하기 까다로운 기능, 또는 다른 Array 함수가 할 수 있는 모든 것들을 reduce로 구현할 수 있다.

 

즉, reduce가 모든 Array 함수들의 아버지라고 할 수 있다.

 

 

 

3. 예시

 

(1) 배열 원소들의 합계 구하기

let input = [1,2,3,4,5];
let output1 = input.reduce((prev, curr) => { console.log("output1"); return prev + curr });     // 15
let output2 = input.reduce((prev, curr) => { console.log("output2"); return prev + curr }, 10); // 25

콜백 함수에 로그 출력을 굳이 넣은 이유는 "output1"은 네 번 출력되고, "output2"는 다섯 번 출력된다.

 

initialValue를 제공하지 않으면 0번 원소를 건너뛰는 reduce의 특징을 확인할 수 있다.

 

 

let output = 0;
input.forEach(x => output += x);

이 정도는 forEach 함수를 통해 더 쉽게 구현할 수 있다.

 

 

 

(2) 음수와 양수 개수 세기

 

let input = [-1,0,8,3,6,-11,33];
let output = input.reduce((prev, curr) => {
    if (curr < 0) prev[0]++;
    else if (curr > 0) prev[1]++;
    return prev;
}, [0,0]);      // [2,4]

initialValue로 제공한 [0,0] 배열을 초기값으로 해서 음수 개수, 양수 개수를 카운팅 할 수 있다.

 

let input = [-1,0,8,3,6,-11,33];
let output = [0,0];
input.forEach(x => {
    if (x < 0) output[0]++;
    else if (x > 0) output[1]++;
})

 

이 역시 forEach를 통해 똑같이 구현하는데 문제가 없는듯하다.

 

사실 이런 간단한 예제는 대부분 forEach, map, filter 셋 중 하나로 더 짧은 코드로 구현이 가능하다.

 

 

 

(3) reduce vs. filter + map

 

let input = [1,2,3,4,5];
let output1 = input.filter(x => x % 2 == 0);    // [2,4]
let output2 = input.map(x => x * 2);            // [2,4,6,8,10]

output1은 입력 배열에서 짝수인 원소만 담은 배열이고

 

output2는 배열의 모든 원소를 2배 곱한 배열이다. 각각 filter와 map을 이용해 쉽게 구현할 수 있다.

 

그렇다면 '짝수인 원소들만 골라서 2배 곱한 배열'을 리턴해야 한다면?

 

filter나 map 중 하나만 사용해서 짧은 코드로 구현이 가능할까?

 

 

let input = [1,2,3,4,5];
let output = input.filter(x => x % 2 == 0)      // [2,4]
                  .map(x => x * 2);             // [4,8]

마땅히 방법이 없는 듯하다. filter는 입력 배열에 존재하는 원소만 리턴 배열에 추가할 수 있고

 

map은 조건에 맞지 않는 원소는 추가하지 않고 continue 시키는 방법이 없다는 한계들이 있기 때문이다.

 

결국 filter를 통해서 리턴 받은 배열을 다시 map의 입력 배열로 사용해야 한다.

 

이 경우 2번의 배열 순회가 일어난다.

 

let input = [1,2,3,4,5];
let output = input.reduce((prev, curr) => {
    if (curr % 2 == 0) prev.push(curr * 2);
    return prev;
}, []);

reduce를 사용해서 한 번의 순회를 하고 동일한 결과 값을 얻을 수 있다.

 

 

let input = [1,2,3,4,5];
let output = [];
input.forEach(x => {
    if (x % 2 == 0) output.push(x * 2);
});

그럼에도 불구하고 forEach로도 한 번만 순회하고 reduce와 동일한 결과값을 얻을 수 있다.

 

이 쯤되면 reduce와 forEach의 차이가 궁금해진다.

 

 

 

4. reduce vs. forEach

 

(1) 재사용

 

let reducer = function(prev, curr) {
    if (curr % 2 == 0) prev.push(curr * 2);
    return prev;
}
let output1 = [1,2,3,4,5].reduce(reducer, []);      // [4,8]
let output2 = [6,7,8,9,10].reduce(reducer, []);     // [12,16,20]

reduce 내부에 들어갈 누산 규칙을 따로 모듈화 시켜서 입력 배열만 바꿔가면서 재사용하기 편하다. 직관적이다.

 

let input = [1,2,3,4,5];
let output = [];
let each = function(x) {
    if (x % 2 == 0) output.push(x * 2);
}
input.forEach(each);

반면 위와 비슷한 방식으로 forEach를 재사용하려면 필연적으로 output 선언이 먼저 필요하다.

 

이 경우 사실상 재사용이라고 부를 수 없다.

 

 

let func = function(input, output) {
    input.forEach(x => {
        if (x % 2 == 0) output.push(x * 2)
    });
    return output;
}
let output1 = func([1,2,3,4,5], []);
let output2 = func([6,7,8,9,10], []);

위를 다시 개선하면 forEach는 이렇게 재사용할 수 있다.

 

모듈화 시킨 범위를 forEach 안에 들어가는 함수보다 더 큰, forEach 자체를 포함하도록 확장한 것이다.

 

forEach뿐만 아니라 filter, map 등의 다른 Array 함수도 재사용하려면 위와 같이 사용해야 한다.

 

 

결론은 재사용 측면에서는 reduce와 forEach와 차이가 존재하며 reduce가 조금 더 사용하기 간편해 보인다.

 

 

 

(2) 리팩토링

 

리팩토링, 그리고 가독성 향상의 여러 방법 중에는 함수를 본래의 목적에 적합하게 사용하는 것이 포함돼있다.

 

비록 map, filter, reduce으로 구현한 기능을 forEach로 구현할 수 있다고 해도 각 함수마다 명시하는 역할이 있다.

 

  • forEach 메서드는 주어진 함수를 배열 요소 각각에 대해 실행한다.

      forEach는 "실행" 하는 의미를 갖고 있기 때문에, 실행하는 경우(로그, API 호출 등)에 적합하다.

  • filter 메서드는 주어진 함수의 테스트를 통과하는 모든 요소를 모아 새로운 배열로 반환한다.

      filter는 "테스트" 하는 의미를 갖고 있기 때문에, 특정 조건에 해당하는 요소를 뽑아내는 경우(a>N 등 if문)에 적합하다.

  • map 메서드는 배열 내의 모든 요소 각각에 대하여 주어진 함수를 호출한 결과를 모아 새로운 배열을 반환한다.

      map은 "변환" 하는 의미를 갖고 있기 때문에, 각 요소를 변환하는 경우(a * N 등)에 적합하다.

  • reduce 메서드는 배열의 각 요소에 대해 주어진 리듀서(reducer) 함수를 실행하고, 하나의 결과 값을 반환한다.

      다른 함수들이 배열로 반환하는 것에 비해, reduce는 단일 값을 반환해야 하는 경우(SUM 등)에 적합하다.

 

각 함수의 이러한 성격들을 잘 이해하고 알맞게 쓸 필요가 있는 것 같다.