Haskell:使用 unsafePerformIO 进行全局常量绑定



有很多关于仔细使用unsafePerformIO全局可变变量的讨论,以及一些语言的补充来支持它(例如 Data.Global )。我有一个相关但不同的问题:将其用于全局常量绑定。这是我认为完全可以的用法:命令行解析。

module Main where
--------------------------------------------------------------------------------
import Data.Bool (bool)
import Data.Monoid ((<>))
import Options.Applicative (short, help, execParser, info, helper, fullDesc,
                            progDesc, long, switch)
import System.IO.Unsafe (unsafePerformIO)
--------------------------------------------------------------------------------
data CommandLine = CommandLine
  Bool               --quiet
  Bool               --verbose
  Bool               --force
commandLineParser = CommandLine
  <$> switch
    (  long "quiet"
    <> short 'q'
    <> help "Show only error messages.")
  <*> switch
    (  long "verbose"
    <> short 'v'
    <> help "Show lots of detail.")
  <*> switch
    (  long "force"
    <> short 'f'
    <> help "Do stuff anyway.")
{- Parse the command line, and bind related values globally for
convenience. This use of unsafePerformIO is OK since the action has no
side effects and it's idempotent. -}
CommandLine cQuiet cVerbose cForce 
  = unsafePerformIO . execParser $ info (helper <*> commandLineParser)
      ( fullDesc
     <> progDesc "example program"
      )
-- Print a message:
say     = say' $ not cQuiet -- unless --quiet
verbose = say' cVerbose     -- if --verbose
say'    = bool (const $ return ()) putStrLn
--------------------------------------------------------------------------------
main :: IO ()
main = do
  verbose "a verbose message"
  say "a regular message"

能够全局引用cQuietcVerbose等是非常有价值的,而不必将它们作为参数传递到任何需要的地方。毕竟,这正是全局标识符的用途:它们有一个值,在程序的任何运行过程中都不会更改 - 碰巧该值是从外部世界初始化的,而不是在程序文本中声明的。

原则上,对

从外部获取的其他类型的常量数据(例如配置文件中的设置)做同样的事情是有意义的——但随后出现了一点:获取这些操作不是幂等的,不像阅读命令行(我在这里稍微滥用了术语"幂等",但相信我被理解了)。这只会添加操作只能执行一次的约束。我的问题是:使用这种形式的代码执行此操作的最佳方法是什么:

data Config = Foo String | Bar (Maybe String) | Baz Int
readConfig :: IO Config
readConfig = do …
Config foo bar baz = unsafePerformIO readConfig

文档向我建议这已经足够了,并且不需要提到的预防措施,但我不确定。我已经看到过添加顶级语法的建议,该语法的灵感来自专门针对这种情况的do表示法:

Config foo bar baz <- readConfig

。这似乎是一个非常好的主意;我宁愿确保该操作最多执行一次,而不是依赖各种编译器设置,并希望不会出现破坏现有代码的编译器行为。

我觉得这些实际上是常数,再加上尽管它们永远不会改变,但明确传递这些东西所涉及的丑陋,强烈主张有一种安全和受支持的方法来做到这一点。不过,如果有人认为我在这里错过了一个重要的观点,我愿意听到相反的意见。

更新

  • 示例中的sayverbose用法不是最好的,因为真正烦恼的不是 IO monad 中的值——这些值可以很容易地从全局IORef中读取参数。问题在于这些参数在纯代码中普遍使用,这些参数必须全部重写以显式获取参数(即使这些参数不会更改,因此不需要是函数参数),或者转换为IO这更糟。有时间我会改进这个例子。

  • 另一种思考方式:我正在谈论的行为类可以通过以下笨拙的方式获得:运行一个通过I/O获取一些数据的程序;获取结果并将它们作为一些全局绑定的值替换到主程序的模板文本中;然后编译并运行生成的主程序。然后,您将安全地在整个程序中轻松引用这些常量。似乎直接实现此模式应该不难。我提了提到unsafePerformIO的问题,但实际上我有兴趣了解这种行为,以及获得它的最佳方法是什么。 unsafePerformIO是一种方式,但它也有缺点。

已知限制:

  • 使用unsafePerformIO,何时发生数据获取操作是不固定的。这可能是一个功能,因此例如,当且仅当实际使用该参数时,才会发生与缺少配置参数相关的错误。如果需要不同的行为,则必须根据需要强制使用seq值。
我不知道

我是否认为顶级命令行解析总是可以的!具体而言,观察当用户提供错误输入时,此备用main会发生什么情况。

main = do
  putStrLn "Arbitrary program initialization"
  verbose "a verbose message"
  say "a regular message"
  putStrLn "Clean shutdown"
> ./commands -x
Arbitrary program initialization
Invalid option `-x'
Usage: ...

现在,在这种情况下,您可以强制使用一个(或全部!)纯值,以便已知解析器已在明确定义的时间点运行。

main = do
  () <- return $ cQuiet `seq` cVerbose `seq` cForce `seq` ()
  -- ...
> ./commands -x
Invalid option `-x'
...

但是,如果你有类似的东西会发生什么——

forkIO (withArgs newArgs action)

唯一明智的做法是{-# NOINLINE cQuiet #-}和朋友,所以System.IO.Unsafe中的一些预防措施确实适用于你。但这是一个需要修补的有趣情况,请注意,您已经放弃了使用备用值运行子计算的能力。例如 ReaderT使用local的解决方案没有这个缺点。

在读取配置文件的情况下,这对我来说似乎是一个更大的缺点,因为长时间运行的应用程序通常是可重新配置的,而不需要停止/启动周期。顶级纯值排除了重新配置。

但是,如果您考虑配置文件和命令行参数的交集,这可能会更加清晰。在许多实用程序中,命令行上的参数会覆盖配置文件中提供的值,鉴于您现在拥有的值,这是不可能的行为。

对于玩具,当然,去野猪。对于其他任何事情,至少使您的顶级值成为IORefMVar。不过,有一些方法仍然可以使非unsafePerformIO解决方案更好。考虑一下——

data Config = Config { say     :: String -> IO ()
                     , verbose :: String -> IO ()
                     }
mkSay :: Bool -> String -> IO ()
mkSay quiet s | quiet     = return ()
              | otherwise = putStrLn s
-- In some action...
  let config = Config (mkSay quietFlag) (mkVerbose verboseFlag)
compute :: Config -> IO Value
compute config = do
  -- ...
  verbose config "Debugging info"
  -- ...

这也尊重了Haskell函数签名的精神,因为现在很明显(甚至不需要考虑IO的开放世界),你的函数的行为实际上取决于程序配置。

-XImplicitParams 在这种情况下很有用。

{-# LANGUAGE ImplicitParams #-}
data CommandLine = CommandLine
  Bool               --quiet
  Bool               --verbose
  Bool               --force
say' :: Bool -> String -> IO ()
say' = bool (const $ return ()) putStrLn
say, verbose :: (?cmdLine :: CommandLine) => String -> IO ()
say = case ?cmdLine of CommandLine cQuiet _ _ -> say' $ not cQuiet
verbose = case ?cmdLine of CommandLine _ cVerbose _ -> say' cVerbose

任何隐式类型并使用sayverbose的内容都将?cmdLine :: CommandLine隐式参数添加到其类型中。

:type (s -> say (show s))
(s -> say (show s))
  :: (Show a, ?cmdLine::CommandLine) => a -> IO ()

我想到的Hackage中的两个案例:

包 cmdargs 使用unsafePerformIO - 将命令行参数视为常量。

在包装 oeis 中,"pure"函数getSequenceByID使用 unsafePerformIO 从 http://oeis.org 上的网页返回内容。它在其文档中指出:

请注意,结果不在 IO monad 中,即使实现需要通过互联网查找信息也是如此。没有副作用可言,从实际的角度来看,该功能在参考上是透明的(OEIS A-数字在理论上可能会改变,但可能性极小)。