Golang 中的不变性
如何利用不变性来增强你的 Golang 应用程序的可读性和稳定性
不变性的概念非常简单. 创建对象 (或结构体) 后, 将永远无法更改它. 这是一成不变的. 尽管这个概念看起来很简单, 但使用它或从中受益并不那么容易.
正如计算机科学 (和生活) 中的大多数事物一样, 有许多种方法可以达到相同的结果, 就不变性而言, 两者没有什么不同. 您应该把它看做是工具包中的一个工具, 并使用在适用的问题场景上. 关于不变性的一个非常好的用例是在您进行并发编程时. Golang 在设计时就考虑了并发性, 因此在 go 中使用并发非常普遍.
无论您使用哪种范例都可以通过以下方法在 Golang 中使用一些不变性概念来使代码更具可读性和稳定性.
仅导出结构体的功能, 而不导出其字段
这与封装类似. 使用非导出字段创建结构, 仅导出作用的函数. 由于您只对那些结构的行为感兴趣, 因此该技术对接口非常有用. 这项技术的另一个很好的补充是将创建函数 (或构造函数) 添加并导出到您的结构中. 这样您可以确保该结构的状态始终有效. 始终保持有效状态可以使代码更加可靠, 因为您不必继续处理要对该结构进行的每个操作的无效状态. 下面是一个非常基本的示例:
package amounts import "errors" type Amount struct { value int } func NewAmount(value int) (Amount, error) { if value < 0 { return Amount{}, errors.New("Invalid amount") } return Amount{value: value}, nil } func (a Amount) GetValue() int { return a.value }
在此程序包中, 我们定义了 Amount
类型, 具有未导出的字段 value
, 构造函数 NewAmount
以及 GetValue
方法用于 Amount
类型. 一旦 NewAmount
函数创建了 Amount
结构, 就无法更改它. 因此它从包的外部来说是不可变的 (尽管在 go 2 中有 更改此内容的建议, 但 go 1 中没有创建不变结构的方法). 此外没有处于无效状态 (在这种情况下为负数) 的 Amount
类型的变量, 因为创建它们的唯一方法已经对此进行了验证. 我们可以从另一个包中调用它:
a, err := amounts.NewAmount(10) *// 处理错误 *log.Println(a.GetValue())
在函数中使用值拷贝替代指针
最基本的概念是在创建一个对象(或者结构体)后,再也不去改变它。但是我们经常在实体状态很重要的应用上工作。不过,程序中实体状态和实体内部表示是不同的。在使用不变性时,我们仍然可以给实体赋予多个状态。这意味着已创建的结构体不会改变,但是它的副本会改变。这并不意味着我们需要手动实现复制结构体中每个字段的功能。
相反地,当调用函数时我们可以依赖 Go 语言复制值的本机行为。对于任意一个会改变实体状态的操作,我们可以创建一个用来接收结构体作为参数(或者作为函数接收器)的函数,在执行完毕之后返回改变后的版本。这是一项非常强大的技术,因为你能够改变副本上的任何内容,而无需更改函数调用者作为参数传递的变量。这意味着没有副作用和可预测的行为。如果相同的结构体被传递给并发函数,每个结构体都会接收到它的副本,而不是指向它的指针。
当你在使用切片功能时,你会看到此行为应用于 [append](https://golang.org/pkg/builtin/#append)
函数
回到我们的例子中,让我们实现 Account
类型,它包含了Amount
类型的 balance
字段。同时,我们添加 Deposit
和 Withdraw
方法来改变 Account
实体的状态。
package accounts import ( "errors" "my-package/amounts" ) type Account struct { balance amounts.Amount } func NewEmptyAccount() Account { amount, _ := amounts.NewAmount(0) return NewAccount(amount) } func NewAccount(amount amounts.Amount) Account { return Account{balance: amount} } func (acc Account) Deposit(amount amounts.Amount) Account { newAmount, _ := amounts.NewAmount(acc.balance.GetValue() + amount.GetValue()) acc.balance = newAmount return acc } func (acc Account) Withdraw(amount amounts.Amount) (Account, error) { newAmount, err := amounts.NewAmount(acc.balance.GetValue() - amount.GetValue()) if err != nil { return acc, errors.New("Insuficient funds") } acc.balance = newAmount return acc, nil }
如果你检查我们创建的方法,他们会觉得我们事实上改变了作为函数接收器的 Account
结构的状态。由于我们没有使用指针,情况并非如此,由于结构体的副本作为这些函数的接收器来传递,我们将更改只在函数作用域内有效的副本,然后返回它。这是在另一个包中调用它的示例:
a, err := amounts.NewAmount(10) acc := accounts.NewEmptyAccount() acc2 := acc.Deposit(a) log.Println(acc.GetBalance()) log.Println(acc2.GetBalance())
命令行上的结果会是这样的:
2020/06/03 22:22:40 {0} 2020/06/03 22:22:40 {10}
如你所见,尽管通过变量 acc
调用了 Deposit
方法,但实际上变量并没有改变,它返回了新的 Account
副本(分配给 acc2
),其包含了改变后的字段。
使用指针具有优于复制值的优点,特别是如果您的结构很大时,在复制时可能会导致性能问题,但是您应始终问自己是否值得,不要尝试过早地优化代码。尤其是在使用并发时。您可能会在一些糟糕的情况下结束。
减少全局或外部状态中的依赖性
不变性不仅可以应用于结构,还可以应用于函数。如果我们用相同的参数两次执行相同的函数,我们应该收到相同的结果,对吗?好吧,如果我们依赖于外部状态或全局变量,则可能并非总是如此。最好避免这种情况。有几种方法可以实现这一目标。
如果您在函数内部使用共享的全局变量,请考虑将该值作为参数传递,而不是直接在函数内部使用。 那会使您的函数更可预测,也更易于测试。整个代码的可读性也会更容易,其他人也将会了解到值可能会影响函数行为,因为它是一个参数,而这就是参数的用途。 这里有一个例子:
package main import ( "fmt" "time" ) var rand int = 0 func main() { rand = time.Now().Second() + 1 fmt.Println(sum(1, 2)) } func sum(a, b int) int { return a + b + rand }
这个函数 sum
使用全局变量作为自己计算的一部分。 从函数签名来看这不是很清楚。 更好的方法是将rand变量作为参数传递。 因此该函数看起来应该像这样:
func sum(a, b, rand **int**) **int** { return a + b + rand }
推荐教程:《Go教程》