이제 참조 투명성과 monad 개념을 통해 프로그램을 어떻게 다루는지 살펴봅시다.
여기 파일을 읽고 쓰는 작은 프로그램이 있습니다:
import { log } from 'fp-ts/Console'
import { IO, chain } from 'fp-ts/IO'
import { pipe } from 'fp-ts/function'
import * as fs from 'fs'
// -----------------------------------------
// 라이브러리 함수
// -----------------------------------------
const readFile = (filename: string): IO<string> => () =>
fs.readFileSync(filename, 'utf-8')
const writeFile = (filename: string, data: string): IO<void> => () =>
fs.writeFileSync(filename, data, { encoding: 'utf-8' })
// 지금까지 살펴본 함수들을 통해 만든 API
const modifyFile = (filename: string, f: (s: string) => string): IO<void> =>
pipe(
readFile(filename),
chain((s) => writeFile(filename, f(s)))
)
// -----------------------------------------
// 프로그램
// -----------------------------------------
const program1 = pipe(
readFile('file.txt'),
chain(log),
chain(() => modifyFile('file.txt', (s) => s + '\n// eof')),
chain(() => readFile('file.txt')),
chain(log)
)
pipe(readFile('file.txt'), chain(log))
위 로직은 프로그램에서 여러번 반복됩니다, 하지만 참조 투명성은 해당 식을 상수로 만들 수 있음을 보장해줍니다:
const read = pipe(readFile('file.txt'), chain(log))
const modify = modifyFile('file.txt', (s) => s + '\n// eof')
const program2 = pipe(
read,
chain(() => modify),
chain(() => read)
)
또한 combinator 를 정의해 활용하면 코드를 더 간결하게 만들 수 있습니다.
const interleave = <A, B>(action: IO<A>, middle: IO<B>): IO<A> =>
pipe(
action,
chain(() => middle),
chain(() => action)
)
const program3 = interleave(read, modify)
또 다른 예제: IO
를 위한 Unix 의 time
명령어와 유사한 함수를 구현하기.
import * as IO from 'fp-ts/IO'
import { now } from 'fp-ts/Date'
import { log } from 'fp-ts/Console'
import { pipe } from 'fp-ts/function'
// 계산 시간을 밀리세컨드 단위로 로그를 남깁니다
export const time = <A>(ma: IO.IO<A>): IO.IO<A> =>
pipe(
now,
IO.chain((startMillis) =>
pipe(
ma,
IO.chain((a) =>
pipe(
now,
IO.chain((endMillis) =>
pipe(
log(`Elapsed: ${endMillis - startMillis}`),
IO.map(() => a)
)
)
)
)
)
)
)
여담. 보시다시피, chain
을 사용하면서 scope 를 유지하는 경우 장황한 코드가 만들어집니다.
monadic 스타일을 기본적으로 지원하는 언어에서는 이러한 상황을 쉽게 해결해주는 "do notation" 이라는 이름으로 통하는 문법을 제공합니다.
Haskell 을 예로들면
now :: IO Int
now = undefined -- Haskell 에서의 `undefined` 는 TypeScript 와 동일한 의미를 가집니다
log :: String -> IO ()
log = undefined
time :: IO a -> IO a
time ma = do
startMillis <- now
a <- ma
endMillis <- now
log ("Elapsed:" ++ show (endMillis - startMillis))
return a
TypeScript 에서는 이러한 문법을 지원하지 않지만, 비슷한 역할을 하는 로직을 구현할 수 있습니다:
import { log } from 'fp-ts/Console'
import { now } from 'fp-ts/Date'
import { pipe } from 'fp-ts/function'
import * as IO from 'fp-ts/IO'
// 계산 시간을 밀리세컨드 단위로 로그를 남깁니다
export const time = <A>(ma: IO.IO<A>): IO.IO<A> =>
pipe(
IO.Do,
IO.bind('startMillis', () => now),
IO.bind('a', () => ma),
IO.bind('endMillis', () => now),
IO.chainFirst(({ endMillis, startMillis }) =>
log(`Elapsed: ${endMillis - startMillis}`)
),
IO.map(({ a }) => a)
)
time
combinator 를 활용한 예제를 살펴봅시다:
import { randomInt } from 'fp-ts/Random'
import { Monoid, concatAll } from 'fp-ts/Monoid'
import { replicate } from 'fp-ts/ReadonlyArray'
const fib = (n: number): number => (n <= 1 ? 1 : fib(n - 1) + fib(n - 2))
// 30 과 35 사이의 임의의 숫자를 인자로 `fib` 함수를 호출합니다
// 또한 입력과 출력을 로그에 남깁니다
const randomFib: IO.IO<void> = pipe(
randomInt(30, 35),
IO.chain((n) => log([n, fib(n)]))
)
// `IO<void>` 용 monoid 인스턴스
const MonoidIO: Monoid<IO.IO<void>> = {
concat: (first, second) => () => {
first()
second()
},
empty: IO.of(undefined)
}
// `mv` 연산을 `n` 번 수행합니다
const replicateIO = (n: number, mv: IO.IO<void>): IO.IO<void> =>
concatAll(MonoidIO)(replicate(n, mv))
// -------------------
// 사용 예제
// -------------------
time(replicateIO(3, randomFib))()
/*
[ 31, 2178309 ]
[ 33, 5702887 ]
[ 30, 1346269 ]
Elapsed: 89
*/
중간 로그를 남길수도 있습니다:
time(replicateIO(3, time(randomFib)))()
/*
[ 33, 5702887 ]
Elapsed: 54
[ 30, 1346269 ]
Elapsed: 13
[ 32, 3524578 ]
Elapsed: 39
Elapsed: 106
*/
Monadic 인터페이스 (map
, of
, chain
) 의 활용에서 가장 흥미로운 측면은 프로그램이 필요한 의존성을 여러 계산을 연결하는 방식 과 함께 주입할 수 있다는 것입니다.
이를 확인하기 위해, 파일을 읽고 쓰는 프로그램을 개선해봅시다:
import { IO } from 'fp-ts/IO'
import { pipe } from 'fp-ts/function'
// -----------------------------------------
// 헥사고날 아키텍쳐에서 "port" 라고 부르는 Deps 인터페이스
// -----------------------------------------
interface Deps {
readonly readFile: (filename: string) => IO<string>
readonly writeFile: (filename: string, data: string) => IO<void>
readonly log: <A>(a: A) => IO<void>
readonly chain: <A, B>(f: (a: A) => IO<B>) => (ma: IO<A>) => IO<B>
}
// -----------------------------------------
// 프로그램
// -----------------------------------------
const program4 = (D: Deps) => {
const modifyFile = (filename: string, f: (s: string) => string) =>
pipe(
D.readFile(filename),
D.chain((s) => D.writeFile(filename, f(s)))
)
return pipe(
D.readFile('file.txt'),
D.chain(D.log),
D.chain(() => modifyFile('file.txt', (s) => s + '\n// eof')),
D.chain(() => D.readFile('file.txt')),
D.chain(D.log)
)
}
// -----------------------------------------
// 헥사고날 아키텍쳐에서 "adapter" 라 부르는 `Deps` 인스턴스
// -----------------------------------------
import * as fs from 'fs'
import { log } from 'fp-ts/Console'
import { chain } from 'fp-ts/IO'
const DepsSync: Deps = {
readFile: (filename) => () => fs.readFileSync(filename, 'utf-8'),
writeFile: (filename: string, data: string) => () =>
fs.writeFileSync(filename, data, { encoding: 'utf-8' }),
log,
chain
}
// 의존성 주입
program4(DepsSync)()
더 나아가, 우리는 프로그램이 실행하는 효과를 추상화할 수 있습니다. 바로 FileSystem
효과를 직접 정의하는 것입니다. (파일 시스템에 동작하는 읽기-쓰기를 의미하는 효과):
import { IO } from 'fp-ts/IO'
import { pipe } from 'fp-ts/function'
// -----------------------------------------
// 프로그램의 효과
// -----------------------------------------
interface FileSystem<A> extends IO<A> {}
// -----------------------------------------
// 의존성
// -----------------------------------------
interface Deps {
readonly readFile: (filename: string) => FileSystem<string>
readonly writeFile: (filename: string, data: string) => FileSystem<void>
readonly log: <A>(a: A) => FileSystem<void>
readonly chain: <A, B>(
f: (a: A) => FileSystem<B>
) => (ma: FileSystem<A>) => FileSystem<B>
}
// -----------------------------------------
// 프로그램
// -----------------------------------------
const program4 = (D: Deps) => {
const modifyFile = (filename: string, f: (s: string) => string) =>
pipe(
D.readFile(filename),
D.chain((s) => D.writeFile(filename, f(s)))
)
return pipe(
D.readFile('file.txt'),
D.chain(D.log),
D.chain(() => modifyFile('file.txt', (s) => s + '\n// eof')),
D.chain(() => D.readFile('file.txt')),
D.chain(D.log)
)
}
우리는 단순히 FileSystem
효과의 정의를 수정하기만 하면, 비동기적으로 실행하는 프로그램으로 변경할 수 있습니다.
// -----------------------------------------
// 프로그램의 효과
// -----------------------------------------
-interface FileSystem<A> extends IO<A> {}
+interface FileSystem<A> extends Task<A> {}
이제 남은 작업은 새로운 정의에 맞게 Deps
인스턴스를 수정하는 것입니다.
import { Task } from 'fp-ts/Task'
import { pipe } from 'fp-ts/function'
// -----------------------------------------
// 프로그램의 효과 (수정됨)
// -----------------------------------------
interface FileSystem<A> extends Task<A> {}
// -----------------------------------------
// 의존성 (수정 안됨)
// -----------------------------------------
interface Deps {
readonly readFile: (filename: string) => FileSystem<string>
readonly writeFile: (filename: string, data: string) => FileSystem<void>
readonly log: <A>(a: A) => FileSystem<void>
readonly chain: <A, B>(
f: (a: A) => FileSystem<B>
) => (ma: FileSystem<A>) => FileSystem<B>
}
// -----------------------------------------
// 프로그램 (수정 안됨)
// -----------------------------------------
const program5 = (D: Deps) => {
const modifyFile = (filename: string, f: (s: string) => string) =>
pipe(
D.readFile(filename),
D.chain((s) => D.writeFile(filename, f(s)))
)
return pipe(
D.readFile('file.txt'),
D.chain(D.log),
D.chain(() => modifyFile('file.txt', (s) => s + '\n// eof')),
D.chain(() => D.readFile('file.txt')),
D.chain(D.log)
)
}
// -----------------------------------------
// `Deps` 인스턴스 (수정됨)
// -----------------------------------------
import * as fs from 'fs'
import { log } from 'fp-ts/Console'
import { chain, fromIO } from 'fp-ts/Task'
const DepsAsync: Deps = {
readFile: (filename) => () =>
new Promise((resolve) =>
fs.readFile(filename, { encoding: 'utf-8' }, (_, s) => resolve(s))
),
writeFile: (filename: string, data: string) => () =>
new Promise((resolve) => fs.writeFile(filename, data, () => resolve())),
log: (a) => fromIO(log(a)),
chain
}
// 의존성 주입
program5(DepsAsync)()
문제 이전 예제에서는 일부러 발생 가능한 오류를 무시했습니다. 예를들면 작업 중인 파일이 존재하지 않을 수 있습니다. 이를 고려해 FileSystem
효과를 어떻게 수정할 수 있을까요?
import { Task } from 'fp-ts/Task'
import { pipe } from 'fp-ts/function'
import * as E from 'fp-ts/Either'
// -----------------------------------------
// 프로그램의 효과 (수정됨)
// -----------------------------------------
interface FileSystem<A> extends Task<E.Either<Error, A>> {}
// -----------------------------------------
// 의존성 (수정 안됨)
// -----------------------------------------
interface Deps {
readonly readFile: (filename: string) => FileSystem<string>
readonly writeFile: (filename: string, data: string) => FileSystem<void>
readonly log: <A>(a: A) => FileSystem<void>
readonly chain: <A, B>(
f: (a: A) => FileSystem<B>
) => (ma: FileSystem<A>) => FileSystem<B>
}
// -----------------------------------------
// 프로그램 (수정 안됨)
// -----------------------------------------
const program5 = (D: Deps) => {
const modifyFile = (filename: string, f: (s: string) => string) =>
pipe(
D.readFile(filename),
D.chain((s) => D.writeFile(filename, f(s)))
)
return pipe(
D.readFile('-.txt'),
D.chain(D.log),
D.chain(() => modifyFile('file.txt', (s) => s + '\n// eof')),
D.chain(() => D.readFile('file.txt')),
D.chain(D.log)
)
}
// -----------------------------------------
// `Deps` 인스턴스 (수정됨)
// -----------------------------------------
import * as fs from 'fs'
import { log } from 'fp-ts/Console'
import { chain, fromIO } from 'fp-ts/TaskEither'
const DepsAsync: Deps = {
readFile: (filename) => () =>
new Promise((resolve) =>
fs.readFile(filename, { encoding: 'utf-8' }, (err, s) => {
if (err !== null) {
resolve(E.left(err))
} else {
resolve(E.right(s))
}
})
),
writeFile: (filename: string, data: string) => () =>
new Promise((resolve) =>
fs.writeFile(filename, data, (err) => {
if (err !== null) {
resolve(E.left(err))
} else {
resolve(E.right(undefined))
}
})
),
log: (a) => fromIO(log(a)),
chain
}
// 의존성 주입
program5(DepsAsync)().then(console.log)
데모