Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save carly-lee/a87538d62c0525cbdd20e85f28e79b58 to your computer and use it in GitHub Desktop.
Save carly-lee/a87538d62c0525cbdd20e85f28e79b58 to your computer and use it in GitHub Desktop.
프로페서 프리스비의 조합 가능한 함수형 자바스크립트로의 소개

egghead.io에 있는 위 강의를 간략히 정리한 스터디 노트입니다.

1. 컨테이너 스타일 유형의 데이터 타입(Box)으로 선형 데이터 흐름 만들기

아래의 예시 코드에서는 여러가지 일이 하나의 함수 안에서 일어납니다. 아래의 함수가 하는 일은 문자열을 받아서 trim하고 그걸 integer로 바꾸고 거기에 숫자 1을 더한 후, 그 숫자 값의 문자를 출력하는 것입니다.

const nextCharForNumberString = str => {
  const trimmed = str.trim()
  const number = parseInt(trimmed)
  const nextNumber = number + 1
  return String.fromCharCode(nextNumber)
}

const result = nextCharForNumberString('  64 ')
console.log( result ) // A

위의 함수는 아래와 같이 다시 쓸 수 있는데, 이 경우는 한눈에 읽기가 힘듭니다.

const nextCharForNumberString = str => String.fromCharCode( parseInt( str.trim() ) +1 )
const result = nextCharForNumberString('  64 ')
console.log( result ) // A

이걸 작은 조각의 한가지 일만 하는 함수들로 나누어서 조합할 수 있습니다. 자바스크립트에서 string은 map을 쓸수 없으므로, 배열에 넣어서 map을 이용하여 연속적으로 함수를 적용할 수 있습니다.

const nextCharForNumberString = str =>
  [str]
  .map(s => s.trim())
  .map(s => parseInt(s))
  .map(i => i + 1)
  .map(i => String.fromCharCode(i)) // [ 'A' ]

위의 코드에서의 특징은 각 함수는 매우 작은 단위의 일 한 가지만 한다는 것이고,
그 행동에 대한 반경은 함수 내로 제한되므로 외부의 변수를 바꾼다든지 하는 부작용이 없다는 것입니다.

const Box = x =>            // [1] Capture the given 'x' into closure.
({
  map: f => Box(f(x)),      // [2] The return value type should be the same for chaining. It is called 'identity functor'.
  fold: f => f(x),          // [3] Unwrap the return value.
  inspect: ()=> `Box(${x})` // [4] Just for easier debugging.
})

const nextCharForNumberString = str => 
  Box(str)
  .map(s => s.trim()) // [5] 'Map' is a composition. It takes input and returns the output. More functions can be added. 
  .map(r => parseInt(r))
  .map(i => i+1)
  .fold(i => String.fromCharCode(i))

const result = nextCharForNumberString('  64 ')
console.log( result ) // Box('A')

2. Refactor imperative code to a single composed expression using Box

아래의 코드가 하는 일은 가격과 퍼센테이지를 받아서, 할인 가격이 얼마나 되는지를 반환하는 것입니다.

  • Imperative codes
const moneyToFloat = str => parseFloat(str.replace(/\$/g, ''))

const percentToFloat = str => {
  const replaced = str.replace(/\%/g, '')
  const number = parseFloat(replaced)
  return number * 0.01
}

const applyDiscount = (price, discount) => {
  const cost = moneyToFloat(price)
  const savings = percentToFloat(discount)
  return cost - cost * savings
}

const result = applyDiscount( '$5.00', '20%' )
console.log( result ) // 4
  • Refactored code
const Box = x =>
({
  map: f => Box(f(x)),     
  fold: f => f(x),
  inspect: ()=> `Box(${x})` 
})

const moneyToFloat = str =>
  Box(str)
    .map(s => s.replace(/\$/g, ''))
    .map(r => parseFloat(r)) // Box allows us to un-nest expression.

const percentToFloat = str =>
  Box(str.replace(/\%/g, ''))
    .map(replaced => parseFloat(replaced))
    .map(number => number * 0.01)

const applyDiscount = (price, discount) => // we can work with multiple variables in a box by nesting with closures.
  moneyToFloat(price)
    .fold(cost =>
      percentToFloat(discount) 
        .fold(savings => cost - cost * savings))

const result = applyDiscount( '$5.00', '20%' )
console.log( result ) // 4

3. Enforce a null check with composable code branching using Either

Either란 Right 내지는 Left로 정의됩니다.
Right과 Left는 Either의 하위 유형입니다.
Right은 Box와 동일한 방식으로 작동하는데, 실행 결과가 참일때 실행됩니다.
Left도 기본 외형은 Box와 같지만, 아무런 실행을 하지 않고 처음 x값 Left에 감싸서 반환합니다. Left는 실행결과가 거짓일때 실행됩니다.

const Right = x =>
({
  map: f => Right(f(x)),     
  fold: (f, g) => g(x),       // [1]
  inspect: ()=> `Right(${x})` 
})

const Left = x =>
({
  map: f => Left(x),          // 모든 실행 요청을 거절하고 x값을 반환 
  fold: (f, g) => f(x),       // [1]
  inspect: ()=> `Left(${x})` 
})

const rightResult = Right(3).map(x => x+1).map(x => x/2).fold(x => 'error', x => x)
console.log( rightResult ) // 2

const leftResult = Left(3).map(x => x+1).map(x => x/2).fold(x => 'error', x => x)
console.log( leftResult ) // error

[1] Right과 Left의 차이는 fold에 있습니다.
Right은 두번째 함수를 실행하고, Left는 첫번째 함수를 실행합니다.
이로써 우리는 중간에 null이나 undefined등의 에러를 피하고 안전하게 map을 사용하여 결과까지 도달 할 수 있습니다.

const Right = x =>
({
  map: f => Right(f(x)),     
  fold: (f, g) => g(x),       
  inspect: ()=> `Right(${x})` 
})

const Left = x =>
({
  map: f => Left(x),          
  fold: (f, g) => f(x),       
  inspect: ()=> `Left(${x})` 
})

const findColor = name =>
  ({red: '#ff4444', blue: '#3b5998', yellow: '#fff68f'})[name]

const result = findColor('red').slice(1).toUpperCase()
console.log( result ) // FF4444 
//만약 우리가 findColor에 없는 green 같은 값을 입력하면, 없는 값에 slice를 실행하게 되어 에러가 나고 프로그램은 멈추게 됩니다.


// using Right and Left  

const fromNullable = x =>
  x ? Right(x) : Left(null)

const findColor = name =>
  fromNullable({red: '#ff4444', blue: '#3b5998', yellow: '#fff68f'}[name])

const result = findColor('green')
                .map(c => c.slice(1)) 
                .fold(e => 'no color', c => c.toUpperCase())
console.log( result ) // no color 

여기서는 우리가 findColor에 없는 값을 입력하더라도, 여전히 반환값은 Left에 감싸여있고 Left에는 map을 실행할 수 있으므로 중간에 null에러가 나지 않고 결과까지 도달 할 수 있습니다.
그럼 우리는 안전하게 에러 메시지를 확인 할 수 있습니다.

4. Use chain for composable error handling with nested Eithers

const Right = x =>
({
  map: f => Right(f(x)),
  fold: (f, g) => g(x),
  inspect: ()=> `Right(${x})`
})

const Left = x =>
({
  map: f => Left(x),
  fold: (f, g) => f(x),
  inspect: ()=> `Left(${x})`
})

const fromNullable = x =>
  x != null ? Right(x) : Left(null)

const fs = require('fs')

const getPort = () => {
  try {
    const str = fs.readFileSync('config.json') // {port: 8888}
    const config = JSON.parse(str)
    return config.port
  }catch(e){
    return 3000
  }
}

// Refactor 'getPort()'

const tryCatch = f => {
  try {
    return Right(f())
  }catch(e){
    return Left(e)
  }
}

const getPort = () =>
  tryCatch(() => fs.readFileSync('config.json')) // [1] Right('')
  .map(c => tryCatch(() => JSON.parse(c))) // [2] Right(Right('')) or Right(Left())
  .fold(e => 3000, c => 
    c.fold(e => 3000, c => c.port)) // Need to fold twice!

const result = getPort()
console.log(result) // 8888

[1] Right으로 감싸진 값을 반환합니다.
[2] 무엇을 입력하는 것과 상관없이 한번 더 감싸진 값을 반환합니다. 이 경우 안의 값은 두번 감싸여져 있으므로 우리는 fold를 두번 해야합니다.

That's why we need 'chain' in the Either.

const Right = x =>
({
  chain: f => f(x), // 기본 작동은 map과 같으나, 함수의 결과 값을 감싸지 않고 반환합니다.
  map: f => Right(f(x)),
  fold: (f, g) => g(x),
  inspect: ()=> `Right(${x})`
})

const Left = x =>
({
  chain: f => Left(x),
  map: f => Left(x),
  fold: (f, g) => f(x),
  inspect: ()=> `Left(${x})`
})

const fromNullable = x =>
  x != null ? Right(x) : Left(null)

const tryCatch = f => {
  try {
    return Right(f())
  }catch(e){
    return Left(e)
  }
}

const fs = require('fs')

const getPort = () =>
  tryCatch(() => fs.readFileSync('config.json'))
  .chain(c => tryCatch(() => JSON.parse(c))) // [1] Right('')
  .fold(e => 3000, c => c.port)

const result = getPort()
console.log(result) // 8888

[1] Since we used 'chain' method, it will return only one 'Right'.

chain과 fold의 차이점은, fold는 최종적으로 값을 Box에서 꺼내서 Right인지 Left인지를 반환하는 것입니다.
반면 chain은 계속해서 mapping을 하기 위한 목적입니다. chain은 실행 스텝의 중간에 쓰이고 fold는 결과를 보기 위한 것이란 차이가 있습니다.

5. Either를 사용하여 리팩토링한 코드 예제들

// Imperative code
const openSite = ()=>{
  if( current_user ){
    return renderPage(current_user)
  }else{
    return showLogin()
  }
}
// Refactored code using Either
const openSite = ()=>
  fromNullable(current_user)
  .fold(showLogin, renderPage)
const getPrefs = user => {
  if( user.premium ){
    return loadPrefs(user.preferences)
  }else{
    return defaultPrefs
  }
}

const getPrefs = user =>
  (user.premium ? Right(user) : Left('not premium'))
  .map(u => u.preferences)
  .fold(() => defaultPrefs, prefs => loadPrefs(prefs))
const streetName = user => {
  const address = user.address
  if(address){
    const street = address.street
    if( street ) return street.name
  }
  return 'no street'
}

const streetName = user =>
  fromNullable(user.address)
  .chain(a => fromNullable(a.street))
  .map(s => s.name)
  .fold(e => 'no street', n => n)
const concatUniq = (x, ys) => {
  const found = ys.filter(y => y === x)[0]
  return found ? ys : ys.concat(x)
}

const concatUniq = (x, ys) =>
  fromNullable(ys.filter(y => y === x)[0])
  .fold(() => ys.concat(x), y => ys)
const wrapExamples = example => {
  if( example.previewPath ){
    try{
      example.preview = fs.readFileSync(example.previewPath)
    }catch(e){}
  }
  return example
}

const readFile = x => tryCatch(() => fs.readFileSync(x))

const wrapExamples = example =>
  fromNullable(example.previewPath)
  .chain(readFile)
  .fold(() => example,
        preview => Object.assign({}, example, { preview }))
const parseDbUrl = cfg => {
  try{
    const c = JSON.parse(cfg)
    if(c.url){
      return c.url.match(/postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+)/)
    }
  }catch(e){
    return null
  }
}

const parseDbUrl = cfg => 
  tryCatch(() => JSON.parse(cfg))
  .chain(c => fromNullable(c.url))
  .fold(e => null,
        u => u.match(/postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+)/))

6. Create types with Semigroups

A type with a concat method that is associative.

associative: (mathematics) giving the same result no matter what order the parts of a calculation are done,
for example (a × b) × c = a × (b × c)
Definition of associative adjective from the Oxford Advanced Learner's Dictionary

const str = "a".concat("b").concat("c") // abc
const arr = [1,2].concat([3,4]).concat([5,6]) // [1, 2, 3, 4, 5, 6]
// 문자열과 배열은 concat 메소드가 있으므로 semigroup에 해당됩니다.

const arr = [1,2].concat(([3,4]).concat([5,6])) // [1, 2, 3, 4, 5, 6]
// 우리가 안쪽에 먼저 concat을 실행해도 결과값은 변하지 않습니다. 그걸 associativity라고 합니다.

Append/prepend grouping doesn't really matter with a semigroup, and that is a great property that holds. You might remember associativity from addition. If we say 1 + (1 + 1) == (1 + 1) + 1.
semigroups에서 어떻게 그룹을 만드는지는 중요하지 않습니다. 어떻게 그룹을 만들어도 semigroups은 항상 같은 결과를 보장합니다.

const Sum = x =>
({
  x,
  concat: ({x:y}) => Sum(x + y), // Since concat receives another 'Sum' type, we need to assign 'x' from other 'Sum' to 'y'
  inspect: () => `Sum(${x})`
})

const res = Sum(1).concat(Sum(2)) // Sum(3)
const res = Sum('a').concat(Sum('b')) // Sum(ab)
const All = x =>
({
  x,
  concat: ({x:y}) => All(x && y),
  inspect: () => `All(${x})`
})

const res = All(true).concat(All(false)) // All(false)
const res = All(true).concat(All(true)) // All(true)
const First = x => // This will always keep the first one.
({
  x,
  concat: _ => First(x),
  inspect: () => `First(${x})`
})
const res = First("blah").concat(First("ice cream")).concat(First("meta programming")) // First(blah)

7. Semigroup examples

유저 Nico는 실수로 두개의 정을 만들었습니다.
우리는 그 두개의 계정을 하나로 합치고 싶습니다. 이것을 semigroup의 특성을 사용하여 합칠 수 있습니다.

const { Map } = require("immutable-ext") // fantasyland extension of immutable.js 

const Sum = x =>
({
  x,
  concat: ({x:y}) => Sum(x + y),
  inspect: () => `Sum(${x})`
})

const All = x =>
({
  x,
  concat: ({x:y}) => All(x && y),
  inspect: () => `All(${x})`
})

const First = x => 
({
  x,
  concat: _ => First(x),
  inspect: () => `First(${x})`
})

// 이름은 두개의 값이 합쳐지면 안되므로 First를 활용하여 처음 값만 간직합니다.
const acct1 = Map({ name: First('Nico'), isPaid: All(true), points: Sum(10),
friends: ['Franklin'] })

const acct2 = Map({ name: First('Nico'), isPaid: All(false), points: Sum(10),
friends: ['Gatsby'] })

const res = acct.concat(acct2)
console.log(res.toJS())

/*
{ name: First(Nico),
  isPaid: All(false),
  points: Sum(20),
  friends: [ 'Franklin', 'Gatsby' ] }
*/

References

@joyeon
Copy link

joyeon commented Sep 1, 2017

👍

@HayeonKimm
Copy link

도움 받았습니다 감사합니당

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment