고차 함수(Higher-Order Function, HOF)는 함수를 인수로 전달받거나, 함수를 반환하는 함수를 말한다. 자바스크립트의 함수는 일급 객체이므로 함수를 값처럼 인수로 전달할 수 있으며 반환할 수도 있다. 고차 함수는 외부 상태의 변경이나 가변(mutable)데이터를 피하고 불변성(immutability)을 지향하는 함수형 프로그래밍에 기반을 두고 있다.
함수형 프로그래밍은 순수 함수(pure function)와 보조 함수의 조합을 통해 로직 내에 존재하는 조건문과 반복문을 제거하여 복잡성을 해결하고 변수의 사용을 억제하여 상태 변경을 피하려는 프로그래밍 패러다임이다. 조건문이나 반복문은 로직의 흐름을 이해하기 어렵게 하여 가독성을 해치고, 변수는 누군가에 의해 언제든지 변경될 수 있어 오류 발생의 근본적인 원인이 될 수 있기 때문이다. 함수형 프로그래밍은 결국 순수 함수를 통해 부수 효과를 최대한 억제하여 오류를 피하고 프로그래밍의 안정성을 높이려는 것이다.
자바스크립트는 고차 함수를 다수 지원한다. 특히 배열은 매우 유용한 고차 함수를 제공한다. 배열 고차 함수는 활용도가 매우 높으므로 사용법을 잘 이해하는 것이좋다.
sort 메서드는 배열의 요소를 정렬한다. 원본 배열을 직접 변경하여 정렬된 배열을 반환한다.
기본적으로 오름차순으로 정렬하는데, 만약 내림차순으로 정렬하고 싶다면 reverse를 사용하면 된다.
const strs = ['Banana' , 'Orange' , 'Apple']
console.log(strs.sort()) // [ 'Apple', 'Banana', 'Orange' ]
strs.reverse()
console.log(strs) // [ 'Orange', 'Banana', 'Apple' ]
이렇게 문자열에 대해서는 정렬이 아주 잘되는데, 문제는 Number에 관해서 정렬을 할 때 이다.
const points = [40, 100, 1, 5 ,2 , 25, 10]
console.log(points.sort()) // [ 1, 10, 100, 2, 25, 40, 5]
괴상하게 정렬된 결과를 얻을 수 있는데, 이는 sort() 함수 내부 동작 때문이다. sort 메서드의 기본 정렬 순서는 유니코드 포인트의 순서를 따른다. 배열의 요소가 숫자 타입이라 할지라도 배열의 요소를 일시적으로 문자열로 변환한 후, 유니코드 포인트의 순서를 기준으로 정렬한다.
가령, '1'의 유니코드 포인트는 U+0031 이고, '2'는 U+0032 이다. 그런데, '10'은 U+0031U+0030이다. 따라서 '2'보다 '10'이 앞서 나오게 되는 것이다.
이를 해결하기 위해서, sort 메서드의 인자로 정렬 순서를 정의하는 비교 함수를 인수로 전달해야 한다. 비교 함수는 양수나 음수 또는 0을 반환해야 하며, 비교 함수의 반환값이 0보다 작으면 비교 함수의 첫번째 함수를 우선하여 정렬하고, 0이면 정렬하지 않으며(명확히 스펙에 적혀있지 않으므로 확실하진 않다), 양수면 두 번째 인수를 우선하여 정렬한다.
뭔가 헷갈리는데, 단순히 말해서 배열의 어떤 수 a와 b를 비교하는데, 음수이면 a가 먼저나와서 [a , b]로 정렬하고, a와 b를 비교하는데 양수이면 b가 먼저나와서 [b, a]가 된다.
이 원리를 이용하여 오름차순으로 정렬할 때와 내림차순으로 정렬할 때를 정리하면 다음과 같다.
const points = [40, 100, -1, 5 ,2 , -25, 10]
console.log(points.sort((a,b)=> a - b)) // 오름 차순
/*
[
-25, -1, 2, 5,
10, 40, 100
]
*/
console.log(points.sort((a,b)=> b - a)) // 내림 차순
/*
[
100, 40, 10, 5,
2, -1, -25
]
*/
헷갈릴 수 있는데, 미리 결과를 정해놓고 생각하면 된다. 오름차순은 [a , b]라면 a < b가 된다. 따라서 a - b하면 음수가 나오고 a가 b보다 먼저나오므로 맞다. 내림차순은 [a, b]라면 a > b 이다. 따라서 b - a이면 음수이기 때문에 a가 b보다 먼저나오므로 맞다.
객체를 요소로 갖는 배열을 정렬하는 것은 다음과 같다. 프로퍼티 값이 문자열인 경우, 산술 연산으로 비교하면 NaN이 나오므로 비교 연산을 사용한다. 비교 함수는 양수/음수/0을 반환하면 되므로 - 산술 연산 대신 비교 연산을 사용할 수 있다.
const todos = [
{id : 4 , content : 'Javascript'},
{id : 2 , content : 'Html'},
{id : 1 , content : 'Css'}
]
function compare(key){
return (a, b) => (a[key] > b[key]) ? 1 : -1
}
todos.sort(compare('id'))
console.log(todos)
/*
[
{ id: 1, content: 'Css' },
{ id: 2, content: 'Html' },
{ id: 4, content: 'Javascript' }
]
*/
참고로 sort 메서드의 정렬 알고리즘은 퀵정렬을 사용하였었는데, 퀵정렬은 불안정한 알고리즘으로 동일한 요소에 대한 순서가 보장되지 않는다. 이에 따라 ES10에서는 timsort 알고리즘을 사용하도록 바뀌었다.
앞서 보았듯이 함수형 프로그래밍은 순수 함수와 보조 함수의 조합을 통해 로직 내에 존재하는 조건문과 반복문을 제거하여 복잡성을 해결하고 뼌수의 사용을 억제하여 상태 변경을 피하려는 프로그래밍 패러다임이다.
특히, for문은 반복을 위한 변수를 선언해야 하며, 조건식과 증감식으로 이루어져있어 함수형 프로그래밍이 추구하는 바와 맞지 않는다.
const numbers = [1,2,3]
const pows = []
for(let i = 0;i < numbers.length; i++){
pows.push(numbers[i] ** 2)
}
console.log(pows) // [ 1, 4, 9 ]
forEach 메서드는 for문을 대체할 수 있는 고차함수이다. forEach는 자신의 내부에서 반복문을 실행한다. forEach의 매개변수로 들어가는 함수는 callback 함수로 매 순회마다 콜백 함수를 반복 호출한다. 위 예제를 forEach로 바꾸면 다음과 같다.
const numbers = [1,2,3]
const pows = []
numbers.forEach(item => pows.push(item ** 2))
console.log(pows) // [ 1, 4, 9 ]
참고로 forEach문은 3개의 인수를 받을 수 있는데 다음과 같다.
arr.forEach((item , index, this) => {
})
하나씩 사용하면 다음과 같다.
const numbers = [1,2,3]
numbers.forEach((item, index, arr) => {
console.log(`요소값 ${item}, 인덱스 : ${index} , ${arr}`)
})
/*
요소값 1, 인덱스 : 0 , 1,2,3
요소값 2, 인덱스 : 1 , 1,2,3
요소값 3, 인덱스 : 2 , 1,2,3
*/
참고로, forEach는 원본 배열을 바꾸지 않는다. 또한 return 값은 언제나 undefined이다.
const numbers = [1,2,3]
const pows = []
const res = numbers.forEach(item => 1)
console.log(numbers) // [ 1, 2, 3 ]
console.log(res) // undefined
forEach의 두 번째 인자는 this 바인딩으로 어떤 값이 들어갈 지 선택하는 것이다. 따라서, 일반 함수로 forEach의 콜백 함수를 만들었다면 두 번째 인자에 this를 넣어주어야 한다. 그래야 일반 함수에 this가 바인딩되기 때문이다.
class Numbers {
numberArray = []
multiply(arr){
arr.forEach(function(item){
this.numberArray.push(item * item)
}, this);
}
}
const numbers = new Numbers()
numbers.multiply([1,2,3])
console.log(numbers.numberArray) // [ 1, 4, 9 ]
물론 그냥 ES6의 화살표 함수를 쓰면 this가 없기 때문에 상위 스코프의 this를 그대로 참조한다. 따라서, multiply 메서드 내부에 있는 this를 참조하게 되는데, 이는 class의 인스턴스이므로 클래스 인스턴스를 this로 forEach에서 받게 된다.
이처럼 forEach를 사용하여 내부에서는 반복문을 통해 순회를 하고, 사용하는 외부에서는 반복문을 은닉하여 로직의 흐름을 이해하기 쉽고 복잡성을 해결한다. 단, forEach는 모든 요소를 순회하여야만 한다. 즉, break나 continue를 사용할 수는 없다.
map메서드는 자신을 호출한 배열의 모든 요소를 순회하면서 인수로 전달받은 콜백 함수를 반복 호출한다. 그리고 콜백 함수의 반환값들로 구성된 새로운 배열을 반환한다. 이때 원본 배열은 변경되지 않는다.
const numbers = [1,4,9]
const root = numbers.map(item => Math.sqrt(item))
console.log(numbers) // [ 1, 4, 9 ]
console.log(root) // [ 1, 2, 3 ]
forEach와 같이 내부적으로 반복문을 실행하여 매번 callback 함수를 실행한다. 그러나 forEach가 언제나 undefined를 반환하는 반면, map은 callback 함수의 반환값들로 구성된 새로운 배열을 반환한다는 차이가 있다.
map 메서드가 생성하여 반환하는 새로운 배열의 length 프로퍼티 값은 map 메서드를 호출한 배열의 length 프로퍼티 값과 반드시 일치한다. 즉 map메서드를 호출한 배열과 map 메서드가 생성하여 반환한 배열은 1 :1 맵핑된다
map의 callback 함수에 들어가는 요소는 다음과 같다.
arr.map((item, index, this) => {
})
forEach와 다를바가 없다.
또, 마찬가지로 map의 두 번째 인자로 this를 넘겨주어, 일반 함수에서 this바인딩이 될 수 있도록 해준다.
class Prefixer {
constructor(prefix){
this.prefix = prefix
}
add(arr){
return arr.map(function(item){
return this.prefix + item
}, this)
}
}
const prefixer = new Prefixer('-webkit-')
console.log(prefixer.add(['html' , 'css'])) // [ '-webkit-html', '-webkit-css' ]
es6의 화살표 문법을 사용하여 메서드의 this를 사용할 수 있도록하면 map의 두 번째 인자인 this를 굳이 쓸 필요는 없다.
filter 메서드는 자신을 호출한 배열의 모든 요소를 순회하면서 인수로 전달받은 콜백 함수를 반복 호출한다. 그리고 콜백 함수의 반환값이 true인 요소로만 구성된 새로운 배열을 반환한다. 이때 원본 배열은 변경되지 않는다.
const numbers = [1,2,3,4,5]
const odds = numbers.filter(item => item % 2) // 홀수인 경우는 1이되고 이는 true이다.
console.log(numbers) // [ 1, 2, 3, 4, 5 ]
console.log(odds) // [ 1, 3, 5 ]
filter 메서드는 콜백 함수의 반환값이 true인 요소만 추출한 새로운 배열을 반환한다. 따라서 filter 메서드가 생성하여 반환한 새로운 배열의 length 프로퍼티 값은 filter 메서드를 호출한 배열의 length 프로퍼티 값과 같거나 작다
filter 메서드의 콜백 함수 인자는 다음과 같다.
arr.filter((item, index, arr)=>{
})
또한, arr.filter의 두 번째 인자에 this를 바인딩할 수 있어 일반 함수를 사용할 때 this 바인딩을 해줄 수 있다. 위와 마찬가지로 그냥 ES6 화살표 문법을 쓰면 알아서 스코프 체인을 따라 this를 찾기 때문에 화살표 문법을 쓰는 것이 좋다.
reduce 메서드는 자신을 호출한 배열을 모든 요소를 순회하며 인수로 전달받은 콜백 함수를 반복 호출한다. 그리고 콜백 함수의 반환값을 다음 순회 시에 콜백 함수의 첫번째 인수로 전달하면서 콜백 함수를 호출하여 하나의 결과값을 만들어 반환한다. 이때 원본 배열은 변경되지 않는다.
즉, reduce 메서드는 첫번째 인수로 콜백 함수, 두 번째 인수로 초기값을 받는다. reduce 콜백 함수는 4개의 인수, 초기값 또는 콜백 함수의 이전 반환값, reduce 메서드를 호출한 배열의 요소값과 인덱스, reduce 메서드를 호출한 배열 자체, 즉 this가 전달된다.
const sum = [1,2,3,4].reduce((acc , cur, index, arr) => acc +cur, 0)
console.log(sum) // 10
reduce의 첫번째 인자는 콜백 함수이다. 콜백 함수의 acc는 accumulator이고 cur은 현재의 값이다. arr는 this이고, reduce의 두 번쨰 인자는 초기값으로 어떤 값을 설정할 지에 대한 것이다.
위의 filter, map과 달리 reduce는 단 하나의 결과값을 반환하게 되고 이 값이 최종 return에 해당하는 값이다.
두번째 인자로 초기값을 굳이 Number로만 받지 않아도 된다. 객체를 받아도 문제가 없다. 이를 이용하여 요소의 중복 횟수를 구할 수 있다.
const arr = [1,1,43,5,43,53,66,66,1,3,2,2]
const res = arr.reduce((acc, cur) => {
acc[cur] = (acc[cur] || 0 ) + 1
return acc
}, {})
console.log(arr) // [1, 1, 43, 5, 43,53, 66, 66, 1, 3,2, 2]
console.log(res) // { '1': 3, '2': 2, '3': 1, '5': 1, '43': 2, '53': 1, '66': 2 }
또한, 배열도 넣을 수 있다. 참고로 reduce를 사용할 때 두 번째 인자, 즉 초기값은 무조건 넣어주는 것이 에러 방지에 있어 좋다.
some 메서드는 자신을 호출한 배열의 요소를 순회하면서 인수로 전달된 콜백 함수를 호출한다. 이때 some 메서드는 콜백 함수의 반환값이 단 한번이라도 참이면 true, 모두 거짓이면 false를 반환한다. 즉, 배열의 요소 중에 콜백 함수를 통해 정의한 조건을 만족하는 요소가 1개 이상 존재하는 지 확인하여 그 결과를 bool 타입으로 반환한다. 단, some 메서드를 호출한 배열이 빈 배열인 경우에는 언제난 false를 반환하므로 주의하도록 하자
some메서드의 콜백 함수 인자는 다음과 같다.
arr.some((item, index, arr) => {
})
some을 통해서 배열에 특정 조건을 만족하는 것이 무엇인지 쉽게 찾을 수 있다.
const arr = [1,1,43,5,43,53,66,66,1,3,2,2]
let result = arr.some((item) => item > 65)
console.log(result) // true
result = arr.some((item)=> item < 0)
console.log(result) // false
마찬가지로 some의 두번째 인자는 this이다. 따라서 일반 함수가 아닌 화살표 함수를 쓰는 것이 좋다.
every 메서드는 자신을 호출한 배열의 요소를 순회하면서 인수로 전달된 콜백 함수를 호출한다. 이때 every 메서드는 콜백 함수의 반환값이 모두 참이면 true, 단 한 번이라도 거짓이면 false를 반환한다. 즉, 배열의 모든 요소가 조건을 만족하는 지 확인하는 것이다.
단, every의 경우 빈 배열을 주면 항상 true를 반환하므로 주의하도록 하자
ervey도 다른 고차 함수와 마찬가지로 콜백 함수를 가지며, 콜백 함수의 인자는 3가지 , item, index, this이다.
const arr = [1,1,43,5,43,53,66,66,1,3,2,2]
let result = arr.every(item => item > 0)
console.log(result) // true
result = arr.every(item => item > 4)
console.log(result) // false
또한, 위와 마찬가지로 두번째 인자는 this이므로 es6 화살표 함수를 콜백 함수로 쓰도록 하자
es6에서 도입된 find메서드는 자신을 호출한 배열의 요소를 순회하면서 인수로 전달된 콜백 함수를 호출하여 반환값이 true인 첫 번째 요소를 반환한다. 콜백 함수의 반환값이 true인 요소가 존재하지 않는다면 undefined를 반환한다.
마찬가지로 find역시도 콜백 함수가 item, index, this로 인자를 받는다. 만약 빈 배열이거나, true인 요소가 없는 경우 undefined를 반환한다.
const users = [
{id : 1, name:'lee'},
{id : 2, name:'kim'},
{id : 3, name:'choi'},
{id : 1, name:'lee'},
]
let result = users.find( (user) => user.id === 2)
console.log(result) // { id: 2, name: 'kim' }
마찬가지로 fot문의 두번째 인자로 this가 들어간다.
es6에서 도입된 findIndex 메서드는 자신을 호출한 배열의 요소를 순회하면서 인수로 전달된 콜백 함수를 호출하여 반환값이 true인 첫번째 요소의 인덱스를 반환한다. 콜백 함수의 반환값이 true인 요소가 존재하지 않는다면 -1을 반환한다ㅣ,
findIndx함수도 콜백 함수는 3개의 인자를 갖는다. item, index, this인 것이다.
const users = [
{id : 1, name:'lee'},
{id : 2, name:'kim'},
{id : 3, name:'choi'},
{id : 1, name:'lee'},
]
let result = users.findIndex( (user) => user.id === 2)
console.log(result) // 1
또한, findIndex의 두번째 인자로 this이다.
es10에서 도입된 flatMap 메서드는 map 메서드를 통해 생성된 새로운 배열을 평탄화한다. 즉, map메서드와 flat 메서드를 순차적으로 실행하는 효과가 있다.
const arr = ['hello' , 'world']
let res = arr.map(x => x.split(''))
console.log(res) // [ [ 'h', 'e', 'l', 'l', 'o' ], [ 'w', 'o', 'r', 'l', 'd' ] ]
console.log(res.flat())
/*
[
'h', 'e', 'l', 'l',
'o', 'w', 'o', 'r',
'l', 'd'
]
*/
res = arr.flatMap(x => x.split(''))
console.log(res)
/*
[
'h', 'e', 'l', 'l',
'o', 'w', 'o', 'r',
'l', 'd'
]
*/
split을 통해 각 문자열들을 잘라서 문자들로 만들어내고 배열에 넣는다. 이후에 flat() 메서드를 호출하면 이중으로된 배열을 하나의 배열로 만들 수 있다.
단, flat() 메서드는 깊이를 인자로 넣어줄 수 있지만, flatMap()은 깊이를 지정할 수 없고 무조건 1이다. 만약 깊이를 지정해야할 때면 flat을 사용하는 것이 좋다.