一.순수 함수
순수 함수란 부작용이 없는 함수 (a function that has no side effects) 를 말하며, 몇 가지 장점이 있습니다:
-
참조 투명성 (referential transparency)
-
코드 추론 가능성 (reason about your code)
P.S. 참조 투명성에 대해서는, [기초 구문_Haskell 노트 1](/articles/기초 구문-haskell 노트 1/#articleHeader1) 참조
부작용 제로 (side effects) 가 핵심이지만, 일부 부작용은 피할 수 없으며 매우 중요합니다. 예를 들어:
-
출력:Console 에 표시, 프린터로 전송, 데이터베이스에 쓰기 등
-
입력:입력 장치에서 사용자 입력 취득, 네트워크 요청 정보 등
그렇다면, 순수 함수를 권장하는 함수형 프로그래밍은 이러한 장면에 어떻게 대응할까요? 2 가지 해법이 있습니다:
-
의존성 주입
-
Effect Functor
二.의존성 주입
We take any impurities in our code, and shove them into function parameters. Then we can treat them as some other function's responsibility.
간단히 말해, 불순한 부분을 파라미터로 제거하는 것입니다
예를 들어:
// logSomething :: String -> String
function logSomething(something) {
const dt = new Date().toISOString();
console.log(`${dt}: ${something}`);
return something;
}
logSomething 함수에는 2 개의 불순한 요소가 있으며, Date 와 console 은 몰래 취득하는 외부 상태이므로, 같은 입력 (something) 에 대해, 반드시 같은 출력 결과가 된다고 할 수 없습니다 (log 동작 및 출력 내용이 모두 불확정). 따라서, 같은 입력이 항상 같은 출력에 대응하도록 하기 위해, 이 기만적인 수단을 채택합니다:
// logSomething: Date -> (String -> *) -> String -> *
function logSomething(d, log, something) {
const dt = d.toISOString();
return log(`${dt}: ${something}`);
}
이렇게 하면, 같은 입력에 대응하는 같은 출력을 실현할 수 있습니다:
const something = "Curiouser and curiouser!"
const d = new Date();
const log = console.log.bind(console);
logSomething(d, log, something);
이것은 정말 어리석어 보이지만, 독선其身은 의미가 없어 보입니다. 실제로, 우리는 몇 가지 일을 수행했습니다:
-
불순한 부분을 박리함
-
그것들을 밀어내고, 코어 코드에서 멀리함 (
logSomething밖으로 취득) -
logSomething을 순수하게 함 (동작이 예측 가능)
의미는 불확실성 (unpredictability) 을 제어하는 데 있습니다:
-
범위 축소:불확실성을 더 작은 함수 (
log) 내로 이동 -
집중 관리:반복해서 범위를 축소하고, 불확실성을 끝 (애플리케이션 입구 등) 으로 밀어내면, 불확실성을 코어 코드에서 멀리하고, 코어 코드의 동작을 예측 가능하게 보증할 수 있습니다
So we end up with a thin shell of impure code that wraps around a well-tested, predictable core.
P.S. 이렇게 하는 것은 테스트에도 유리하며, 이 불순한 얇은 껍질을 교체하기만 하면, 코어 코드를 시뮬레이트된 테스트 환경에서 실행할 수 있고, 실행 환경 전체를 시뮬레이트할 필요가 없습니다
하지만, 이 파라미터화된 의존성 주입 방식은 완벽하지 않으며, 그 단점은:
-
메서드 시그니처가 김:예를 들어
app(document, console, fetch, store, config, ga, (new Date()), Math.random) -
인자 전달 체인이 김:예를 들어 React 에서顶层 컴포넌트에서 어떤 리프 컴포넌트까지
props를 릴레이하여 전달
긴 메서드 시그니처의 장점은 어떤 불순한 것에 호출 의존하는지를 명확히 표시하는 것이지만, 층층이 인자를 전달하는 것은 확실히 번거롭습니다
三.지연 함수 (Lazy Function)
또 하나의 부작용을 제어하는思路는, 부작���을 낳는 부분을 보호하는 것입니다 (지하철 방폭구에 넣음), 이 방호 껍질을 가지고 연산에 참여하고, 결과가 필요할 때까지 껍질을 열어 값을 취득하지 않습니다
예를 들어:
// fZero :: () -> Number
function fZero() {
console.log('발사 핵탄두');
// Code to launch nuclear missiles goes here
return 0;
}
분명히, fZero 는 순수 함수가 아니며, 극히 큰 부작용이 있습니다 (핵탄두를 발사합니다). 안전을 위해, 이 위험한 조작을 방폭구에 넣습니다:
// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
return fZero;
}
다음으로 이 공을 자유롭게 조작해도 핵탄두가 발사되지 않습니다:
const zeroFunc = returnZeroFunc();
roll(zeroFunc);
knock(zeroFunc);
하지만, returnZeroFunc 는 여전히 순수 함수가 아닙니다 (외부의 fZero 에 의존), 의존 관계를 받아들입시다:
// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
function fZero() {
console.log('Launching nuclear missiles');
// Code to launch nuclear missiles goes here
return 0;
}
return fZero;
}
결과를 직접 반환하지 않고, 기대되는 결과를 반환할 수 있는 함수를 반환합니다 ([thunk](/articles/게으른 io 에서 말하다-haskell 노트 6/#articleHeader3) 같은 의미), 이에 유추:
// fIncrement :: (() -> Number) -> (() -> Number)
function fIncrement(f) {
return () => f() + 1;
}
const fOne = fIncrement(fZero);
const fTwo = fIncrement(fOne);
const fThree = fIncrement(fTwo);
// And so on…
우리는 몇 가지 특수한 수치를 정의하고, 이러한 수치에 대한 임의의 조작 (전달, 계산 등) 은 안전하고 부작용이 없습니다:
// fMultiply :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fMultiply(a, b) {
return () => a() * b();
}
// fPow :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fPow(a, b) {
return () => Math.pow(a(), b());
}
// fSqrt :: (() -> Number) -> (() -> Number)
function fSqrt(x) {
return () => Math.sqrt(x());
}
const fFour = fPow(fTwo, fTwo);
const fEight = fMultiply(fFour, fTwo);
const fTwentySeven = fPow(fThree, fThree);
const fNine = fSqrt(fTwentySeven);
// No console log or thermonuclear war. Jolly good show!
이러한 조작은 공식 변환에相当하며, 최종적인 대수 계산 시에만 진정한 부작용을 낳습니다. 부작용을 침전시키고, 의존성 주입의 방식은 부작용을 떠오르게 하는ようなもので, 두 방식 모두 부작용을 분리하고, 불확실성을 제어하는 목적을 달성할 수 있습니다
하지만, 수치의 정의가 바뀌었기 때문에 (수치에서 수치를 반환하는 함수로), 가、감、승、제……수치에 기반한 산술 연산의 한整套를 재정의해야 하며, 이는 정말 어리석고, 더 좋은 방법이 있습니까?
四.Effect Functor
至此, 우리는 수치를 수치를 반환하는 함수에 매핑하고, 수치 연산을 이 특수한 수치를 조작할 수 있는 함수에 매핑했습니다. 잠깐, 매핑、방폭구、포장、포장된 것을 조작……무엇이 생각나십니까?
맞습니다, [Functor](/articles/깊이 typeclass 를 탐구하다-haskell 노트 4/#articleHeader5) 입니다:
-- Haskell
class Functor f where
fmap :: (a -> b) -> f a -> f b
fmap 이 정의하는 동작은 바로 컨테이너 내의 내용 (값) 에 매핑을 수행하고, 완료 후 컨테이너에 다시 넣는 것입니다
이것이 바로 지연 함수 방식에서 절실히 원하는 것이 아닙니까?
JS 로 구현试试看, 먼저 컨테이너 타입 (Effect) 을 생성:
// Effect :: Function -> Effect
function Effect(f) {
return {
get: () => f
}
}
컨테이너가 있으면 상자 채우기/상자 열기 조작이 가능합니다:
// 부작용을 포함하는 메서드
function fZero() {
console.log('some side effects...');
return 0;
}
// 상자 채우기, fZero 를 Effect 에 넣음
const eZero = Effect(fZero);
// 상자 열기, Effect 에서 fZero 를 꺼냄
eZero.get();
-- Haskell 중의 에 대응
-- 상자 채우기
let justZero = Just (\x -> 0)
-- 상자 열기
let (Just fZero) = justZero in fZero
다음으로 fmap 을 구현:
// fmap :: ((a -> b), Effect a) -> Effect b
function fmap(g, ef) {
let f = ef.get();
return Effect(x => g(f(x)));
}
// test
let eOne = fmap(x => x + 1, Effect(fZero));
eOne.get()(); // 1
또는 더地道 (함수 시그니처가 일치) 한 curried 버전:
const curry = f => arg => f.length > 1 ? curry(f.bind(null, arg)) : f(arg);
// fmap :: (a -> b) -> Effect a -> Effect b
fmap = curry(fmap);
// test
let eOne = fmap(x => x + 1)(Effect(fZero));
eOne.get()(); // 1
Effect 를 실행하는 get()() 는 조금 장황해 보이므로, 간소화하고, 동시에 fmap 도 받아들여, 이들을 더 JS 의 맛에 맞춥니다:
// Effect :: Function -> Effect
function Effect(f) {
return {
get: () => f,
run: x => f(x),
map(g) {
return Effect(x => g(f(x)));
}
}
}
试玩一下:
const increment = x => x + 1;
const double = x => x * 2;
const cube = x => x ** 3;
// (0 + 1) * 2 ^ 3
const calculations = Effect(fZero)
.map(increment)
.map(double)
.map(cube)
calculations.run(); // 8
이 일련의 map 연산은 모두 부작용을 포함하지 않고, 마지막에 run() 해서 비로소 fZero 의 부작용을 일으킵니다. 이것이 바로 지연 함수 방식의 의미: 부작용을 모래처럼 마지막에 침전시키고, 상층의 물을 순수 투명하게 보증합니다
P.S. 위에서 구현한 Effect 는 실제로 함수 Functor 에相当하며, [함수에 대한 매핑 조작은 실제로 함수 합성](/articles/functor 와 applicative-haskell 노트 7/#articleHeader3) 입니다:
-- Haskell
instance Functor ((->) r) where
fmap = (.)
(.) :: (b -> c) -> (a -> b) -> a -> c
(.) f g = \x -> f (g x)
// 즉
map(g) {
return Effect(x => g(f(x)));
}
따라서 중요한 점은 함수 합성 (compose) 에 있습니다:
// 특수값
const fZero = x => 0;
// 보통 함수
const double = x => x + x;
// 직접 double fZero 는 불가
// Functor fmap 개념을 도입
const compose = (f, g) => x => g(f(x));
// double 을 변경하지 않고, double fZero 를 실현
compose(fZero, double)();
// (0 + 1) * 2 ^ 3
// compose(compose(compose(fZero, increment), double), cube)();
五.まとめ
의존성 주입이든 Effect Functor 방식이든, 부작용을 처리하는 원칙은 그것이 가져오는 불확실성을 일정 범위 내로 제한하고, 다른 부분이 순수한 특성을 유지할 수 있도록 하는 것입니다
사방에 부작용이 뒤섞인 애플리케이션을 모래가 섞인 물 한 잔으로 본다면, 두 방식의 차이는 물을 맑게 하는思路가 다릅니다:
-
의존성 주입:모래를 떠오르게 하여 최상층에 시키고, 불순한 얇은 껍질을 형성하여,下方的 물을 순수하게 유지
-
Effect Functor:모래를 컵 바닥에 침전시키고, 상방의 물을 맑고 투명하게
확실히, 부작용은 여전히 존재하며,消除된 것은 아닙니다. 하지만, 유사한 방식을 통해 대부분의 코드가 순수한 특성을 유지하고, 순수 함수가 가져오는 결정적인 이점 (think less) 을 누릴 수 있습니다:
You can be confident that the only thing affecting their behaviour are the inputs passed to it. And this narrows down the number of things you need to consider. In other words, it allows you to think less.
아직 댓글이 없습니다