Level 4-1. 고차 함수

soheey·2021년 5월 25일
0

First-class citizen

특별한 대우를 받는 함수

자바스크립트의 세계에서 특별한 대우를 받는 것들이 있습니다. 이런 것들을 일급 객체(first-class citizen)라고 합니다. 그 중 하나가 함수(function)입니다. 즉 자바스크립트에서 함수는 (자바스크립트가 나온 시점을 고려했을 때) 아래와 같이 특별하게 취급됩니다.

  • 변수에 할당(assignment)할 수 있다.
  • 다른 함수의 인자(argument)로 전달될 수 있다.
  • 다른 함수의 결과로서 리턴될 수 있다.

이는 함수를 데이터(string, number, boolean, array, object)를 다루듯이 다룰 수 있다는 걸 의미합니다. 변수에 저장할 수 있기 때문에 배열의 요소나 객체의 속성값으로 저장하는 것도 가능합니다.

여러분들은 이미 함수를 변수에 저장하는 방법을 배웠습니다. 바로 함수 표현식(function expression) 입니다. 아래의 함수 표현식은 함수 선언식(function declaration)과 다르게 호이스팅(hoisting)이 적용되지 않습니다.

  1. 변수에 함수를 할당하는 경우
// 아래는 변수 square에 함수를 할당하는 함수 표현식입니다.
// 자바스크립트에서 함수는 일급 객체이기 때문에 변수에 저장할 수 있습니다.

// 함수 표현식은 할당 전에 사용할 수 없습니다.
// square(7); // --> ReferenceError: Can't find variable: square

const square = function (num) {
  return num * num;
};

// square에는 함수가 저장되어 있으므로 (일급 객체), 함수 호출 연산자 '()'를 사용할 수 있습니다.
output = square(7);
console.log(output); // --> 49

함수 선언식의 호이스팅에 지나치게 의존하는 것은 코드의 유지 보수 측면에서 좋지 않습니다. 코드 리뷰나 디버깅을 할 때 코드를 위 아래로 왔다 갔다하게 될 수 있습니다. 함수 선언식의 경우, 어느 위치에나 함수를 선언할 수 있고 함수의 실행 위치도 중요하지 않기 때문입니다. 반면, 함수 표현식은 함수의 할당과 실행의 위치가 중요하기 때문에 코드의 위치가 어느 정도 예측 가능합니다.

호이스팅을 제외하면 함수 선언식과 함수 표현식의 차이는 크게 없어 보이고, 실제로도 그렇습니다. 다만 함수 표현식의 경우, 함수가 변수에 저장될 수 있다는 사실을 좀 더 분명하게 드러낼 수 있습니다.

한편, 변수에 저장된 데이터는 함수의 인자로 전달되거나 함수 내에서 리턴값으로 사용될 수 있습니다. 앞서 함수가 변수에 저장될 수 있다는 사실로부터, 함수 역시 다른 함수의 인자로 전달되거나 다른 함수 내에서 리턴될 수 있다는 것을 알 수 있습니다. 자바스크립트의 고급 주제인 고차 함수(higher order function) 학습은 여기서부터 시작됩니다.

What is HoF?

고차 함수란

고차 함수(higher order function)는 함수를 인자(argument)로 받거나 함수를 리턴하는 함수를 말합니다. 이 때 다른 함수(caller)의 인자(argument)로 전달되는 함수를 콜백 함수(callback function)라고 합니다.

콜백 함수를 전달받은 함수는 이 콜백 함수를 호출(invoke)할 수 있습니다. caller는 조건에 따라 콜백 함수의 실행 여부를 결정할 수도 있고, 심지어 여러 번 실행할 수도 있습니다. 특히 콜백 함수는 어떤 작업이 완료되었을 때 호출되는 경우가 많아서 답신 전화를 뜻하는 콜백이라는 이름이 붙여졌습니다.

한편, '함수를 리턴하는 함수'만을 일컫는 용어가 따로 존재하고 실제로도 많이 쓰입니다. 이런 함수를 이를 고안해 낸 논리학자 하스켈 커리(Haskell Curry)의 이름을 따라 커리 함수라고 합니다. 따로 커리 함수라는 용어를 사용하는 경우, 고차 함수란 용어를 '함수를 인자로 받는 함수'에만 한정지어서 사용하기도 합니다. 하지만 엄밀한 의미에서 고차 함수는 커리 함수를 포함합니다. 이번 과정에서는 고차 함수로 용어를 통일하겠습니다.

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

function doubleNum(func, num) {
  let doubledArr = [];
  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. 함수를 인자로 받고, 함수를 리턴하는 경우
function 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

Built-in HoFs

내장 고차 함수 이해하기

자바스크립트에는 기본적으로 내장(built-in)되어 있는 고차 함수들이 있습니다. 바로 배열 메소드들 중 일부가 고차 함수에 해당합니다. 이번 시간에는 이 중 하나인 filter에 대해서 학습해 보겠습니다.

배열의 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;
};

// 보다 정확한 정의
// Immersive 과정에서 프로토 타입과 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)을 리턴해야 합니다. (적어도 filter 메소드는 이런 함수를 기대하고 있습니다.) 이 점을 반영하여 처음 본 코드를 완성하면 아래와 같습니다.

// 함수 표현식
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']
  • 자바스크립트 배열 메소드 중 고차 함수 학습하기 (js array method)
    • forEach, find, filter, map, reduce, sort, some, every

Why HoFs?

높은 수준에서 생각하기

컴퓨터 공학의 근간을 이루는 여러 개념 중에 추상화(abstraction)가 있습니다. 추상화의 다른 말은 요약입니다. 복잡한 어떤 것을 압축해서 핵심만 추출한 상태로 만드는 것이 추상화입니다. 사실 이 세상은 추상화로 가득차 있습니다. 자바스크립트(를 비롯한 많은 프로그래밍 언어) 역시 추상화의 결과입니다.

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

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

아래의 getAverage 함수는 수(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 함수는 값(배열)을 전달 받아, 이 값을 가지고 복잡한 작업을 수행합니다. 이는 값 수준에서의 추상화입니다.

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

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

값 수준의 추상화: 단순히 (value)을 전달 받아 처리하는 수준
사고의 추상화: 함수(사고의 묶음)를 전달 받아 처리하는 수준

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

고차함수 = 함수를 전달 받거나 함수를 리턴한다 = 사고(함수)에 대한 복잡한 로직은 감추어져 있다 = 사고 수준에서의 추상화

추상화의 수준이 높아진만큼 생산성도 비약적으로 상승합니다. 이를 예시를 통해 살펴 보겠습니다.

Example of using HoFs

사고 수준의 추상화의 예시

const data = [
  {
    gender: 'male',
    age: 24,
  },
  {
    gender: 'male',
    age: 25,
  },
  {
    gender: 'female',
    age: 27,
  },
  {
    gender: 'female',
    age: 22,
  },
  {
    gender: 'male',
    age: 29,
  },
];

위와 같이 주어진 데이터에 대해서 순차적으로 처리하고 싶은 작업들이 있을 때, 모든 작업을 하나의 함수로 작성할 수도 있습니다. 예를 들어 남성들의 평균 나이를 구한다고 할 때, 아래와 같은 함수를 작성할 수 있습니다.

function getAverageAgeOfMaleAtOnce(data) {
  const onlyMales = data.filter(function (d) {
    // arr.filter는 배열의 각 요소에 인자로 전달 받은 함수를 적용하고,
    // 그 결과가 true인 요소만을 갖는 배열을 리턴합니다.
    return d.gender === 'male';
  });

  const numOfMales = onlyMales.length;

  const onlyMaleAges = onlyMales.map(function (d) {
    // arr.filter는 배열의 각 요소에 인자로 전달 받은 함수를 적용하고,
    // 각 결과를 요소로 갖는 배열을 리턴합니다.
    return d.age;
  });

  const sumOfAges = onlyMaleAges.reduce(function (acc, cur) {
    // arr.reduce는 배열의 각 요소에 인자로 전달 받은 함수를 적용하고,
    // 각 결과를 두 번째 인자로 전달 받은 초기값(0)에 누적한 결과를 리턴합니다.
    return acc + cur;
  }, 0);

  return sumOfAges / numOfMales;
}

위에 제시된 getAverageAgeOfMaleAtOnce 함수는 배열 메소드를 적절하게 사용하여 순차적으로 원하는 작업을 수행합니다. 꽤 괜찮은 코드이지만, '남성'의 '평균 나이'만 구하는 작업에만 국한된 함수입니다. 개선점을 찾아보자면, 'male'을 매개변수화(parameterization)하여 조금 더 일반적인(generic) 함수로 변경할 수도 있습니다. 그래도 어디까지나 '남성' 또는 '여성'의 '평균 나이'를 구하는 작업에 그칩니다.

한편, filter, map, reduce 등의 배열 메소드는 다른 목적을 위해서 사용될 수도 있습니다. 예를 들어 '남성' 중 '최연소 나이'를 구하거나, '여성' 중 '최연소 나이와 최연장 나이의 차이'를 구할 때 이미 작성된 로직을 그대로 쓸 수 있습니다.

고차 함수를 통해 이를 쉽게 달성할 수 있습니다. 아래의 compose 함수는 입력받은 함수들을 순차적으로 결합하는 고차 함수입니다. 각각의 작업(filter, map, reduce)들은 별도의 함수로 분리되어, compose의 인자로 전달되는 콜백 함수가 됩니다.

function getOnlyMales(data) {
  return data.filter(function (d) {
    return d.gender === 'male';
  });
}

function getOnlyAges(data) {
  return data.map(function (d) {
    return d.age;
  });
}

function getAverage(data) {
  const sum = data.reduce(function (acc, cur) {
    return acc + cur;
  }, 0);
  return sum / data.length;
}

function compose(...funcArgs) {
  // compose는 여러 개의 함수를 인자로 전달받아 함수를 리턴하는 고차 함수입니다.
  // compose가 리턴하는 함수(익명 함수)는 임의의 타입의 data를 입력받아,
  return function (data) {
    // funcArgs의 요소인 함수들을 차례대로 적용(apply)시킨 결과를 리턴합니다.
    let result = data;
    for (let i = 0; i < funcArgs.length; i++) {
      result = funcArgs[i](result);
    }
    return result;
  };
}

// compose를 통해 함수들이 순서대로 적용된다는 것이 직관적으로 드러납니다.
// 각각의 함수는 다른 목적을 위해 재사용(reuse)될 수 있습니다.
const getAverageAgeOfMale = compose(
  getOnlyMales, // 배열을 입력받아 배열을 리턴하는 함수
  getOnlyAges, // 배열을 입력받아 배열을 리턴하는 함수
  getAverage // 배열을 입력받아 `number` 타입을 리턴하는 함수
);

const result = getAverageAgeOfMale(data);
console.log(result); // --> 26

이처럼 고차 함수를 통해 사고 수준에서의 추상화를 달성할 수 있습니다. 각각의 작업들은 다른 목적을 위해 재사용될 수 있습니다. 다른 함수를 작성하고, 새로운 작업들의 조합을 만들어 보시기 바랍니다.

Advanced

  • MapReduce 학습하기 (MapReduce Model)
  • 자바스크립트에서 커링(currying)과 클로져(closure)의 차이 이해하기 (js closure vs curry)
  • 선언형 프로그래밍(declarative programming)과 절차형 프로그래밍(imperative programming)의 차이를 배열 메소드를 통해 이해하기 (js imperative vs declarative)
  • 함수의 조합(function composition)에 대해 학습하기 (javascript function composition)
  • bubble sort, insertion sort, quick sort, merge sort, radix sort (4-2 Underbar)

0개의 댓글