背景
在开发中经常会发生一个变量在多个地方赋值的情况。最讨厌的情况是写代码改了某个变量的值,一运行发现还是没改,因为又在后面一个不知道哪里的地方被改了。怎么办呢? 首先我认为一个变量在多个地方赋值是一个非常常见且自然的现象,我们要做的不是谴责、减少这种行为,而是要好好管理。 所以诉求就变成,希望新改的逻辑一定能生效以及希望知道一个变量最终被哪个逻辑赋值。 我想到可以用优先级的形式去管理赋值,每次对同一个变量赋值都要声明一个优先级,变量最终的值是优先级最高的赋值。于是我写下了下面的Go代码,思路就是用一个变量存下当前赋值的最高优先级以及对应的值。
Battle实现
go
type Priority interface {
~int | ~int32 | ~int64
}
// Battle: 用于同一个变量在多个地方赋值,battle一个优先级决定最终的赋值
type Battle[P Priority, V any] struct {
TargetValue *V
CurrentValue *V
CurrentPriority P
}
// NewBattle: 初始化要设置一个最大的priority
func NewBattle[P Priority, V any](priority P, targetValue *V) *Battle[P, V] {
return &Battle[P, V]{
TargetValue: targetValue,
CurrentPriority: priority,
}
}
// Set: value传nil表示放弃赋值
func (b *Battle[P, V]) Set(priority P, value *V) {
if value == nil {
return
}
if priority > b.CurrentPriority {
return
}
b.CurrentPriority = priority
b.CurrentValue = value
}
// SetVal: 帮忙转换为指针,这个不会放弃赋值
func (b *Battle[P, V]) SetVal(priority P, value V) {
b.Set(priority, &value)
}
// SetTarget: 为不影响其他人直接修改原变量,仅在最后对原变量赋值一次,同时打log
func (b *Battle[P, V]) SetTarget(logName string) {
if b.CurrentValue != nil {
*b.TargetValue = *b.CurrentValue
}
if logName != "" {
log.Printf("Battle %s %d", logName, b.CurrentPriority)
}
}
// GetBattlePtrValue: 用于本来类型*V就是指针,并想用nil来表示不赋值的语义的时候,获得**V,用于Set函数
func GetBattlePtrValue[V any](value *V) **V {
if value == nil {
return nil
}
return &value
}
Demo1及讲解
下面是一个Demo。
go
type BattleDemoValue int
const (
BattleDemoValueLogic2 BattleDemoValue = iota
BattleDemoValueLogic1 BattleDemoValue = iota
BattleDemoValueOldLogic BattleDemoValue = iota
)
func Demo1(hitLogic2 bool) {
var demoValue string
battleDemoValue := NewBattle(BattleDemoValueOldLogic, &demoValue)
var logic2Value *string
if hitLogic2 {
logic2 := "logic2"
logic2Value = &logic2
}
battleDemoValue.Set(BattleDemoValueLogic2, logic2Value)
battleDemoValue.SetVal(BattleDemoValueLogic1, "logic1")
battleDemoValue.SetTarget("DemoValue")
UseDemoValue(demoValue)
}
下面讲解Demo1。
- 首先定义优先级类型。优先级用枚举定义,每个枚举值都用iota赋值。这样看到代码就能知道一共有哪几个需求。需求间的优先级是通过枚举的位置决定的,越往上,枚举值越小,优先级越高。需要调整优先级也只需要调整代码行的顺序就可以了,改动很小。因为绝大部分情况下,新需求都是优先级最高的,所以这个枚举值的发展就是不断往最上面插入一行。因为每次插入第一行都要写个iota,而下面的代码没必要改,所以Demo1里每个枚举值都写上了iota。
- 对于要赋值的变量,如Demo1里的demoValue,使用NewBattle创建一个battle变量battleDemoValue。调用NewBattle时要指定一个最低的优先级,这里为BattleDemoValueOldLogic。NewBattle使用了类型推导,使我们创建battle变量时不需要写明优先级与value的类型。
- 对于每个需求的赋值,调用Set或SetVal把值暂存到battle变量中。调用Set和SetVal都需要传入这个需求的优先级,只有传入的优先级比battle变量暂存的优先级高才会更改battle变量暂存的值。因为每个需求未必会命中,比如Demo1里hitLogic2为true才会命中logic2逻辑,所以我把Set定义为传入value的指针,如果传入nil表示放弃赋值,不会比较优先级,传入非nil才会比较优先级,尝试赋值。SetVal则用在不会放弃赋值的情况,帮忙把value转为指针。
- 在最靠近最终需要使用目标变量的地方,调用SetTarget把battle变量的暂存值赋值给目标变量,比如Demo1里在UseDemoValue的上一行调用SetTarget。之所以会有SetTarget是为了兼容不使用Battle的情况。比如说,老代码就没使用Battle,其他合作者可能也不使用Battle。为了减少对他人代码的影响,只在最后要使用目标变量的时候调用SetTarget改变目标变量的值。SetTarget也集成了打日志功能,可以告诉我们最终是哪个需求赋的值。
Demo2
如果目标变量是一个指针,我们还想使用Set的话,就需要两层指针,用起来很别扭。于是我加了一个辅助函数GetBattlePtrValue来应对这种情况。Demo2演示其使用。
go
func Demo2(hitLogic2 bool) {
var demoValue *string
battleDemoValue := NewBattle(BattleDemoValueOldLogic, &demoValue)
var logic2Value *string
if hitLogic2 {
logic2 := "logic2"
logic2Value = &logic2
}
battleDemoValue.Set(BattleDemoValueLogic2, GetBattlePtrValue(logic2Value))
logic1 := "logic1"
battleDemoValue.SetVal(BattleDemoValueLogic1, &logic1)
battleDemoValue.SetTarget("DemoValue")
UseDemoValue(*demoValue)
}
讨论
下面是讨论。
- 首先是我们的诉求:希望新改的逻辑一定能生效以及希望知道一个变量最终被哪个逻辑赋值。这两者都满足了,前提是SetTarget到使用目标变量之间不能再出现对目标变量的赋值。新逻辑一定生效是用把新逻辑优先级设为最高的方法。判断变量最终被哪个逻辑赋值是靠SetTarget打的日志。至于这个前提,我认为,只要有合作者有完全的修改代码的权限,就没有办法从理论上阻止合作者乱改,人家甚至可以删库跑路,但是这一般不会发生,因为我们都很自觉。我认为这不是一个问题,就算真的改坏了,也很好发现,很好改。
- 除了满足了我们的诉求,用Battle还有以下好处:
- 可以看到一个全局的需求列表,以及各需求的优先级关系,比较坦诚清晰,而且优先级可以轻松调整。
- 对老代码修改很小,可以直接在老代码上用Battle开发新需求。比如在Demo1中,可能存在很多老代码直接修改目标变量。我们把最低的优先级BattleDemoValueOldLogic分给这些修改。
- 不要求所有赋值都使用Battle。其实跟上一点是一样的。其他合作者可以继续用自己喜欢的方式开发。
- 对不同需求的赋值顺序没有依赖,最终都是以优先级决定赋值。赋值也可以散落在不同函数中,使用起来比较灵活。
- 关于用Battle的成本。对于一个新接入Battle的变量,需要进行Demo1讲解的1~4步,对于一个已接入Battle的变量新加入一个需求,需要进行1、3步。对于后者,成本基本等同于不使用Battle。但对于前者,还是比不使用Battle多了一点代码量,尤其是需要接入多个变量的情况。我认为多个目标变量可共用一个优先级类型,这样可以降低一点代码量。不过这里的度要自行把握,如果所有变量共用一个优先级类型,则所有需求会叠在一起,难以看清哪些需求会改哪个目标变量;如果一个目标变量一个优先级类型,则会创建过多类型,过于繁琐。不过总的来说,我认为Battle已经是一个额外代码量比较少的工具了。
- 关于并发。Battle显然不是并发安全的。在设计之初我想过设计成并发安全,但后来发现大部分场景下都不需要并发安全,要改成并发安全成本也不高,所以并发安全是一个过度设计,就砍掉了。