객체로부터 URL의 querystring을 알아내는 함수 queryStr을 만들어보자.
const queryStr = (obj) =>
go(
obj,
Object.entries,
map(([k, v]) => `${k}=${v}`),
reduce((a, b) => `${a}&${b}`)
);
log(queryStr({ limit: 10, offset: 10, type: "notice" }));
주어진 객체의 [ key , value ] 쌍을 알아내는 Object.entries를 사용하고
구조분해로 key와 value를 받아 mapping하고
reduce로 separtor를 '&'로 설정하면 완성된다.
여기서 obj를 받아 그대로 obj를 전달하고 있기 때문에 pipe로 간략화할 수 있다.
const queryStr = pipe(
Object.entries,
map(([k, v]) => `${k}=${v}`),
reduce((a, b) => `${a}&${b}`)
);
log(queryStr({ limit: 10, offset: 10, type: "notice" }));
여기까지 봤을 때, reduce가
배열의 모든 요소를 연결해 하나의 string으로 만드는 Array.join()과 비슷하다고 느낄 수 있지만,
다른점은 Array.join()은 정말 배열과만 호환되는 함수이고
reduce는 iterable값을 순회하며 축약할 수 있기 때문에 join함수보다 더 다형성이 높다고 할 수 있다.
그럼, 다형성이 높은 join함수를 reduce를 통해 구현해보자.
const join = curry((sep = ",", iter) =>
reduce((a, b) => `${a}${sep}${b}`, iter)
);
지금 만든 이 join은 배열이 아니어도 사용할 수 있다.
다음 예시를 참고하자.
function* a() {
yield 10;
yield 11;
yield 12;
yield 13;
}
log(join(" - ", a()));//10 - 11 - 12 - 13
이 join은 reduce를 통해 축약했기 때문에 iterable protocol을 따르고 있다.
이 말은 즉, join이 받을 값들을 '지연'할 수 있다는 것이다.
위 코드 map을 L.map으로 바꾸고 테스트 해도 같은 값을 얻는데,
이처럼 아직 연산이 되지 않은 상태의 iterator를 join에게 주면 join이 next로 하나씩 풀어가며 계산한다.
여기까지는 reduce함수로 join함수를 만들었다면,
이제 take함수로 조건을 만족하는 특정값 하나를 꺼내는 find함수를 만들어보자.
const users = [
{ age: 32 },
{ age: 31 },
{ age: 37 },
{ age: 28 },
{ age: 25 },
{ age: 32 },
{ age: 31 },
{ age: 37 },
];
이 users에서 조건을 만족하는 값 하나를 꺼내보겠다.
const find = (f, iter) =>
go(
iter,
filter(f),
take(1), //하나만 꺼내기
([a]) => a //배열 깨주기
);
log(find((u) => u.age < 30)(users));
30세보다 적은 나이 중 처음으로 만난 { age : 28 }을 꺼냈다.
근데 여기서 다음과 같이 filter안에 log를 찍어보면 take가 하나의 값을 꺼내지만 도는건 모두 돌고 있음을 확인할 수 있다.
const find = (f, iter) =>
go(
iter,
filter((a) => (log(a), f(a))),
(a) => (log(a), a),
take(1), //하나만 꺼내기
([a]) => a //배열 깨주기
);
log(find((u) => u.age < 30, users));
하나만 꺼내고 싶은데 왜 굳이 전체를 순회하는가?
이는 비효율적이다.
계속 반복하고 있지만, 이럴 때 사용하는 방법이?
Lazy evaluation(지연 평가)이다.
위 코드의 filter 대신 L.filter로 수정한 후 코드를 실행해보자.
위 실행결과에서 알 수 있듯이
take에게 연산을 미루면
조건에 맞는지 하나하나 평가 후 조건에 맞는 객체를 찾으면 결과를 주고 실행을 종료한다.
currying까지 해준 최종 코드는 다음과 같다.
const find = curry((f, iter) => go(iter, L.filter(f), take(1), ([a]) => a));
log(find((u) => u.age < 30, users));
이제, 전에 만들었던 map과 filter를 L.map, L.filter로 쉽게 만들어보겠다.
//출력 코드
log(map((a) => a + 10, L.range(4)));
const map = curry((f, iter) => go(
iter,
L.map(f)
));
여기까지 하면 앞으로 평가를 할 준비가 되어있는 '지연된' 값이 출력된다. (밑 사진 참고)
이 상태에서 지연된 값 모든걸 take해주면 원래 map함수 동일한 함수가 될 것이다.
그리고 pipe로 묶어준 최종 코드는 다음과 같다.
//map을 L.map으로 구현
const map = curry(pipe(L.map, take(Infinity)));
filter함수도 똑같이 구현하면
const filter = curry((f, iter) => {
curry(pipe(L.filter, take(Infinity)));
});
take(Infinity)가 공통으로 사용되므로 하나로 묶어주자.
const takeAll = take(Infinity);
const map = curry(pipe(L.map, takeAll));
const filter = curry(pipe(L.filter, takeAll));
이전에 breakpoint를 찍으며 확인하기 위해 세세하게 적어놨던 L.map과 L.filter의 코드도 다시 간략화해주겠다.
L.map = curry(function* (f, iter) {
for (const a of iter) {
yield f(a);
}
});
L.filter = curry(function* (f, iter) {
for (const a of iter) {
if (f(a)) yield a;
}
});
이제 L.flatten()과 L.flatMap()을 만들어보자.
우선 L.flatten()은 [..[1, 2], 3, 4, ...[7, 8, 9]] 같은 배열이 있을 때
[1, 2, 3, ... 7, 8, 9]와 같이 펼쳐서 하나의 배열로 만드는 함수이다.
//수동적인 경우
log([...[1, 2], 3, 4, ...[5, 6], ...[7, 8, 9]]);
위와 같이 개발자가 array를 수동적으로 펼쳐주는 동일한 동작을 동적으로 만들어주는 iterator를 return하는 함수가 L.flatten() 함수이다.
const isIterable= a => a&&a[Symbol.iterator];
L.flatten=function*(iter){
for(const a of iter){
if(isIterable(a)) for(const b of a) yield b;
else yield a;
}
}
var it= L.flatten([[1,2],[3,4],[5,6],[7,8,9]]);
// log(it.next());
// log(it.next());
// log(it.next());
log([...it]);
1line) iterable인지를 판단하는 isIterable 함수 생성.
a가 null인 경우를 방지해 a&& 붙이기.
3line) iterable protocol을 이용한 지연적으로 동작하는 함수를 만들어야하기 때문에 generator을 선언.
5line) a가 iterable인 경우 한 depth 순회를 더함. (a안에 있는 모든 값을 yield)
10line) iterator를 return.
11~13line) it.next()의 log를 찍어보면 하나하나 flat하게 펼쳐짐.
take와 함께 사용하면 다음과 같이 응용 가능하다.
const flatten = pipe(L.flatten, takeAll);
log(
flatten([
[1, 2],
[3, 4],
[5, 6],
[7, 8, 9],
])
);
log(
take(
6,//펼쳐진 것 중 6개만 사용하겠다.
L.flatten([
[1, 2],
[3, 4],
[5, 6],
[7, 8, 9],
])
)
);
다음으로 L.flatMap은 flatten과 map을 하는 함수이다.
두 메서드를 따로 호출하지 않고 한번에 해결할 수 있기 때문에 더 효율적이다.
기본적으로 JS는 '지연적'으로 동작하지 않기 때문에 최신 JS에 이 flatMap()이 추가되었다.
Array.prototype.flatMap()
배열의 각 요소에 주어진 콜백 함수를 적용한 다음
그 결과를 한 단계씩 평탄화하여 형성된 새 배열을 반환
//사용 예시
const arr1 = [1, 2, 1];
const result = arr1.flatMap((num) => (num === 2 ? [2, 2] : 1));
console.log(result); //[1, 2, 2, 1]
Array뿐만이 아니라 iterable 모두에 적용할 수 있는 flatMap을 구현하려면 어떻게 해야할까?
배웠던 L.map, L.flatten 모두 지연적으로 동작하는 함수이기 때문에 이 두 함수를 pipe로 묶어주면 될 것이다.
L.flatMap = curry(pipe(L.map, L.flatten));
var it = L.flatMap(map((a) => a * a), [[1, 2], [3, 4], [5, 6, 7]]);
log(it.next());
log(it.next());
log(it.next());
L.flatMap = curry(pipe(L.map, L.flatten));
const flatMap = curry(pipe(L.map, flatten));
log(flatMap((a) => a, [[1, 2], [3, 4], [5, 6,7]]));
1line) L.flatten으로 지연된 값을 사용해 구현한 경우.
2line) flatten으로 평가를 완료한 값(이미 flat된 값)을 사용해 구현한 경우.
//응용 예시
log(flatMap(L.range, [1, 2, 3])); //[0, 0, 1, 0, 1, 2]
1이 L.range 되어서 0
2가 L.range 되어서 0,1
3이 L.range 되어서 0,1,2
이제 거의 다 왔다!
2차원 배열을 가지고 기존에 작성한 함수들을 응용하는 연습을 해보자.
const arr = [
[1, 2],
[3, 4, 5],
[6, 7, 8],
[9, 10],
];
go(
arr,
L.flatten,
L.filter((a) => a % 2),
take(3),
log
); //[1, 3, 5]
지연된 함수로 실행 시 원하는 값이 나오면 실행을 종료한다.
(1,3,5까지 순회하고 6부터 10은 순회하지 않음.)
여태까지 지연성, 이터러블 중심 프로그래밍에 대해 열심히 달려왔는데, 이게 실무와 어떤 관련이 있을까?
위에선 이용하는 데이터가 2차원 배열이었는데, 데이터를 조금 더 실무적인 것으로 바꾸면 느낌이 온다.
<script>
var users= [
{name: 'a', age: 21, family: [
{name: 'a1', age: 53}, {name: 'a2', age: 47},
{name: 'a3', age: 16}, {name: 'a4', age: 15}
]},
{name: 'b', age: 24, family: [
{name: 'b1', age: 58}, {name: 'b2', age: 51},
{name: 'b3', age: 19}, {name: 'b4', age: 22}
]},
{name: 'c', age: 31, family: [
{name: 'c1', age: 64}, {name: 'c2', age: 62}
]},
{name: 'd', age: 20, family: [
{name: 'd1', age: 42}, {name: 'd2', age: 42},
{name: 'd3', age: 11}, {name: 'd4', age: 7}
]}
];
</script>
이와같이 유저 자신에 대한 정보와 가족 구성원에 관한 정보가 있는 데이터가 있다.
여기서 성인인(age가 20이상인) 사람 4명의 나이를 더하는 코드는 다음과 같이 작성할 수 있다.
go(
users,
L.flatMap((u) => u.family),
L.filter((u) => u.age > 20),
L.map((u) => u.age),
take(4),
reduce(add),
log
);
이제 정말 함수형 프로그래밍과 친해진 기분이 든다.
객체 지향 프로그래밍은 데이터를 우선 정리 후 메소드를 이후에 만들면서 프로그래밍하지만,
함수형 프로그래밍은 이미 조합되어 있는 함수에 맞는 데이터를 구성하는식으로 코드를 작성한다.
이처럼 함수가 더 우선순위에 있는 프로그래밍이기 때문에 함수형 프로그래밍이다.
또한, 리스트 프로세싱(List Processing), 이터러블 프로그래밍(Iterable Programming), 컬렉션 중심 프로그래밍, 어플리케이티브 프로그래밍(Applicative Programming)이라 불리는 것들 모두 고차함수와 보조함수를 적절히 조합하며 사고하고 프로그래밍하는 것이다.
우린 실무에서 이러한 코드를 정말 많이 사용하게 될 것이다!