본 게시물은 유인동님의 함수형 프로그래밍과 JavaScript ES6+ 강의를 참조하여 정리한 글입니다.

함수 레벨 스코프

대부분의 프로그래밍 언어는 블록 레벨 스코프를 가진다. 그러나 자바스크립트의 var키워드로 선언된 변수는 함수레벨 스코프를 가지게 된다. 즉, 블록 레벨 스코프에서 var를 선언하면 호이스팅 되어 전역변수가 되어버려서 아래와 같은 코드도 가능해진다.

if (i === undefined){
  i = 2;
  console.log(i) // 이런 괴상한 코드도 가능하다. 에러가 뜨지 않고 2가 뜬다.
  if (i === undefined){
    var i = 4;
  }
}

따라서 es6이후에 제공하며 블록 레벨 스코프를 가지는 let, const를 사용하는 것이 바람직하다.
함수형 프로그래밍이 적용되는 부분은 const만 사용한다.(변수가 변할 일이 없기 때문이다.)




렉시컬(정적) 스코프 및 클로저

함수가 정의되는 시점에 자신의 내부 슬롯에 상위 스코프에 대한 참조를 저장한다. 이것이 중요한 이유는 함수 호출 후 실행이 완료되어 실행 컨텍스트 스택에서 해당 함수의 실행 컨텍스트가 제거되더라도 렉시컬 환경은 남아있게된다. 따라서 아래와 같이 동작한다.

이처럼 중첩함수의 내부함수가 외부함수의 렉시컬 환경을 참조하고, 외부함수보다 더 오래살아있는 것을 '클로저'라고 한다. state를 은닉하거나 특정 함수에게만 state변경을 허용하기 위해 사용된다.




화살표 함수에서의 클로저

이 경우는 함수를 만들어 리턴하는 함수가 된다.(중첩함수이며 클로저를 리턴하는 함수)
즉, return되는 함수에서 외부 함수의 파라미터를 기억한다.




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

이터러블 : 이터레이터를 리턴하는 {Symbol.iterator}()를 가진 값

const arr = [1, 2, 3] 
let iterator = arr[Symbol.iterator]();

이터레이터 : { value, done } 객체를 리턴하는 next()를 가진 값

iterator.next(){value: undefined, done: true}

이터러블 / 이터레이터 프로토콜 : 이터러블을 for...of, 전개 연산자 등과 함께 동작하도록한 규약


- 이터레이터의 동작

const arr = [1, 2, 3];
arr[Symbol.iterator](); // f value() { [native code]
let iter1 = arr[Symbol.iterator](); // iterator == undefined
iterator.next(); // {value :1, done: false}
iterator.next(); // {value :2, done: false}
for (const a of iter1) console.log(a); // 3하나만 순회한다.

이를 통해 Array는 Symbol.iterator를 실행한 iter1를 next()를 통해 순회하면서 next안쪽에 value로 떨어지는 값을 출력하고 있는 것이다. Set이나 Map, 전개연산자 또한 이 규약을 따른다.

- 사용자 정의 이터러블을 구현해보기

  • 간단한 형태
const iterable = { // value로 3, 2, 1 순회하는 iterable만들기
  [Symbol.iterator](){ // 우선 이터레이터 Method를 가지고 있다.
    let i = 3;
    return{
      next(){ // 이터레이터는 next Method를 return한다.
        return i === 0 ? { done : true } : { value : i--, done : false } // next는 value와 done을 가지고 있는 객체를 return한다.
      }
    }
  }
}

let iterater = iterable[Symbol.iterator]();
//console.log(iterater.next()); // {value: 3, done: false}
//console.log(iterater.next()); // {value: 2, done: false}
//console.log(iterater.next()); // {value: 1, done: false}
//console.log(iterater.next()); // {done: true}
for (const a of iterater) console.log(a); // 3, 2, 1
  • Well-Formed 이터레이터 (이터레이터가 자기 자신을 반환하는 Symbol.iterator()를 가지고 있어야 한다.)
const iterable = { // value로 3, 2, 1 순회하는 iterable만들기
  [Symbol.iterator](){ // 우선 이터레이터 Method를 가지고 있다.
    let i = 3;
    return{
      next(){ // 이터레이터는 next Method를 return한다.
        return i === 0 ? { done : true } : { value : i--, done : false } // next는 value와 done을 가지고 있는 객체를 return한다.
      }, // 쉼표연산자, 여기선 next method와 [Symbol.iterator] method를 평가하고[Symbol.iterator]를 반환한다. 
      [Symbol.iterator](){ return this;} // 이터레이터도 이터러블이 되도록 만들어준다.
    }
  }
}

let iterator = iterable[Symbol.iterator]();
iterator.next();
iterator.next();
for (const a of iterator) console.log(a); // 1



제너레이터

제너레이터 : 이터레이터이자 이터러블을 생성하는 함수, 어떠한 값도 순회할 수 있는 형태로 만들 수 있다.
참고로 제너레이터는 화살표 함수를 사용할 수 없다.

function* gen(){
 yield 1;
 if (false) yield 2; // 이 부분은 건너뛰게 된다.
 yield 3; 
 return 100; // return에선 done이 false라 for..of문 등에서 포함되지 않는다.
}
 let iter = gen();
 console.log(iter.next()); // {value: 1, done:false}
 console.log(iter.next()); // {value: 3, done:false}
 console.log(iter.next()); // {value: undefined, done:true}
 console.log(iter.next()); // {value: undefined, done:true}

- 제너레이터를 활용한 홀수 순회 함수 만들기

function* infinity(a) { // 무한 수열 함수
  while (true) yield a++;
}

function* limit(limitValue, iter){ // iter를 계속 yield하다가 limitValue만나면 return
  for (const a of iter){ // for...of을 순회하기 때문에 iter가 이터러블 프로토콜을 따라야한다.
    yield a;
    if (a == limitValue) return;
  }
}

function* case1(limitValue) { 
  yield 1;
  yield 3;
  yield 5;
  yield 7;
  yield 9; // 수동으로 계속 추가해주기 귀찮으니까 infinity()를 통해 case2()를 만든다.
}

function* case2(limitValue){ 
  for (const a of infinity(1)) {
    if (a % 2) yield a;
    if (a == limitValue) return; // 이 부분이 더러워서 limit()를 통해 case3()을 만든다.
  }
}

function* case3(limitValue){ 
  for (const a of limit(limitValue, infinity(1))) {
    if (a % 2) yield a;
  }
}

const itercase2 = case2(5);
itercase2.next(); // {value: 1, done:false}
itercase2.next(); // {value: 3, done:false}
itercase2.next(); // {value: 5, done:false}
itercase2.next(); // {value: 7, done:false}

for (const a of case3(10)) console.log(a); // 1 3 5 7 9 (실행마다 라인변경)
console.log(...case3(10)) // 1 3 5 7 9 전개연산자

const [head, ...tail] = case(10);
console.log(head); // 1 
console.log(tail); // [3, 5, 7, 9]

const [첫번째, 두번째, ...나머지] = case(10);
console.log(첫번째); // 1 
console.log(두번째); // 3
console.log(나머지); // [5, 7, 9]

자바스크립트에서는 이터레이블/이터러블 프로토콜을 통해 활용할 수 있는 문법과 기능들이 많다. 특히 이 프로토콜이 적용된 라이브러리 혹은 함수를 이용하면 그것들을 조합해서 함수형 프로그래밍을 하기 좋다.

Map, Filter, Reduce

- 이터러블 프로토콜을 따르는 인자를 받는 map 구현해보기

const log = console.log; // 이를 통해 앞으로 로그는 log()로 표현할 것이다.
// Data Source
const products = [
  { name: '반팔티', price: 15000 },
  { name: '긴팔티', price: 20000 },
  { name: '핸드폰케이스', price: 15000 },
  { name: '후드티', price: 30000 },
  { name: '바지', price: 25000 },
];

// map
const map = (f, iter) => {
  // f : 함수 파라미터, iter : 이터러블 프로토콜을 따르는 파라미터
  let result = [];
  for (const a of iter) {
    result.push(f(a));
    // 어떤 값을 수집할 것인지는 f에게 위임한다. (iter의 인자 a를 파라미터로 받는 함수)
  }
  return result;
}
// map은 아래의 코드와 유사하다. 하지만 console.log()는 외부의 영향을 직접적으로 일으키기에
// 사이드 이팩트를 지양하는 함수형 프로그래밍에서는 return을 해서 값만 넘겨준다.
// let names = [];
// for (const p of products) {
//   names.push(p.name);
// }
// log(names);

// p => p.name함수와 products를 파라미터로 넣어 호출
const userMapTest = map(p => p.name, products); // 상품명 출력
const mapTest =  products.map(p => p.price); // 상품 가격 출력
log(userMapTest);
log(mapTest);

// let prices = [];
// for (const p of products) {
//   prices.push(p.price);
// }
// log(prices);

데이터 소스에서 특정 데이터만 추출하고 싶을 때, map을 사용하게 된다. 기본으로 제공되는 Array.prototype.map()이 있지만, 위처럼 임의로 정의해서 사용할 수도 있다.

중요한 점은 for문 내부에서 push()할 때, 어떤 값을 선택할 것인지에 관한 로직은 파라미터로 받은 함수에게 위임하여 시킨다는 것이다.

- 임의로 구현한 map의 다형성

// 아래 코드는 document.querySelectorAll('*')가 Array를 상속받지 않기 때문에 에러가 난다.
log(document.querySelectorAll('*').map(el => el.nodeName)); // error(함수없음)

// 하지만 임의로 만든 map 이용하면 document.querySelectorAll('*')가
// 이터러블 프로토콜을 따르기 때문에 정상적으로 동작한다.
log(map(el => el.nodeName, document.querySelectorAll('*')));
// ["HTML","HEAD","META","META","META","TITLE","BODY","DIV","SCRIPT"]

function* gen() {
  yield 2;
  if (false) yield 3;
  yield 4;
}
log(map(a => a * a, gen())); // [4, 16]


let m = new Map();
m.set('a', 10);
m.set('b', 20);
log(map(([k, a]) => [k, a * 2], m)); // ['a', 20], ['a', 40]
log(new Map(map(([k, a]) => [k, a * 2], m))); // 새로운 Map객체 {'a' => 20, 'b' => 40}

자바스크립트의 map은 array인 경우에만 사용 가능하다. 하지만 새로 정의한 map은 위와 같이 이터러블 프로토콜을 따르는 경우라면 모든 경우에서 사용 가능한 다형성을 갖추고 있다.
앞으로 브라우저에서 제공되는 각종 헬퍼 함수들은 이터러블 프로토콜을 따를 것이기 때문에 활용도가 높을 것이다.


앞으로 나올 filter와 reduce도 map처럼 새로 적용하여 기존의 명령형 프로그래밍을 함수형으로 리팩토링 하는 것을 서술할 것이다.

- 이터러블 프로토콜을 따르는 인자를 받는 filter 구현해보기

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

// 명령형으로 코딩하는 경우
let under20000 = [];
for (const p of products) {
  if (p.price < 20000) under20000.push(p);
}
log(...under20000);

let over20000 = [];
for (const p of products) {
  if (p.price >= 20000) over20000.push(p); 
}
log(...over20000);

// filter를 통한 리팩토링. 위와 같은 결과
log(...filter(p => p.price >= 20000, products));
log(...filter(p => p.price < 20000, products));

// 활용 예시
log(filter(n => n % 2, [1, 2, 3, 4]));

const generateSequence = function* () {
  yield 1;
  yield 2;
  yield 3;
  yield 4;
  yield 5;
}
log(filter(n => n % 2, generateSequence())); // [1, 3, 5]

- 이터러블 프로토콜을 따르는 인자를 받는 reduce 구현해보기

const nums = [1, 2, 3, 4, 5];

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 add = (a, b) => a + b;

log(reduce(add, 0, nums)); // 파라미터 : reduce(함수, 초기값, iter)
// 15
log(add(add(add(add(add(0, 1), 2), 3), 4), 5));
// 15 reduce의 내부에선 이 처럼 재귀적으로 동작하게 된다.
log(reduce(add, nums)); // 파라미터 : reduce(함수, 초기값, iter)
// 15
// 인자가 2개만 입력되었을 경우 iter에는 이터러블을 이터레이터로 만들고
// 첫번째 값을 next()하여 꺼낸 후 넣어 주게 된다.
// 가령 (f, [1, 2, 3])을 입력했을 경우 (f, 1, [2, 3])이 된다.

// 명령형으로 코딩하는 경우
let total = 0;
for (const n of nums) {
  total = total + n;
}
log(total); // 15

// reduce를 활용하여 products가격의 합계 구하기
const total_products_price = (total_price, product) => total_price + product.price
log(reduce(total_products_price, products));
// 이 경우 초기 값이 object인 { name: '반팔티', price: 15000 }로 들어가서 정상적으로 더해지지 않는다.
// 따라서 초기값을 0으로 설정해 줘야 정상적으로 동작한다.
log(reduce(total_products_price, 0, products));
// 105000

- map+filter+reduce 중첩사용

// map, filter, reduce중첩
log(reduce(add, map(p => p.price, filter(p => p.price < 20000, products))));

// 보기 편하게 재배열
log(
  reduce(
    add, 
    map(p => p.price, 
      filter(p => p.price < 20000, products))));
// 1. products의 price를 20000미만으로 필터한다.
// 2. 1을 통해 걸러진 값을 map을 통해 price만 뽑아낸다.
// 3. 뽑아낸 price를 add로 출력을 한다.

log(
  reduce(
    add, 
    filter(n => n < 20000, 
      map(p => p.price, products))));
// 이처럼 순서를 바꾸어 실행해도 된다.



함수형 사고

// 함수형프로그래밍을 위한 사고흐름
// 상황 : products의 가격들의 합계를 내고 싶다.

// 1. 우선 reduce의 두번째 인자로 숫자로 된 배열이 들어오길 기대하며
// log와 reduce와 add까지는 별 생각 없이 작성할 수 있다.
log(
  reduce(
  add, [1, 2, 3, 4]));

// 2. map을 통해 reduce의 두번째 인자를 product를 숫자로 된 배열로 평가되도록 해준다.
log(
  reduce(
  add, map(p => p.price, products)));

// 3. filter를 통해 map의 두번째 인자를 특정 조건에 의해 걸러진 배열로 평가되도록 해준다.
log(
  reduce(
    add, 
    map(p => p.price, 
      filter(p => p.price < 20000, products))));

0개의 댓글