JS에서의 map

기본

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('!')의 결과가 함수니까.

map을 만들어보자

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)처럼 호출할 수 있겠다.

map의 일반화

실패할 수 있는 계산

한발짝 더 나아가보자. 우리가 실패할 수 있는 계산을 진행한다고 생각해보자. 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을 적용하면, 배열의 요소가 존재하는 경우에만 계산을 진행할 수 있다는 것이다. 즉, 예외처리나 복구는 세상의 종말까지 미룰 수 있다.

정말이다. 그 대상이 존재하는지 아닌지 관계없이 '정말' 필요할 때까지 함수를 마음껏 덧붙일 수 있다.

이거 Option(Maybe) 아닌가요?

맞다. 이 아이디어가 바로 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을 정의하고 쓸 수 있는게 아닐까?

펑터(함수자, Functor)

우리의 추론이 맞다. 이 일반적인 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또한 펑터를 이용하여 더 나은 문법을 지원할 수 있을 것이다. 실제로 ScalaFuture는 펑터로, 리스트나 다른 펑터들과 똑같이 사용할 수 있다.

profile
즐기는 거야

0개의 댓글