3P by neo 1달전 | ★ favorite | 댓글 2개

사이드 이펙트를 일급 값으로 다루기

  • Haskell에서는 부수효과(예: 랜덤 숫자 생성, 출력 등)를 ‘일급 값(first class value)’처럼 취급함
  • 즉, randomRIO(1, 6)처럼 부수효과를 생성하는 함수 호출 자체가 결과값이 아니라, “언젠가 실행될 동작을 기술하는 객체”를 반환함
  • 이 객체는 실제로 실행될 때 랜덤 값을 만들어내지만, 그 이전에는 단순히 실행 계획만을 담고 있음
  • IO Int 같은 타입은 “실제로 실행되면 Int를 만들어내는 동작”을 나타내며, 호출 시점에 바로 실행되지 않고, 이후 필요한 시점에 실행됨
  • 이러한 특성으로 인해 “함수 호출 = 즉시 실행”이라는 전통적 절차적 언어와 달리, Haskell에서는 부수효과를 조합하고 나중에 실제로 실행할 수 있음

De-mystifying do blocks

  • do 블록은 마법적 구문이 아니라, 사실상 부수효과를 연결(bind)하고 순서대로 실행(then)하는 두 연산자로 구성됨

then

  • *> 연산자는 왼쪽의 부수효과를 실행한 후 결과값을 버리고, 오른쪽 부수효과를 이어서 실행함
  • 예를 들어 putStr "hello" *> putStrLn "world"는 두 출력을 순서대로 합친 하나의 IO () 동작을 만듦
  • do 블록에서 여러 줄을 쓰면 내부적으로 이런 순차 실행 연산을 이용함

bind

  • >>= 연산자는 왼쪽 부수효과를 실행해서 얻은 값을 오른쪽 함수에 넘기는 역할을 함
  • 예: randomRIO(1, 6) >>= print_side는 주사위 결과를 print_side로 전달해 출력하는 부수효과를 만듦
  • do 블록에서 <- 패턴이 이 연산자를 간편하게 표현해 주는 개념임

Two operators are all of do blocks

  • 결국 do 블록은 *>, >>= 이 두 연산자로 구축됨
  • 코드 가독성과 간편함 때문에 do 문법을 많이 쓰지만, 이보다 더 풍부한 부수효과 조합 함수들을 활용해야 Haskell의 장점을 더 살릴 수 있음

Functions that operate on side effects

  • 부수효과를 더 다양하게 다룰 수 있는 여러 함수가 표준 라이브러리에 존재함

pure

  • pure x는 “아무런 추가 부수효과 없이 x 값을 결과로 만드는 동작”을 생성함
  • 예: loaded_die = pure 4는 항상 4를 반환하는 IO Int를 만듦

fmap

  • fmap :: (a -> b) -> IO a -> IO b 형태로, 부수효과 결과값에 순수 함수를 적용해 새 결과값을 만드는 동작을 생성함
  • 예: length <$> getEnv "HOME"처럼, 환경변수를 가져오는 부수효과에 length를 적용해 길이를 구하는 동작을 생성할 수 있음

liftA2, liftA3, …

  • liftA2, liftA3 같은 함수들은 여러 부수효과 결과를 하나의 순수 함수로 결합해 새 부수효과를 만듦
  • 예: liftA2 (+) (randomRIO(1,6)) (randomRIO(1,6))는 두 개의 주사위 값을 합산하는 부수효과를 생성함
  • <$><*> 조합으로도 동일한 작업을 수행할 수 있음

Intermission: what’s the point?

  • 이런 방식은 다른 언어에서도 가능한 단순 기능처럼 보이지만, Haskell에서는 언제든 부수효과 동작을 변수로 뽑아내거나 재조합해도 실행 시점이나 결과가 달라지지 않는 장점이 있음
  • 부수효과를 독립적으로 다룸으로써, 코드 리팩터링 시 혼동이 적고, 함수식 추론(equational reasoning)에 기반한 안전한 재사용이 가능함

sequenceA

  • sequenceA [IO a] -> IO [a]는 “부수효과 동작들의 리스트”를 “리스트 결과를 내는 단일 부수효과 동작”으로 바꿔줌
  • 예: 여러 log 동작을 리스트로 모아두고, 나중에 sequenceA로 한 번에 실행시키는 식으로 활용 가능함
  • 무한 반복되는 부수효과(예: repeat (randomRIO(1,6)))도 리스트로 보관했다가 필요한 만큼만 take n 해서 sequenceA로 실행 가능함

Interlude: convenience functions

  • void, sequenceA_, replicateM, replicateM_ 등은 결과값을 사용하지 않거나 반복 실행할 때 편리함
  • 예: replicateM_ 500 (putStrLn "I will not cheat again.")처럼 반복횟수를 직접 세지 않고 부수효과를 여러 번 실행할 수 있음

traverse

  • traverse :: (a -> IO b) -> [a] -> IO [b]는 리스트의 각 요소에 부수효과 함수를 적용해 결과를 리스트로 모으는 동작을 만듦
  • sequenceA는 사실 traverse id와 동일하며, traverse_는 결과를 버리는 버전임

for

  • fortraverse와 같은 기능이지만 인자를 반대로 받음

  • 예: for numbers $ \n -> ... 형태로 “for 루프” 같은 구문을 자연스럽게 표현함

  • 이런 조합 덕에 다른 언어에서 별도 문법으로 처리해야 하는 반복, 순회, 데이터 구조 변환을 Haskell에서는 라이브러리 함수 조합으로 구현할 수 있음

Leaning into the first classiness of effects

  • Haskell에서 부수효과를 일급값으로 적극 활용하면, 코드 중복 감소나 구조 개선이 가능함
  • 예를 들어, 캐시를 사용한 큰 수 소인수분해 로직에서 IO 대신 State 등을 사용해 “부수효과가 존재하지만 외부엔 영향을 주지 않는” 구조를 만들 수 있음
  • 이런 식으로 구조화된 부수효과는 필요한 부분에만 적용되며, 그 외 코드는 순수 함수로 유지할 수 있어 안전성과 유연성을 동시에 확보함
  • 최종적으로 evalState 등으로 실제 부수효과를 실행하고 결과를 순수한 값으로 만들 수 있음

Things you never need to care about

  • 예전 Haskell 시절부터 있었던 여러 이름(>>, return, mapM, 등)은 현행 함수(*>, pure, traverse 등)로 대체 가능함
  • 이들은 “옛날 이름 혹은 모나드 중심 설계”에서 유래했고, 요즘은 Applicative나 더 일반적 Functor 기반 접근을 권장함

Appendix A: Avoiding success and uselessness

  • “Haskell은 성공을 피한다”라는 말은 “언어가 인기나 편의성 때문에 근본 가치를 희생하지 않는다”는 의미임
  • “Haskell is useless”는 처음에 완전한 순수 함수만 허용해 정말 아무것도 못하는 언어처럼 보였지만, 이후 부수효과를 ‘일급’으로 다루는 방식이 도입되면서 실용성을 획득했다는 맥락임

Appendix B: Why fmap maps over both side effects and lists

  • fmap는 매우 일반적인 형태(Functor f => (a -> b) -> f a -> f b)로, 리스트, Maybe, IO 같은 다양한 컨테이너나 부수효과 타입에 공통적으로 적용됨
  • 리스트에 fmap를 적용하면 모든 원소에 함수를 씌우고, IO에 적용하면 결과값에 함수를 씌움
  • 이처럼 “함수를 적용할 수 있는 구조” 전반이 Functor로 불림

Appendix C: Foldable and Traversable

  • Foldable은 원소를 순회하며 처리할 수 있는 구조임
  • Traversable은 순회뿐 아니라, 새 요소로 같은 모양의 구조를 재생성할 수 있는 구조임
  • sequenceAtraverse가 원래 구조를 유지하며 값을 모을 수 있으려면 해당 구조가 Traversable이어야 함
  • 트리나 Set 같은 자료구조는 구조가 값에 따라 달라질 수 있어, 순회만 가능한 경우(Foldable)와 실제 구조를 재생성할 수 있는 경우(Traversable)가 구분됨
  • 필요에 따라 리스트로 변환 후 traverse를 쓰는 방식 등을 통해 유연하게 부수효과를 처리할 수 있음

Reddit 보다보면 광고가 많이 뜹니다.. 근데 이름부터 약간 심리적 허들이 생겨요.
왠지 되게 어렵고 강력한 언어 느낌..

Hacker News 의견
  • Haskell의 타입 시스템은 다른 인기 있는 언어들과 비교할 때 복잡함이 있음. 특히 *>, <*>, <*와 같은 연산자들은 코드베이스 전반에 걸쳐 학습 곡선을 높임

    • 한 달 동안 Haskell을 사용하지 않으면 >>=>> 같은 연산자를 다시 공부해야 생산성을 유지할 수 있음
    • Haskell의 개념을 사람들과 대화하지 않고 혼자 공부하면 어려움이 있음
  • Haskell은 명령형 프로그래밍을 개선하는 데 도움을 줌

    • 첫 번째 클래스 효과와 패턴을 사용하여 보일러플레이트 코드를 제거할 수 있음
    • 타입 안전성을 통해 상대적으로 버그 없는 코드를 빠르게 작성할 수 있음
  • traverse/mapM의 일반화된 버전은 리스트뿐만 아니라 모든 Traversable 타입에 대해 작동하며 매우 유용함

    • traverse :: Applicative f => (a -> f b) -> t a -> f (t b) 형태로 사용 가능
    • 다른 언어에서는 비슷한 효과를 얻기 위해 많은 코드를 수동으로 작성해야 했음
  • Haskell은 강력한 모나드를 가지고 있으며, 이는 Haskell을 더욱 절차적으로 만듦

    • do 블록에서 중간 변수를 사용할 수 있음
  • Haskell로 작성된 소프트웨어로는 ImplicitCAD가 있음

  • Haskell의 코드가 절차적 언어처럼 읽히지만, 부작용 함수와 함께 작업할 때의 장점을 제공함

    • IO 모나드와 함께 작업하는 것은 복잡하며, 다른 모나드 타입을 사용하고자 할 때 더욱 복잡해짐
  • >><i>>의 오래된 이름이며, 두 연산자는 왼쪽 결합 연산자임

    • >>infixl 1로 정의되고 <i>>infixl 4로 정의되어 있어, <i>>>>보다 더 강하게 결합됨
  • Haskell의 IO aa는 비동기와 동기와 유사하게 느껴질 수 있음

    • 첫 번째는 대기해야 하는 약속/미래를 반환함
  • 다른 언어에서는 console.log("abc")와 같은 함수로 간단한 IO를 수행할 수 있음

    • Haskell의 IO와 다른 점이 있는지에 대한 의문이 있음
  • Haskell을 시도해보지 않은 사람들은 GHC 확장을 사용한 실제 Haskell이 너무 복잡하다고 느낄 수 있음

    • 이는 Haskell에 대한 관심을 떨어뜨릴 수 있음