함수형 프로그래밍과 JavaScript ES6+ 7. 지연성 1 (1)

|

inflearn의 함수형 프로그래밍과 JavaScript ES6+를 보고 공부한 것을 정리합니다.

range와 느긋한 L.range

range

숫자 하나를 받고, 그 숫자의 크기만큼 배열을 리턴하는 함수

const range = l => {
    let i = -1;
    let res = [];
    while(++i < l) {
        res.push(i);
    }
    return res;
};

log(range(5)); // [0, 1, 2, 3, 4]
log(range(2)); // [0, 1]

이 안에 있는 모든 값들을 더하는 코드를 구현

const add = (a, b) => a + b;

var list = range(4);
log(reduce(add, list)); // 6

L.range

똑같은 일을 다르게 구현해서 해본다. 느긋한 range를 만든다.

const L = {};
L.range = function *(l) {
    let i = -1;
    while(++i < l) {
        yield i;
    }
};

var list = L.range(4);
log(reduce(add, list));

제너레이터를 통해 만든 range

중간에 log(list)를 통해 출력해보면 다음과 같다.

views
log(list)

range는 배열이, L.range는 다른 것이 출력된다. 이를 좀 더 자세히 보게 되면 이터레이터라는 것을 알게 된다.

views
이터레이터

그런데 둘 다 같은 결과를 만든 이유는 무엇일까? reduce라는 함수가 이터러블을 받기 때문이다. 배열과 이터레이터 모두 이터러블이기 때문에, 안에서 이터러블을 이터레이터로 만든 후 안에 있는 값을 하나씩 조회하면서 결과를 만들게 된다.

느긋한 L.range와 그냥 range의 차이

코드를 다음과 같이 약간 바꿔서 내부 과정을 확인해보자.

const range = l => {
    let i = -1;
    let res = [];
    while(++i < l) {
        log(i, 'range');
        res.push(i);
    }
    return res;
};

const L = {};
L.range = function *(l) {
    let i = -1;
    while(++i < l) {
        log(i, 'L.range');
        yield i;
    }
};

range의 경우에는 reduce에 전달하기 전에, 그러니까 이미 range(4)가 실행된 시점에 즉시 이 부분의 코드가 배열로 완전히 평가된다.

views

그러나 L.range는 이 함수의 어떤 부분도 실행되지 않았다는 것을 확인할 수 있다. 그렇다면 언제 처음 이 코드가 평가되는 것일까? -> 이 이터레이터의 내부를 순회할 때마다 하나씩 값이 평가된다. 다음과 같이 next()를 해 보면 알 수 있다.

var list = L.range(4);
log(list);
log(list.next());
views
next()의 결과
views
next()를 더 많이 해보자

그냥 rangerange()를 실행했을 때 이미 모든 부분이 평가되면서 값이 만들어진다. 하지만 L.rangeL.range()를 실행한 시점에서는 아무 값도 평가되지 않는다.

var a = [0, 1, 2]

라고 선언이 되어 있다면 이 a는 아직 필요한 값이라고 볼 수 없다. (엄밀히 말하자면!) a의 값을 순회하여 사용자에게 필요한 어떤 값을 만들어낼 때까지는 그저 메모리만 차지하고 있게 되는 것이다. 이러한 이유로 L.range는 배열 형태가 되지 않은 채로, 다시 말하면 실제로 평가가 완벽하게 되지 않은 상태로 있다가 reduce 안에서 값이 필요할 때까지 기다렸다가 평가가 이루어지면서 값을 꺼내도록 한 것이다.

정리하면 다음과 같다. range는 array를 만들어 놓은 뒤, 배열로 전달되어 동작한다. L.range는 array를 만들지 않고 하나씩 값을 꺼내기만 한다.

한편 이 range로부터 생성된 array가 reduce 안에서 처리될 때, 생략된 과정이 있다. reduce 안에서 이 array를 이터레이터로 만들고 next하면서 순회하게 되는 과정이 그것이다. 하지만 L.range는 실행됐을 때 이미 이터레이터를 만든다. 이 이터레이터는 자기 자신을 그대로 리턴하는 이터러블이다. 그리하여 해당하는 함수를 실행하면 이미 만들어져 있는 이터레이터를 그냥 리턴하고 순회하기 때문에 조금 더 효율적이라고 말할 수 있다.

range와 느긋한 L.range 테스트

function test(name, time, f) {
    console.time(name);
    while (time--) f();
    console.timeEnd(name);
}

test('range', 10, () => reduce(add, range(1000000)));
test('L.range', 10, () => reduce(add, L.range(1000000)));

그렇게까지 드라마틱한 차이는 나지 않지만 L.rangerange에 비해 좀더 효율적이라는 점을 알 수 있다.

views
test 결과

take

이터레이터를 순회하는 또다른 함수 take

const take = curry((l, iter) => { // limit, iterable
    let res = [];
    for (const a of iter) {
        res.push(a);
        if(res.length == l) return res;
    }
    return res;
});

이터러블 프로토콜을 따른다. 이터러블 안의 값을 next를 통해 순회하고 꺼내서 push만 하는 단순한 로직을 가지고 있다.

log(take(5, range(100))); // [0, 1, 2, 3, 4]
log(take(5, L.range(100))); // [0, 1, 2, 3, 4]

L.range같이 지연성을 가지는 값을 이터레이터로 만들게 되면 전혀 다른 함수가 이터레이터 프로토콜만 따를 때, 얼마든지 조합이 가능하다. 자바스크립트 고유의 프로토콜을 통해서 가능해지는 것이기 때문에 조합성이 높고, 잘 구현할 수 있다. 효율성적인 측면에서도 마찬가지로 훨씬 낫다.

console.time('');
log(take(5, range(100000)));
console.timeEnd('');

console.time('');
log(take(5, L.range(100000)));
console.timeEnd('');
views
효율성의 차이

이러한 효율성의 차이가 발생하는 이유는, range 같은 경우에는 100000 크기의 array를 만들고 5개를 뽑지만, L.range는 5개만 만들고 뽑기 때문이다. 이를 활용하면 다음과 같은 것도 가능하다.

log(take(5, L.range(Infinity)));
views
beyond the infinity

무한수열로 표현하게 되더라도 어차피 5개의 값만 만들기 때문에 똑같은 시간이 소요된다. 하지만 range 안에 무한수열을 넣게 되면 브라우저가 뻗는다.

다음과 같은 작업에서도 L.range가 더 효율적이다.

console.time('');
go(
    range(10000),
    take(5),
    reduce(add),
    log
);
console.timeEnd('');

console.time('');
go(
    L.range(10000),
    take(5),
    reduce(add),
    log
);
console.timeEnd('');
views

L.range의 지연성은 takereduce와 같은 함수를 만날 때 연산이 시작되게 된다. 제너레이터로 이터레이터를 리턴하는 함수를 실행했을 때에는 해당하는 연산이 안에서 이루어지지 않는다. reduce와 같이 배열 안의 첫번째 값과 두번째 값을 꺼내서 연산을 필요로 하는 함수나 몇 개의 길이를 가지는지 모르는 어레이에서 두 개의 결과만 뽑는 것과 같은 함수에서, 최대한 연산을 미루다가 그 때 연산을 처음 하는 기법인 것이다.

이터러블 중심 프로그래밍에서의 지연 평가 (Lazy Evaluation)

= 제때 계산법, 느긋한 계산법 제너레이터/이터레이터 프로토콜을 기반으로 구현된다.

지연 평가는 게으른 평가라고도 이야기하지만 영리하다는 뜻이 함께 들어 있다. 즉, 그냥 게으르기만 한 것이 아니라 최대한 게으르면서도 가장 영리하게 평가한다는 뜻이다.

제때 계산법이라고도 부르는 이유는 이 지연 평가가 가장 필요할 때까지 평가를 미루다가 가장 필요할 때 해당하는 코드들을 평가하면서 값들을 만들어나가는 기법이기 때문이다. L.range 함수에서 봤던 것처럼 큰 크기의 배열을 미리 만들어 놓는 것이 아니라 그 이후에 필요한 연산이었던 reduce를 하면서 add를 한다고 할 때, 즉 필요한 값을 뽑을 때만 그 값을 만들어내면서 값을 만드는 것을 최소화하고 연산을 좀더 효율적으로 줄이는 아이디어이다.

그러나 이전의 자바스크립트에서는 이러한 구현이 어려웠다. 엄밀히 말하면, 구현 자체는 가능했지만 공식적인 구현이라고 보기에는 어려웠다. 언어 자체에서 해당하는 로직을 구현할 수 있도록 기반이 되는 프로토콜이 있지 않았기 때문이다.

그러므로 지연 평가를 위한 별개의 연산이 많이 추가되어야 했다. 이것이 큰 연산은 아니지만, 공식적인 자바스크립트의 값이나 프로토콜이 아니었기 때문에 서로 다른 라이브러리나 함수에서 함께 사용되기는 어려웠다.

그러나 ES6에서는 이터러블/이터레이터 프로토콜로 인해 지연성으로 코드의 평가를 미루고 코드를 값으로 다루는 프로그래밍을 할 때 보다 공식적인 자바스크립트의 일반 값으로써 구현할 수 있게 되었다. 나아가 서로 다른 라이브러리나 함수들이 안전한 조합성, 합성성 등에서 향상되었다.

그래서 이 강의에서는 제너레이터 기반으로 이터러블 중심 프로그래밍에서의 지연 평가를 구현할 것이다. (= 리스트 중심 프로그래밍, 컬렉션 중심 프로그래밍) 이터러블 중심 프로그래밍이란 map, filter, reduce, take 같은 함수들을 기반으로 프로그래밍하는 것을 말한다.

정리하자면, 이터터블 중심 프로그래밍을 할 때 어떻게 지연성을 구현할 수 있고 어떻게 지연성에 대해 보다 공식적인 값으로서 조합성을 만들어갈 수 있는지에 대해 알아볼 것이다.

L.map

앞서 만들었던 map을 지연성을 가진 L.map으로 만들되 제너레이터/이터레이터 프로토콜을 통해 구현할 것이다.

좀더 구체적으로 표현하면 L.map

  1. 제너레이터 함수
  2. 이터레이터를 반환
  3. 이 반환되는 이터레이터는 평가를 미루는 성질을 가지고 평가 순서를 달리 조작할 수 있는 준비가 되어 있음 (지연성)
L.map = function *(f, iter) {
    for (const a of iter) yield f(a);
};

var it = L.map(a => a + 10 , [1, 2, 3]);
log(it.next());
log(it.next());
log(it.next());
views
L.map

next()를 통해 평가하는 만큼의 값만 얻어올 수 있다.

해당하는 이터레이터를 전개 연산자를 사용하여 평가할 수도 있다.

views
[...it]

L.map 자체에서는 새로운 array를 만들지도 않고 값 하나하나마다 순회하면서 yield를 통해 함수가 적용된 값을 next를 통해 하나씩 전달하게 된다. 지연성을 가지는 이터레이터 객체를 내가 원하는 방법으로 평가할 수 있게 되는 것이다. it.next().value와 같이 평가할 수도 있다.

views
it.next().value

L.filter

L.filter = function *(f, iter) {
    for (const a of iter) if (f(a)) yield a;
};

var it = L.filter(a => a % 2, [1, 2, 3, 4]);
log(it.next());
log(it.next());
log(it.next());

yield가 총 네 번 되는 것이 아니라 원하는 상황에서만 yield된다. next를 여러 번 했을 때 done: true가 되기 전까지, 해당하는 값을 필터링하여 next했을 때 value가 꺼내지도록 구성하였다.

views
L.filter

200310TIL

|

오늘 한 것

  • 자바스크립트 함수형 프로그래밍 7강 수강 및 정리
  • 지원서 작성 및 제출

var, let, const

|

let과 const는 ES6 업데이트 이후 추가된 변수 선언 방식

1. var, let, const의 할당

var

var을 이용하여 변수를 선언할 경우, 유연한 변수 선언이 가능하다.

var name = "galaxy"
console.log(name); // galaxy

var name = "milkyway"
console.log(name); // milkyway

변수를 한 번 더 선언해도 에러가 나오지 않고 출력 시점마다 각기 다른 값이 출력된다. 이는 간단한 테스트에는 편리하지만, 코드량이 어마어마하게 많아질 경우, 변수가 어디에서 어떻게 사용되는지 정확하게 파악하기가 힘들고, 값이 바뀔 우려가 있다.

let, const : 변수 재할당 불가

letconst는 이러한 var의 단점을 보완하기 위해 만들어진 변수 선언 방식이다. let을 사용해 위의 예제를 바꾸어 보면 에러가 발생한다.

let name = "galaxy"
console.log(name); // galaxy

let name = "milkyway"
console.log(name);
// Uncaught SyntaxError: Identifier 'name' has already been declared

name이 이미 선언되었다는 에러 메시지가 발생한다. const로 바꾸어 해 보아도 마찬가지의 에러 메시지가 발생한다. 즉, letconst는 변수 재할당이 되지 않는다.

const : immutable한 변수

letconst의 차이점은 immutable한 변수가 선언되는가의 여부이다. 우선 다음과 같은 예제를 보면

let name = "galaxy"
console.log(name); // galaxy

let name = "milkyway"
console.log(name);
// Uncaught SyntaxError: Identifier 'name' has already been declared

name = "surfin"
console.log(name); // surfin

let은 변수에 담긴 값을 변경할 수 있다. 즉, mutable한 변수를 선언한다. 그러나 const의 경우에는 변수 재선언, 변수 재할당 모두 불가능하다.

const name = "galaxy"
console.log(name); // galaxy

const name = "milkyway"
console.log(name);
// Uncaught SyntaxError: Identifier 'name' has already been declared

name = "surfin"
console.log(name);
// Uncaught TypeError: Assignment to constant variable.

2. 호이스팅

호이스팅(Hoisting)이란, var 할당문이나 function 선언문 등을 해당 스코프의 선두로 옮긴 것처럼 동작하는 특성을 말한다. 즉, 다음과 같은 동작에서 에러가 발생하지 않는다.

const hoisting = () => {
  console.log("First-Name:", name);
  var name = "Kim";
  console.log("Last-Name:", name);
}

hoisting();
// First Name : undefined
// Last Name : Kim

아직 선언되지 않은 변수인 name을 출력하는데, undefined가 출력될 뿐 에러는 발생하지 않는다. 즉, 지역변수 name의 선언이 함수의 최사단에서 선언된 것처럼 호이스트되었기 때문이다.

자바스크립트는 ES6에서 도입된 let, const를 포함하여 모든 선언(var, let, const, function, function*, class)을 호이스팅한다.

하지만 var로 선언된 변수와는 달리 let으로 선언된 변수를 선언문 이전에 참조하면 참조 에러 (ReferenceError) 가 발생한다.

console.log(foo); undefined
var foo;

console.log(bar);
// Error: Uncaught ReferenceError: bar is not defined
let bar;

이는 let으로 선언된 변수는 스코프의 시작에서 변수의 선언까지 일시적 사각지대 (Temporal Dead Zone; TDZ)에 놓이기 때문이다.

변수의 생성 단계

자바스크립트에서 변수는

  1. 선언 단계
  2. 초기화 단계
  3. 할당 단계 에 걸쳐 생성된다.

var으로 선언된 변수는 선언 단계와 초기화 단계가 한 번에 이루어진다. 따라서 변수 선언문 이전에 변수를 참조해도 ReferenceError가 발생하지 않는다.

console.log(name); // undefined

var name;
console.log(name); // undefined

name = "milkyway" // 할당문에서 할당 단계가 실행된다.
console.log(name); // milkyway

let으로 선언된 변수는 선언 단계와 초기화 단계가 분리되어 진행된다.

// 스코프의 선두에서 선언 단계가 실행됨
// 아직 변수가 초기화(메모리 공간 확보 및 undefined로 초기화)되지 않은 상태
// 따라서 변수 선언문 이전에 변수를 참조할 수 없다.

console.log(name); // ReferenceError: name is not defined

let name; // 변수 선언문에서 초기홛 단계가 실행된다.
console.log(name); // undefined

name = "milkyway"; // 할당문에서 할당 단계가 실행된다.
console.log(name); // milkyway

letconst로 선언된 변수는 호이스팅되지 않는 것이 아니다. 스코프에 진입할 때 변수가 만들어지고 TDZ가 생성되지만, 코드 실행이 변수가 실제 있는 위치에 도달할 때까지 액세스할 수 없는 것이다. (초기화되지 않았기 때문에) letconst 변수가 선언된 시점에서 제어 흐름은 TDZ를 떠난 상태가 되며, 변수를 사용할 수 있게 된다.

변수의 유효범위

var는 기본적으로 function scope를 가지게 되고 letscope는 block scope를 가지게 된다.

var

var name = "This is milkyway";
if (typeof name === 'string'){
    var result = true;
} else {
    var result = false;
}

console.log(result); // result : true

‘let’과 const

var name = "This is milkyway";
if (typeof name === 'string'){
    const result = true;
} else {
    const result = false;
}

console.log(result); // result : result is not defined

정리

변수 선언에는 기본적으로 const를 사용하고, 재할당이 필요한 경우에 한정하여 let을 사용하는 것이 좋다. 그리고 객체를 재할당하는 경우는 생각보다 흔하지 않다. const를 사용하면 의도치 않은 재할당을 방지해 주기 때문에 보다 안전하다.

  1. 재할당이 필요한 경우에 한정해 let을 사용한다. 이때, 변수으 ㅣ스코프는 최대한 좁게 만든다.
  2. 재할당이 필요 없는 상수와 객체에는 const를 사용한다.

참고

200375TIL

|

오늘 한 것

  • 자바스크립트 var, let, const 차이점 정리

자바스크립트 전개 연산자

|

전개 연산자 (Spread syntax, …)

  • 배열 또는 객체를 하나하나 넘기는 용도로 사용된다.
const arr = [1, 2, 3];
let test_arr = [4, 5, 6];
let test_arr2 = [4, 5, 6];

test_arr.push(arr);
console.log(test_arr); // [4, 5, 6, [1, 2, 3]]

// ES6
test_arr2.push(...arr);
console.log(test_arr2); // [4, 5, 6, 1, 2, 3]

push 메서드를 사용할 때 전개 연산자를 사용하지 않은 코드는 array 전체가 들어가 2차원 배열이 되었지만, 전개 연산자를 사용하게 되면 array 내부의 요소 하나하나가 삽입된다.

전개 구문의 활용

  • 전개 구문을 사용하면 배열이나 문자열과 같이 반복 가능한 문자를 0개 이상의 인수 (함수로 호출할 경우) 또는 요소 (배열 리터럴의 경우)로 확장하여, 0개 이상의 키-값의 쌍으로서 객체로 확장시킬 수 있다. (Mozilla 공식 문서의 설명)
  • 즉, 인수로서 어떠한 객체를 전달할 때, 해당 객체를 인수들의 모음으로써 분해하여 함수에 전달할 수 있다는 뜻이다.
  1. 함수 호출

    apply() 대체

    • 숫자로만 이루어진 배열의 원소들의 합을 구하기 위해, sum 메서드를 사용한다고 생각해보자. 직관적으로 생각할 수 있는 메서드의 사용은 다음과 같다.
function sum(x, y, z) {
  return x + y + z;
}

const numbers = [1, 2, 3];

console.log(sum(numbers));

위와 같이 코드를 실행하면 1,2,3undefinedundefined가 출력된다.

원하는 결과값 6을 얻기 위한 일반적인 방법은 Function.prototype.apply() 메서드를 사용하는 것이다.

apply() 메서드는 일반적으로 특정 함수나 메서드를 호출하기 위해 객체를 원하는 값으로 명시적으로 매핑하기 위해 사용된다. 이 경우에는 arr 배열을 인수 리스트로서 넘긴다.

console.log(sum.apply(null, numbers)); // output : 6

이를 좀 더 직관적으로 만들어주는 것이 전개 연산자이다.

console.log(sum(...numbers)); // 6

인수 목록의 모든 인수는 전개 구문을 사용할 수 있으며, 여러 번 사용될 수도 있다.

function myFunction(v, w, x, y, z) { }
var args = [0, 1];
myFunction(-1, ...args, 2, ...[3]);
// myFunction(-1, 0, 1, 2, 3) 과 같은 의미

new에 적용

new를 사용해 생성자를 호출할 때, 배열과 apply를 직접 사용하는 것은 불가능했다. 하지만, 전개 구문을 통해 배열을 new와 함께 쉽게 사용할 수 있다.

var dataFields = [1970, 0, 1]; // 1 Jan 1970
var d = new Date(...dataFields);
log(d); // Thu Jan 01 1970 00:00:00 GMT+0900 (대한민국 표준시)
  1. 배열 리터럴에서의 전개

    더 강력한 배열 리터럴

    이미 존재하는 배열을 일부로 하는 새로운 배열을 생성하는 데에도 전개 연산자가 사용될 수 있다.

var parts = ['shoulders', 'knees'];
var lyrics = ['head', ...parts, 'and', 'toes'];
log(lyrics); // ["head", "shoulders", "knees", "and", "toes"]

...은 배열 리터럴의 어디에서든 사용될 수 있으며 여려 번 사용될 수도 있다.

배열 복사

var arr = [1, 2, 3];
var arr2 = [...arr]; // arr.slice()와 유사
arr2.push(4);

log(arr); // [1, 2, 3]
log(arr2); // [1, 2, 3, 4]

arr는 영향을 받지 않고, arr2만 변경된다. 하지만 ...로서 배열을 복사할 때에, 1레벨 깊이에서만 효과적으로 동작한다. (얕은 복사) 그러므로, 다차원 배열을 복사하는 것에는 적합하지 않다.

var a = [[1], [2], [3]];
var b = [...a];
b.shift().shift();

log(a); // [[], [2], [3]]
log(b); // [[2], [3]]

배열 연결

일반적으로 배열을 존재하는 배열의 끝에 이어 붙이는 데에는 Array.prototype.concat()이 사용되었다.

var arr1 = [0, 1, 2];
var arr2 = [3, 4, 5];
// arr2 의 모든 항목을 arr1 에 붙임
arr1 = arr1.concat(arr2);
log(arr1); // [0, 1, 2, 3, 4, 5]

전개 구문을 사용하면 다음과 같다.

var arr1 = [0, 1, 2];
var arr2 = [3, 4, 5];
arr1 = [...arr1, ...arr2];
log(arr1); // [0, 1, 2, 3, 4, 5]

전개 연산자는 unshift() (존재하는 배열의 시작 지점에 배열의 값들을 삽입) 역시 대체할 수 있다.

var arr1 = [0, 1, 2];
var arr2 = [3, 4, 5];
Array.prototype.unshift.apply(arr1, arr2);
log(arr1); // [3, 4, 5, 0, 1, 2]
var arr1 = [0, 1, 2];
var arr2 = [3, 4, 5];
arr1 = [...arr2, ...arr1];
log(arr1) // [3, 4, 5, 0, 1, 2]
  1. 객체 리터럴에서의 전개 연산자 활용 전개 연산자는 배열은 물론, 객체에서도 사용될 수 있다. 그러나 역시 1차원 깊이에서만 제대로 작동한다.
const obj = {
  "Name": "AJu",
  "Git":"zoz0312"
}

const test_obj = {
  "test1":1,
  "test2":2
}

const a_merge = {obj, test_obj}
const b_merge = {...obj, ...test_obj}

console.log(a_merge);
/*
{
    obj: {
        "Name":"AJu",
        "Git":"zoz0312"
    },
    test_obj: {
        "test1":1,
        "test2":2
    }
}
*/

console.log(b_merge);
/*
{
    "Name":"AJu",
    "Git":"zoz0312",
    "test1":1,
    "test2":2
}
*/

이처럼 객체의 복사를 하는 데에도 전개 연산자가 활용될 수 있다.

주의

전개 구문 (spread 프로퍼티인 경우 제외) 은 이터러블 객체에만 적용된다.

var obj = {'key1': 'value1'};
var array = [...obj]; // TypeError: obj is not iterable

함수 호출에서 전개 구문을 사용할 때, 자바스크립트 엔진의 인수 길이 제한을 초과하지 않도록 주의해야 한다.

참고