你并不需要 effect system

Lobsters Hottest 新闻

摘要

作者在重写 Haskell 生产代码库时,逐一检视 effect system 的用途,认为其大部分功能并非必需,并指出 early return 与错误处理才是 effect system 真正被使用的地方,进而质疑其必要性。

<p><a href="https://lobste.rs/s/okq4dm/you_don_t_need_effect_system">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/10/05 19:15

# 你并不需要效果系统 来源:https://burningwitness.github.io/blog/posts/against-effect-systems/ ## 这一切是怎么来的 几个月前发生了一件颇为不寻常的事:我获准重写一个旧的生产代码库。里面没什么花哨的东西,只是几个服务之间互相传递消息、调用其他地方的接口。大部分代码是我多年前写的,而且大部分都糟糕透顶——因为当时的我根本不知道自己在做什么。 就像任何一个用 Haskell 写的*真实*代码库一样,它依赖一个效果系统来完成……嗯,一些事情。 事实上,和大多数社区成员一样,我无法准确地阐述效果系统究竟是做什么的。是的,我知道至少有十种效果系统(https://github.com/sayo-hs/heftia/blob/542963d4449d31a0c17a41a1acf56c74ed79ac0d/README.md#comparison),它们彼此互不兼容,却又似乎完全可以互相替换。更聪明的人已经把目标归纳为:追踪副作用、Mock 以及内部一致性(https://discourse.haskell.org/t/why-use-an-effect-system/10841/115),这强烈暗示着普通 IO 无法做到这些事——而这一点我既无法证实也无法证伪。 于是现在,有了从零开始重组整个系统的机会,我得以提出那个问题: ### 我到底用效果系统做了什么? - **我需要它来传递参数吗?**不需要,我可以手动完成。 - **它能让代码库更安全吗?**不能,追踪副作用并不能阻止任何人往效果实现中塞入糟糕的 `IO`、导入 `unsafePerformIO`(https://hackage.haskell.org/package/base-4.22.0.0/docs/System-IO-Unsafe.html#v:unsafePerformIO),或者去计算一个无限列表的和¹(https://burningwitness.github.io/blog/posts/against-effect-systems/#footnote-1)。 - **我在 IO 之外使用它吗?**没有,对于涉及可变状态的纯函数来说,`ST` 单子就完全够用了。 - **把 IO 当作独立效果来追踪有什么好处吗?**没有,实际上恰恰相反:代码库中散布着各种小效果,而我*不想*追踪它们。例如,随机数生成很常见,但每次生成随机值的步骤都不一样。唯一共享的行为是使用生成器状态,而且即便如此,把状态传来传去是否比使用全局(https://hackage.haskell.org/package/random-1.3.1/docs/System-Random-Stateful.html#v:globalStdGen)生成器更有好处也不清楚。 - **我用过高阶效果吗?**没有,我使用的所有效果都不需要重叠或嵌套。我又不是在做什么高深的火箭科学;我更希望我用的东西不要附带一整本独立的手册。 在扔掉几乎所有功能之后,我终于发现了我唯一使用效果系统的地方:错误处理。但仔细一看,那不过是执行某些其他效果然后…… ## 提前返回 这可能是整个 Haskell 里最令人尴尬的问题。一次又一次(https://stackoverflow.com/questions/15441956/how-do-i-make-a-do-block-return-early),一次又一次(https://www.reddit.com/r/haskell/comments/1qlr4if/how_to_handle_early_returns_or_conditions_in/),人们带着完全相同的基本问题走进来:"我怎样才能从一个函数中提前返回?" 而一次又一次,他们得到的都是同样三个选项: 1. 让错误成为函数中的最后一条语句,使用 `Either` 或续延。*代码看起来糟透了,而且不断向右漂移。* 2. 抛出一个异常。*这大概相当于在自家后院埋一颗地雷。* 3. 使用效果系统²(https://burningwitness.github.io/blog/posts/against-effect-systems/#footnote-2)。*???* 问题是,效果系统并没有什么特殊的第三种错误处理方式,它们只是把另外两种方式包装了一下。值得注意的是,`ExceptT`(https://hackage.haskell.org/package/transformers-0.6.3.0/docs/Control-Monad-Trans-Except.html#t:ExceptT)是第一种方法的忠实实现,它把一个 `Either` 贯穿每一个动作。这显然效率很低,而且众所周知它无法很好地组合,所以我更倾向于第二种方法。 ### 类型安全的异常 要实现这一点,我们需要某种方式来携带"某个特定资源(在我们的例子中是一个异常)只能在某个特定函数内使用(抛出)"这一知识。毫不意外,这个问题早在几十年前就已经通过 `bracket`(https://wiki.haskell.org/Bracket_pattern)解决了,用于文件句柄和裸指针。虽然在我们的例子中没有什么东西需要分配,但值处于类型层面: ```haskell {-# LANGUAGE RoleAnnotations #-} module Early ( Early , leave , runEarly ) where import Control.Exception import Data.Typeable type role Early nominal data Early a = Early data ReturningEarly a = ReturningEarly a instance Typeable e => Show (ReturningEarly e) where show = displayException instance Typeable e => Exception (ReturningEarly e) where displayException (ReturningEarly _) = "Early return exception" leave :: Typeable e => Early e -> e -> IO a leave _Early e = throwIO $ ReturningEarly e runEarly :: Typeable e => (Early e -> IO a) -> IO (Either e a) runEarly f = catch (Right <$> f Early) (\(ReturningEarly e) -> pure $ Left e) ``` 然后这个模块可以这样使用: ```haskell import Control.Monad import Early example :: Early () -> IO Bool example early = do putStrLn "Ran this action" when True $ do leave early () putStrLn "Didn't run this action" pure True ``` ``` ghci> runEarly example Ran this action Left () ``` 这段代码除了使用 `bracket` 本身带来的注意事项外,没有任何其他问题。 ## 幕间 就这样,我用效果系统的理由已经全部用完了。 从这里开始已经没有什么故事可讲了:我按照塑造 `Early` 的方式塑造了其他每一个效果,一切都顺理成章地落了位。下面的内容是我发现的一些要点,尽我所能地组织起来。 ## 普通 IO 中的效果 很不幸,为"效果"这个词找到定义,和为"效果系统"找到定义一样费劲。如果我信任 Reddit 上的某些人(https://www.reddit.com/r/haskell/comments/1c9czmn/what_are_effects/),那么"效果"就是一种约定:只能通过类型层面追踪的公共接口来访问特定的副作用。 `Early` 和"句柄模式"(https://jaspervdj.be/posts/2018-03-08-handle-pattern.html#a-database-handle)中的"句柄"都符合这个定义: 1. 它们都有接口(`leave`、`createUser`)和实现(`runEarly`、`withHandle`)。可以对比 eff(https://github.com/hasura/eff/blob/7d7f9ab77f3c7f473d52457e41e9b6e1870d72f6/README.md#eff-in-action)中类似的 effect/handler 分离。 2. 它们在类型层面被追踪,对比一下: ```haskell createUser :: Handle -> Text -> IO User listDirectory :: FileSystem :> es => FilePath -> Eff es [FilePath] leave :: Early e -> e -> IO a throwError :: Error e :> es => e -> Eff es a ``` 更一般地说,普通 `IO` 中的效果是一个充当接口函数与实现函数之间桥梁的数据类型。因此数据类型构造器是实现细节,不应被导出。 效果作为函数参数被追踪;这与偏好使用约束的效果系统非常不同。其中一个好处是我们不必处理消除歧义(https://github.com/polysemy-research/polysemy/tree/cf2efec15ae57a46a81694d361da55da8d7b1d73/polysemy-plugin)、重排序(https://hackage.haskell.org/package/polysemy-1.9.2.0/docs/Polysemy.html#g:7)或重新解释(https://hackage.haskell.org/package/polysemy-1.9.2.0/docs/Polysemy.html#g:11)效果所带来的复杂性。 ### 模块结构 效果系统倾向于把所有定义都放在单个模块中,但这并非硬性要求。例如,`Early` 可以拆分为: ```haskell module Effect.Early.Leave (Early, leave) where ``` ```haskell module Effect.Early.Runner (Early, runEarly) where ``` ```haskell module Effect.Early.Internal (Early, leave, runEarly) where ``` 这可以用来在模块级别追踪函数的访问权限;当一个效果有多个接口(比如,多个程序依赖同一个效果数据类型)和/或多个实现(比如,Mock)时,这特别有用。 ### 错误处理 效果可以抛出异常,但如果它们这样做,也要负责捕获它们。和数据类型构造器一样,异常是实现细节。 不过效果实现通常不会被单独调用,它们会被堆叠成一摞(https://discourse.haskell.org/t/effectful-how-to-prevent-big-effect-runner-functions/7173)大得可怕的层次,位于程序的最边缘。在我们的例子中,堆叠可以开箱即用,但每一层都会把成功的情况推到链的更深处: ```haskell runFoo :: (Foo -> IO a) -> IO (Either FooError a) stack :: (Foo -> Bar -> Qux -> IO a) -> IO (Either FooError (Either BarError (Either QuxError a))) stack f = runFoo $ \foo -> runBar $ \bar -> runQux $ \qux -> f foo bar qux ``` 这可以通过使用稍微复杂一些的类型来很好地解决³(https://burningwitness.github.io/blog/posts/against-effect-systems/#footnote-3): ```haskell runFoo :: (Foo -> IO (Either e a)) -> IO (Either (Either e FooError) a) lift :: IO a -> IO (Either Void a) lift = fmap Right stack :: (Foo -> Bar -> Qux -> IO a) -> IO (Either (Either (Either (Either Void QuxError) BarError) FooError) a) stack f = runFoo $ \foo -> runBar $ \bar -> runQux $ \qux -> lift $ f foo bar qux ``` ### 重复效果 传递两个同名的效果会极其混乱;传递类似 `Tagged "helper" Database` 这样的东西则会是一场巨大的麻烦。如果用户需要两个相同的效果,他们应该在自己这边把函数包装成漂亮的新类型名(例如 `HelperDatabase`)。 实现函数中重复的异常处理可以用类似的方式解决:提供一个额外的参数并对其匹配,本质上让用户把一个效果叫做"database 1",另一个叫做"database 2"。 ## 真实世界中的效果 让我们来看看我在代码库中最终拥有(或没有拥有)的所有效果类别。 ### 参数传递,更讲究的版本 配置数据通常在应用启动时、调用实现栈之前读取,并作为函数参数或"reader"效果传入。 试图在效果系统之外实现一个"reader",结果就是一堆多余的包装: ```haskell data Conf = Conf { bar :: Int , baz :: Bool , qux :: String } getBar :: Conf -> Int getBar = bar runConf :: Conf -> Conf runConf = id ``` 因此我倾向于把配置数据类型放在单独的模块中,并作为参数传递。 ### Mock 可以被 Mock 的效果就是一个函数的积: ```haskell data Database = Database { createUser :: Text -> IO (Maybe User) , getUserMail :: User -> IO [Mail] } runRealDatabase :: RealDatabase -> Database runRealDatabase db = Database { createUser = Database.createUser db , getUserMail = Database.getUserMail db } ``` 永远不会失败的效果——比如日志和指标收集——是 Mock 的特例: ```haskell import Data.ByteString.Builder import Data.Text (Text) import Data.Text.Encoding import System.IO newtype Logger = Logger (Builder -> IO ()) note :: Logger -> Text -> IO () note (Logger f) = f . encodeUtf8Builder runLogger :: Handle -> Logger runLogger handle = Logger $ \msg -> hPutBuilder handle $ msg <> "\n" ``` ### 资源追踪 我将以数据库效果为例。以下是实现函数的大致轮廓: ```haskell import Control.Exception import Database.PostgreSQL.Simple import GHC.Stack import System.IO newtype Database = Database Connection runDatabase :: ConnectInfo -> (Database -> IO (Either e a)) -> IO (Either (Either e DatabaseError) a) runDatabase connInfo f = mask $ \unmask -> do conn <- connect connInfo let cleanup = close conn ei <- unmask (Right <$> f (Database conn)) `catch` \ex -> case fromException ex of Just dbEx -> pure $ Left (dbEx :: DatabaseException) Nothing -> _ case ei of Right (Right a) -> _ Right (Left e) -> _ Left ex -> _ ``` 为了使效果在实现栈中的位置无关紧要,情况 *(1)*、*(2)* 和 *(4)* 都应该使用相同的清理函数。 接口函数可以直接使用资源,不过在大量使用异常的库中,更方便的做法是把所有使用都通过一个辅助函数来分发: ```haskell data DatabaseException = DatabaseException CallStack DatabaseExceptionKind data DatabaseExceptionKind = DatabaseSqlException SqlError | _ handlesPostgres :: HasCallStack => Database -> (Connection -> IO a) -> IO a handlesPostgres (Database conn) f = let rethrow :: (e -> DatabaseExceptionKind) -> e -> IO a rethrow kind = throwIO . DatabaseException callStack . kind in f conn `catches` [ Handler $ rethrow DatabaseSqlException ] createUser :: Database -> Text -> IO (Maybe User) createUser db = handlesPostgres db $ \conn -> _ conn ``` ## 把一切整合起来 ### 接口 如果把参数传递推到极致,你最终会得到一个看起来像这样的函数: ```haskell endpoint :: AuthConf -> ServiceConf -> Logger -> Metrics -> Early ServerError -> Database -> Cache -> Messaging -> AuthHeader -> EndpointRequest -> IO EndpointResponse endpoint authConf serviceConf logger metrics early database cache messaging authHeader EndpointRequest {..} = do ``` 为了应对这个问题,"句柄模式"那篇文章建议嵌套效果;而"ReaderT 设计模式"(https://academy.fpblock.com/blog/2017/06/readert-design-pattern/)那篇文章则建议把所有效果都装进一个记录里。这两种解决方案都是错的。 GHC 有针对未使用参数(https://downloads.haskell.org/ghc/latest/docs/users_guide/using-warnings.html#ghc-flag-Wunused-matches)的警告,既然我们把效果当作参数,就可以利用它来指出未使用的效果。要发挥这一点,代码的结构必须保证每条语句至多使用一个效果。然后一堆语句打包成一个函数,函数又打包成更大的函数,直到我们得到上面那个 `endpoint`。 举个例子,创建一个用户实际上至少需要三个效果: ```haskell newUser :: Database -> Text -> IO (Maybe User) createUser :: Logger -> Early ServerError -> Database -> Text -> IO User createUser logger early database qux = do mayUser <- newUser database qux case mayUser of Just user -> pure user Nothing -> do note logger "Could not create a user entry" leave early err500 ``` 这种方法反过来也适用:如果我们需要弄清楚是什么导致了一个 `DatabaseError`,只需要追踪哪些函数被传入了 `Database` 参数即可。 ### 实现 运行实现栈和在任何效果系统中一样直接: ```haskell main :: IO () main = do conf <- readConfiguration let logger = runLogger stderr metrics <- runMetrics (getMetricsConf conf) withDatabasePool (getDatabaseConf conf) $ \dbPool -> do withMessagingEnv (getMessagingConf conf) $ \msgEnv -> do note logger "Initialization complete" consumeMessagesForever msgEnv $ \newMsg -> runStack logger metrics dbPool msgEnv $ \early database messaging -> process logger metrics early database messaging newMsg runStack :: Logger -> Metrics -> DatabasePool -> Me ```

相似文章

抱歉,但你仍然需要思考

Hacker News Top

一篇评论文章分析了 DHH 让 AI 代理将 Campfire 从 Rails 重写为 Rust/Elixir/Go 的结果,指出在未人工审阅代码的情况下,重写引入了大量隐含的架构决策与性能问题(如阻塞式数据库操作、锁的误用),说明目前仍不能完全放弃阅读代码和思考。

将构建系统集成到编译器中

Lobsters Hottest

Lucas Ma 探索了在 OCaml 编译器中使用效应来创建按需编译器服务,通过反转文件查找控制权并管理全局状态快照,从而实现更灵活的编译。

Effectful 递归方案

Lobsters Hottest

《Effekt》编程语言博客文章演示了如何利用效应和处理器实现递归方案(特别是 catamorphisms),以此取代传统的基于函子的方法,从而避免了对无限递归类型的依赖。