JS에서의 함수형 프로그래밍 초석 다지기

자바스크립트에서의 함수 이해하기

자바스크립트를 다루면서 객체지향적인 코드를 몇 번 짜봤으나 함수형 프로그래밍에 대해서는 깊이 생각하지 않았었던 것 같습니다. 때문에, 이를 공부하면서 생겼던 의문들을 해결하고자 글을 쓰게 되었습니다.

 

프로그래밍에서 평가는 코드가 계산(Evaluation)되어 값을 만드는 것입니다. 

JS를 사용해본 개발자라면 "JS에서의 함수는 일급 객체다"라는 말을 많이들 들어봤을 겁니다. 그렇다면 일급이란 무엇을 의미하는 걸까요?

 

일급이란

  • 으로 다룰 수 있고,
  • 변수에 담을 수 있고,
  • 함수의 인자로 사용될 수 있고,
  • 함수의 결과로 사용될 수 있는 것을 의미합니다.

각각에 대한 예시를 살펴보도록 합시다.

// 함수를 변수에 담을 수 있습니다.
const add5 = a => a + 5;

// 함수를 인자로 사용될 수 있습니다.
const f2 = (fn) => (a) => fn(a);
const f3 = f2(add5); // 함수를 리턴하는 함수이자, fn을 기억하는 클로저가 됩니다.
f3(4); // 9

// 함수를 반환값으로 사용할 수 있습니다.
const f1 = () => () => {};

 

일급에 대한 정의와 그 예를 통해 "함수가 일급 객체다"라는 말을 좀 더 이해할 수 있게 되는 것이죠.

저희는 함수를 조합성과 추상화의 도구로 사용할 수 있게 됩니다.

 

 

ES6에서의 순회와 이터러블, 이터레이터 프로토콜이 어떤 관계에 있는 걸까?

ES6 이전에서의 리스트 순회를 진행할 때, 다음과 같은 for문을 이용했었습니다.

var list = [1, 2, 3];

for(var i=0; i<list.length; i++){
  console.log(list[i]);
}

var str = 'abc'; //유사배열
for (var i=0; i<str.length; i++){
  console.log(str[i]);
}

 

ES6에서부터는 리스트 순회를 진행할 때 for-of 문을 사용할 수 있게 되었습니다.

const list = [1, 2, 3];
for (const a of list){
  console.log(a)
}

const str = 'abc'; //유사배열
for (const a of str){
  console.log(a)
}

인덱스를 통해 요소에 접근하는 방식보다 더 선언적인 순회가 가능해졌음을 확인할 수 있습니다. 

 

그럼 for-of문은 문법 내부적으로 보면 index를 통해 요소를 가져오는 것일까요? 

결과를 먼저 말씀드리자면 아닙니다! 이터러블 & 이터레이터 프로토콜을 사용하여 순회가능한 객체, for-of 문 등을 지원하게 되는 것이죠.

그렇다면 이터러블 / 이터레이터 프로토콜에 대해 알아보도록 합시다.

 

이터러블이란 이터러블 프로토콜을 따르는 객체이며, `[Symbol.iterator]` 메서드를 가진 객체입니다.

이 때, 이 메서드는 이터레이터를 리턴합니다. (배열의 경우, `Array.prototype[Symbol.iterator]` 메서드를 상속받기 때문에 이터러블입니다.

 

그렇다면 이터레이터는 무엇일까요?

 `{value: 값, done: true/false}` 형태의 객체를 리턴하는 `next()`메서드를 가진 객체입니다. 

 

즉, 쉽게 풀어서 설명하자면

이터러블은 순회할 수 있는 값들의 모음이며, 순회하기 위해서는 이터레이터가 필요하고, `[Symbol.iterator]` 메서드를 호출하면 이터레이터가 반환됩니다.

이터레이터는 `next()` 메서드를 통해 데이터들을 순회할 수 있습니다. 이 때 반환되는 데이터의 형태는 `{value: 값, done: true/false}`입니다.

 

그림으로 도식화하면 다음과 같게 되는 것이죠.

배열 뿐만 아니라, 이터러블/이터레이터 프로토콜을 따르는 이터러블이라면 for-of 문과 같은 문법을 사용할 수 있게 되는 것이죠.

 

대표적인 예가 Map과 Set 객체입니다.

먼저 Set 객체를 사용해보도록 하죠.

const set = new Set([1, 2, 3]);

for(const a of set) {
  console.log(a);
}

const iterator = set[Symbol.iterator](); // Set Iterator
iterator.next(); // {value: 1, done: false}
iterator.next(); // {value: 2, done: false}
iterator.next(); // {value: 3, done: false}
iterator.next(); // {value: undefined, done: true}

 

set이 이터러블이며, set의 `Symbol.iterator` 메서드를 호출하여 이터레이터를 얻습니다. 

이터레이터의 `next` 메서드를 호출하여 각각의 원소들을 순회합니다.

 

그 다음은 Map 객체입니다.

const map = new Map([['a', 1], ['b', 2], ['c', 3]]);

for(const a of map) {
  console.log(a);
}

const iterator = map[Symbol.iterator](); // Map Iterator
iterator.next(); // {value: ["a", 1], done: false}
iterator.next(); // {value: ["b", 2], done: false}
iterator.next(); // {value: ["c", 3], done: false}
iterator.next(); // {value: undefined, done: true}

for (const key of map.keys()){
  console.log(key);
}

for (const key of map.values()){
  console.log(key);
}
for (const key of map.entries()){
  console.log(key);
}

const iterator = map.values();
const iterator2 = iterator[Symbol.iterator]();
iterator2 === iterator // true
iterator2.next(); // {value: 1, done: false}
iterator2.next(); // {value: 2, done: false}
iterator2.next(); // {value: 3, done: false}
iterator2.next(); // {value: undefined, done: true}

map도 이터러블이고, set과 동일하게 동작합니다. 다만 `keys()`와 `values()`, `entries()` 메서드를 통해 이터레이터를 반환받을 수 있다는 점이 특이합니다.

또한, 이터레이터는 `Symbol.iterator` 함수를 가지고 있는데 이 함수는 자기 자신을 참조하며, 이 규약까지 지킨 이터레이터를 Well-formed Iterator라고 부릅니다.

 

`Well-formed iterator`를 구현한다면 어떤 점이 좋을까요? 

그것은 이터레이터가 실행되고 나서도, 중간부터 for-of 문 등의 문법에 사용될 수 있다는 점이 장점입니다.

 

그렇다면 진짜 for-of문이 이터러블/이터레이터 프로토콜을 따르는 문법일까요?

이를 테스트해보는 코드를 작성해보도록 합시다.

const arr = [1,2,3]; // 이터러블 생성

// Symbol.iterator 메서드를 없애버림
arr[Symbol.iterator] = null;

for (const a of arr) {
  console.log(a);  // Uncaught TypeError: arr is not iterable
}

 

 

이제 이터러블과 이터레이터 프로토콜에 대해 어느정도 이해가 되셨으리라 생각됩니다.

 

그럼 커스텀 이터러블, 이터러블/이터레이터 프로토콜을 만들어봅시다.

const iterable = {
  [Symbol.iterator](){
    let i = 3;
    return {
      next() {
        return i === 0 ? {done: true} :{ value: i--, done: false }
      }
    }
  }
}

 

 

이터러블은 `Symbol.iterator` 메서드를 가진 객체이며, `Symbol.iterator` 메서드는 `next` 메서드를 가진 객체를 리턴합니다.

`next` 메서드는 `value` 속성과 `done` 속성을 가진 객체를 반환합니다.

 

이렇게 만든 이터러블을 사용해보도록 합시다.

const iterator = iterable[Symbole.iterator]();
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: umdefined, done: true}

for (const a of iterable) {
  console.log(a);
}

for (const a of iterator){
  console.log(a); // TypeError: iterator is not iterable
}

 

하지만 이터레이터를 실행하고 나서 중간에 for-of 문 등에 사용하면 에러가 발생합니다.

const iter = iterable[Symbol.iterator]();

iter.next(); // {value: 3, done: false}

for (const it of iter){
  it.next(); // 다음과 같은 에러가 발생: VM365:1 Uncaught TypeError: iter is not iterable at <anonymous>:1:18
}

 

그렇다면 저희가 만든 이터러블, 이터러블/이터레이터 프로토콜을 Well-formed Iterator를 만족하도록 진화시켜보도록 하겠습니다.

const iterable = {
  [Symbol.iterator](){
    let i = 3;
    return {
      next() {
        return i === 0 ? {done: true} :{ value: i--, done: false }
      },
      [Symbol.iterator]() {
        return this; 
      }
    }
  }
}

 

이렇게 구현을 하게 된다면 이터레이터를 실행하고 나서 중간에 for-of 문 등에 사용해도 문제가 발생하지 않습니다.

const iter = iterable[Symbole.iterator]();
iter.next(); 
for (const a of iter){
  console.log(a); 
}

 

제너레이터와 이터레이터를 알아보자

제네레이터는 이터러블이면서 동시에 이터레이터입니다. 이터레이터를 리턴해주는 함수입니다.

function *gen(){ // 제네레이터 함수는 이터러블이라고 생각하면 됩니다.
  yield 1;
  yield 2;
  yield 3;
}

let iter = gen(); // 제너레이터 함수를 호출한다는 것은 이터레이터를 반환한다고 보면 됩니다.

console.log(iter[Symbol.iterator]() === iter); // true (Well-Formed Iterator)
console.log(iter.next()); // { value: 1, done: false }
console.log(iter.next()); // { value: 2, done: false }
console.log(iter.next()); // { value: 3, done: false }
console.log(iter.next()); // { value: undefined, done: true }

제네레이터 함수는 이터러블이며, 함수를 호출한다는 것이 `Symbol.iterator` 메서드를 호출한다고 보면 되고 Well-formed Iterator를 반환해줍니다. 

Well-formed Iterator는 `Symbol.iterator` 메서드를 가진 이터레이터이므로, 이터러블이라고도 생각할 수 있습니다.

`yield` 값은 몇 번의 `next()` 메서드를 사용하여 값을 꺼내줄 것인지를 알 수 있습니다.

제너레이터의 반환값은 이터레이터이자 이터러블입니다.

 

만약 제너레이터 함수에 리턴값을 넣어줄 수도 있고, 조건문을 통해서 분기처리하여 yield 문을 실행할 수 있습니다.

function *gen(){
  yield 1;
  if(false) yield 2;
  yield 3;
  return 100;
}

let iter = gen(); 
console.log(iter.next()); // { value: 1, done: false }
console.log(iter.next()); // { value: 3, done: false }
console.log(iter.next()); // { value: 100, done: true }

 

출처
https://inpa.tistory.com/entry/JS-%F0%9F%93%9A-%EC%9D%B4%ED%84%B0%EB%9F%AC%EB%B8%94-%EC%9D%B4%ED%84%B0%EB%A0%88%EC%9D%B4%ED%84%B0-%F0%9F%92%AF%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4#iterable_prococal_/_iterator_prococal