你并不需要 effect system
摘要
作者在重写 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
```
相似文章
为什么我对效果系统(2025)感到兴奋
作者解释了为什么编程语言中的效果系统令人兴奋,与当前系统相比,它提供了对资源交互更好的控制、可组合性和可测试性。
Show HN: Pure Effect – 无需数据库,在笔记本上复现生产环境bug
Pure Effect 是一个零依赖的 JavaScript/TypeScript 效应库,通过将副作用表示为纯数据来分离业务逻辑与 I/O,无需数据库即可复现生产环境 bug。
抱歉,但你仍然需要思考
一篇评论文章分析了 DHH 让 AI 代理将 Campfire 从 Rails 重写为 Rust/Elixir/Go 的结果,指出在未人工审阅代码的情况下,重写引入了大量隐含的架构决策与性能问题(如阻塞式数据库操作、锁的误用),说明目前仍不能完全放弃阅读代码和思考。
将构建系统集成到编译器中
Lucas Ma 探索了在 OCaml 编译器中使用效应来创建按需编译器服务,通过反转文件查找控制权并管理全局状态快照,从而实现更灵活的编译。
Effectful 递归方案
《Effekt》编程语言博客文章演示了如何利用效应和处理器实现递归方案(特别是 catamorphisms),以此取代传统的基于函子的方法,从而避免了对无限递归类型的依赖。