순수함수와 부분함수

첫 번째 챕터에서 순수함수에 대한 비공식적인 정의를 보았습니다:

순수함수란 같은 입력에 항상 같은 결과를 내는 관찰 가능한 부작용없는 절차입니다.

위와같은 비공식적 문구를 보고 다음과 같은 의문점이 생길 수 있습니다:

  • "부작용"이란 무엇인가?
  • "관찰가능하다"는 것은 무엇을 의미하는가?
  • "같다"라는게 무엇을 의미하는가?

이 함수의 공식적인 정의를 살펴봅시다.

참고. 만약 XY 가 집합이면, X × Y곱집합 이라 불리며 다음과 같은 집합을 의미합니다

X × Y = { (x, y) | x ∈ X, y ∈ Y }

다음 정의 는 한 세기 전에 만들어졌습니다:

정의. 함수 f: X ⟶ YX × Y 의 부분집합이면서 다음 조건을 만족합니다, 모든 x ∈ X 에 대해 (x, y) ∈ f 를 만족하는 오직 하나의 y ∈ Y 가 존재합니다.

집합 X 는 함수 f정의역 이라 하며, Yf공역 이라 합니다.

예제

함수 double: Nat ⟶ Nat 곱집합 Nat × Nat 의 부분집합이며 형태는 다음과 같습니다: { (1, 2), (2, 4), (3, 6), ...}

Typescript 에서는 f 를 다음과 같이 정의할 수 있습니다

const f: Record<number, number> = {
  1: 2,
  2: 4,
  3: 6
  ...
}

위 예제는 함수의 확장적 정의라고 불리며, 이는 정의역의 요소들을 꺼내 그것들 각각에 대해 공역의 원소에 대응하는 것을 의미합니다.

당연하게도, 이러한 집합이 무한이라면 문제가 발생한다는 것을 알 수 있습니다. 모든 함수의 정의역과 공역을 나열할 수 없기 때문입니다.

이 문제는 조건적 정의라고 불리는 것을 통해 해결할 수 있습니다. 예를들면 (x, y) ∈ f 인 모든 순서쌍에 대해 y = x * 2 가 만족한다는 조건을 표현하는 것입니다.

TypeScript 에서 다음과 같은 익숙한 형태인 double 함수의 정의를 볼 수 있습니다:

const double = (x: number): number => x * 2

곱집합의 부분집합으로서의 함수의 정의는 수작적으로 어떻게 모든 함수가 순수한지 보여줍니다: 어떠한 작용도, 상태 변경과 요소의 수정도 없습니다. 함수형 프로그래밍에서 함수의 구현은 가능한 한 이상적인 모델을 따라야합니다.

문제. 다음 절차(procedure) 중 순수 함수는 무엇일까요?

const coefficient1 = 2
export const f1 = (n: number) => n * coefficient1

// ------------------------------------------------------

let coefficient2 = 2
export const f2 = (n: number) => n * coefficient2++

// ------------------------------------------------------

let coefficient3 = 2
export const f3 = (n: number) => n * coefficient3

// ------------------------------------------------------

export const f4 = (n: number) => {
  const out = n * 2
  console.log(out)
  return out
}

// ------------------------------------------------------

interface User {
  readonly id: number
  readonly name: string
}

export declare const f5: (id: number) => Promise<User>

// ------------------------------------------------------

import * as fs from 'fs'

export const f6 = (path: string): string =>
  fs.readFileSync(path, { encoding: 'utf8' })

// ------------------------------------------------------

export const f7 = (
  path: string,
  callback: (err: Error | null, data: string) => void
): void => fs.readFile(path, { encoding: 'utf8' }, callback)

함수가 순수하다는 것이 꼭 지역변수의 변경 (local mutability)을 금지한다는 의미는 아닙니다. 변수가 함수 범위를 벗어나지 않는다면 변경할 수 있습니다.

mutable / immutable

예제 (monoid 용 concatALl 함수 구현)

import { Monoid } from 'fp-ts/Monoid'

const concatAll = <A>(M: Monoid<A>) => (as: ReadonlyArray<A>): A => {
  let out: A = M.empty // <= local mutability
  for (const a of as) {
    out = M.concat(out, a)
  }
  return out
}

궁극적인 목적은 참조 투명성 을 만족하는 것입니다.

우리의 API 사용자와의 계약은 API 의 signature 와 참조 투명성을 준수하겠다는 약속에 의해 정의됩니다.

declare const concatAll: <A>(M: Monoid<A>) => (as: ReadonlyArray<A>) => A

함수가 구현되는 방법에 대한 기술적 세부 사항은 관련이 없으므로, 구현 측면에서 많은 자유를 누릴 수 있습니다.

그렇다면, 우리는 부작용을 어떻게 정의해야 할까요? 단순히 참조 투명성 정의의 반대가 됩니다:

어떤 표현식이 참조 투명성을 지키지 않는다면 "부작용" 을 가지고 있습니다

함수는 함수형 프로그래밍의 두 가지 요소 중 하나인 참조 투명성의 완벽한 예시일 뿐만 아니라, 두 번째 요소한 합성 의 예시이기도 합니다.

함수 합성:

정의. 두 함수 f: Y ⟶ Zg: X ⟶ Y 에 대해, 함수 h: X ⟶ Z 는 다음과 같이 정의된다:

h(x) = f(g(x))

이는 fg합성 이라하며 h = f ∘ g 라고 쓴다.

fg 를 합성하려면, f 의 정의역이 g 의 공역에 포함되어야 한다는 점에 유의하시기 바랍니다.

정의. 정의역의 하나 이상의 값에 대해 정의되지 않는 함수는 부분함수 라고 합니다.

반대로, 정의역의 모든 원소에 대해 정의된 함수는 전체함수 라고 합니다.

예제

f(x) = 1 / x

함수 f: number ⟶ numberx = 0 에 대해서는 정의되지 않습니다.

예제

// `ReadonlyArray` 의 첫 번째 요소를 얻습니다
declare const head: <A>(as: ReadonlyArray<A>) => A

문제. 왜 head 는 부분함수 인가요?

문제. JSON.parse 는 전체함수 일까요?

parse: (text: string, reviver?: (this: any, key: string, value: any) => any) =>
  any

문제. JSON.stringify 는 전체함수 일까요?

stringify: (
  value: any,
  replacer?: (this: any, key: string, value: any) => any,
  space?: string | number
) => string

함수형 프로그래밍에서는 순수 및 전체함수 만 정의하는 경향이 있습니다. 지금부터 함수라는 용어는 "순수하면서 전체함수" 를 의미합니다. 그렇다면 애플리케이션에 부분함수가 있다면 어떻게 해야 할까요?

부분함수 f: X ⟶ Y 는 특별한 값(None 이라 부르겠습니다)을 공역에 더함으로써 언제나 전체함수로 되돌릴 수 있습니다. 정의되지 않은 모든 X 의 값을 None 에 대응하면 됩니다.

f': X ⟶ Y ∪ None

Y ∪ NoneOption(Y) 와 동일하다고 정의한다면

f': X ⟶ Option(Y)

TypeScript 에서 Option 을 정의할 수 있을까요? 다음 장에서 그 방법을 살펴보겠습니다.