新编程范式之数据总是有效

2023-12-18  本文已影响0人  筑梦之队

备注:本文中所有的示例代码均使用golang实现

在软件编程中,方法是被使用得最广泛的结构;也是出现问题最多的结构。
方法接收一些参数(0个或多个),返回一些值(0个或多个)。
对于方法的输入参数,程序员很少会有疑问,在使用中也很少出现错误;但是对于方法的返回值,程序员却经常犯错。我们将常见错误分为以下2类:
1、具有多义性的单返回值,在使用前未进行有效性的判断
2、意义明确的多返回值,在使用前未进行有效性的判断
在进行代码的展示之前,我们先定义一些基础的数据类型和变量。

type Player struct {
  Id int64
  Lv int32
}

var (
  playerMap = make(map[int64]*Player, 1024) // key: Player's Id
)

让我们先看看第一类错误:具有多义性的单返回值,在使用前未进行有效性的判断。
相信大家对于以下的代码都习以为常了。

func GetPlayer(id int64) *Player {
  playerPtr, exists := playerMap[id]
  if !exists {
    return nil
  }

  return playerPtr
}

以上的方法GetPlayer的返回值具有二义性,或为空,或为玩家对象引用。调用方在使用返回值之前必须先判断其是否为空。

playerPtr := GetPlayer(1024)
if playerPtr == nil {
  return
}

playerPtr.Lv++

如果忘记判断返回值的有效性,则可能出现空引用从而导致程序panic。

22 playerPtr := GetPlayer(1024)
23 playerPtr.Lv++

PS D:\GoProject\testFunc> go run .\main.go
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x1 addr=0x18 pc=0x79cb55]                                                                                            
goroutine 1 [running]:                                                 
main.main()                                                            
        D:/GoProject/testFunc/main.go:23 +0x35                         
exit status 2

那么如果判断了返回值的有效性,是不是就一定不会出现问题了呢?还有一种常见的出错场景。

25 playerPtr := GetPlayer(1024)
26 if playerPtr == nil {
27  log.Printf("Player: %d is not exists.", playerPtr.Id) // 此处playerPtr是空值,但是却被用于记录日志了,从而导致 panic。
28  return
29 }

31 playerPtr.Lv++

PS D:\GoProject\testFunc> go run .\main.go
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x0 addr=0x0 pc=0x5e6e31]
goroutine 1 [running]:
main.main()
        D:/GoProject/testFunc/main.go:27 +0x51
exit status 2

接下来,让我们再看看第二类错误:意义明确的多返回值,在使用前未进行有效性的判断。
为了解决第一类问题,我们可以引入另外一个返回值来标识数据是否存在,如下实例代码所示:

func GetPlayer(id int64) (*Player, bool) {
  playerPtr, exists := playerMap[id]
  return  playerPtr, exists
}

调用方在使用返回值之前必须先判断第二个参数是否有效。

playerPtr, exists := GetPlayer(1024)
if !exists {
  return
}

playerPtr++

现在GetPlayer方法的两个返回值不再具有二义性,而是各自表示一个明确的含义;但是方法的调用方依然可能由于不小心或者在代码的维护中未对第二个返回值进行判断,如下代码所示:

playerPtr, _ := GetPlayer(1024)
playerPtr++

又或者,虽然对返回值进行了正确的判断,但是却错误地使用了无效的数据,如下代码所示:

20 playerPtr, exists := GetPlayer(1024)
21 if !exists {
22  log.Printf("Player: %d is not exists.", playerPtr.Id) // 此处playerPtr是空值,但是却被用于记录日志了,从而导致 panic。
23  return
24 }

26 playerPtr++

PS D:\GoProject\testFunc> go run .\main.go
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x0 addr=0x0 pc=0x7b6e2a]
goroutine 1 [running]:
main.main()
        D:/GoProject/testFunc/main.go:22 +0x4a
exit status 2

我们已经非常小心地判断方法的返回值,但是为什么还是可能出现错误呢?这是因为,无论是否有效,被调用的方法已经返回了所有的数据;而调用方可能由于各种原因误用了无效的返回值。
从软件工程的角度来说,代码只会被写一次,但是会被维护(阅读和修改)无数次;也许第一次写的时候是正确的,但是在维护的过程中可能被错误地使用了。因为维护者可能没有准确地理解上下文,或者只是单纯地想要记录一行日志。
管理学中的墨菲定律说:一件事情如果可能出错,那么就一定会出错。虽然这中说法不够严谨,但只要我们把时间线拉长,把范围扩大,再加上程序员的水平参差不齐;在一个项目的整个生命周期中,在成百上千的同类型代码中,就一定会出错。

那有没有办法可以彻底解决这个问题呢?号称内存安全的编程语言Rust给出了它的解决方案:保证给出的返回值总是有效的数据。那如何才能保证返回值总是有效的数据呢?让我们引入一个新的数据类型Option:

import "fmt"

type Option[T any] struct {
    // none and data are mutual exclusive
    none bool
    data T
}

func NewNoneOption[T any]() Option[T] {
    return Option[T]{
        none: true,
    }
}

func NewDataOption[T any](data T) Option[T] {
    return Option[T]{
        data: data,
    }
}

func (this Option[T]) HasNone() bool {
    return this.none
}

func (this Option[T]) HasData() bool {
    return !this.none
}

// Data returns the underlying data.
// Panic if there is no data.
func (this Option[T]) Data() T {
    if this.none {
        panic(fmt.Errorf("check validity first"))
    }

    return this.data
}

通过引入新的类型Option,将真正的数据和数据的有效性信息隐藏起来,然后通过对外提供方法来达到保证返回值都是有效的数据的目的。我们可以通过实际的代码来体会这种思想:


func GetPlayer(id int64) Option[*Player] {
    type OptionDataType = *Player

    playerPtr, exists := playerMap[id]
    if !exists {
        return NewNoneOption[OptionDataType]()
    }

    return NewDataOption(playerPtr)
}

25 playerOption := GetPlayer(1024)
26 if playerOption.HasNone() {
27  return
28 }

29 playerPtr := playerOption.Data()
30 playerPtr.Lv++

在第29行代码之前,我们并没有获得真正的Player数据;而在我们获得Player数据时,我们知道它一定是有效的数据。无论我们如何使用,都不会再出现问题了。
那我们有没有可能在判断不存在的时候误用了返回值呢?让我们添加一行代码;

PS D:\GoProject\testFunc> go build
# testFunc
.\main.go:29:56: playerOption.Id undefined (type Option[*Player] has no field or method Id)

由于方法的返回值是Option,而不是*Player,导致编译失败;我们再也无法错误地使用方法的返回值了。

总结:
在新的编程思想的指引下,我们终于可以放心地使用方法的返回值了。这种思想的应用范围其实非常广泛,在Rust中就有Option/Result/Mutex等类型应用了该思想。感兴趣的同学可以自行去研究一下。

上一篇下一篇

猜你喜欢

热点阅读