-
JavaScript 함수형 프로그래밍 (Functional Programming)JavaScript 2021. 5. 15. 19:21
@ 업데이트 중...
@ 대부분의 코드 결과는 codesandbox에서 작성한 코드의 결과를 적습니다. 참고하세요
함수형 프로그래밍(Functional Programming)이란?
- 외부에서 관찰 가능한 부수효과가 제거된 불편 프로그램을 작성하기 위해 순수함수를 선언적으로 평가하는 것
함수형 프로그래밍 특징
- 선언적 (명령형이 아니다)
- 부수효과가 없다. (순수 함수)
- 상태 변이를 최소화한다.
- 불변성
- 참조 투명성
부수 효과(side effect)가 발생하는 경우
- 전역범위에서 변수, 속성, 자료구조를 변경
- this 사용
- 사용자 입력을 처리
- 함수의 원래 인수 값을 변경
- 예외를 일으킨 해당 함수가 예외를 붙잡지 않고 그대로 예외를 던짐
- 화면 또는 로그 파일에 출력
- HTML 문서, 브라우저 쿠키, DB에 질의
- 등
참조 투명성(referential transparency)?
- 어떤 함수가 동일한 입력을 받았을 때 동일한 결과를 내는 것 -> 이러한 함수를 참조 투명한 함수라고 부른다.
이제 함수형 프로그래밍이라는 단어를 FP로 부르겠습니다.
FP의 장점
- 간단한 함수들로 작업을 분해한다.
- 흐름 체인(fluent chain)으로 데이터를 처리한다.
- 리액티브 패러다임을 실현하여 이벤트 중심 코드의 복잡성을 줄인다.
- FP와 OOP의 특징 비교 (뭐가 더 좋고 뭐가 더 안 좋다는 의미가 아닙니다!)
함수형 프로그래밍 객체지향형 프로그래밍 합성 단위 함수 객체(클래스) 프로그래밍 스타일 선언적 명령형 데이터와 기능 독립적인 순수함수와 느슨하게 결합 클래스 안에서 메서드와 단단히 결합 상태 관리 객체를 불변 값으로 취급 인스턴스 메서드를 통해 객체를 변이시킴 제어 흐름 함수와 재귀 루프와 조건 분기 스레드 안전 동시성 프로그래밍 가능 캡슐화하기 어려움 캡슐화 모든 것이 불변이라 필요 없음 데이터 무결성을 지키기 위해 필요함 값 객체 패턴(value object pattern)
FP에서는 모든 것을 값으로 취급한다. 자바스크립트에서는 객체가 불변이 아닌 변하는 값이기 때문에 이 또한 값으로 취급해야 한다.
이를 불변으로 만들어주기 위해서는 여러가지 방법이 존재한다.
반환값을 객체로 반화하는 값 객체 패턴이 있다.
function code(x, y) { return { x, y, transform(dx, dy) { return { x: x + dx, y: y + dy } } } }
그 다음에는 Object.freeze (얕은 복사)를 구현하는데 깊은 곳까지 수작업으로 동결한다.
freeze는 읽기만 가능하고 최상위 객체만 동결하기 때문에 내부까지 확실하게 동결해야 한다.
const isObject = obj => obj && typeof obj === 'object' function deepFreeze(obj) { if (isObject(obj) && !Object.isFrozen(obj)) { Object.keys(obj).forEach(key => deepFreeze[key]) Object.freeze(obj) } return obj }
렌즈(lense) 또는 함수형 레퍼런스(functional reference): 상태적 자료형의 속성에 접근하여 불변화하는 함수형 프로그래밍 기법
렌즈는 람다(링크)와 같은 라이브러리를 사용하면 된다. 또한 중첩 속성까지 불변화해준다.
getter와 setter를 제공하기 때문에 this를 사용할 일이 없어 부수효과도 방지해준다.
멘털 모델
- 동적인 부분: 전체 변수의 상태와 함수 출력 같은 부분
- 정적인 부분: 설계 가독성 및 표현성 같은 부분
ES6에서 지원하는 꼬리물기 최적화
항수 (arity, 함수의 길이)
- 함수가 받는 인수의 개수
함수의 호환 요건
- 형식이 호환되어야 한다. -> 한 함수의 반환 형식과 수신 함수의 인수 형식이 일치해야 함
- 항수(arity) -> 수신 함수는 앞 단계 함수가 반환한 값을 처리하기 위해 적어도 하나 이상의 매개변수를 선언해야 함
튜플(tuple)
- 함수형 언어는 튜플이라는 자료구조를 지원한다.
- 보통 한 번에 2~3개 값을 묶어 (a, b, c)와 같이 쓴다.
- 형식이 다른 원소를 한데 묶어 다른 함수에 건네주는 일이 가능한 불변성 자료구조
- 자바스크립트에서는 객체 리터럴이나 배열 같은 형식으로 반환하는 방법을 사용한다.
함수 간에 데이터를 변환할 때 튜플이 유리한 점
- 불변성: 튜플은 한 번 만들어지면 내용을 바꿀 수 없다.
- 임의 형식의 생성 방지: 튜플은 전혀 무관한 값을 서로 연관지을 수 있다. 데이터를 묶겠다고 새로운 형식을 정의하고 인스턴스화하는 것은 괜스레 데이터 모형을 복잡하게 한다.
- 이형 배열(heterogeneous array)의 생성 방지: 형식이 다른 원소가 배열에 섞여있으면 형식을 검사하는 방어 코드를 수반하므로 다루기가 까다롭다. 배열은 태생 자체가 동일한 형식의 객체를 담는 자료구조이다.
하스켈 언어 표기법
/* <function-name> :: <inputs*> <output> ---------------|-----|-------------|------------| 함수 이름 형식을 0개 이상의 형식 단일 출력 방식 알려주는 연산자 */ // example) isEmpty :: String -> Boolean const isEmpty = s => !s || !s.trim()
커링 (currying)
- 다변수 함수가 인수를 전부 받을 때까지 실행을 보류, 또는 지연시켜 단계별로 나뉜 단항 함수의 순차열로 전환하는 기법
- 하스켈과 같은 순수 함수형 언어는 커링을 기본으로 지원하지만 자바스크립트는 자동으로 함수를 커리할 수 없으므로 어쩔 수 없이 코드를 직접 구현해야 한다.
비커리된 함수와 커리된 함수의 차이점
// 함수의 인자로 a, b, c가 있다고 할 때의 상황 // 일반 함수(비커리된 함수)는 인수에 a만 값을 넣으면 b, c는 undefined로 처리된다. f(a) -> f(a, undefined, undefined) // 반면 커리된 함수는 모자란 나머지 인수가 다 채워지길 기다리는 새로운 함수가 반환된다. f(a) -> f(b, c) f(a, b) -> f(c) f(a, b, c) -> 결과 도출
수동 커리
// 두 인수를 수동으로 커리 function curry2(fn) { return function(firstArg) { return function(secondArg) { return fn(firstArg, secondArg) } } } const fn = (a, b) => { return a + b } console.log(curry2(fn)(1)) // f () {} console.log(curry2(fn)(1)(2)) // 3
커링 기법의 사용처
- 함수 팩토리를 모방
- 재사용 가능한 모듈적 함수 템플릿을 구현
부분적용과 매개변수 바인딩
- 부분적용(partial application)
함수의 일부 매개변수 값을 처음부터 고정시켜 항수가 더 적은 함수를 생성하는 기법
(예를 들어, 매개변수가 5개인 함수가 있을 때 3개의 값을 제공하면 나머지 2개의 매개변수를 취할 함수가 생겨남)
커링과 부분적용의 차이점
1. 커링은 부분 호출할 때마다 단항 함수를 중첩 생성하며 내부적으로는 이들을 단계별로 합성하여 최종 결과를 낸다.
커링은 여러 인수를 부분 평가하는 식으로도 변용할 수 있어서 개발자가 평가 시점과 방법을 좌지우지할 수 있다.
2. 부분 적용은 함수 인수를 미리 정의된 값으로 묶은(할당한) 후, 인수가 적은 함수를 새로 만든다.
이 결과 함수는 자신의 클로저에 고정된 매개변수를 가지고 있으며, 후속 호출 시 이미 평가를 마친 상태이다.
- 매개변수 바인딩 (_.bind)
함수 조합기(function combinator)
- 함수 또는 다른 조합기 같은 기본 장치를 조합하여 제어 로직처럼 작동시킬 수 있는 고계 함수
-> identity, tap, alternation, sequence, fork, join...
함수자(functor)와 모나드(monad)
- 함수자: 함수에 매핑 가능한 단순 자료형을 생성하는 것 => 부수 효과가 없어야 하고 합성이 가능해야 한다.
- 모나드: 다양한 방식으로 에러를 처리하는 로직이 들어있는 자료형 => 함수자가 건드리는 컨테이너가 모나드
함수형 프로그래밍에서 try-catch를 잘 사용하지 않는 이유
- 합성이나 체이닝을 할 수 없다.
- 예외를 던지는 행위는 함수 호출에서 빠져나가는 구멍을 찾는 것이므로 단일한, 예측가능한 참조 투명성 원리에 위배
- 예기치 않게 스택이 풀리면 함수 호출을 벗어나 전체 시스템에 영향을 미치는 부수효과를 일으킴
- 에러를 조치하는 코드가 당초 함수를 호출한 지점과 동떨어져 있어서 비지역성 원리에 위배됨
- 함수의 단일 반환값에 써야할 에너지를 catch 블록을 선언해 특정 예외를 붙잡아 처리하는데에 낭비하면서 호출자의 부담이 증가
- 다양한 에러 조건을 처리하는 블록들이 중첩되어 사용성의 어려움
FP에서의 에러 처리 기법
1. 불안전한 값을 컨테이닝(감싸기)
값을 함수형 자료형으로 감싼다. 감싼 함수에서 에러 처리를 하면 된다.
class Wrapper { constructor(value) { this.value = value } map(f) { return f(this.value) } fmap(f) { return new Wrapper(f(this.value)) }
본격적인 함수자 사용- 승급(lifting): 어떤 값을 래퍼로 감싸 일반화하는 (에러가 날 가능성까지 감안하여 안전하게 감싸는) 행위
// fmap :: (A -> B) -> Wrapper(A) -> Wrapper(B) <= Wrapper는 임의의 컨테이너형
const wrap = (val) => new Wrapper(val) const plus = _.curry((a, b) => a + b) const plus3 = plus(3) const two = wrap(2) // -> Wrapper(2) const five = two.fmap(plus3) // -> Wrapper(2)에서 2에다 3을 더하여 새로 생성한 Wraper(5)를 반환 console.log(five) two.fmap(plus3).fmap(plus3) // -> Array의 map, filter와 같은 원리이다. // fmap이 함수자인 덕분에 함수 체이닝을 할 수 있다.
모나드 사용
class Empty { map(f) { return this } fmap(__) { return new Empty() } toString() { return 'Empty ()' } } const empty = () => new Empty() const isEven = (n) => Number.isFinite(n) && (n % 2 === 0) // => 도우미 함수 const half = (val) => isEven(val) ? wrap(val / 2) : empty() console.log(half(5)) // Empty {constructor: Object} console.log(half(4)) // Wrapper {value: 2, constructor: Object}
모나드의 중요 개념
- 모나드: 모나드 연산을 추상한 인터페이스를 제공
- 모나드형(monadic type): 모나드 인터페이스를 실제로 구현한 형식
모나드형의 인터페이스
- 형식 생성자(type constructor): 모나드형을 생성함 (Wrapper 생성자와 비슷하다)
- 단위 함수(unit function): 어떤 형식의 값을 모나드에 삽입한다. (wrap, empty 함수와 비슷하나 모나드에서는 of라고 명명)
- 바인드 함수(bind function): 연산자를 서로 체이닝한다. (함수자의 fmap에 해당하고 flatMap이라고도 한다)
- 조인 연산(join operation): 모나드의 자료 구조의 계층을 눌러편다. [평탄화 Flatten] 모나드 반환 함수를 다중 합성할 때 특히 중요하다.
다음은 Wrapper를 모나드형 인터페이스로 변경한 코드이다.
class Wrapper { // <= 형식 생성자 constructor(value) { this.value = value } static of(a) { // <= 단위 함수 return new Wrapper(a) } map(f) { // <= 바인드 함수 return Wrapper.of(f(this.value)) } join() { // <= 조인 연산 if (!this.value instanceof Wrapper) { return this } return this.value.join() } toString() { return `Wrapper (${this.value})` } }
모나드를 이용한 에러 처리 디자인 패턴
// Java, Scala 등의 언어에서는 Maybe를 Optional 또는 Option이라고 하고 // Just, Nothing도 각각 Some, None으로 용어가 살짝 다르지만 의미는 같다.
FP에서는 Maybe/Either형으로 에러를 구상화(reify)(thing, 어떤 것으로 만듬)하여 이런 일들을 처리한다고 한다.
- 불순 코드를 처리
- null 체크 로직을 정리
- 예외를 던지지 않음
- 함수 합성을 지원
- 기본값 제공 로직을 한 곳에 모음
- Maybe
Maybe 모나드는 Just, Nothing 두 하위형으로 구성된 빈 형식(표식형 marker type)으로서 주 목적은 null 체크 로직을 효과적으로 통합하는 것이다.
- Just (value): 존재하는 값을 감싼 컨테이너를 나타냄
- Nothing(): 값이 없는 컨테이너, 또는 추가 정보 없이 실패한 컨테이너를 나타냄, Nothing값에도 얼마든지 함수 적용 가능
class Maybe { // 컨테이너형 static just(a) { return new Just(a) } static nothing() { return new Nothing() } static fromNullable(a) { return a !== null ? Maybe.just(a) : Maybe.nothing() } static of(a) { return just(a) } get isNothing() { return false } get isJust() { return false } } class Just extends Maybe { constructor(value) { super() this.value = value } get value() { return this.value } map(f) { return Maybe.fromNullable(f(this.value)) } getOrElse() { return this.value } filter(f) { return Maybe.fromNullable(f(this.value)) ? this.value : null } chain(f) { return f(this.value) } toString() { return `Maybe.Just(${this.value})` } } class Nothing extends Maybe { map(f) { return this } get value() { throw new TypeError(`Nothing 값을 가져올 수 없습니다.`) } getOrElse(other) { return other } filter(f) { return this.value } chain(f) { return this } toString() { return `Maybe.Nothing` } }
- Either
Either는 절대로 동시에 발생하지 않는 두 값 a, b를 논리적으로 구분한 자료구조로서 다음 두 경우를 모형화한 형식이다.
- Left(a): 에러 메시지 또는 예외 객체를 담는다.
- Right(b): 성공한 값을 담는다.
class Either { constructor(value) { this.value = value } get value() { return this.value } static left(a) { return new Left(a) } static right(a) { return new Right(a) } static fromNullable(val) { return val !== null && val !== undefined ? Either.right(val) : Either.left(val) } } class Left extends Either { map(_) { return this } get value() { throw new TypeError(`Left(a)값을 가져올 수 없습니다.`) } getOrElse(other) { return other } orElse(f) { return f(this.value) } chain(f) { return this } getOrElseThrow(a) { throw new Error(a) } filter(f) { return this } toString() { return `Either.Left(${this.value})` } } class Right extends Either { map(f) { return Either.of(f(this.value)) } getOrElse(other) { return this.value } orElse() { return this } chain(f) { return f(this.value) } getOrElseThrow(a) { return this.value } filter(f) { return Either.fromNullable(f(this.value)) ? this.value : null } toString() { return `Either.Right(${this.value})` } }
쓰지 않는 연산을 추가한 이유는 의도적으로 추가한 자리끼우개 목적이라고 한다. 이렇게 하면 상대편 모나드가 작동할 때 안전하게 함수 실행을 건너뛰게 하기 위해서다.
튜플을 쓰지 않는 이유
- 튜플은 곱 형식, AND 관계이고 Either는 OR의 관계이기 때문에 (에러가 있거나 없거나) 두 가지 경우가 모두 발생하는 경우가 아니라면 Either가 더 적절하다.
- I/O
I/O 모나드로 외부 자원과 상호작용
class IO { constructor(effect) { if (!_.isFunction(effect)) { throw `IO 사용법: 함수는 필수입니다!`; } this.effect = effect } static of(a) { return new IO(() => a) } static from(fn) { return new IO(fn) } map(fn) { let self = this return new IO(() => fn(self.effect())) } chain(fn) { return fn(this.effect()) } run() { return this.effect() } }
함수 최적화
0. FP의 동작원리는 자바스크립트의 실행 컨텍스트를 알면 쉽게 알 수 있다!
1. lazy function evaluation
하스켈 같은 함수형 언어는 기본적으로 모든 함수 표현식을 느긋하게 평가한다. (lazy function evaluation)
반면 자바스크립트는 조급하게 평가(eager evaluation, greedy evaluation)하기 때문에 함수 결과값이 필요한지 따져볼 시간도 없이 변수에 바인딩되자마자 표현식 평가를 마친다.// 조급한 평가(eager evaluation) [1, 2, 3, ... , 9, 10] range(1, 10) ----------------------------> take(3) => 결과: [1, 2, 3] // 느긋한 평가 (lazy function evaluation) [함수 호출을 나중으로 미룸] range(1, 10) ----------------------------> take(3) => 원소를 단 3개만 생성 => 결과: [1, 2, 3]
- 불필요한 계산 피하기// 명령형 프로그래밍으로 변환하면 다음과 같다 // 필요할 때만 호출해서 평가를 늦추는 전략 const student = findStudent('444-44-4444') if (student !== null) { // ... } else { // ... } // FP const alt = R.curry((func1, func2, val) => func1(val) || func2(val)) // curry에서 한 쪽만 호출하여 불필요한 계산 건너뛰기 const showStudent = R.compose( append('#student-info'), alt(findStudent, createNewStudent) ) showStudent('444-44-4444')
- 함수형 라이브러리에서 단축 융합(shortcut fusion)을 사용
단축 융합은 몇 개 함수의 실행을 하나로 병합하고 중간 결과를 계산할 때 사용하는 내부 자료구조의 개수를 줄이는 함수 수준의 최적화이다. 자료구조가 줄면 대량 데이터를 처리할 때 필요한 과도한 메모리 사용을 낮출 수 있다.
// lodash를 활용한 단축 융합 // values 함수를 호출하면 전체 함수 순차열을 몽땅 실행하도록 만드는 함수 // lodash가 내부적으로 프로그램 실행을 최적화한다 _.chain([p1, p2, p3, p4, p5, p6, p7, p8]) .filter(isValid) .map(_.property('address country')) .reduce(gatherStats, {}) .value() .sortBy() .reverse() .first() .value() const square = x => Math.pow(x, 2) const isEven = x => x % 2 === 0 const numbers = _.range(200) const result = _.chain(numbers).map(square).filter(isEven).take(3).value() console.log(result, result.length) // [0, 4, 16], 3
2. memoization
메모이제이션 알고리즘을 구현하면 된다.3. tail call optimization
ES6부터 신설된 컴파일러 개선 항목으로서 재귀 호출 실행을 단일 프레임으로 눌러 펴 실행한다.
재귀 프로그램이 제일 마지막에 다른 함수(보통 자기 자신)를 호출할 경우에만 TCO가 일어난다.
이 때 마지막 호출이 꼬리 위치에 있다고 부른다.
// 비꼬리 호출 함수 const factorial = (n) => n === 1 ? 1 : n * factorial(n - 1) // 꼬리 호출 함수 const factorial = (n, current = 1) => n === 1 ? current : factorial(n - 1, n * current)
References
도서: 함수형 프로그래밍 (http://www.yes24.com/Product/Goods/58181696?OzSrank=19)
@@@@@
'JavaScript' 카테고리의 다른 글
JavaScript 디자인 패턴 (0) 2021.05.12 함수 호출 방법 (0) 2021.04.25 JavaScript Garbage Collector (0) 2021.01.07 Map, Set (0) 2021.01.06 브라우저 동작 원리와 Progressive Render, Built-in 객체 (0) 2021.01.06