重构Haskell代码以添加IO

5

我有一个疑问,关于IO的引入在程序中的范围。假设我程序中深处的一个函数被修改以包括一些IO操作,我如何将这个变化隔离开来,而不必同时改变路径上的每个函数呢?

例如,在一个简单的例子中:

a :: String -> String
a s = (b s) ++ "!"

b :: String -> String
b s = '!':(fetch s)

fetch :: String -> String
fetch s = reverse s

main = putStrLn $ a "hello"

(这里的fetch更实际地来说,可能是从一个静态Map中读取一个值,作为其结果。)但是,假如由于某些业务逻辑的改变,我需要在一些数据库中查找fetch返回的值(我可以通过调用getLine来说明):

fetch :: String -> IO String
fetch s = do
    x <- getLine
    return $ s ++ x

所以我的问题是,如何避免在此链中重写每个函数调用?
a :: String -> IO String
a s = fmap (\x -> x ++ "!") (b s)

b :: String -> IO String
b s = fmap (\x -> '!':x) (fetch s)

fetch :: String -> IO String
fetch s = do
    x <- getLine
    return $ s ++ x

main = a "hello" >>= putStrLn

我发现如果函数本身不相互依赖,重构会更加简单。对于一个简单的示例,这是可以接受的:

a :: String -> String
a s = s ++ "!"

b :: String -> String
b s = '!':s

fetch :: String -> IO String
fetch s = do
    x <- getLine
    return $ s ++ x

doit :: String -> IO String
doit s = fmap (a . b) (fetch s)

main = doit "hello" >>= putStrLn

但我不确定在更复杂的程序中是否实用。到目前为止,我发现唯一真正隔离IO添加的方法是使用unsafePerformIO,但是由于其名称本身,如果可以避免,我不想这样做。有没有其他方法来隔离这个变化?如果重构很大,我会倾向于避免这样做(特别是在期限等情况下)。

感谢任何建议!


3
类比一个更复杂的程序,我会以不同的方式来重构,具体来说,我会从 b 中重构对 fetch 的依赖,可能采用高阶函数的方式。然后,纯净部分的程序可以保持纯净,你会接近于第二种解决方案。 - luqui
2个回答

7
以下是我使用的几种方法。
  • Reduce dependencies on effects by inverting control. (One of the methods you described in your question.) That is, execute the effects outside and pass the results (or functions with those results partially applied) into pure code. Instead of having mainabfetch, have mainfetch and then mainab:

    a :: String -> String
    a f = b f ++ "!"
    
    b :: String -> String
    b f = '!' : f
    
    fetch :: String -> IO String
    fetch s = do
      x <- getLine
      return $ s ++ x
    
    main = do
      f <- fetch "hello"
      putStrLn $ a f
    

    For more complex cases of this, where you need to thread an argument to do this sort of “dependency injection” through many levels, Reader/ReaderT lets you abstract over the boilerplate.

  • Write pure code that you expect might need effects in monadic style from the start. (Polymorphic over the choice of monad.) Then if you do eventually need effects in that code, you don’t need to change the implementation, only the signature.

    a :: (Monad m) => String -> m String
    a s = (++ "!") <$> b s
    
    b :: (Monad m) => String -> m String
    b s = ('!' :) <$> fetch s
    
    fetch :: (Monad m) => String -> m String
    fetch s = pure (reverse s)
    

    Since this code works for any m with a Monad instance (or in fact just Applicative), you can run it directly in IO, or purely with the “dummy” monad Identity:

    main = putStrLn =<< a "hello"
    
    main = putStrLn $ runIdentity $ a "hello"
    

    Then as you need more effects, you can use “mtl style” (as @dfeuer’s answer describes) to enable effects on an as-needed basis, or if you’re using the same monad stack everywhere, just replace m with that concrete type, e.g.:

    newtype Fetch a = Fetch { unFetch :: IO a }
      deriving (Applicative, Functor, Monad, MonadIO)
    
    a :: String -> Fetch String
    a s = pure (b s ++ "!")
    
    b :: String -> Fetch String
    b s = ('!' :) <$> fetch s
    
    fetch :: String -> Fetch String
    fetch s = do
      x <- liftIO getLine
      return $ s ++ x
    
    main = putStrLn =<< unFetch (a "hello")
    

    The advantage of mtl style is that you can have multiple different implementations of your effects. That makes things like testing & mocking easy, since you can reuse the logic but run it with different “handlers” for production & testing. In fact, you can get even more flexibility (at the cost of some runtime performance) using an algebraic effects library such as freer-effects, which not only lets the caller change how each effect is handled, but also the order in which they’re handled.

  • Roll up your sleeves and do the refactoring. The compiler will tell you everywhere that needs to be updated anyway. After enough times doing this, you’ll naturally end up recognising when you’re writing code that will require this refactoring later, so you’ll consider effects from the beginning and not run into the problem.

你对于怀疑unsafePerformIO是正确的!它不仅因为违反参考透明性而不安全,还因为它可能会破坏类型内存并发安全性——你可以使用它将任何类型强制转换为任何其他类型,导致段错误,或者引起死锁和并发错误,这通常是不可能的。你告诉编译器某些代码是纯净的,因此它会假设它可以执行所有与纯净代码相同的转换,例如复制、重新排序或甚至删除它,这可能完全改变您的代码的正确性和性能。 unsafePerformIO的主要合法用例是像使用FFI包装外部代码(您知道是纯净的)或进行GHC特定的性能优化;否则要避免使用它,因为它不是普通代码的“逃生口”。

我大部分都同意这个观点,但是unsafePerformIO及其类似物实际上也可以用于其他一些方面,例如实现惰性I/O和(更合理的可能是)惰性并行抽象(参见Control.Parallel.Strategies)。 - dfeuer
@dfeuer:是的,没错,它对于低级别的东西很有用——尽管我在这方面更喜欢使用unsafeInterleaveIO——我只是想强调它不适用于通用代码。 - Jon Purdy

5

首先,重构并没有你想象的那么糟糕。一旦你做出了第一次更改,类型检查器就会指向接下来几个需要修改的地方。但是假设你有理由从一开始就怀疑可能需要额外的功能来使函数正常运行。一种常见的方法(称为mtl风格,取自单子转换库)是使用约束表达你的需求。

class Monad m => MonadFetch m where
  fetch :: String -> m String

a :: MonadFetch m => String -> m String
a s = fmap (\x -> x ++ "!") (b s)

b :: MonadFetch m => String -> m String
b s = fmap (\x -> '!':x) (fetch s)

instance MonadFetch IO where
  -- fetch :: String -> IO String
  fetch s = do
  x <- getLine
  return $ s ++ x

instance MonadFetch Identity where
  -- fetch :: String -> Identity String
  fetch = Identity . reverse

您不再被绑定到特定的单子上:您只需要一个可以获取数据的单子。操作任意MonadFetch实例的代码是纯净的,除了它可以获取数据。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接