go关于数据和语义的设计哲学

838 阅读8分钟

「这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战」。

本文接前三篇

这篇文章的重点是数据和在代码中应用值/指针语义的设计哲学。

值语义将值保存在栈上,这减少了垃圾收集器(GC)的压力。 然而,值语义要求存储、跟踪和维护任何给定值的各种副本。 指针语义将值放在堆上,这会给GC带来压力。 然而,指针语义是有效的,因为只需要存储、跟踪和维护一个值。 — Bill Kennedy

如果希望整个软件代码保持完整性和可读性,那么对于给定类型的数据,值/指针语义的一致使用是至关重要的。 因为,如果在函数之间传递数据时更改了数据的语义,就很难维护代码的清晰和一致的心智模式。 代码库和团队越大,代码库中的bug、数据竞争和副作用就越多。

心智模式

要求一个开发人员维护一个超过(约10k行代码的心智模式已经是相当高的要求了。 但是,如果我们继续假设每个开发人员有1万行代码,那么一个100名开发人员的团队将需要维护一个达到100万行代码的代码库。 也就是需要协调、分组、跟踪和持续沟通的100个人。

Debugging

我不相信使用调试器,除非你已经失去了对代码的心智模型,而现在又在试图理解问题上浪费精力。 ,当调试器成为对任何可观察到的错误的第一反应时,你就知道你在滥用调试器了。如果在生产中遇到了问题,会要求什么? 对,log。 如果在开发期间日志不能为你工作,那么当产品缺陷出现时,它们肯定也不能为你工作。 日志需要有一个代码库的心智模式,这样您就可以通过阅读代码来发现错误。

可读性

C是我见过在能力和表现力之间最好的平衡。你可以通过简单的编程来做任何你想做的事情你会有一个很好的心智模型来预测机器会发生什么,可以合理地预测它的运行速度,了解....上的情况——Brian Kernighan

我相信Brian的这句话同样适用于Go。 维持这种“心理模式”是最重要的。 它促进了完整性、可读性和简单性。 这些都是编写良好的软件的基础,使其能够被维护和持续使用。 编写代码,为给定类型的数据维护值/指针语义的一致使用,是实现这一点的一个重要方法

数据导向设计

如果你不理解数据,你就不理解问题。这是因为所有问题都是惟一的,并且针对您正在处理的数据。当数据变化时,您的问题也在变化。当问题发生变化时,算法(数据转换)也需要随之改变 - Bill Kennedy

所处理的每个问题都是一个数据转换问题。编写的每个函数和运行的每个程序都接受一些输入数据并产生一些输出数据。从这个角度来看,对软件的心智模式是对这些数据转换的理解(即,它们是如何在代码库中组织和应用的)。“少即是多”的态度对于用更少的层次、陈述、概括、更少的复杂性和更少的努力来解决问题至关重要。这让开发者和团队更加轻松,同时也让硬件更容易执行这些任务。

类型

如果数据驱动所做的一切,那么表示数据的类型是关键的。“类型就是生命”,因为类型为编译器提供了确保数据完整性的能力。 类型还驱动并规定语义规则代码必须尊重它所操作的数据。 这就是正确使用值/指针语义的地方:从类型开始。

数据(携带能力)

当一段数据具有某种能力是实际的或合理的时,方法是有效的。 - William Kennedy

值/指针语义的想法并没有给Go开发人员带来正面的冲击,直到他们不得不决定一个方法的接收器类型。 这是一个经常出现的问题:我应该使用值接收器还是指针接收器? 一旦听到这个问题,就知道开发人员没有很好地掌握这些语义。方法的目的是提供给数据一份能力。 想一想。 一段数据可以有做某事的能力。 我总是希望把重点放在数据上,因为正是数据驱动着程序的功能。 数据驱动您编写的算法、以及可以实现的性能。

多态

多态意味着你写了一个特定的程序,它根据它所操作的数据的不同而有不同的行为。 ——汤姆·库尔茨(BASIC语言的发明者

一个函数可以根据它所操作的数据而表现出不同的行为。 数据的行为将函数从它们可以接受和使用的具体数据类型中解耦出来。 这是数据具有功能的一个核心原因。 正是这种思想是架构和设计能够适应变化的系统的基石。

原型

“除非开发人员非常清楚软件的用途,否则软件很有可能会出问题。 如果开发人员不了解和理解应用程序,那么获取尽可能多的用户输入和体验是至关重要的。 ——Brian Kernigha

总是首先关注于理解具体的数据和算法,需要数据转换来解决问题。 首先采用这种原型方法,并编写可以部署在生产环境中的具体实现(如果这样做合理且实际)。 一旦具体的实现开始工作,并且了解了哪些工作和哪些不工作,就应该将重点放在重构上,通过提供数据功能将实现与具体数据解耦。

语义指南

在声明特定数据类型时,必须决定将使用哪种语义,值还是指针。 接受或返回该类型数据的API必须尊重为该类型选择的语义。 API不允许指定或更改语义。

以下是基本的指引:

  • 在声明类型时,必须确定使用的是什么语义。
  • 函数和方法必须尊重给定类型的语义选择。
  • 避免使用与给定类型对应的语义不同的方法接收器。
  • 避免让接受/返回数据的函数使用与给定类型对应的语义不同的语义。
  • 避免更改给定类型的语义。

内置类型

Go的内置类型表示数字、文本和布尔数据。 这些类型应该使用值语义处理。 除非有很好的理由,否则不要使用指针来共享这些类型的值。

作为一个例子,看看strings包中的这些函数声明。

func Replace(s, old, new string, n int) string
func LastIndex(s, sep string) int
func ContainsRune(s string, r rune) bool

所有这些函数在API的设计中都使用了值语义。

引用类型

引用类型表示语言中的切片、映射、接口、函数和通道类型。 这些类型应该使用值语义,因为它们被设计成保持在堆栈上并最小化堆压力。 它们允许每个函数拥有自己的值副本,而不是每个函数调用都会导致潜在的分配。 这是可能的,因为这些值包含在调用之间共享底层数据结构的指针

除非有很好的理由,否则不要使用指针来共享这些类型的值。 共享一个片或将值映射到调用堆栈到Unmarshal函数是一个例外。 例如,看看在net包中声明的这两个类型。

type IP []byte
type IPMask []byte

IPIPMask类型都基于字节。 这意味着它们都是引用类型,并且应该遵循值语义规则。 下面是一个名为Mask的方法,它是为接受IPMask值的IP类型声明的。

func (ip IP) Mask(mask IPMask) IP {
    if len(mask) == IPv6len && len(ip) == IPv4len && allFF(mask[:12]) {
        mask = mask[12:]
    }
    if len(mask) == IPv4len && len(ip) == IPv6len && bytesEqual(ip[:12], v4InV6Prefix) {
        ip = ip[12:]
    }
    n := len(ip)
    if n != len(mask) {
        return nil
    }
    out := make(IP, n)
    for i := 0; i < n; i++ {
        out[i] = ip[i] & mask[i]
    }
    return out
}

注意,此方法是一种变异操作,使用的是值语义API样式。 它使用一个IP值作为接收者,并根据传入的IPMask值创建一个新的IP值,并将它的一个副本返回给调用者。 该方法尊重对引用类型使用值语义这一事实。内置函数append也是如此。

var data []string
data = append(data, "string")

append函数对这个变异操作使用值语义。 将一个slice值传递给append,在发生变异之后,它返回一个新的slice值。例外就是进行unmarshaling,需要指针语义。

func (ip *IP) UnmarshalText(text []byte) error {
  	if len(text) == 0 {
  		*ip = nil
  		return nil
  	}
  	s := string(text)
  	x := ParseIP(s)
  	if x == nil {
  		return &ParseError{Type: "IP address", Text: s}
  	}
  	*ip = x
  	return nil
  }

UnmarshalText方法正在实现解码。 如果不使用指针语义,它将无法工作。 但这是可以的,因为共享一个价值通常是安全的。

用户自定义类型

这就是你需要做最多决定的地方。 在声明类型时,必须决定将使用什么语义。如果我们写时间包的API,定义下述类型。

type Time struct {
    sec  int64
    nsec int32
    loc  *Location
}

会用什么语义呢?看看Time包中这个类型的实现,以及工厂函数Now

func Now() Time {
  	sec, nsec := now()
  	return Time{sec + unixToInternal, nsec, Local}
  }

工厂函数是类型中最重要的函数之一,因为它告诉我们所选择的语义是什么。 Now函数清楚地表明,值语义正在发挥作用。 这个函数创建一个Time类型的值,并将该值的一个副本返回给调用者。 共享时间值是不必要的,它们不需要在堆中结束。另外,看看Add方法

func (t Time) Add(d Duration) Time {
  	t.sec += int64(d / 1e9)
  	nsec := t.nsec + int32(d%1e9)
  	if nsec >= 1e9 {
  		t.sec++
  		nsec -= 1e9
  	} else if nsec < 0 {
  		t.sec--
  		nsec += 1e9
  	}
  	t.nsec = nsec
  	return t
  }

可以再次看到Add方法尊重为类型选择的语义。 Add方法使用一个值接收器来操作它自己的Time值的副本,其中Time值的副本被用来进行调用。 然后,它将Time值的一个新副本返回给调用。下面是一个接受Time值的函数:

再一次,值语义被用来接受Time类型的值。 指针语义的唯一使用这些Unmarshal相关的函数:

func (t *Time) UnmarshalBinary(data []byte) error {
func (t *Time) GobDecode(data []byte) error {
func (t *Time) UnmarshalJSON(data []byte) error {
func (t *Time) UnmarshalText(data []byte) error {

大多数时候,使用值语义的能力是有限的。 当数据从一个函数传递到另一个函数时,复制数据是不正确或不合理的。 对数据的更改需要隔离到单个值并共享。 这时需要使用指针语义。 如果不能100%确定进行复制是正确的或合理的,那么请使用指针语义。

查看os包中File类型的工厂函数。

func Open(name string) (file *File, err error) {
    return OpenFile(name, O_RDONLY, 0)
}

Open函数返回一个File类型的指针。 这意味着应该使用指针语义并且总是共享File值。 将语义从指针更改为值可能会对程序造成破坏。 当一个函数共享一个值时,应该假设不允许复制指针所指向的值。 如果这样做,结果将是不确定的。

在更多的API中,将看到指针语义的一致使用

func (f *File) Chdir() error {
    if f == nil {
        return ErrInvalid
    }
    if e := syscall.Fchdir(f.fd); e != nil {
        return &PathError{"chdir", f.name, e}
    }
    return nil
}

Chdir方法使用指针语义,即使File值永远不会发生变化。 方法必须尊重类型的语义约定。

func epipecheck(file *File, e error) {
    if e == syscall.EPIPE {
        if atomic.AddInt32(&file.nepipe, 1) >= 10 {
            sigpipe()
        }
    } else {
        atomic.StoreInt32(&file.nepipe, 0)
    }
}

这里有一个名为epipecheck的函数,它使用指针语义来接受File值。 再次注意,对于File类型的值,指针语义的使用是一致的。

总结

值/指针语义的一致使用帮助我们保持代码的一致性和可预测性。 还允许每个人维护一个清晰和一致的代码心智模式。 随着代码库和团队的扩大,值/指针语义的一致使用变得更加重要。 Go的神奇之处在于,在指针和值语义之间的选择超越了接收器和函数参数的声明。 它在语言中无处不在,从for range如何工作,到接口机制、函数值和切片。

参考翻译

www.ardanlabs.com/blog/2017/0…