JSON和Go

本文翻译自《JSON and Go》。

Andrew Gerrand

2011/01/25

介绍

JSON(JavaScript对象表示法)是一种简单的数据交换格式。在语法上,它类似于JavaScript的对象和列表。它最常用于web后端和浏览器中运行的JavaScript程序之间的通信,但也用于许多其他地方。它的主页json.org提供了一个非常清晰和简洁的标准定义。

使用json包,从Go程序读取和写入json数据很简单。

编码

为了编码JSON数据,我们使用Marshal函数。

func Marshal(v interface{}) ([]byte, error)

给定Go结构体Message

type Message struct {
    Name string
    Body string
    Time int64
}

Message的一个实例:

m := Message{"Alice", "Hello", 1294706395881547000}

我们可以使用json.Marshal函数得到m的JSON编码版本:

b, err := json.Marshal(m)

如果一切顺利,err将为nilb将是包含此JSON数据的[]byte

b == []byte(`{"Name":"Alice","Body":"Hello","Time":1294706395881547000}`)

只有可以表示为有效JSON的数据结构才可以被编码:

  • JSON对象只支持字符串作为键;要编码Go的map类型,它必须是map[string]T格式(其中T是json包支持的任何Go类型)。
  • 无法对通道、复数和函数类型进行编码。
  • 不支持循环数据结构;他们会使Marshal陷入无限循环。
  • 指针将被编码为它们指向的值(如果指针为空,则编码为“null”)。

json包只访问结构体类型的导出字段(以大写字母开头的字段)。因此,只有结构体的导出字段才会出现在JSON的输出中。

解码

要解码JSON数据,我们使用Unmarshal函数。

func Unmarshal(data []byte, v interface{}) error

我们必须首先创建一个存储解码数据的变量:

var m Message

并调用json.Unmarshal,将一个[]byte的JSON数据和一个指向m的指针传递给它:

err := json.Unmarshal(b, &m)

如果b包含适合m的有效JSON,则在调用之后err将为nil,并且来自b的数据将存储在结构体m中,就像通过如下赋值一样:

m = Message{
    Name: "Alice",
    Body: "Hello",
    Time: 1294706395881547000,
}

Unmarshal如何识别存储解码数据的字段?对于给定的JSON键“Foo”,Unmarshal将查看目标结构体的字段来查找(按以下优先级顺序):

  • 带有“Foo”标签(tag)的导出(公有)字段(有关结构体标签的更多信息,请参阅Go语言规范),
  • 名为“Foo”的导出字段,或
  • 其他“Foo”单词的不区分大小写的匹配项,例如名为“FOO”或“FoO”的导出字段。

当JSON数据的结构与Go类型不完全匹配时会发生什么?

b := []byte(`{"Name":"Bob","Food":"Pickle"}`)
var m Message
err := json.Unmarshal(b, &m)

Unmarshal将只解码它可以在目标类型中找到的字段。在这种情况下,只会填充mName字段,而忽略Food字段。当你希望从大型JSON数据中仅选择几个特定字段时,此行为特别有用。这也意味着目标结构体中任何未导出(私有)的字段都不会受到Unmarshal的影响。

但是,如果你事先不知道JSON数据的结构怎么办?

带接口的通用JSON

interface{}(空接口)类型描述了一个具有零个方法(没有一个方法)的接口。每个Go类型都至少实现了零个方法,因此都实现了空接口。

空接口可以用作通用的容器类型:

var i interface{}
i = "a string"
i = 2011
i = 2.777

类型断言访问底层的具体类型:

r := i.(float64)
fmt.Println("the circle's area", math.Pi*r*r)

或者,如果底层类型未知,则可以使用switch语句来确定类型:

switch v := i.(type) {
case int:
    fmt.Println("twice i is", v*2)
case float64:
    fmt.Println("the reciprocal of i is", 1/v)
case string:
    h := len(v) / 2
    fmt.Println("i swapped by halves is", v[h:]+v[:h])
default:
    // i的类型不是以上类型中的一种
}

json包使用map[string]interface{}[]interface{}值来存储任意JSON对象或数组;它会愉快地将任何有效的JSON blob解码为一个普通的interface{}值。interface{}值默认使用的底层Go类型是:

  • bool用于JSON布尔值,
  • float64用于JSON数字值,
  • string用于JSON字符串值,以及
  • nil用于JSON的null(空值)。

解码任意数据

考虑这个存储在变量b中的JSON数据:

b := []byte(`{"Name":"Wednesday","Age":6,"Parents":["Gomez","Morticia"]}`)

在不知道此数据的内部结构的情况下,我们可以使用Unmarshal将其解码为interface{}值:

var f interface{}
err := json.Unmarshal(b, &f)

此时,f中的Go值将是一个map,其键为字符串,其值存储为空接口interface{}值:

f = map[string]interface{}{
    "Name": "Wednesday",
    "Age":  6,
    "Parents": []interface{}{
        "Gomez",
        "Morticia",
    },
}

要访问此数据,我们可以使用类型断言来访问f的底层map[string]interface{}

m := f.(map[string]interface{})

然后我们可以使用range语句遍历这个map,并使用switch语句来判断其值的具体类型:

for k, v := range m {
    switch vv := v.(type) {
    case string:
        fmt.Println(k, "is string", vv)
    case float64:
        fmt.Println(k, "is float64", vv)
    case []interface{}:
        fmt.Println(k, "is an array:")
        for i, u := range vv {
            fmt.Println(i, u)
        }
    default:
        fmt.Println(k, "is of a type I don't know how to handle")
    }
}

通过这种方式,你可以使用未知内部结构的JSON数据,同时仍然享受类型安全的好处。

引用类型

让我们定义一个Go类型来包含上一个示例中的数据:

type FamilyMember struct {
    Name    string
    Age     int
    Parents []string
}

var m FamilyMember
err := json.Unmarshal(b, &m)

将该数据解码为FamilyMember值按预期工作,但如果我们仔细观察,我们会发现发生了一件了不起的事情。通过var语句,我们分配了一个FamilyMember结构体,然后将指向该值的指针提供给Unmarshal函数,但此时Parents字段是一个nil切片值。为了填充Parents字段,Unmarshal函数在幕后分配了一个新切片。这是Unmarshal解码它支持的引用类型(指针、切片和映射)的典型方式。

考虑解码到这个数据结构中:

type Foo struct {
    Bar *Bar
}

如果JSON中有一个Bar字段,Unmarshal函数将会分配一个新的Bar实例并填充它。否则,Bar将被保留为nil指针。

由此产生了一个有用的模式:如果你有一个接收几种不同消息类型的应用程序,你可以定义“接收者”结构,例如

type IncomingMessage struct {
    Cmd *Command
    Msg *Message
}

发送方可以填充JSON对象的Cmd字段和/或Msg字段,具体取决于他们想要传达的消息类型。Unmarshal函数在将JSON解码为IncomingMessage结构时,将仅分配JSON数据中存在的那个数据结构。具体要处理哪种消息,程序员只需测试CmdMsg是否不为nil。

数据流的编码器和解码器

json包提供了DecoderEncoder类型来支持读写JSON数据流的操作。NewDecoderNewEncoder函数包装了io.Readerio.Writer接口类型。

func NewDecoder(r io.Reader) *Decoder
func NewEncoder(w io.Writer) *Encoder

下面是一个示例程序,它从标准输入流中读取一系列JSON对象,从每个对象中删除除了Name字段以外的所有字段,然后将对象写入标准输出流:

package main

import (
    "encoding/json"
    "log"
    "os"
)

func main() {
    dec := json.NewDecoder(os.Stdin)
    enc := json.NewEncoder(os.Stdout)
    for {
        var v map[string]interface{}
        if err := dec.Decode(&v); err != nil {
            log.Println(err)
            return
        }
        for k := range v {
            if k != "Name" {
                delete(v, k)
            }
        }
        if err := enc.Encode(&v); err != nil {
            log.Println(err)
        }
    }
}

由于io.Readerio.Writer的广泛使用,这些DecoderEncoder类型可以用于广泛的场景,例如读取和写入HTTP连接、WebSocket或文件等。

参考

有关详细信息,请参阅json包的文档。有关json的示例用法,请参阅jsonrpc包的源文件。

Go映射(map)实战

本文翻译自《Go maps in action》。

Andrew Gerrand

2013/02/06

介绍

哈希表是计算机科学中最有用的数据结构之一。许多哈希表的实现具有不同的属性,但通常它们都提供快速查找、添加和删除这些功能。Go提供了实现哈希表的内置的map类型。

定义和初始化

Go的map类型如下所示:

map[KeyType]ValueType

其中KeyType可以是任何可比较的类型(稍后将详细介绍),ValueType可以是任何类型,包括另一个map!

此变量m是字符串键到int值的映射:

var m map[string]int

map类型是引用类型,类似指针或切片,因此上面的m值为nil;它不指向已初始化的map。读取时,nil map的行为类似于空map,但尝试写入nil map会导致运行时panic;不要那样做。初始化一个map,请使用内置的make函数:

m = make(map[string]int)

make函数分配并初始化map数据结构,并返回指向它的map值。该数据结构在运行时的实现细节,不由语言本身决定。在本文中,我们将关注map的使用,而不是它们的实现。

使用map

Go提供了一种熟悉的语法来处理map。以下语句将键“route”设置为值66

m["route"] = 66

以下语句检索存储在键“route”下的值并将其赋值给新变量i

i := m["route"]

如果请求的键不存在,我们将获得值的类型的零值。在本例的情况下,值类型是int,因此零值是0

j := m["root"]
// j == 0

内置的len函数返回map中的元素的个数:

n := len(m)

内置的delete函数从map中删除一个元素:

delete(m, "route")

delete函数不返回任何内容,如果指定的键不存在,就什么也不做。

双值赋值运算可以测试键是否存在:

i, ok := m["route"]

在此语句中,第一个值i被赋予存储在键“route”下的值。如果该键不存在,则i是值类型的零值0。第二个值ok是一个布尔值,如果键存在于map中则为真,否则为假。

要在不检索值的情况下测试键是否存在,可以使用下划线来省略第一个返回值:

_, ok := m["route"]

要遍历map的内容,请使用range关键字:

for key, value := range m {
    fmt.Println("Key:", key, "Value:", value)
}

要使用一些数据初始化一个map,请使用map字面量:

commits := map[string]int{
    "rsc": 3711,
    "r":   2138,
    "gri": 1908,
    "adg": 912,
}

可以使用以下语法来初始化一个空map,这在功能上与使用make函数相同:

m = map[string]int{}

利用零值

当键不存在时,检索map返回零值是很有用的一个特性。

例如,map里的布尔值可以用作类似集合的数据结构(回想一下,布尔类型的零值为false)。此示例遍历链接列表的Nodes并打印它们的值。它使用Node指针的map来检测列表中的循环。

    type Node struct {
        Next  *Node
        Value interface{}
    }
    var first *Node

    visited := make(map[*Node]bool)
    for n := first; n != nil; n = n.Next {
        if visited[n] {
            fmt.Println("cycle detected")
            break
        }
        visited[n] = true
        fmt.Println(n.Value)
    }

如果n已被访问,表达式visited[n]为true,如果n不存在则为false。无需使用二值形式来测试map中是否存在n;默认返回零值已经够用了。

另一个有用的零值实例是map的切片值。append到一个nil切片会分配一个新的切片,所以把一个值append到一个map的切片值无需检查键是否存在。在以下示例中,切片people填充了Person值。每个Person都有一个Name字段和一个Likes切片字段。该示例创建了一个map,将每个爱好(作为likes的键)与喜欢它的那些人(作为likes的值)相关联。

    type Person struct {
        Name  string
        Likes []string
    }
    var people []*Person

    likes := make(map[string][]*Person)
    for _, p := range people {
        for _, l := range p.Likes {
            likes[l] = append(likes[l], p)
        }
    }

打印出所有喜欢奶酪的人:

for _, p := range likes["cheese"] {
        fmt.Println(p.Name, "likes cheese.")
    }

打印出喜欢培根的人数:

fmt.Println(len(likes["bacon"]), "people like bacon.")

请注意,由于rangelen都将nil切片视为零长度切片,因此即使没有人喜欢奶酪或培根(尽管不太可能),最后两个示例仍然能正常工作。

键的类型

如前所述,map的键可以是任何可比较的类型。Go语言规范对此进行了精确定义,但简而言之,可比较类型是布尔类型、数字类型、字符串类型、指针类型、通道类型和接口类型,以及仅包含这些类型的结构体或数组。值得注意的是,没有切片、map和函数,这些类型不能使用==进行比较,因此不能用作map的键。

显然,字符串、整数和其他基本类型应该可以用作map的键,但可能出乎意料的是结构体作为map的键。结构体可以从多个维度作为键。例如,以下map可用于按国家/地区统计网页点击率:

hits := make(map[string]map[string]int)

这个map的键是字符串类型,值是另一个map(字符串到整数的映射)类型。外部map的每个键是网页的路径。内部map的每个键都是两个字母的国家/地区代码。此表达式检索澳大利亚人加载某个网页的次数:

n := hits["/doc/"]["au"]

不幸的是,这种方法在添加数据时并不灵活,对于任何给定的外部键,你必须检查内部map是否存在,并在需要时创建它:

func add(m map[string]map[string]int, path, country string) {
    mm, ok := m[path]
    if !ok {
        mm = make(map[string]int)
        m[path] = mm
    }
    mm[country]++
}
add(hits, "/doc/", "au")

我们可以使用带有结构体键的单个map的设计来消除所有的复杂性:

type Key struct {
    Path, Country string
}
hits := make(map[Key]int)

当越南人访问主页时,增加(并可能创建)适当的计数器,使用一行代码就能实现:

hits[Key{"/", "vn"}]++

同样,看看有多少瑞士人看过/ref/spec网页:

n := hits[Key{"/ref/spec", "ch"}]

并发

map对于并发使用是不安全的:Go没有定义当你同时读取和写入它们时会发生什么。如果你需要从并发执行的goroutine读取和写入map,则访问必须通过某种同步机制进行调解。保护map的一种常见方法是使用sync.RWMutex

此语句声明一个counter变量,它是一个包含map和内嵌sync.RWMutex的匿名结构体。

var counter = struct{
    sync.RWMutex
    m map[string]int
}{m: make(map[string]int)}

要从counter读取,请获取读锁:

counter.RLock()
n := counter.m["some_key"]
counter.RUnlock()
fmt.Println("some_key:", n)

要写入counter,请获取写锁:

counter.Lock()
counter.m["some_key"]++
counter.Unlock()

迭代顺序

使用range循环遍历map时,Go语言没有指定迭代顺序,并且不保证从一次迭代到下一次迭代是相同顺序的。如果你需要稳定的迭代顺序,则必须维护一个单独的数据结构来指定该顺序。以下例子使用单独排序的键切片,来按键在切片里的顺序打印输出map[int]string

import "sort"

var m map[int]string
var keys []int
for k := range m {
    keys = append(keys, k)
}
sort.Ints(keys)
for _, k := range keys {
    fmt.Println("Key:", k, "Value:", m[k])
}

复杂性守恒定律 (The Law of Conservation of Complexity or Tesler’s Law)

复杂性守恒定律 (The Law of Conservation of Complexity or Tesler’s Law):系统中存在着一定程度的复杂性,并且不能减少。

系统中的某些复杂性是无意的。这是由于结构不良,错误或者糟糕的建模造成的。这种无意的复杂性可以减少或者消除。然而,由于待解决问题有固有的复杂性,这些复杂性是内在的。这些复杂性可以转移,但不能消除。

该定律有趣的一点是,即使简化整个系统,内在的复杂性也不会降低。它会转移给用户,并且用户必须以更复杂的方式行事。

现实世界的复杂度无法使用代码来消除,如果你想少写代码,就要多写配置;如果你想少写配置,就要多写注解……

业务逻辑无法使用代码来化简或消除,业务逻辑不写在代码里,那就一定会转移到配置文件里或者其他什么地方。

业务逻辑不是程序员能控制的,程序员只负责代码实现,公司的领导层或许可以通过流程再造来改变业务逻辑。

因为现实世界的复杂性无法在代码层面消除,过度地抽象、解耦、套用设计模式反而会增加代码的复杂度。

关于配置文件

配置文件尽量保持简单直白,不要有分支或循环逻辑。分支或循环逻辑应该放到控制器里,因为控制器就是写业务逻辑代码的,改需求改的是控制器里代码,不变化的代码封装在模型里。配置文件里的配置信息应该是控制器的辅助,而不是相反。

由于外部环境的改变而经常跟着改变的变量值应该写在配置文件里,例如数据库配置信息测试环境一套,生产环境另一套,系统环境变量,要启用的进程数、线程数,公司名称、学校名称等,而不要把业务逻辑中的流程控制语句写在配置文件里。

总之,不要过度设计,不要过早优化,等版本稳定下来了再用设计模式重构代码也不迟。当然如果你的项目不缺钱也不缺时间,那么过早优化完全没有问题。

参考

https://github.com/nusr/hacker-laws-zh

https://en.wikipedia.org/wiki/Law_of_conservation_of_complexity

https://ferd.ca/complexity-has-to-live-somewhere.html

https://www.zhihu.com/question/429538225

Go切片(slice):用法和内部结构

本文翻译自《https://go.dev/blog/slices-intro》。

Andrew Gerrand

2011年1月5日

介绍

Go的切片(slice)类型提供了一种方便有效的方法来处理有类型的数据的序列。切片类似于其他语言中的数组,但具有一些不同寻常的属性。本文将介绍切片是什么以及它们的使用方法。

数组

切片类型是建立在Go的数组类型之上的抽象,因此要理解切片我们必须首先理解数组。

数组类型通过指定长度和元素类型来定义。例如,类型[4]int表示一个包含四个整数的数组。数组的大小是固定的;它的长度是其类型的一部分(例如[4]int[5]int是不同的、不兼容的类型)。数组可以用通常的方式索引,所以表达式s[n]访问第n个元素,从0开始。

var a [4]int
a[0] = 1
i := a[0]
// i == 1

数组不需要显式初始化;数组的零值是其所有元素本身为零值的现成(ready-to-use)数组:

// a[2] == 0,是int类型的零值

[4]int的内存表示形式仅为顺序排列的四个整数值:

Go语言的数组是值类型的。数组变量表示整个数组;它不是指向第一个数组元素的指针(C语言中的情况)。这意味着当你分配或传递数组值时,你将拷贝其所有内容(为了避免拷贝,你可以用一个指针指向数组,这是指向数组的指针,而不是数组本身)。可以将数组视为一种结构体,它具有索引字段而非命名字段,它是一种固定大小的复合值。

数组字面量可以这样指定:

b := [2]string{"Penn", "Teller"}

或者,你也可以让编译器为你计数数组元素的个数:

b := [...]string{"Penn", "Teller"}

这两种情况b的类型都是[2]string

切片

数组有它的空间,但有点不灵活,所以你不会在Go代码中经常看到数组。不过,切片无处不在。它们以数组为基础,提供强大的功能和便利。

切片的类型是[]T,其中T是切片里的元素的类型。与数组类型不同,切片类型没有指定长度。

切片字面量的声明就像数组字面量一样,只是你省略了元素计数:

letters := []string{"a", "b", "c", "d"}

可以使用内置函数make创建切片,该函数的签名如下:

func make([]T, len, cap) []T

其中T代表要创建的切片的元素的类型。make函数接收一个类型T、一个长度len和一个可选的容量cap。调用时,make分配一个数组并返回一个引用该数组的切片。

var s []byte
s = make([]byte, 5, 5)
// s == []byte{0, 0, 0, 0, 0}

当省略cap参数时,它默认等于指定的长度len。这是同一代码的更简洁的版本:

s := make([]byte, 5)

可以使用内置的lencap函数检查切片的长度和容量。

len(s) == 5
cap(s) == 5

接下来的两节讨论长度和容量之间的关系。

切片的零值为nil。对于nil切片,lencap函数都将返回0。

切片也可以通过“切片”现有切片或数组来形成。切片是通过指定一个半开放区间来完成的,其中两个索引由冒号分隔。例如,表达式b[1:4]创建一个包含b的下标从1到3的元素的切片(所得切片的索引将是0到2)。

b := []byte{'g', 'o', 'l', 'a', 'n', 'g'}
// b[1:4] == []byte{'o', 'l', 'a'}, sharing the same storage as b

切片表达式的开始和结束索引是可选的;它们分别默认为0和切片的长度:

// b[:2] == []byte{'g', 'o'}
// b[2:] == []byte{'l', 'a', 'n', 'g'}
// b[:] == b

这也是在给定一个数组的情况下,创建一个指向它的切片的语法:

x := [3]string{"Лайка", "Белка", "Стрелка"}
s := x[:] // 一个指向数组x的切片

切片内部

一个切片是一个数组片段的描述符。它由指向数组的指针、片段的长度及其容量(片段的最大长度)组成。

之前由make([]byte, 5)创建的变量s的结构如下:

长度是切片引用的数组元素的个数。容量是底层数组中的元素的个数(从切片指针指向的元素开始数)。在接下来的几个示例中,长度和容量之间的区别将变得更加清晰。

当我们对s进行切片时,观察切片数据结构的变化及其与底层数组之间的关系:

s = s[2:4]

切片不会复制底层数组的数据。它将创建一个指向原始数组的新切片值。这使得切片操作与处理数组索引一样高效。因此,修改新切片值的元素(而不是新切片值本身)会修改原始切片的元素:

d := []byte{'r', 'o', 'a', 'd'}
e := d[2:]
// e == []byte{'a', 'd'}
e[1] = 'm'
// e == []byte{'a', 'm'}
// d == []byte{'r', 'o', 'a', 'm'}

之前我们将s切成比其容量短的长度。 我们可以通过再次切片来增加s的容量:

s = s[:cap(s)]

切片不能超出其容量。尝试这样做会导致运行时panic,就像索引超出切片或数组边界时一样。同样,不能将切片重新切片到0以下来访问数组中较早的元素。

增长切片的元素(copyappend函数)

为了增加切片的容量,必须创建一个新的、更大的切片,并将原始切片的内容复制到其中。这项技术是其他语言的动态数组在幕后的实现方式。下一个示例通过创建一个新的切片t,将s的内容复制到t,然后将t赋值给s,从而使s的容量加倍:

t := make([]byte, len(s), (cap(s)+1)*2) // +1是为了防止cap(s) == 0的情况
for i := range s {
        t[i] = s[i]
}
s = t

内置的copy函数使这种常见的循环操作变得更容易。顾名思义,copy将数据从源切片复制到目标切片。它返回复制的元素个数。

func copy(dst, src []T) int

copy函数支持在不同长度的切片之间进行复制(长度较短的那个切片复制或被复制完毕就不再继续)。此外,copy可以处理共享同一底层数组的源切片和目标切片,正确地处理元素部分重叠的切片。

使用copy函数,我们可以简化上面的代码片段:

t := make([]byte, len(s), (cap(s)+1)*2)
copy(t, s)
s = t

一个常见的操作是将数据追加到切片的末尾。此函数将字节元素附加到字节切片,必要时会增大切片的容量,返回更新后的切片:

func AppendByte(slice []byte, data ...byte) []byte {
    m := len(slice)
    n := m + len(data)
    if n > cap(slice) { // 如果有必要,重新分配一个底层数组
        // 考虑到未来的数据增长,在此处加倍底层数组的容量。
        newSlice := make([]byte, (n+1)*2)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:n]
    copy(slice[m:n], data)
    return slice
}

可以这样使用AppendByte

p := []byte{2, 3, 5}
p = AppendByte(p, 7, 11, 13)
// p == []byte{2, 3, 5, 7, 11, 13}

AppendByte这样的函数很有用,因为它们提供了对切片增长方式的完全控制。 根据程序的特性,可能需要分配更小或更大的块,或者对重新分配的大小设置上限。

但是大多数程序不需要完全控制,因此Go提供了一个适合大多数用途的内置append函数,签名如下:

func append(s []T, x ...T) []T

append函数将元素x附加到切片s的末尾,并在需要更大容量时扩大切片。

a := make([]int, 1)
// a == []int{0}
a = append(a, 1, 2, 3)
// a == []int{0, 1, 2, 3}

要将一个切片附加到另一个切片,请使用...将第二个参数展开为列表。

a := []string{"John", "Paul"}
b := []string{"George", "Ringo", "Pete"}
a = append(a, b...) // 等价于"append(a, b[0], b[1], b[2])"
// a == []string{"John", "Paul", "George", "Ringo", "Pete"}

由于切片的零值(nil)就像一个零长度切片,你可以声明一个切片变量,然后在循环中附加到它:

// Filter函数返回一个新切片,它只包含s切片中那些使fn函数返回true的元素
func Filter(s []int, fn func(int) bool) []int {
    var p []int // == nil
    for _, v := range s {
        if fn(v) {
            p = append(p, v)
        }
    }
    return p
}

一个可能的“陷阱”

如前所述,重新切片不会复制底层数组。整个数组将保存在内存中,直到不再被引用为止。有时这会导致程序只需要一小部分数据,但将所有数据保存在内存中。

例如,FindDigits函数将一个文件加载到内存中,并在其中搜索第一组连续数字,将它们作为一个切片返回。

var digitRegexp = regexp.MustCompile("[0-9]+")

func FindDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    return digitRegexp.Find(b)
}

此代码的行为确实满足要求,但返回的[]byte指向包含整个文件的数组。由于切片引用了原始数组,只要切片一直存在,垃圾收集器就无法释放数组;为了文件的几个有用字节,就将文件的全部内容保存在内存中。

要解决此问题,可以在返回之前将感兴趣的数据复制到新切片:

func CopyDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    b = digitRegexp.Find(b)
    c := make([]byte, len(b))
    copy(c, b)
    return c
}

可以使用append函数简化上述函数的代码。这留给读者作为练习。

进一步阅读

Effective Go包含对切片数组的深入处理,Go语言规范定义了切片及其相关的辅助函数。

SpringBoot运行单元时测试报错:java.lang.IllegalStateException Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or…

错误描述

SpringBoot运行单元测试时报错:java.lang.IllegalStateException: Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or @SpringBootTest(classes=…) with your test

我的软件环境

JDK 11.0

SpringBoot 2.7

Maven 3.8.7

出错原因

src/test/java目录下的包结构和src/main/java目录下的包结构应该一样,否则就会报上述错误。

我的src/main/java目录下的包是com.comp.example,但是src/test/java目录下的包是com.sample.example,两者不一样,因此报上述错误。

解决方法

把src/test/java目录下的包结构和src/main/java目录下的包结构改成一样即可。

参考

https://stackoverflow.com/questions/47487609/unable-to-find-a-springbootconfiguration-you-need-to-use-contextconfiguration

JDBC连接MySQL数据库警告:Establishing SSL connection without server’s identity verification is not recommend

JDBC连接MySQL出现如下警告:

Establishing SSL connection without server’s identity verification is not recommended. According to MySQL 5.5.45+, 5.6.26+ and 5.7.6+ requirements SSL connection must be established by default if explicit option isn’t set. For compliance with existing applications not using SSL the verifyServerCertificate property is set to ‘false’. You need either to explicitly disable SSL by setting useSSL=false, or set useSSL=true and provide truststore for server certificate verification.

原因是高版本MySQL需要指明是否进行SSL连接。

解决方法是在JDBC连接MySQL的URL字符串中加入useSSL=true或者useSSL=false即可,例如:

jdbc:mysql://127.0.0.1:3306/framework?serverTimezone=UTC&characterEncoding=utf8&useSSL=false

如果你的连接确实没有暴露给网络(仅限本地主机),或者你在没有真实数据的非生产环境中工作,那么可以肯定的是:通过包含选项useSSL=false来禁用SSL没有坏处。

如果要使用useSSL=true,需要以下一组选项才能使SSL使用证书、主机验证并禁用弱协议选项:

  • useSSL=true
  • sslMode=VERIFY_IDENTITY
  • trustCertificateKeyStoreUrl=file:path_to_keystore
  • trustCertificateKeyStorePassword=password
  • enabledTLSProtocols=TLSv1.2

因此,作为一个示例,让我们假设你希望使用SSL将其中一种Atlassian产品(Jira、Confluence、Bamboo等)连接到MySQL服务器,你需要执行以下主要步骤:

  • 首先,确保你有一个为MySQL服务器主机生成的有效的SSL/TLS证书,并且CA证书安装在客户端主机上(如果你使用自签名,那么你可能需要手动执行此操作,但对于流行的公共CA,它已经在那里了)。
  • 接下来,确保java密钥库包含所有CA证书。在Debian/Ubuntu上,这是通过运行以下命令来实现的:
update-ca-certificates -f
chmod 644 /etc/ssl/certs/java/cacerts
  • 最后,更新Atlassian产品使用的连接字符串以包含所有必需的选项,这在Debian/Ubuntu上类似于:
jdbc:mysql://mysql_server/confluence?useSSL=true&sslMode=VERIFY_IDENTITY&trustCertificateKeyStoreUrl=file%3A%2Fetc%2Fssl%2Fcerts%2Fjava%2Fcacerts&trustCertificateKeyStorePassword=changeit&enabledTLSProtocols=TLSv1.2&useUnicode=true&characterEncoding=utf8

更多相关信息请参见MySQL官方文档https://dev.mysql.com/doc/connector-j/8.0/en/connector-j-reference-using-ssl.html

参考

https://beansandanicechianti.blogspot.com/2019/11/mysql-ssl-configuration.html

CentOS7 yum安装MySql报错The GPG keys listed for the MySQL 8.0 Community Server repository are already installed but they are not correct for this package.的解决方法

在CentOS7中使用yum安装MySql8的时候,遇到这个问题。

原因是,MySQL GPG密钥已过期,无法从官方存储库安装或升级MySQL包。其他详细信息也可以在MySQL网站上找到:https://bugs.mysql.com/bug.php?id=106188

解决方法是,在运行安装程序之前导入密钥:

rpm --import https://repo.mysql.com/RPM-GPG-KEY-mysql-2022

对于Ubuntu系统:

wget -q -O - https://repo.mysql.com/RPM-GPG-KEY-mysql-2022 | apt-key add -

参考

https://support.cpanel.net/hc/en-us/articles/4419382481815

https://forums.cpanel.net/threads/mysql-upgrade-process-failed-the-gpg-keys-listed-for-the-mysql-8-0-community-server-repository-are-already-installed-but-they-are-not-correct-for.697213/

编译原理名词解释

元字符

关于元字符,类似元数据(meta data)的概念,元数据是用来描述数据的数据,例如一篇文章的关键字、一首歌的长度等。元字符是描述其他字符的字符,主要用在正则表达式中,例如在正则表达式中’.’号就是其中一个元字符,’.’号代表任意字符,一个’.’号匹配任意一个字符。在不同的正则表达式实现方式中,使用的元字符可能会有所不同。

C和C++有符号数溢出是一种未定义行为

本文翻译自《Signed Overflow》。

C和C++语言标准规定,有符号数的溢出是未定义的行为。在C99标准的第6.5节。在C++98标准中,它位于第5节[expr]的第5段。这意味着正确的C/C++程序在计算表达式时绝不能生成有符号数溢出。这也意味着编译器可能会假设程序永远不会产生有符号数溢出。

gcc在做优化时很早就利用了这一事实。例如,考虑这个函数:

int f(int x) { return 0x7ffffff0 < x && x + 32 < 0x7fffffff; }

如果有符号算术只是在溢出时回绕,则这等价于0x7ffffff0 < x,因为将32加到这样的值将产生负数。然而,即使是2001年发布的gcc 2.95.3,也会将此函数优化为只返回0的代码。这是有效的,因为编译器可能会假设在正确的程序中不会发生有符号数溢出,并且x + 32得到一个负数的情况只能从有符号数溢出发生。

最近gcc已经开始使用有符号数溢出这一未定义行为来实现更好的边界测试。特别是,考虑这样的代码:

int f()
{
int i;
int j = 0;
for (i = 1; i > 0; i += i)
++j;
return j;
}

这段代码假设如果它不断地将一个正数加倍,它最终会得到一个负数。也就是说,它期望有符号数溢出以某种方式运行。当前主流的gcc会认为在正确的程序中不会发生有符号数的溢出,并将这段代码编译成无限循环。

一些gcc用户对这种优化感到惊讶,并且在2007年初左右提出了强烈抗议。有一段时间有人建议gcc编译应该始终使用-fwrapv选项-fwrapv告诉编译器将有符号数的溢出视为回绕。这是Java定义有符号数溢出的方式。该选项是在2003年为gcc 3.3引入的。

-fwrapv的缺点是它会抑制优化。大多数程序不会产生有符号数溢出,而且正如我们所见,正确的程序都不会产生。当编译器可以假定不会发生有符号数溢出时,它可以生成更好的代码。这尤其出现在循环中。当循环使用有符号整数索引时,编译器可以做得更好,因为它不必考虑索引溢出的可能性。

考虑这个简单的例子:

int f(int i) { int j, k = 0; for (j = i; j < i + 10; ++j) ++k; return k; }

此函数返回什么?当编译器可以假设有符号数溢出是未定义行为时,此函数将编译为只返回10的代码。使用-fwrapv选项,代码就不那么简单了,因为i可能恰好有一个像0x7ffffff8这样的值,它会在循环期间溢出。虽然这是一个简单的例子,但很明显,像这样的循环总是在实际代码中发生。

然而,gcc确实需要回应社区用户的担忧。我在gcc 4.2中引入了两个新选项。第一个是-fno-strict-overflow,告诉编译器,它可能不会假设有符号数溢出是未定义行为。第二个是-Wstrict-overflow,告诉编译器在使用有符号数溢出这个未定义行为来实现优化的情况下发出警告。使用这些选项,可以检测程序中发生有符号数溢出的情况,并且可以禁用依赖有符号数溢出进行的优化,直到程序修复。-Wstrict-overflow甚至发现了一个很小的情况,即gcc本身依赖于有符号数溢出这个未定义行为,在处理常数0x80000000的除法时。

这自然会引出一个问题:-fwrapv-fno-strict-overflow之间有什么区别?在不使用普通二进制补码算法的处理器上有一个明显的区别:-fwrap需要二进制补码溢出,而-fno-strict-overflow则不需要。然而,目前还没有这种处理器被普遍使用。在实践中,我认为这两个选项生成的代码的行为始终相同。然而,它们对优化器的影响不同。例如,此代码:

int f(int x) { return (x + 0x7fffffff) * 2; }

使用-fwrapv-fno-strict-overflow编译出不同汇编代码。之所以出现这种差异,是因为-fwrapv精确地指定了溢出的行为方式,而-fno-strict-overflow只是说编译器不应该优化溢出。对于当前的编译器,使用-fwrapv(和-O2 -fomit-frame-pointer),我可以看到:

movl $1, %eax
subl 4(%esp), %eax
addl %eax, %eax
negl %eax
ret

而使用-fno-strict-overflow选项我看到:

movl 4(%esp), %eax
leal -2(%eax,%eax), %eax
ret

运算结果一样,但是使用了不同的算法。

建议:迁移到Go 2

建议:迁移到Go 2

本文翻译自《Proposal: Go 2 transition》。

作者:Ian Lance Taylor

最后更新时间:2018年10月15日

摘要

本文是关于如何在尽可能少地破坏的情况下,从Go 1迁移到Go 2进行不兼容更改的建议。

背景

目前Go语言和标准库都遵守Go 1兼容性保证。该文档的目标是承诺Go的新版本不会破坏现有的程序。

Go 2进展的目标之一是对会破坏兼容性保证的语言和标准库进行更改。由于Go是在分布式开源环境中使用的,因此我们不能依赖卖旗日(flag day)(译者注:指一种既不向前兼容也不向后兼容的软件更改,其制作成本高且撤消成本高)。我们必须允许使用不同版本的Go编写的不同包可以互相操作。

每种语言都会经历版本转换。作为背景,这里有一些关于其他语言所做的事情的记录。你可以随意跳过本节的其余部分。

C

C语言版本由ISO标准化过程驱动。C语言开发非常注重向后兼容性。在第一个ISO标准C90之后,每个后续标准都保持严格的向后兼容性。在引入新关键字的地方,它们被引入到C90保留的命名空间中(一个下划线后跟一个大写的ASCII字母),并且可以通过特定头文件中的#define宏访问它们,这些头文件以前并不存在(例如_Complex<complex.h>中定义为复数,而_Bool<stdbool.h>中定义为bool)。C90中定义的基本语言语义都没有改变。

此外,大多数C编译器都提供选项来精确定义代码应该针对哪个版本的C标准进行编译(例如,-std=c90)。大多数标准库实现都支持在包含头文件之前使用#define定义的特性宏,用来准确指定应该提供哪个版本的库(例如,_ISOC99_SOURCE)。虽然这些特性以前存在过Bug,但它们相当可靠并且被广泛使用。

这些选项的一个关键特性是,能让使用不同的语言版本和库版本编写的代码,通常都可以链接在一起并按照预期工作。

第一个标准C90确实对以前的C语言实现进行了重大更改,可以非正式地认为以前的C语言就是K&R C。引入了新关键字,例如volatile(实际上这可能是C90中唯一的新关键字)。 整数表达式中整数提升的精确实现从无符号保留(unsigned-preserving)更改为值保留(value-preserving)。幸运的是,很容易检测到由于使用了新关键字而编译错误的代码,并且很容易调整该代码。整数提升的变化实际上让新手用户没有那么难理解,有经验的用户大多使用显式转换,来确保在具有不同整数大小的系统之间的可移植性,因此虽然没有自动检测该问题,但在实践中并没有多少代码被破坏。

也有一些恼人的变化。C90引入了三字母词,它改变了一些字符串常量的行为。编译器适应了诸如-no-trigraphs-Wtrigraphs之类的选项。

更严重的是,C90引入了未定义行为(undefined behavior)的概念,并声明调用未定义行为的程序可能会采取任何操作。在K&R C中,被C90描述为未定义行为的情况大多被视为C90中所谓的依赖具体实现的行为(implementation-defined behavior):程序将采取一些不可移植但可以预测行为的操作。编译器编写者吸收了未定义行为的概念,并开始编写假定该行为不会发生的编译优化。这造成了令不熟悉C标准的人感到惊讶的影响。我不会在这里详细介绍,但其中一个示例(来自我的博客)是有符号数的栈溢出

当然C仍然是内核开发的首选语言和计算行业的胶水语言。尽管它已被更加新的语言部分取代了,但这并不是新版本的C做出的任何选择之过。

我在这里看到的教训是:

  • 向后兼容性很重要。
  • 小部分破坏兼容性是可以的,只要人们可以通过编译器选项或编译器错误发现这些破坏。
  • 可以选择特定语言/库版本的编译器选项很有用,前提是使用不同选项编译的代码可以链接在一起。
  • 没有限制的未定义行为会让用户感到困惑。

C++

C++语言的版本现在也由ISO标准化过程驱动。与C一样,C++也非常关注向后兼容性。历史上,C++在添加新关键字方面更加自由(C++ 11中有10个新关键字)。这很正常,因为较新的关键字往往相对较长(constexprnullptrstatic_assert),使得使用新关键字作为标识符的代码很容易找到编译错误。

C++使用与C中相同的选项来指定语言和库的标准版本。在未定义的行为方面,C++遇到与C相同的问题。

C++中一个突破性变化的例子是在for循环的初始化语句中声明的变量范围的变化。在C++的预标准版本中,该变量的范围扩展到for循环所在的封闭块的末尾,就好像它是在for循环之前声明的一样。在第一个C++标准C++ 98的开发过程中,对其进行了修改,使其范围仅限于for循环本身。编译器通过引入诸如-ffor-scope之类的选项进行了调整,以便用户可以控制变量的预期范围(在一段时间内,当既不使用-ffor-scope也不使用-fno-for-scope进行编译时,GCC编译器使用了旧的范围,但警告任何依赖这一行为的代码)。

尽管向后兼容性相对较强,但用新版本的C++(如C++ 11)编写代码往往与用旧版本的C++编写代码有着非常不同的感觉。这是因为样式已更改为使用新的语言和库功能。原始指针不太常用,使用范围循环而不是迭代器,诸如右值引用和移动语义等新概念被广泛使用,等等。熟悉C++旧版本的人很难理解用新版本编写的代码。

C++当然是一种非常流行的语言,正在进行的语言修改过程并没有损害它的流行性。

除了C的教训,我还想补充一点:

  • 在保持向后兼容的同时,新版本可能会有非常不同的感觉。

Java

与我讨论的其他语言相比,我对Java的了解较少,因此这里可能存在更多错误,当然也存在更多偏见。

Java在字节码级别很大程度上向后兼容,这意味着Java N+1版本的库可以调用由Java版本N(以及N-1、N-2等)编写和编译的代码。Java源代码也大多是向后兼容的,尽管它们会不时添加新的关键字。

Java文档非常详细地介绍了从一个版本迁移到另一个版本时可能出现的兼容性问题。

Java标准库非常庞大,每个新版本都会添加新包。包也会不时被弃用。使用已弃用的包将在编译时引发一个警告(警告可能会被关闭),并且在几次发布后,已弃用的包将被删除(至少在理论上是这样)。

Java似乎没有太多的向后兼容性问题。问题集中在JVM上:较旧的JVM通常不会运行较新版本的库,因此你必须确保你的JVM至少与你要使用的最新库所需的一样新。

Java按理说具有某种前向兼容性问题,因为JVM字节码提供了比CPU更高级别的接口,这使得引入不能使用现有字节码直接表示的新特性变得更加困难。

这种前向兼容性问题是Java泛型使用类型擦除的部分原因。更改现有字节码的定义会破坏已经编译成字节码的现有程序。扩展字节码以支持泛型类型需要定义大量额外的字节码。

从某种程度上说,这种前向兼容性问题对于Go来说并不存在。由于Go编译为机器代码,并通过生成额外的机器代码来实现所有必需的运行时检查,因此不存在类似的前向兼容性问题。

但总的来说:

  • 请注意兼容性问题可能会限制编程语言未来的更改。

Python

Python 3.0(也称为Python 3000)于2006年开始开发,最初于2008年发布。2018年过渡仍未完成。有些人继续使用Python 2.7(2010年发布)。这不是Go 2想要效仿的路径。

这种缓慢过渡的主要原因似乎是缺乏向后兼容性。Python 3.0故意与早期版本的Python不兼容。值得注意的是,print从语句更改为函数,字符串更改为使用Unicode。Python通常与C代码结合使用,后者的变化意味着任何将字符串从Python传递到C的代码都需要调整C代码。

因为Python是一种解释型语言,并且因为没有向后兼容性,所以不可能在同一个程序中混合使用Python 2和Python 3代码。这意味着对于使用一系列库的典型的程序,每个库都必须先转换为Python 3,然后才能转换程序。由于程序处于各种转换状态,库必须同时支持Python 2和3。

Python支持from __future__ import FEATURE形式的语句。像这样的语句以某种方式改变了Python对文件其余部分的解释。例如,from __future__ import print_functionprint从语句(如在 Python 2中)更改为函数(如在Python 3中)。这是一种渐进地把代码更新到新的语言版本的方式,并使在不同的语言版本之间共享相同代码变得更加容易。

因此我们知道:

  • 向后兼容性是必不可少的。
  • 与其他语言的接口的兼容性很重要。
  • 升级到新语言版本也受到你使用的代码库所支持的版本的限制。

Perl

Perl 6的开发过程始于2000年。Perl 6规范的第一个稳定版本于2015年发布。这不是Go 2想要效仿的路径。

这条道路如此缓慢有很多原因。Perl 6有意不向后兼容:它旨在修复语言中的缺陷。Perl 6旨在通过规范来表示,而不是像以前版本的Perl那样,通过实现来表示。Perl 6从一组更改建议开始,随着时间的推移会不断发展,再继续发展得更多。

Perl支持use feature,类似于Python的from __future__ import。它更改了文件其余部分的解释方式,以使用新语言的指定功能。

  • 不要成为Perl 6。
  • 设定并遵守最后期限。
  • 不要一下子改变一切。

建议

语言更改

迂腐地说,我们必须使用一种方式来谈论特定的语言版本。Go语言的每个更改首先出现在Go的发行版中。我们将使用Go版本号来定义语言版本。这是唯一合理的选择,但它可能会造成混淆,因为标准库的更改也与Go版本号相关联。在考虑兼容性时,有必要在概念上将Go语言版本与标准库版本分开。

作为特定更改的一个示例,类型别名(type aliase)首先在Go语言1.9版本中可用。类型别名是向后兼容语言更改的一个示例。所有用Go语言1.0到1.8版本编写的代码在Go语言1.9版本继续与以前相同的方式工作。但使用类型别名的代码需要使用Go语言1.9或更高版本才能编译运行。

增加语言特性

类型别名是增加语言特性的一个示例。使用类型别名语法type A = B的代码无法在1.9版本之前的Go中编译。

类型别名和自Go 1.0以来的其他向后兼容的更改向我们表明,对于语言特性的增加,包没有必要显式声明它们所需的最低语言版本。一些包使用类型别名这一新特性。当用Go 1.8工具编译这样的包时,编译失败。包作者可以简单地说:升级到Go 1.9,或者降级到包的早期版本。Go工具不需要知道这个要求;因为无法使用旧版本的工具进行编译暗示了这一点。

程序员当然需要了解语言特性的增加,但工具不需要。Go 1.8工具和Go 1.9工具都不需要明确知道Go 1.9中增加了类型别名,除了Go 1.9编译器可以编译类型别名而Go 1.8编译器不能,这个有限的意义之外。指定最低语言版本以获得更好的不支持语言特性的错误消息的可能性,将在下文讨论。

移除语言特性

我们还必须考虑从语言中删除特性的语言更改。例如,issue 3939建议我们删除string(i)转换整数值i。如果我们在Go版本1.20中进行此更改,那么使用此语法的包将在Go 1.20中停止编译。(如果你更愿意将向后不兼容的更改限制在新的主版本中,那么在此讨论中将1.20替换为2.0;问题仍然存在。)

在这种情况下,使用旧语法的包没有简单的修改方法。虽然我们可以提供将1.20之前的代码转换为可工作的1.20代码的工具,但我们不能强制包作者运行这些工具。一些软件包可能没有维护但仍然有用。一些组织可能希望升级到1.20而不必重新验证他们所依赖的软件包的版本。一些软件包作者可能希望使用1.20,他们的软件包现在已经损坏,但没有时间修复他们的软件包。

这些场景表明我们需要一种机制,来指定可以用来构建软件包的Go语言的最高版本。

重要的是,指定Go语言的最高版本不应被视为要使用的Go工具的最高版本。随Go 1.20版本发布的Go编译器必须能够构建Go 1.19版本编写的包。这可以通过模仿C编译器支持的-std选项向编译器(以及,如果需要,汇编器和链接器)添加一个选项来完成。当编译器看到选项时,可能是-lang=go1.19,它将使用Go 1.19语法来编译代码。

这需要编译器以某种方式支持所有以前的版本。如果证明支持旧语法很麻烦,则可以通过将代码从旧版本转换到当前版本来实现-lang选项。这将使对旧版本的支持不在编译器的合理范围内,并且转换器对于想要更新其代码的人可能很有用。支持旧的语言版本不太可能会成为一个重大问题。

当然,即使包是用语言版本1.19的语法构建的,它在其他方面也必须是1.20版本的包:它必须与1.20版本的代码链接,能够调用和被1.20版本的代码调用,等等。

go工具需要知道最大语言版本,以便它知道如何调用编译器。我们继续考虑模块,此信息的逻辑位置位于go.mod文件。模块M的go.mod文件可以为其定义的包指定最大语言版本。当M作为其他模块的依赖项被下载时,它的最大语言版本将会被遵循。

最高语言版本不是最低语言版本。如果一个模块需要1.19版本的语言特性,但可以用1.20构建,我们可以说最大语言版本是1.20。如果我们使用Go 1.19版本来构建,我们低于最大版本值,但使用1.19语言版本来构建不是不可以。可以忽略大于当前工具支持的最大语言版本。如果我们稍后使用Go 1.21版本来构建该模块,我们可以使用-lang=go1.20选项使用最高1.20版本的语言特性。

这意味着这些工具可以自动设置最大语言版本。当我们使用Go 1.30发布模块时,我们可以将模块标记为具有最大语言版本1.30。该模块的所有用户都会看到这个最高版本并做正确的事情。

这意味着我们将不得不无限期地支持该语言的旧版本。如果我们在1.25版本之后删除了一个语言特性,如果使用-lang=go1.25选项(或-lang=go1.24或任何其他包含支持功能)。当然,如果没有使用-lang选项,或者选项是-lang=go1.26或更高版本,则该功能将不可用。由于我们不希望大规模删除现有语言功能,因此这应该是一个可管理的负担。

我相信这种方法足以实现移除语言特性

最小语言版本

为了获得更好的错误消息,允许模块文件指定最低语言版本可能很有用。但这不是必需的:如果一个模块使用了语言版本1.N中引入的特性,那么用1.N-1版本构建它将编译失败。这可能令人困惑,但在实践中,问题很可能是显而易见的。

也就是说,如果模块可以指定最低语言版本,那么在使用1.N-1构建时,go工具可以立即生成一条清晰的错误消息。

最低语言版本可能由编译器或其他工具设置。编译每个文件时,查看它使用的特性,并使用这些特性确定最低语言版本。不需要很精确。

这只是建议,不是要求。随着Go语言的变化,它可能会提供更好的用户体验。

语言重定义

Go语言也能够以不添加或删除的方式进行更改,即对特定语言结构的工作方式进行更改。例如,在Go 1.1中,64位主机上int类型的大小从32位更改为64位。这一变化相对无害,因为该语言本就没有指定int的确切大小。然而,一些Go 1.0程序使用Go 1.1进行编译后,可能会停止工作。

重新定义语言是这样一种情况,即我们的代码在版本1.N和版本1.M中都能成功编译,其中M>N,但两个版本中代码的含义不同。例如,issue 20733提出范围循环(range loop)中的变量应在每次迭代中重新定义。尽管在实践中,这种变化似乎更可能是一种修复程序而不是破坏程序,但从理论上来说,这种变化可能会破坏某些程序。

请注意,新关键字通常不会导致语言重新定义,但我们必须小心确保在引入新关键字之前确实如此。例如,如果我们按照错误处理草案设计中的建议引入关键字 check,并且我们允许像check(f())这样的代码,如果check被定义为同一个包中的函数名,这可能看起来是一个语言重新定义。但是在引入check关键字之后,任何定义这样一个函数名的尝试都将失败。因此,使用check的代码无论在何种意义上都不可能同时使用1.N和1.M版本进行编译。新关键字可以作为移除语言特性(check作为非关键字使用时)或添加语言特性(check是关键字时)来处理。

为了让Go生态系统在向Go 2的过渡中幸存下来,我们必须尽量减少此类语言重新定义。如前所述,成功的语言通常基本上没有超出特定程度的重新定义。

当然,语言重新定义的复杂性在于我们不再能依赖编译器来检测问题。查看重新定义的语言结构时,编译器无法知道其含义。在存在重新定义的语言结构时,我们也无法确定最大语言版本。因为我们不知道该语言结构是打算用旧含义还是新含义进行编译。

唯一的可行性可能是让程序员设置软件包的语言版本。在这种情况下,它可能是最低或最高语言版本,视情况而定。它必须以不会被任何工具自动更新的方式设置。当然,设置这样的版本很容易出错。随着时间的推移,最大的语言版本会导致令人惊讶的结果,因为人们试图使用新的语言功能,但都失败了。

我认为唯一可行的安全方法是不允许重新定义语言。

我们被当前的语义所困扰。这并不意味着我们无法改进它们。例如,对于issue 20733,即range问题,我们可以更改range循环,以便禁止获取range参数的地址,或从函数字面值中引用它。这不是一个语言重新定义;这将是一个语言特性移除。这种方法可能会消除Bug,而不会意外破坏代码。

构建标签(Build tags)

构建标签是一种现有机制,程序可以使用它来根据要发布的程序版本选择要编译的文件。

构建标签可用于给出发布的程序的版本号,它们看起来就像Go语言的版本号,但是,学究式地说,它们是不同的。在上面的讨论中,我们讨论了使用Go版本 1.N的编译器来编译Go语言版本为1.N-1的代码。使用构建标签是不可能做到的。

构建标签可设置用于编译特定文件的最大版本号或最小版本号,或同时设置两者。这是一种方便的方式来利用只有在特定版本之后才可用的语言更改;也就是说,这可用于在编译文件时设置最低语言版本号。

如上所述,对于语言更改最有用的是可以根据这种更改设置最大语言版本。构建标签并没有以有用的方式提供这一点。如果使用构建标签将当前版本设置为最大版本,则你的包将不会再生成以后的版本。只有将最高语言版本号设置为当前版本之前的版本号时,才能设置最高语言版本,并且还需要一个用于发布后续版本的当前包的副本。也就是说,如果你使用1.N进行构建,那么使用!1.N+1构建标签是没有帮助的。你可以使用!1.M构建标签,其中M<N,并且在几乎所有情况下,你都还需要一个单独的文件,其构建标记为!1.M+1

构建标签可用于处理语言重新定义:如果语言版本1.N有语言重新定义,程序员可以使用!1.N构建标签,使用旧语义和使用一个1.N构建标签的不同文件。然而,这些重复实现需要大量的工作,一般来说很难知道何时需要,而且很容易出错。构建标签的可用性不足以克服先前关于不允许任何语言重新定义的讨论。

导入“go2”

可以为Go添加一种机制,类似于Python的from __future_import和Perl的use feature。例如,我们可以使用一个特殊的导入路径,import "go2/type-aliases"。这将把所需的语言功能放在使用它们的文件中,而不是隐藏在go.mod文件中。

这将提供一种方法来描述文件所需的语言特性添加集合。它更复杂,因为它不依赖于语言版本,而是将语言分解为单独的功能。没有明显的办法消除这些特殊import,因此它们会随着时间的推移而积累。Python和Perl通过故意进行向后不兼容的更改来避免累积的问题。在转到Python3或Perl6之后,可以丢弃累积的特性。由于Go试图避免向后不兼容,因此没有明确的方法可以消除这些导入。

此机制无法处理移除语言特性的情况。我们可以引入删除导入,例如import "go2/no-int-to-string",但不清楚为什么会有人使用它。实际上,根本没有办法删除语言特性,即使是那些容易混淆和出错的特性。

这种方法似乎不适合Go。

标准库改变

迁移到Go 2的好处之一是有机会发布一些兼容Go 1的标准库包。另一个好处是有机会将许多(也许是大部分)软件包移出六个月的发布周期。如果模块实验成功,甚至有可能尽早开始做这件事,使一些包的发布周期更快。

我建议继续保持六个月的发布周期,但将其视为编译器/运行时的发布周期。我们希望Go发行版开箱即用,因此发行版将继续包含与今天大致相同的软件包集合的当前版本。然而,其中许多包实际上有它们自己的发布周期。使用给定Go版本的人将能够明确选择使用标准库包的新版本。事实上,在某些情况下,他们可能会使用旧版本的标准库包。

不同的发布周期需要包维护者投入更多的资源。只有当我们有足够的人手来管理它,有足够的测试资源来测试它时,我们才能做到这一点。

我们还可以继续对所有内容使用六个月的发布周期,但将可分离的包单独提供兼容的、不同的版本。

核心标准库

尽管如此,标准库的某些部分仍必须被视为核心库。这些库与编译器和其他工具紧密相关,必须严格遵循发布周期。不得使用这些库的旧版本或新版本。

理想情况下,这些库将保留在当前版本1上。如果似乎有必要将其中任何一个更改为版本2,则必须根据具体情况进行讨论。目前我看不出有什么理由这样做。

核心库的暂定清单是:

  • os/signal
  • plugin
  • reflect
  • runtime
  • runtime/cgo
  • runtime/debug
  • runtime/msan
  • runtime/pprof
  • runtime/race
  • runtime/tsan
  • sync
  • sync/atomic
  • testing
  • time
  • unsafe

我可能乐观地从这个列表中省略了net、os和syscall包。我们将看看我们能管理什么。

伴生标准库

伴生标准库是那些包含在Go发行版中但独立维护的包。当前标准库中的大部分内容其实都是这种包。这些软件包将遵循与今天相同的规则,并可选择在适当的情况下迁移到v2。可以使用go get升级或降级这些标准库包。特别是,这些包可以独立于每六个月的核心库发布周期,修复Bug发布自己的次版本。

go工具必须能够区分核心库和伴生库。我不知道这将如何运作,但它似乎是可行的。

将标准库包移动到v2时,必须规划好同时使用包的v1和v2版本的程序。这些程序必须按预期运行,如果不可能,就必须干净利落地迅速运行失败。在某些情况下,这将涉及修改v1版本以使用也由v2版本使用的核心库。

标准库包必须能够使用Go语言的旧版本进行编译,至少是我们目前支持的前两个发布周期的Go语言版本。

从标准库中移除包

标准库包支持的go get能力将允许我们从发布中删除包。这些包将继续存在和维护,人们能够在需要时检索它们。但是,默认情况下它们不会随Go版本一起发布。

这将包括像以下这样的包:

  • index/suffixarray
  • log/syslog
  • net/http/cgi
  • net/http/fcgi

以及其他似乎没有被广泛使用的包。

我们应该在适当的时候为旧包制定弃用政策,将这些包设置为不再维护。弃用政策也适用于移至v2版本的v1版本的软件包。

或者这可能被证明是有问题的,我们不应该弃用任何现有的包,也不应该将它们从标准的Go发行版本中删除。

Go 2

如果上述过程按计划进行,那么在某种重要意义上永远不会有Go 2。或者,换句话说,我们将慢慢过渡到新的语言和库特性。我们可以在过渡期间的任何时候决定我们现在是Go 2,这可能是一个很好的营销方式。或者我们可以跳过它(从来没有C 2.0,为什么要有Go 2.0?)。

C、C++和Java等流行语言从来没有v2版本。实际上,它们始终处于版本1.N,尽管它们使用不同的名称来称呼该状态。我认为我们应该效仿它们。事实上,从不兼容的新版本语言或核心库的意义上说,完全意义上的Go 2对我们的用户来说不是一个很好的选择。不夸张地说,一个真正的Go 2版本可能是有害的。