const xs = ['a', 'b', 'c']
const ex = (mark, body) => body + mark
const ys = xs.map(x => ex('!', x)) // [a!, b!, c!]
map
은 어떤 함수를 받아서 순회할 수 있는 것에 그 함수를 적용하여 새로운 순회 가능한 것을 돌려준다. 리액트 등에서 데이터를 기반으로 여러 컴포넌트 등을 만들 때 자주 사용한다.
function bar() {
const xs = ['create', 'read', 'update', 'delete']
const foo = x => <li> { x } </li>
return <div> { xs.map(foo) } </div>
}
위와 같은 식으로 사용해서 말이다.
함수의 인자를 한 번에 다 받는 게 아니라, 여러 인자들로 나누어 받는 기법인 currying이 적용된 함수와 같이 사용하면 더 편리하게 할 수 있다. 가장 위의 예제를 다시 살펴보자.
const xs = ['a', 'b', 'c']
const exC = mark => body => body + mark
const ex = exC('!')
const ys = xs.map(ex) // [a!, b!, c!]
만약 이 때 함수가 커링된 것이라면 위와 같은 식으로 좀 더 편하게 사용할 수 있다. 느긋하게 계산하는 것은 좀 더 재사용성을 높여준다.
앞서 말한 map
은 고차함수의 대표적인 예시이다. 고차함수란 함수를 인자로 받거나 함수를 결과로 내놓는 함수들이다. 이런 맥락에서 보면 우리가 앞서 작성한 exC
도 고차함수라고 볼 수 있겠다. exC('!')
의 결과가 함수니까.
type fn<A, B> = (a: A) => B
function imap<A, B>(xs: Array<A>) {
return function(f: fn<A, B>) {
let ret: Array<B> = []
for (let x of xs) {
ret.push(f(x))
}
return ret
}
}
못생기기는 했지만 위와 같이 구현할 수 있을 것이다. 이 함수는 map([1, 2, 3])(x =>x + 1)
처럼 호출할 수 있겠다.
한발짝 더 나아가보자. 우리가 실패할 수 있는 계산을 진행한다고 생각해보자. try catch
도 지겹고, &&
등은 께름칙하다. 하지만 map
의 다음과 같은 성질을 잘 이용하면 문제를 멋지게 처리할 수 있을 것 같다.
[1].map(x => x + 5) // [6]
[].map(x => x + 5) // [ ]
내가 리액트를 사용 중이라면, 다음과 같이 응용할 수 있을 것이다.
const failableObject = ???
function comp(obj) {
const computation = x => ???
return <div> { computation(obj) }
}
export default RootComponent(props) {
return <div> { [failableObject].map(comp) } </div>
}
물론 위 코드는 당연히 직접 돌릴 수 없다. 하지만 핵심 아이디어는 다음과 같다. 실패할 수 있는 계산을 배열로 감싸고, map
을 적용하면, 배열의 요소가 존재하는 경우에만 계산을 진행할 수 있다는 것이다. 즉, 예외처리나 복구는 세상의 종말까지 미룰 수 있다.
정말이다. 그 대상이 존재하는지 아닌지 관계없이 '정말' 필요할 때까지 함수를 마음껏 덧붙일 수 있다.
맞다. 이 아이디어가 바로 scala
, haskell
등의 언어에서 지겹게 보고 듣는 Option
이다. 앞서 말했던 것을 조금 더 다듬어서, 다음과 같이 만들어보자.
type fn<A, B> = (a: A) => B
const none = 'None' as const
type None = typeof none
type Option<A> = A | None
const isNone = <A>(ma: Option<A>): ma is None => ma === none
function pure<A>(procedure: () => A) {
try {
return procedure()
} catch (e) {
return none
}
}
const map = <A, B>(ma: Option<A>) => (f: fn<A, B>) => {
return isNone(ma) ? none : f(ma)
}
이제 실패할 수 있는 계산을 잘 감싸 문제를 풀 수 있게 되었다. 그런데, 여기서 더 추상화할 수는 없을까?Array<A>
랑 Option<A>
를 넘어, 어떤 F<A>
에 대해 map
을 정의하고 쓸 수 있는게 아닐까?
우리의 추론이 맞다. 이 일반적인 map을 정의하기 위해 함수자를 정의하자.
trait Functor[F[_]] {
def map[A, B](fa: F[A])(f: A => B): F[B]
def lift[A, B](f: A => B): F[A] => F[B] =
fa => map(fa)(f)
}
위의 스칼라 코드는 Functor의 예시다. 이 트레잇을 상속해 구현함으로써 map
을 우리의 클래스에서 잘 사용할 수 있다. 또한 lift를 통해 A => B
의 시그니처를 가진 함수를 F[_]
에 대해 F[A] => F[B]
로 변환시킬 수 있다. 이 고차 함수를 통해 우리는 함수들을 안전하게 합성할 수 있다.
우리는 이미 수많은 펑터를 주위에서 만나고 있다. 자바스크립트의 배열, 우리가 방금 만들어낸 Option
이 좋은 예시이다. 무언가를 감싼다는 관점에서는, Promise
또한 펑터를 이용하여 더 나은 문법을 지원할 수 있을 것이다. 실제로 Scala
의 Future
는 펑터로, 리스트나 다른 펑터들과 똑같이 사용할 수 있다.