【问题标题】:how to refactor this Haskell chain of functions code?如何重构这个 Haskell 函数链代码?
【发布时间】:2015-11-13 04:27:21
【问题描述】:

我有一些软件设计经验,我现在正在学习 Haskell。在许多现实世界的软件开发中,都会遇到类似给定的情况,例如,如下所示:

假设,我有这个代码

f1 a b c d = e 
 where
  e1 = f2 b c (f3 a)
  e2 = f4 d
  e = e1 + e2

f2 b c d = n + c + d
 where
  n = f5 b

f5 n = n*n

f3 a = a * 2

f4 a = a + 3

现在,如果我想更改 f5 使其接受另一个参数,我将不得不更改所有函数链直到 f1。 如下图所示。注意添加的参数 x。

f1 a b c d x = e -- f1 needs to be changed
 where
  e1 = f2 b c (f3 a) x
  e2 = f4 d
  e = e1 + e2

f2 b c d x = n + c + d -- f2 needs to be changed
 where
  n = f5 b x

f5 n x = n*n -- f5 changed (**bang**)

f3 a = a * 2

f4 a = a + 3

这是做这种事情的正常 Haskell 方式还是有更好(更 Haskell 风格)的方式? 我知道 API 的这种变化会干扰客户端代码,但是如何将影响保持在最低限度,有什么 Hasekll 方法吗?

在更一般的层面上:Haskell 在这种情况下的表现如何(特别是考虑到它的不可变状态特性)?在这方面它为开发人员提供了什么?还是 Haskell 本身在这方面没有任何作用,而这只是一个我们必须跟上的困难软件工程问题(没有未来证明之类的东西)?

对于在一篇帖子中提出多个问题,我深表歉意,但我无能为力,因为这些问题相互关联。另外,我找不到类似的问题,如果我可能错过了,很抱歉。

【问题讨论】:

  • 好吧,你总是可以改变类型签名和参数列表(你正在编写类型签名,不是吗?),然后看看编译器抱怨的地方。修复第一个点,然后返回步骤 2,直到所有错误都耗尽。如果您有一组传递给许多函数的通用参数,那么创建一个新的数据类型来保存所有这些值可能会更好,那么您可以避免更改函数参数,而只需更改该类型的定义。这是一个软件问题,而不是真正的 Haskell 问题。

标签: haskell design-patterns refactoring idioms


【解决方案1】:

您可以做的一件事是将这样的参数捆绑到一个参数对象中,正如 bhelkir 在评论中所建议的那样。如果您向该对象添加新参数,您仍然需要更改调用f1 的客户端代码,并更改该新参数的直接消费者(此处为f5),但这些都是不可避免的:必须有人提供x 在某个时候,您需要它成为客户端;而且您必须以某种方式消耗x,否则为什么要添加它?

但是,您可以避免更改f1f2 等中间函数,因为它们可以忽略它们不关心的新字段。您可以通过使用((->) t)Applicative 实例(通常称为Reader)来传递这个对象,而不是手动执行它,从而获得一点幻想。这是一种写法:

module Test where
import Control.Applicative

data Settings = Settings {getA :: Int,
                          getB :: Int,
                          getC :: Int,
                          getD :: Int}
f1 :: Settings -> Int
f1 = liftA2 (+) f2 f4
-- f1 = do
--   e1 <- f2
--   e2 <- f4
--   return $ e1 + e2

f2 :: Settings -> Int
-- probably something clever with liftA3 and (+) is possible here too
f2 s = f5 s + getC s + f3 s

f3 :: Settings -> Int
f3 = liftA (* 2) getA

f4 :: Settings -> Int
f4 = liftA (+ 3) getD

f5 :: Settings -> Int
-- f5 = liftA (join (*)) getB -- perhaps a bit opaque
f5 = liftA square getB
  where square b = b * b

现在,这有其优点和缺点:f1 中的逻辑(即,知道您需要用a 调用f3)已经转移到f3 本身,这将会发生对于最初读取参数并在将其传递给某个辅助函数之前对其进行处理的任何函数。这可能比原来的更清晰,或者它可能会掩盖f1 背后的意图,具体取决于您的问题域。您总是可以更明确地编写一个函数,例如通过修改传入的Settings 对象以在传递它之前更改其a 字段,就像我以f2 为例所做的那样。更一般地说,您可以使用最方便的样式编写任何函数:do-notation、应用函数或对传入的记录对象进行简单的旧模式匹配。

但最大的好处是添加新参数非常容易:您只需在Settings 记录中添加一个字段,然后在需要它的函数中读取它:

module Test where
import Control.Applicative

data Settings = Settings {getA :: Int,
                          getB :: Int,
                          getC :: Int,
                          getD :: Int,
                          getX :: Int}
f1 :: Settings -> Int
f1 = liftA2 (+) f2 f4
-- f1 = do
--   e1 <- f2
--   e2 <- f4
--   return $ e1 + e2

f2 :: Settings -> Int
-- probably something clever with liftA3 and (+) is possible here too
f2 s = f5 s + getC s + f3 s

f3 :: Settings -> Int
f3 = liftA (* 2) getA

f4 :: Settings -> Int
f4 = liftA (+ 3) getD

f5 :: Settings -> Int
f5 = liftA2 squareAdd getB getX
  where squareAdd b x = b * b + x

请注意,除了data Settingsf5 之外,所有内容都相同。

【讨论】:

  • 这方面的巧妙之处在于,它也适用于未来 API 更改的默认设置。所以如果你有一个defaultSettings,你可以让用户做defaultSettings { getC = 3 }来修改getC。最好的部分是,如果您决定添加一个 getE 字段,defaultSettings { getC = 3 } 代码仍然有效 :)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-04-19
  • 2021-07-15
  • 2016-12-28
相关资源
最近更新 更多