우선 함수형 프로그래밍 공부를 시작하며 알아야할 용어가 있다.
평가 : 코드가 계산되어 값을 만들어내는 것
일급함수 : 자바스크립트에서 함수는 일급함수이며 이는 함수가 값으로 다뤄질 수 있음을 의미한다.
고차함수 : 함수를 인자로 받아서 다루는 함수
함수형 프로그래밍에 대해서 익숙해지려면 우선 위의 단어들부터 익숙해져야했다 😂
이터레이터와 이터러블, 제너레이터
먼저 이터레이터와 이터러블에 대해 알아보자.
// es5 에서의 리스트 순회
const list = [1,2,3]
for (let i =0 ;i < list.length ; i++) {
console.log(list[i]);
}
//es6에서의 리스트 순회
for(const a of list) {
console.log(a);
}
es6의 순회는 단순히 코드가 간단해진 것 이상의 효과를 개발자에게 가져다준다. list는 list[0], list[1] 이렇게 접근이 가능하지만 set은 set[0] 이런식의 접근이 불가능함 이는 map 에서도 마찬가지 하지만 아래와 같은 것이 모두 가능하다
for(const a of set) {
console.log(a);
}
for(const a of map) {
console.log(a);
}
이는 모두 이터러블/이터레이터 프로토콜을 따르기 때문이다. 이터러블을 따르게 되면 다형성 높다.
⇒ 이터러블/이터레이터 프로토콜이란 : 이터러블을 for... of 를 통해 동작하도록 한 규약
객체에 Symbol.iterator이라는 키 값을 통해 값을 확인해보면 함수가 나온다. 콘솔로그에 출력해보자면
console.log(list[Symbol.iterator]); // 실행결과는 함수임을 알 수 있다.
해당 함수는 실행해보면 이터레이터를 리턴한다.
이터레이터는 next라는 함수를 가지고 있으며 이 반환 결과는 value, done 이라는 키값이 있다.
이터레이터는 iter[Symbol.iterator] 하면 자기자신을 그대로 리턴한다. 이 두가지 조건을 만족하는 이터레이터를 Well-formed 이터레이터라고 한다.
이터레이터를 한번 구현해보자!
const iterable = {
[Symbol.iterator]: () => {
let i = 3;
return {
next: () => {
return i == 0 ? { done: true } : { value: i--, done: false };
},
[Symbol.iterator]() {
console.log(this) //this 복습
return this;
}
};
},
(this 복습 - 해당 라인의 출력 결과는 전역객체이다. arrow는 lexical한 this를 가지기 때문!)
js는 이터레이터를 가지고 활용가능한 문법이 많다. 그 예시 중 하나가 전개연산자. 이터레이터 제너레이터를 활용 잘하면 조합성, 활용성을 더 높일 수 있음.
그래서 제너레이터란? - 이터레이터를 생성해주는 함수
//간단한 제너레이터 만들어보기
//제너레이터라는 것을 통해 어떤 값도 순회할 수 있게 만들 수 있다.
function *generator() {
yield 1;
}
const iter = gen()[Symbol.iterator]();
console.log(iter.next()); // { value : 1, done : false }
// 아래와 같은 제너레이터를 만들어 볼 수 있음.
function *infinity(i=0) {
while(true) yield i++;
}
function *limit(limit) {
for( const a of infinity()) {
if (a > limit) return;
else yield a;
}
}
function *odds(number) {
for (const a of limit(number)) {
if(a % 2) yield a;
}
}
let iter = odds(11);
for(const a of iter) console.log(a);
Map, Filter, Reduce
1. Map
const map = (f, iter) => {
let res= [];
for( const a of iter ){
res.push(f(a));
}
return res;
}
let m = new Map();
m.set('a', 10);
m.set('b', 20);
map(([key, value]) => [key, value + 1] , m);
const products = [
{name : '발', price: 15000},
{name : '바지', price: 24000},
{name : '폰', price: 2022},
{name : '모자', price: 300},
]
위의 map 함수는 Array.prototype.map 함수보다 다형성이 더 높다. 이터러블 프로토콜을 따르는 인자를 넣어주면 모두 동작하기 때문이다. 예를 들어 document querySelector의 결과는 NodeList로 map 함수가 없다. 하지만 위처럼 map 함수를 짜면 이터러블을 따르는 어떤 대상도 순회할 수 있어서 훨씬 더 다형성이 높다.
2. Filter
const filter = (f, iter ) => {
let res = [];
for(const a of iter) {
if(f(a)) res.push(a);
}
return res;
}
위처럼 하면 두번째 인자인 iter가 이터러블 프로토콜을 따른다면 동작이 가능하도록 되었으므로, 이제 저 필터는 더 다양한 인자를 받아서 동작하는 것이 가능함.
3. Reduce
const reduce = (f, acc, iter)=> {
if(!iter) {
[acc, ...iter] = [...acc]; //1
iter = acc[Symbol.iterator](); // 2
acc = iter.next().value; // 2
}
for (const a of iter ){
acc = f(acc, a);
}
return acc;
}
console.log(reduce((a,b) => a+b.price,0, products))
** 리듀스 함수를 짤때에 1번 코드와 2번코드가 뭐가 어떻게 다른지를 좀 고민해봤다. 1번도 2번도 다 정상 동작하는데 어디서 문제가 될까.
일단 1번과 2번의 차이는 iter가 이터러블이냐 이터레이터냐의 차이이다. 좀 더 구체적으로 말하면 next()를 호출할 수 있냐 없냐의 차이가 되겠다.
만약 for(const a for iter) 이 부분을 좀 더 원시적인 코드로 구현하게 되면 iter.next()를 호출할 일이 생기게 되는데 이때 만약 1번 코드로 구현했다면 iter.next()는 호출할 수가 없게된다.
유인동 선생님의 답변 - 불필요한 복사나 순회가 일어날 수 있다고 말씀하셨다. ...은 말그대로 전개연산자이니 1번코드는 순회와 복사가 일어나는 방식인 것이다..!
이들을 활용한 함수형 사고란?
const add = (a,b) => a+b;
// 원래 하던 프로그래밍
const res = products.filter(a => a.price > 10000).reduce((acc,a) => acc+a.price, 0)
// 함수형 프로그래밍
const res2 = reduce(add, filter(a => a > 12000, map(a => a.price, products)))
함수형 프로그래밍의 사고 순서는 이와 같다.
1. reduce(add, ~~~ )
음,, 저 물결 자리에는 숫자 배열이 들어와야겠네. add 함수는 두 숫자를 더하는 거니까
2. reduce(add, map(a ⇒ a.price, ~~~))
물결 자리에는 이제 products 배열(위에 참고)이 들어가면 되겠네 특정 조건을 넣어볼까?
3. reduce(add, map(a ⇒ a.price, filter( ~~~ , products)))
물결 자리에는 어떤 조건을 넣을까~ 고민
함수형 프로그래밍에서는 코드를 값으로 다루는 아이디어를 많이 사용한다.
어떤 함수가 코드인 함수를 받아서 원하는 시점에 평가할 수 있어서 표현력을 높일 수 있다! (여기서 표현력이란?)
우선 위에 있던 예제를 보기 편하게 리펙토링 해보자.
인자를 넣어주는 순서대로 실행을 해서 마지막에 결과값을 내놓는 go라는 함수를 만들것이다.
// 함수형 프로그래밍
const res2 = reduce(add, filter(a => a > 12000, map(a => a.price, products)))
const go = (initial, ...f) => reduce((acc, f) => f(acc) ,initial, f)
// 위 go는 아래와 같이 수정 가능하다
// initial 인자를 주지 않아도 동작하기 때문
const go = (...args) => reduce((acc, f) => f(acc), args);
const pipe = (f, ...fs) => (...as) => go(f(...as), ...fs);
//파이프 함수는 내부적으로 go를 사용하는, 첫번째 인자 acc 만 따로 받는 함수이다.
go(
products,
products => filter(p => p.price < 20000, products),
products => map(m => m.price, products),
products => reduce(add, products),
console.log
)
(go, pipe 함수들을 드디어 강의 안보고 혼자 짜게 됐다...... 감격)
go 를 사용하여 읽기가 좀 더 편해졌다. 하지만 아직 아쉽다. products ⇒ 라는 코드가 계속 나오는 것을 없애기 위해 curry 라는 함수를 만들 것이다. curry는 함수를 값으로 다루고 원하는 시점에 평가시키는 함수이다.
const curry = (f) => (a, ..._) => _.length ? f(a,..._) : (..._) => f(a, ..._)
go(
products,
filter(p => p.price < 20000),
map(m => m.price),
reduce(add),
console.log
)
const function = curry(function) 이렇게 커리안에 함수를 넣어줌으로써 사용 가능하다. 저렇게 하면 해당 함수는 인자를 한번에 받지않고 두번에 나눠서 받을 수 있게 된다.
그럼 a ⇒ console.log(a) 는 console.log 이므로 위처럼 go 내부가 훨씬 깔끔해진다.
이 과정을 다시 되새겨보면 go를 통해 순서를 뒤집어 조금 더 직관적으로 읽히도록 하고 curry를 통해서 좀 더 코드를 간결하게 만들었다. 함수를 값으로 다루는 go, curry 고차 함수를 활용해서 표현력을 높였다. 이제 이 고차함수들을 좀 더 다뤄보며 마무리하자면
const add_condition = (con) => pipe(
filter(con),
total_price
)
const total_price = pipe(
map(m => m.price),
reduce(add)
)
go(
products,
add_condition(p => p.price < 20000),
console.log
)
이런식으로 간결하게 작성하는 것이 가능해진다.
고차함수들을 함수의 조합으로 잘게 나누며 중복을 제거해서 재사용성을 높이고 표현력을 높였다!
관련글
https://www.inflearn.com/course/functional-es6 수업을 듣고 정리한 글입니다.
'함수형 프로그래밍' 카테고리의 다른 글
자바의 함수형 프로그래밍 (0) | 2021.09.24 |
---|---|
JS 함수형 프로그래밍 - 병렬처리 (0) | 2021.08.01 |
JS 함수형 프로그래밍 - 비동기, Promise [2] (0) | 2021.08.01 |
JS 함수형 프로그래밍 - 비동기, Promise [1] (0) | 2021.08.01 |
JS 함수형 프로그래밍 - 지연성 (0) | 2021.08.01 |