添加IO时重构Haskell



我关心IO在程序中的引入程度。假设我的程序中的一个函数被更改为包含一些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,但就其名称而言,如果我能帮助它,我不想这么做。有其他方法隔离这种更改吗?如果重构是实质性的,我会开始觉得不必这么做(尤其是在截止日期等)。

谢谢你的建议!

下面是我使用的一些方法。

  • 通过反转控制来减少对效果的依赖(您在问题中描述的方法之一。)也就是说,在外部执行效果,并将结果(或部分应用了这些结果的函数)传递到纯代码中。代替mainabfetch,具有mainfetch然后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
    

    对于更复杂的情况,需要通过多个级别传递一个参数来进行这种"依赖注入",Reader/ReaderT允许您在样板上进行抽象。

  • 编写纯代码,您预计从一开始就可能需要monadic风格的效果(Polymorphic over the selection of monad。)然后,如果您最终需要该代码中的效果,则不需要更改实现,只需要更改签名

    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)
    

    由于此代码适用于任何具有Monad实例的m(或者实际上仅适用于Applicative),因此您可以直接在IO中运行它,也可以纯粹使用"dummy"monadIdentity:

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

    然后,当你需要更多的效果时,你可以使用"mtl样式"(正如@dfeuer的回答所描述的)根据需要启用效果,或者如果你在任何地方都使用相同的monad堆栈,只需将m替换为具体类型,例如:

    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")
    

    mtl风格的优点在于,您可以对效果进行多种不同的实现。这就使得测试&嘲笑很容易,因为您可以重用逻辑,但使用不同的"处理程序"运行它以用于生产&测试。事实上,使用代数效果库(如freer-effects),您可以获得更大的灵活性(以牺牲一些运行时性能为代价),它不仅可以让调用者更改如何处理每个效果,还可以更改处理它们的顺序

  • 卷起袖子进行重构编译器会告诉您任何需要更新的地方。这样做足够长的时间后,您会自然而然地意识到以后编写的代码需要进行重构,因此您将从一开始就考虑影响,而不会遇到问题。

你怀疑unsafePerformIO是对的!它不仅不安全,因为它破坏了引用透明性,还不安全,它可以破坏类型内存并发的安全性——您可以使用它将任何类型强制为任何其他类型,导致segfault,或导致通常不可能发生的死锁和并发错误。你告诉编译器有些代码是纯代码,所以它会假设它可以对纯代码进行所有转换,比如复制、重新排序,甚至删除它,这可能会完全改变代码的正确性和性能。

unsafePerformIO的主要合法用例是使用FFI包装外部代码(你知道这是纯的),或者进行GHC特定的性能黑客攻击;否则请远离它,因为它并不是普通代码的"逃生通道"。

首先,重构并不像你想象的那么糟糕。一旦你进行了第一次更改,类型检查器就会将你指向接下来的几个,以此类推。但假设你从一开始就有理由怀疑你可能需要一些额外的功能来运行一个函数。一种常见的方法(称为mtl样式,在monad转换器库之后)是在约束中表达您的需求。

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

你不再与某个特定的monad绑定:你只需要一个可以获取的monad。在任意MonadFetch实例上操作的代码是纯的,只是它可以获取。

最新更新