코드스테이츠-부트캠프 [JavaScript - 고차함수]

김희목·2024년 3월 1일
0

코드스테이츠

목록 보기
11/56

일급 객체

JavaScript에 특별한 대우를 받는 일급 객체(first-class citizen)가 있습니다.

대표적인 일급 객체 중 하나가 함수입니다. JavaScript에서 함수는 아래와 같이 특별하게 취급됩니다.

  1. 변수에 할당(assignment)할 수 있다.

  2. 다른 함수의 전달인자(argument)로 전달될 수 있다.

  3. 다른 함수의 결과로써 리턴될 수 있다.

함수를 변수에 할당할 수 있기 때문에, 함수를 배열의 요소나 객체의 속성 값으로 저장할 수 있습니다. 함수를 데이터(string, number, boolean, array, object)처럼 다룰 수 있습니다.

고차함수

고차 함수(higher order function)는 함수를 전달인자(argument)로 받을 수 있고, 함수를 리턴할 수 있는 함수입니다.

이전 콘텐츠에서 확인했듯이, 함수는 변수에 저장할 수 있습니다. 그리고 함수는, 함수를 담은 변수를 전달인자로 받을 수 있습니다. 마찬가지로, 함수 내부에서 변수에 함수를 할당할 수 있습니다. 그리고 함수는 이 변수를 리턴할 수 있습니다. 여기서 변수에 할당하지 않고 함수를 바로 이용할 수 있습니다. 어떤 고차 함수에 함수를 전달인자로 전달하고, 고차 함수는 함수 자체를 리턴합니다. 변수가 빠졌을 뿐, 동일하게 동작합니다.

이때 다른 함수(caller)의 전달인자(argument)로 전달되는 함수를 콜백 함수(callback function)라고 합니다.

어떤 작업이 완료되었을 때 호출하는 경우가 많아서, 답신 전화를 뜻하는 콜백 함수라는 이름이 붙여졌습니다.

콜백 함수를 전달받은 고차 함수(caller)는, 함수 내부에서 이 콜백 함수를 호출(invoke)할 수 있고, 조건에 따라 콜백 함수의 실행 여부를 결정할 수도 있습니다. 아예 호출하지 않을 수도 있고, 여러 번 실행할 수도 있습니다. 특정 작업의 완료 후에 호출하는 경우는 이후에 충분히 접할 수 있습니다.

'함수를 리턴하는 함수'는 모양새가 특이한 만큼, 부르는 용어가 따로 있습니다. '함수를 리턴하는 함수'를 고안해 낸 논리학자 하스켈 커리(Haskell Curry)의 이름을 따, 커링 함수라고 합니다.

따로 커링 함수라는 용어를 사용하는 경우에는, 고차 함수라는 용어를 '함수를 전달인자로 받는 함수'에만 한정해 사용하기도 합니다. 그러나 정확하게 구분하자면, 고차 함수가 커링 함수를 포함합니다. 이번 유닛부터는 '함수를 리턴하는 함수'와 '함수를 전달인자로 받는 함수' 모두, 고차 함수로 사용합니다.


  1. 다른 함수를 인자로 받는 경우
function double(num) {
  return num * 2;
}

function doubleNum(func, num) {
  return func(num);
}

/*
 * 함수 doubleNum은 다른 함수를 인자로 받는 고차 함수입니다.
 * 함수 doubleNum의 첫 번째 인자 func에 함수가 들어올 경우
 * 함수 func는 함수 doubleNum의 콜백 함수입니다.
 * 아래와 같은 경우, 함수 double은 함수 doubleNum의 콜백 함수입니다.
 */
let output = doubleNum(double, 4);
console.log(output); // -> 8
  1. 함수를 리턴하는 경우
function adder(added) {
  return function (num) {
    return num + added;
  };
}

/*
 * 함수 adder는 다른 함수를 리턴하는 고차 함수입니다.
 * adder는 인자 한 개를 입력받아서 함수(익명 함수)를 리턴합니다.
 * 리턴되는 익명 함수는 인자 한 개를 받아서 added와 더한 값을 리턴합니다.
 */

// adder(5)는 함수이므로 함수 호출 연산자 '()'를 사용할 수 있습니다.
let output = adder(5)(3); // -> 8
console.log(output); // -> 8

// adder가 리턴하는 함수를 변수에 저장할 수 있습니다.
// javascript에서 함수는 일급 객체이기 때문입니다.
const add3 = adder(3);
output = add3(2);
console.log(output); // -> 5
  1. 함수를 인자로 받고, 함수를 리턴하는 경우
unction double(num) {
  return num * 2;
}

function doubleAdder(added, func) {
  const doubled = func(added);
  return function (num) {
    return num + doubled;
  };
}

/*
 * 함수 doubleAdder는 고차 함수입니다.
 * 함수 doubleAdder의 인자 func는 함수 doubleAdder의 콜백 함수입니다.
 * 함수 double은 함수 doubleAdder의 콜백으로 전달되었습니다.
 */

// doubleAdder(5, double)는 함수이므로 함수 호출 기호 '()'를 사용할 수 있습니다.
doubleAdder(5, double)(3); // -> 13

// doubleAdder가 리턴하는 함수를 변수에 저장할 수 있습니다. (일급 객체)
const addTwice3 = doubleAdder(3, double);
addTwice3(2); // --> 8

내장 고차 함수

JavaScript에는 기본적으로 내장된 고차 함수가 여럿 있습니다. 그중에서 배열 메서드들 중 일부가 대표적인 고차 함수에 해당합니다.


filter

배열의 filter 메서드는, 모든 배열의 요소 중에서 특정 조건을 만족하는 요소를 걸러내는 메서드입니다.

예를 들어 number 타입을 요소로 갖는 배열에서 짝수만을 걸러내거나, 18 보다 작은 수만을 걸러냅니다. string 타입을 요소로 갖는 배열에서, 길이가 10 이하인 문자열만 걸러내거나, 'korea' 같은 특정 문자열만 걸러낼 수도 있습니다.

// 아래 코드에서 '짝수'와 '길이 5 이하'는 문법 오류(syntax error)에 해당합니다.
// 의미만 이해해도 충분합니다.
let arr = [1, 2, 3, 4];
let output = arr.filter(짝수);
console.log(output); // ->> [2, 4]

arr = ['hello', 'code', 'states', 'happy', 'hacking'];
output = arr.filter(길이 5 이하)
console.log(output); // ->> ['hello', 'code', 'happy']

여기서 걸러내는 기준이 되는 특정 조건은 filter 메서드의 전달인자로 전달됩니다.

이때 전달되는 조건은 함수의 형태입니다. filter 메서드는, 걸러내기 위한 조건을 명시한 함수를 전달인자로 받기 때문에 고차 함수입니다. filter 메서드가 동작하는 방식을 조금 더 자세히 살펴보면 다음과 같습니다.

// 아래 코드는 정확한 표현 방식은 아닙니다.
// 의미만 이해해도 충분합니다.

let arr = [1, 2, 3];
// 배열의 filter 메서드는 함수를 전달인자로 받는 고차 함수입니다.
// arr.filter를 실행하면 내부적으로 arr에 접근할 수 있다고 생각해도 됩니다.
arr.filter = function (arr, func) {
  const newArr = [];
  for (let i = 0; i < arr.length; i++) {
    // filter에 전달인자로 전달된 콜백 함수는 arr의 각 요소를 전달받아 호출됩니다.
    // 콜백 함수가 true를 리턴하는 경우에만 새로운 배열에 추가됩니다.
    if (func(arr[i]) === true) {
      newArr.push(this[i]);
    }
  }
  // 콜백 함수의 결과가 true인 요소들만 저장된 배열을 리턴합니다.
  return newArr;
};

/*
 * filter 메서드의 보다 정확한 정의는 아래와 같습니다. 아래 코드를 이해하기 위해서는 다음 유닛에서 프로토타입과 this에 대한 학습이 필요합니다.
 * Array.prototype.filter = function(func) {
 *   const arr = this;
 *   const newArr = []
 *   for(let i = 0; i < arr.length; i++) {
 *     if (func(arr[i]) === true) {
 *       newArr.push(this[i])
 *     }
 *   }
 *   return newArr;
 * }
 */

filter 메서드는 배열의 요소를 콜백 함수에 다시 전달합니다.

콜백 함수는 전달받은 배열의 요소를 받아 함수를 실행하고, 콜백 함수 내부의 조건에 따라 참(true) 또는 거짓(false)을 리턴해야 합니다. 처음 본 코드에 이 점을 반영하여 다시 코드를 작성하면, 다음과 같습니다

/ 함수 표현식
const isEven = function (num) {
  return num % 2 === 0;
};

let arr = [1, 2, 3, 4];
// let output = arr.filter(짝수);
// '짝수'를 판별하는 함수가 조건으로서 filter 메서드의 전달인자로 전달됩니다.
let output = arr.filter(isEven);
console.log(output); // ->> [2, 4]

const isLteFive = function (str) {
  // Lte = less then equal
  return str.length <= 5;
};

arr = ['hello', 'code', 'states', 'happy', 'hacking'];
// output = arr.filter(길이 5 이하)
// '길이 5 이하'를 판별하는 함수가 조건으로서 filter 메서드의 전달인자로 전달됩니다.
let output = arr.filter(isLteFive);
console.log(output); // ->> ['hello', 'code', 'happy']

실제 filter 활용 예시

filter 활용 시, 아래 과정을 꼭 기억하세요.

  1. 배열의 각 요소가
  2. 특정 논리(함수)에 따르면, 사실(true)일 때
  3. 따로 분류합니다(filter).

map

map 메서드는 모든 요소를 특정 논리에 의해 다른 요소로 반환되게 하는 메서드 입니다.

실제 map 활용 예시

map 활용 시, 아래 과정을 꼭 기억하세요.

  1. 배열의 각 요소가
  2. 특정 논리(함수)에 의해
  3. 다른 요소로 지정(map) 됩니다.
// 만화책 모음
const cartoons = [
  {
    id: 1,
    bookType: 'cartoon',
    title: '식객',
    subtitle: '어머니의 쌀',
    createdAt: '2003-09-09',
    genre: '요리',
    artist: '허영만',
    averageScore: 9.66,
  },
  {
    id: 2,
    // .. 이하 생략
  },
  // ... 이하 생략
]; 

// 만화책 한 권의 부제를 리턴하는 로직(함수)
const findSubtitle = function (cartoon) {
  return cartoon.subtitle;
}; 

// 각 책의 부제 모음 
const subtitles = cartoons.map(findSubtitle); // ['어머니의 쌀', ...]


map은 이렇게 하나의 데이터를 다른 데이터로 매핑(mapping) 할 때 사용합니다.

reduce

자바스크립트의 reduce함수는 배열의 각 요소를 순회하며 callback함수의 실행 값을 누적하여 하나의 결과값을 반환 합니다.

즉 배열의 여러개의 값을 하나의 값으로 축소해준다.

reduce 메서드는 고차 함수이다. 첫번째 인수 자리에 콜백함수가 들어온다. 두번째 인수 자리(생략가능)에는 초기값이 들어온다.

reduce 메서드는 자신을 호출한 배열의 모든 요소를 순회하며 인수로 전달받은 콜백함수를 반복 호출한다. 이때 원본 배열은 변경 되지 않는다.

reduce 메서드의 콜백함수에도 4개의 인수가 있다.
이론으로만 설명하면 모호할 수 있으니 reduce 메서드를 활용한 예시를 들어 설명해보겠다.
일반적으로 reduce 메서드는 배열 요소들의 평균을 구할 때 많이 쓴다.

const array1 = [20, 35, 1, 98, 46, 5];
array1.reduce((a, b) => (a + b)) / array1.length; 
//결과 : 205

위의 표현식은 (20 + 35 + 1 + 98 + 46 + 5 ) / array.length 를 계산한 것과 같다.
그렇다면 어떻게 위의 결과가 나오는지 순서대로 하나씩 따져보자.

(1) a : 20, b : 35
(2) a : 55(20 + 35), b : 1
(3) a : 56(20 + 35 + 1), b : 98
(4) a : 154(20 + 35 + 1 + 98), b : 46
(5) a : 200(20 + 35 + 1 + 98 + 46), b : 5
(6) array1.reduce((a, b) => (a + b)) 에서 return 되는 값 = a + b = 205 
(7) 205 / 6 = 34.166666666666664

reduce 메서드의 콜백함수의 인수

array1.reduce((a, b) => (a + b))

1) 1번째 인수 : 누적값, 2번째 인수 : 현재값
reduce 메서드의 첫번째 인수인 콜백함수의 a는 누적 값, b 는 현재 값이다.
reduce 를 호출했을 때 return 되는 값은 마지막 최종 누적값이다.

reduce 메서드의 두번째 인수 초기값이 있다면,
초기 값은 reduce 메서드의 콜백함수의 첫번째 순회의 누적 값이 된다.
초기값 자리가 비어있다면 배열의 첫번째 요소가 콜백함수의 첫번째 누적 값이 된다.
단, reduce 메서드를 호출할 때 초기값을 생략하지 않고 언제나 전달하는 것이 안전한다.

[1, 2, 3, 4].reduce((a, c) => (a * c), 1); //24
//a : 1, c: 1
//a : 1, c : 2
//a : 2, c : 3
//a : 6, c : 4

2) 3번째 인수 : index

array1.reduce((a, b, i) => {a[i] = c; return a}, {})

reduce 메서드 콜백함수 세번째 인수에 index 가 들어온다.
reduce 메서드를 호출한 배열의 현재 순회하는 요소의 index를 가져올 수 있다.


3) 4번째 인수 : this

[0, 1, 2, 3, 4].reduce(function(accumulator, currentValue, currentIndex, array) {
  return accumulator + currentValue;
});

4번째 인수는 reduce 메서드를 호출한 배열 자체이다.


결론

reduce 메서드의 콜백함수에는 총 4개의 인수가 존재한다.
1) 1번째 : 초기값 또는 누적값(콜백함수의 이전 반환값)
2) 2번째 : 현재값 (reduce 메서드를 호출한 배열의 요소값)
3) 3번째 : index (reduce 메서드를 호출한 배열의 요소값의 index)
4) 4번째 : this (reduce 메서드를 호출할 배열자체)

◈ 참고
아래 3개의 문은 모두 같은 표현이다. reduce 메서드를 사용할 때는 반드시 값을 리턴(return)해주어야 한다.
단, return 과 { 가 만나면 생략이 가능하다. 즉, return 안의 문이 하나일 경우에는 return 과 {} 를 생략해 줄수 있다. 식을 괄호()로 묶는 것은 상관 없다.

[1, 2, 3, 4].reduce((a, c) => return {a + c});
[1, 2, 3, 4].reduce((a, c) => a + c);
[1, 2, 3, 4].reduce((a, c) => (a + c));

reduce 메서드를 활용해서 아래의 항목들을 구현할 수 있다.

평균 구하기
최대값 구하기
요소의 중복 횟수 구하기
중첩 배열의 평탄화 (Array.prototype.flat 메서드를 사용하는 것이 직관적)
중복 요소 제거 (Array.prototype.filter 메서드를 사용하는 것이 직관적, Set 사용하는것도 좋다.)


고차 함수의 중요성

복잡한 어떤 것을 압축해서 핵심만 추출한 상태로 만드는 것이 추상화입니다.

우리가 살아가는 이 세상은, 추상화로 가득 차 있습니다. '-1'을 표현하는 현실의 방법은 존재하지 않습니다. 그러나 우리는 '-1'이라는 문자를 보고, "-1은 0보다 1만큼 작은 수다."라고 설명할 수 있습니다.

이렇듯, 인간은 추상화를 통해 생각하고 표현합니다. 추상화를 이용하면, 효율적이고 편하게 생각할 수 있기 때문입니다.

브라우저 창에 주소를 입력했을 때, 어떤 일이 일어나는지 정확하게 알고 있나요? 입력한 내용을 전파하고, 어디 서버로 갔다가 다른 서버로 가는 등 그런 복잡한 내용을, 일상생활에서는 몰라도 됩니다. 우리는 그저 주소창에 올바른 주소를 입력하면, 브라우저가 해당 사이트를 보여 준다는 것만 알고 있습니다.

JavaScript를 비롯한 많은 프로그래밍 언어 역시, 추상화의 결과입니다. 컴퓨터를 구성하는 장치(중앙처리장치, CPU; Central Processing Unit)는 0과 1만 이해합니다. 크롬 개발자 도구의 콘솔(console) 탭에서 다음의 코드를 입력했을 때, 어떤 과정을 거쳐 10이 출력되는지 몰라도 10을 출력할 수 있습니다. 그런 복잡한 것들은 크롬의 JavaScript 해석기(엔진)가 대신해 주기 때문입니다

function sum(num1, num2) {
  return num1 + num2;
}
const output = sum(3, 7);
console.log(output); // --> 10

컴퓨터의 내부 구조에 대한 고민이 추상화로 해결되었습니다. 우리는 JavaScript의 문법(syntax)을 올바르게 사용하는 것만으로, JavaScript가 없었을 때보다 다양한 프로그램을 보다 쉽게 작성할 수 있습니다. 이처럼 고민거리가 줄어들고, 그래서 문제의 해결이 더 쉬워지는 것이 추상화의 이점입니다.

추상화 = 생산성(productivity)의 향상

한편 프로그램을 작성할 때, 자주 반복해서 사용하는 로직은 별도의 함수로 작성하기도 합니다. 이 역시 추상화의 좋은 사례입니다. 추상화의 관점에서 함수를 바라보면, 함수는 사고(thought) 또는 논리(logic)의 묶음입니다.

아래의 getAverage 함수는 number 타입을 요소로 갖는 배열을 입력받아, 모든 요소의 평균값을 리턴합니다. 앞으로는 number 타입을 요소로 갖는 배열을 인자로 전달하기만 하면, 복잡한 로직은 신경 쓰지 않아도 평균값을 얻을 수 있습니다.

function getAverage(data) {
  let sum = 0;
  for (let i = 0; i < data.length; i++) {
    sum = sum + data[i];
  }
  return sum / data.length;
}

let output = getAverage([1, 2, 3]);
console.log(output); // --> 2

output = getAverage([4, 2, 3, 6, 5, 4]);
console.log(output); // --> 4

함수를 통해 얻은 추상화를, 한 단계 더 높인 것이 고차 함수입니다. getAverage 함수는 값(배열)을 전달받아, 이 값을 가지고 복잡한 작업을 수행합니다. 이는 값 수준에서의 추상화입니다.

  1. 함수 = 값을 전달받아 값을 리턴한다. = 값에 대한 복잡한 로직은 감추어져 있다. = 값 수준에서의 추상화

고차 함수는 이 추상화의 수준을 사고의 추상화 수준으로 끌어올립니다.

  1. 값 수준의 추상화: 단순히 값(value)을 전달받아 처리하는 수준

  2. 사고의 추상화: 함수(사고의 묶음)를 전달받아 처리하는 수준

다시 말해 고차 함수를 통해, 보다 높은 수준(higher order)에서 생각할 수 있습니다.

고차 함수 = 함수를 전달받거나 함수를 리턴한다. = 사고(함수)에 대한 복잡한 로직은 감추어져 있다. = 사고 수준에서의 추상화
추상화의 수준이 높아지면 생산성도 비약적으로 상승할 수 있습니다.

0개의 댓글