[#7] 함수형 최적화
자바스크립트 함수 실행의 내부원리
- 자바스크립트는 싱글 스레드로 작동
- 전역 context는 단 하나만 존재함
- 모든 함수는 전역 context를 공유함
- 자바스크립트 초기 실행시 콜스택에는 맨 밑에 "전역 context 프레임"이 위치하게됨
- 함수 context에 개수 제한은 없음(FE에서는 브라우저마다 제한개수다름)
- 함수를 호출할 때마다 실행 context (= 함수 context프레임)이 생성됨
- 함수 context 프레임은 각 내부 지역 변수개수만큼 메모리를 차지함 (함수 본체에 변수 선언 많을수록 메모리 많이 소요)
- 커리된 함수를 지나치게 많이 쓰면 콜스택에 영향 줌(= 오버헤드 많이 발생 = 메모리 많이씀 = 실행 속도 느려질수있음)
- 재귀도 지나치면 콜스택에 영향줌(스택오버플로우 와르르 멘션되면 에러 뜨긴함)
성능 향상을 위한 전략
1) Lazy function evaluation (함수를 느긋하게 평가하는 것)
//느긋하게 평가한다는 것은 평가 시점을 아래 조건문처럼 조정할 수 있다는 것
if(!student){
return createNewStudent();
}
else {
return student;
}
1 - 1 ) 불필요한 계산을 피하기
함수 조합기, 함수형 도구를 이용해서 중복을 줄이고, 불필요한 계산을 넘어가도록 만들기
1 - 2 ) 단축 융합을 활용
단축 융합? 실행전에 전체 프로그램을 미리 정의해 두고,
함수가 어떻게 작동하든 신경안쓰고 무슨일을 하는지만 밝히도록 함
몇개의 함수 실행을 하나로 병합하고, 내부 자료구조 개수를 줄여서 함수수준의 최적화를 하는 것
const num = _.range(200); //1~200까지의 숫자 배열 생성
_.chain(num)
.map((x)=> Math.pow(x,2))
.filter((x)=> x % 2)
.take(3) //1 ~200까지 신경쓰는 것이 아니라, 3개 까지만 신경쓰고 나머지 값들에 대해서는 신경 쓰지 않음
.value()
2) '필요할 때 부르리' 전략
반복적인 계산을 피하기 위해 "캐시"와 같은 역할을 하는 코드를 만들어보자
(캐시 ? cost가 많이 드는 연사능ㄹ 하기전에 일단 질의 하는 중간 저장소)
function cachedFn(cache, fn, args){
let key = fn.name + JSON.stringify(args);
if(contains(cache, key){ //주어진 키값으로 이전에 함수가 실행된적 있는지 캐시를 뒤지기
return get(cache, key); // 캐시에 값이 있으면 바로 반환
} else { //캐시에 값이 없으면 함수를 실행
let result = fn.apply(this, args);
put(cache, key, result);
return result;
}
}
cachedFn(cache, findPerson , "짱구"); // 처음엔 함수를 실행하지만
cachedFn(cache, findPerson , "짱구"); // 두번째 실행하면 캐시에 보관된 값을 바로 읽음
🥺 하지만 이렇게 함수의 실행 결과 사이에서 일일히 캐시함수를 호출하게끔 하는건 좀 별로 임
=> 그렇다면! 함수형 언어의 "메모화 memoization" 메커니즘을 사용해보자 😃
2 - 1 ) 메모화
함수형 코딩에서 참조투명성의 원리(동일 in이면 동일 out한다)를 이용해서
"결과를 입력과 연관시키는 것"을 통해 cacheFn와 같은 로직을 함수호출시 적용할수 있음
계산량이 많은 함수를 메모화 하기
let 코드할인중 = 'functional_js_50_off";
rot13(코드할인중); //shapgvbany_wf_50_bss 리턴되도록 rot13아래 예시
let rot13 = str => str.replace(/[a-zA-Z]/g, char =>
String.fromCharCode((char <= 'Z' ? 90 : 122) >= (char = char.charCodeAt(0) + 13) ? char :" char - 26));
(char = char.charCodeAt(0) + 13) ? char : char - 26));
rot13에 memoize()적용하기
//함수 정의부를 감싸기
let rot13 = (str => str.replace(/[a-zA-Z]/g, char =>
String.fromCharCode((char <= 'Z' ? 90 : 122) >= (char = char.charCodeAt(0) + 13) ? char :" char - 26));
(char = char.charCodeAt(0) + 13) ? char : char - 26))).memoize();
//함수 객체 메서드를 호출하기
let rot13 = rot13.memoize();
momoize()를 어떻게 적용할까?
자바스크립트는 메모화를 지원하고있지 않음
그래서 아래와 같이 Function 객체를 보강하는 방식으로 언어에 내장할수 있음
Function.prototype.memoized = function(){
let key = JSON.stringify(arguments); // 입력을 문자화 해서 함수 식별자를 얻음
this._cache = this._cache || {}; //함수의 내부 지역 캐시만듦
this._cache[key] = this._cache[key] || this.apply(this, arguments); //이전에 함수 실행한적 있는지 캐시를 읽음
return this._cache[key]; //값이 있으면 이전연산결과 반환하고, 값이 없으면 계산해서 반환
}
Function.prototype.memoize = function(){ // 함수 메모화를 활성화
let fn = this;
if(fn.length == 0 || fn.length > 1){
return fn; //단항함수만 메모함
}
return function(){
return fn.memoized.apply(fn, arguments);
};
}
* 위 처럼 캐시하면 인수가 여러개인 함수는 캐시하기 어려움
=> 커링을 통해 다항함수를 단항함수로 바꾸기
* 큰덩어리 함수보다 잘게 쪼개진 함수일수록 메모화 효과가 커짐
= 덩어리가 크다는 것은 자료구조를 조회하는 등의 동작이 입력들 사이에 껴있다는 것이기 때문에,
자료구조를 조회하고 동작을 했었는지 검증하기보다는,
이를 분리해서 검증해야 되는 동작만 동작했었는지 확인하여 메모를 적용하는 것이 성능상 이득일 것임
* 재귀호출에도 메모 적용가능
const 팩토리얼 = ((n) => n == 0 ? 1 : (n * 팩토리얼(n - 1))).memoize();
console.time();
팩토리얼(5)
console.timeEnd(); //처음 불렀을때: 0.06201171875 ms
console.time();
팩토리얼(5)
console.timeEnd(); //두번째 불렀을때 : 0.020263671875 ms
*재귀 호출에 적용된 메모화로도 최적화 되지 않은경우 ? " 꼬리 재귀 호출 (TCO) 로 재귀를 만들자 "
2 - 2 ) 꼬리 재귀 호출 ? 함수의 마지막 구문에서 재귀가 진행되도록 코드를 짜는 것
const factorial = (n, cur = 1) => //cur와 같이 후속 호출과 공유할 데이터 함수는 인수로 빼기
n == 1 ? cur : factorial(n - 1 , n * cur);
//factorial(n - 1 , n * cur)재귀호출을 함수의 마지막 context에 위치시키는 것
//for문으로 구현한것 처럼 실행할 수 있음
꼬리 재귀 호출이 최적화하는 이유 ?
재귀실행시 앞선 호출이 계속 콜스텍에 쌓여있는데,
마지막에 호출하는 형태는 콜스텍에서 지우고 상태값은 인수형태로 다음 함수에 넘겨줌
(콜스택에 새 프레임이 계속 쌓이는 것이 아닌, 이전에 다쓴 프레임을 재활용)