본문 바로가기

Study/VanillaJS

함수형 자바스크립트 빠르게 읽어보기

서론

시작하기 앞서 이 글은 함수형 자바스크립트 관련 키워드를 나열한 포스팅으로 이 순서는 꼭 정답이 아니라는 점을 염두하고 읽길 바란다. 본 키워드는 인프런 강의 함수형 프로그래밍과 JavaScript ES6+ 를 참고하였다.

순수 함수

외부의 상태를 변경하지 않는 함수, 어떤 함수에 동일한 인자를 주었을 떄 항상 같은 값을 리턴하는 함수

function add(a, b) {return a + b}

순수 함수하면 항상 나오는 add 함수, 위 함수는 순수함수이다.
순수함수의 특징은 외부의 값을 변경하지 않기 때문에 평가 시점이 중요하지 않다는 점이 있다.

커링

커링은 여러 개의 인자를 가진 함수를 호출할 경우, 파라미터의 수보다 적은 수의 파라미터를 인자를 받은 경우, 누락된 파라미터를 인자로 받는 기법을 말한다.
앞에 사용했던 add 함수를 이용해보자.

function add(a, b) {
  return a + b
}

function curry(f) {
  return function (a) {
    return function (b) {
      return f(a, b)
    }
  }
}

add = curry(add)
const add10 = add(10)
console.log(add10(5)) // 15

curry 함수는 함수를 받아 해당 함수의 첫번째 인자를 받은 후 두번째 인자를 받을 때 해당 함수를 평가한다. add 함수에 curry 함수를 적용시키므로써 a (10)를 받고 다음 b (5)를 받을 때 a + b (15)를 평가하는 함수가 되었다.

curry 함수를 리펙토링하였다.

const curry = f => (a, ..._) => _.length ? f(a, ..._) : (..._) => f(a, ..._)

파이프 라인

함수의 인자로 함수를 받아 해당 함수를 원하는 시점에 평가할 수 있도록 하는 기법이다. pipecompose 라는 함수로 표현한다.

그 중 pipe 함수를 구현해보겠다.
우선 기본이 되는 reduce 함수를 구현해보았다. 기존의 reduce 메서드와 동일한 효과를 내는 함수로 내부적인 코드는 잠시 후 나올 이터러블 / 이터레이터 프로토콜과 관련이 있다.

const reduce = curry((f, acc ,iter) => {
    if(!iter) {
        iter = acc[Symbol.iterator]()
        acc = iter.next().value
    }
    for(const a of iter) {
        acc = f(acc, a)
    }
    return acc
})

pipe 함수는 함수 리스트를 받아 해당 함수들을 순차적으로 실행시켜주는 함수를 리턴한다.

const pipe = (...func) => val => reduce((a, f) => f(a), val, func)

const pipe1 = pipe(
  v => v + 10, // (1)
  v => v * 2, // (2)
  v => v - 1 // (3)
)
console.log(pipe1(1)) // 21

pipe1 함수는 들어온 값에 10을 더한 값에, 2를 곱한 값에 1을 뺸 값을 리턴한다.

이터러블 / 이터레이터 프로토콜

이터레이션 프로토콜은 ES6부터 도입된 프로토콜로 데이터 컬렉션 순회를 위해 추가되었다. 이터레이션 프로토콜에는 이터러블 프로토콜이터레이터 프로토콜이 있다.

이터러블 프로토콜

이터러블 프로토콜을 준수한 객체를 이터러블
이터러블Symbol.iterator 메서드가 있는 객체를 뜻하고 Symbol.iterator 는 이터레이터를 반환

이터레이터 프로토콜

이터레이터 프로토콜을 준수한 객체를 이터레이터
이터레이터next 메서드를 소유하고, 이를 호출할 시 valuedone 프로퍼티를 갖는 리절트 객체를 반환
리절트 객체는 이터러블을 모두 순회한 경우에는 valueundefined, donetrue를 반환

대표적인 이터러블인 배열을 가지고 예시를 살펴보자.

let list = [1, 2, 3, 4, 5]
let iter = list[Symbol.iterator]() // 이터레이터 반환  
let firstObj = iter.next()
let secondObj = iter.next()  
let thirdObj = iter.next()
let doneObj = iter.next()
console.log(firstObj) // {value: 1, done: false}
console.log(secondObj) // {value: 2, done: false}
console.log(thirdObj) // {value: 3, done: false}
console.log(doneObj) // {value: undefined, done: true}

두 프로토콜이 중요한 이유

for ...of, 전개 연산자, 나머지 연산자 등이 위 프로토콜을 통해 동작
ES6에서 제공하는 빌트인 이터러블, 즉 코드의 다형성을 높여줌

Array, String, Map, Set, TypedArray(Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array), DOM data structure(NodeList, HTMLCollection), Arguments

제너레이터

제너레이터 함수는 이터러블을 생성 (반환) 하는 함수

예제 코드이다.

const sampleGenerator = function* () {
  yield "hello"
  yield "my name is"
  yield "papico"
  yield ":)"
}

for (const val of sampleGenerator()) {
  console.log(val)
}

// hello
// my name is
// papico
// :)

제너레이터의 특징

  • yield 를 통한 값 전달
  • function* 키워드를 이용한 생성

위 코드는 간단한 예시를 위한 코드이고 일반적으로 제너레이터는 지연 평가, 비동기 상황에서 자주 이용한다. 그리고 제너레이터를 통해 생성한 이터레이터는 next() 메서드를 이용하여 제너레이터 함수로 값을 넘길 수 있다.

map, filter, reduce

Array의 기본 메서드인 map, filter, reduce 를 다형성을 높여서 개발할 수 있다.

map

const map = (f, iter) => {
  let arr = []
  for (const a of iter) {
    arr.push(f(a))
  }
  return arr
}

filter

const filter = (f, iter) => {
  let arr= []
  for (const a of iter) {
    if(f(a)) arr.push(a)
  }
  return arr
}

reduce

const reduce = (f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator]()
    acc = iter.next().value
  }
  for (const a of iter) {
    acc = f(acc, a)
  }
  return acc
}

지연평가

말 그대로 함수의 평가를 늦추는 방법이다.

기본적으로 자바스크립트는 평가를 조급하게 하는데 값이 필요해 질때 평가를 할 수 있으면 해당 동작이 가능해진다.

const infinity = function* () {
  let i = -1
  while (true) {
    yield ++i
  }
}
let infi = infinity()
console.log(infi.next()) // {value: 0, done: false}
console.log(infi.next()) // {value: 1, done: false}
console.log(infi.next()) // {value: 2, done: false}
console.log(infi.next()) // {value: 3, done: false}
console.log(infi.next()) // {value: 4, done: false}
console.log(infi.next()) // {value: 5, done: false}

infinity 제네레이터 함수는 무한 반복을 사용하고 있지만 next() 메서드가 불릴 경우에만 평가하기 때문에 부하가 일어나지 않는다.

이런 지연평가는 자바스크립트의 성능에 많은 영향을 미치는데 해당 관련 내용은 지연 평가 성능을 확인해보자.

명령형 코드 => 함수형 코드

명령형 코드를 함수형 코드로 변환하는데 알아두면 좋은 규칙들이 있다.

  • if 문을 filter 함수로
  • 값 변화후 할당을 map 함수로
  • break 를 take 함수로
  • 축약과 합산을 reduce 함수로
  • while 을 range 함수로
  • 부수적인 효과를 each 함수로

위 함수중 몇몇 함수는 아직 소개가 되지 않은 함수들도 있다. 이는 함수형 라이브러리에서 사용하는 함수로 효과를 직접 구현해도 무관하다.
위 규칙들을 이용해서 프로그래밍 입문의 기초인 별찍기를 함수형 코드로 작성해보겠다.

사용하는 함수들

별찍기를 하는데 사용하는 함수들을 소개하겠다.

range 함수

인자로 받은 정수만큼의 배열을 리턴한다. 옵션으로 시작 값과 limit을 넘겨줄 수 도 있다.

const range = (start, limit) => {
  let res = []
  if (!limit)(limit = start, start = 0)
  while (true) {
    if(res.length === limit) return res
    res.push(start++)
  }
  return res
}

curry 함수

앞서 다루었던 커링 함수와 동일한 역할을 한다.

const curry = f => (a, ..._) => _.length ? f(a, ..._) : (..._) => f(a, ..._)

map 함수

앞서 작성했던 map 함수에 커링을 하여 인자를 나누어 받을 수 있도록 하였다.

const map = curry((f, iter) => {
    let res = []
    for(const a of iter) {
        res.push(f(a))
    }
    return res
})

reduce 함수

앞서 작성했던 reduce 함수와 동작을 동일하지만 인자가 1개 들어왔을 경우 커링을 적용시켰다.

const reduce = (f, acc, iter) => {
    if (!acc) return iter => reduce(f, iter)
    if (!iter) {
        iter = acc[Symbol.iterator]()
        acc = iter.next().value
    }
    for(const a of iter) {
        acc = f(acc, a)
    }
    return acc
}

pipe 함수

앞서 파이프 라인을 설명할때 사용했던 pipe 함수와 동일하다.

const pipe = (...func) => val => reduce((a, f) => f(a), val, func)

go 함수

pipe 함수를 응용해서 만든 함수로 파이프 라인으로 넘길 인자를 동시에 받는다.

const go = (val, ...func) => pipe(...func)(val)

join 함수
문자를 받아 배열의 요소들을 해당 문자를 요소들 사이에 넣어 합쳐진 문자열을 리턴하는 함수이다.

const join = sep => reduce((a, b) => `${a}${sep}${b}`)

위 함수들을 이용하여 별찍기를 함수형 코드로 작성해보겠다. 현재 위 함수들의 코드가 이해가 되지 않는다면 잠시 멈춰 함수의 동작을 생각해보는 것도 좋을 것 같다.

추억의 별찍기

인자로 받은 수만큼의 별을 찍는 함수를 구현해보자. 기존 명령형으로 작성한 코드를 살펴보자.

명령형 코드

const star = (limit) => {
    let stars = ""
    for(let i = 1; i <= limit; i++) {
        let str = ""
        for(let j = 0; j < i; j++) {
            str += "*"
        }
        str += "\n"
        stars += str
    }
    console.log(stars)
}

stars 라는 값에 "*" 누적 시켜 출력하는 구조이다.

위 함수를 함수형 코드로 리팩토링해보겠다. 해당 과정 까지의 콘솔 출력값을 계속 표시해 어떻게 출력 값이 변경되는지 알아보겠다.

초기 함수형태이다.

  1. while 이나 for 는 range 함수를 사용
const star = limit => go(
  range(1, limit),
  console.log
)

star(5)
// [1, 2, 3, 4, 5]
  1. 해당 요소의 값 만큼의 내부 배열을 만든다.
const star = limit => go(
  range(1, limit),
  map(v => go(
    range(v),
  )),
  console.log
)

star(5)
// [0]
// [0, 1]
// [0, 1, 2]
// [0, 1, 2, 3]
// [0, 1, 2, 3, 4]

얼추 별 찍기와 비슷한 모양이 출력되었다.

  1. 값의 변화를 일으킬 경우 map 함수를 사용
const star = limit => go(
  range(1, limit),
  map(v => go(
    range(v),
  )),
  console.log
)

star(5)
// ["*"]
// ["*", "*"]
// ["*", "*", "*"]
// ["*", "*", "*", "*"]
// ["*", "*", "*", "*", "*"]
  1. 축약과 합산은 reduce를 이용한다.

reduce 함수를 이용해서 만든 join 함수를 사용하였다.

const star = limit => go(
  range(1, limit),
  map(v => go(
    range(v),
    map(_ => "*"),
    join("")
  )),
  console.log
)

star(5)
// ["*", "**", "***", "****", "*****"]

배열의 요소들을 개행 문자로 join 하여 출력하였다.

const star = limit => go(
  range(1, limit),
  map(v => go(
    range(v),
    map(_ => "*"),
    join("")
  )),
  join("\n"),
  console.log
)
star(5)

===== 출력 =====
*
**
***
****
*****

마무리

함수형 자바스크립트의 키워드를 빠르게 살펴보았다. 자신의 부족한 부분을 찾아 내 것으로 만드는 과정도 중요한 것 같다.