Go 语言的 15 个你可能不知道的细节

过去一年使用 Go 语言过程中,我最喜欢的几个小技巧。

学习新知识的最佳方式之一,就是定期记录所学内容。过去一年,我一直在用这种方式学习 Go 编程语言。以下是我最喜欢的几个鲜为人知的语言细节。

直接遍历整数范围

从 Go 1.22 版本起,可直接遍历整数范围:

for i := range 10 {
    fmt.Println(i + 1) // 1, 2, 3 ... 10
}

重命名包

Go 的 LSP 不仅能重命名普通变量,还能重命名 。新命名后的包将在所有引用处自动更新。额外福利:它甚至会重命名目录!

元素周期表

泛型函数签名的约束

可使用 ~ 运算符约束泛型类型签名。例如对类型化常量可这样操作:

package main

import (
	"fmt"
)

type someConstantType string

const someConstant someConstantType = "foo" // Underlying type is a string

func main() {
	msg := buildMessage(someConstant)
	fmt.Println(msg)
}

func buildMessage[T ~string](value T) string { // This accepts any value whose underlying type is a string
	return fmt.Sprintf("The underlying string value is: '%s'", value)
}

当具体类型是 Go 中的类型化常量(类似其他语言的 enum)时,此特性尤为实用。

基于索引的字符串插值

Go 支持基于索引的字符串插值:

package main

import (
	"fmt"
)

func main() {
	fmt.Printf("%[1]s %[1]s %[2]s %[2]s %[3]s", "one", "two", "three") // yields "one one two two three"
}

当需多次插入相同值时,此特性可减少重复并提升代码可读性。

time.After 函数

time.After 函数创建一个通道,该通道将在 x 秒后收到消息。结合 select 语句使用时,可轻松为其他例程设置截止时间。

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan string, 1)

	go func() {
		time.Sleep(2 * time.Second)
		ch <- "result"
	}()

	select {
	case res := <-ch:
		fmt.Println("Received:", res)
	case <-time.After(1 * time.Second):
		fmt.Println("Timeout: did not receive a result in time")
	}
}

embed

“embed” 包允许将非 Go 文件直接嵌入 Go 二进制文件中,运行时无需从磁盘读取。

可嵌入HTML、JS甚至图像文件。将资源直接编译到二进制文件中能显著简化部署流程。

字符串中len()函数的使用及UTF-8陷阱

Go语言的内置len()函数返回的并非字符串的字符数,而是字节数——因为字符串字面量并非按字符占用单字节(故采用rune类型)。

package main

import (
	"fmt"
)

func main() {
	s := "Hello 世界"
	fmt.Println(len(s)) // Prints 11!

	for i := 0; i < len(s); i++ {
		fmt.Printf("index %d: value %cn", i, s[i]) // Iterates over bytes. This will not work as expected....
		/*
			index 0: value H
			index 1: value e
			index 2: value l
			index 3: value l
			index 4: value o
			index 5: value
			index 6: value ä
			index 7: value ¸
			index 8: value <96>
			index 9: value ç
			index 10: value <95>
			index 11: value <8c>
		*/
	}

	for i, r := range s { // The range keyword iterates through runes.
		fmt.Printf("byte %d: %sn", i, string(r))
		/*
			byte 0: H
			byte 1: e
			byte 2: l
			byte 3: l
			byte 4: o
			byte 5:
			byte 6: 世
			byte 9: 界
		*/

	}
}

Rune在Go中对应代码点,其长度介于1至4字节之间。更复杂的是,尽管字符串字面量采用UTF-8编码,但本质上仍是任意字节集合,这意味着理论上可能存在包含无效数据的字符串。此时Go会将无效UTF-8数据替换为替代字符。

package main

import (
	"fmt"
)

func main() {

	invalidBytes := []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xFF} // "Hello" + invalid byte
	s := string(invalidBytes)

	for _, r := range s {
		fmt.Printf("%c ", r) // Prints: H e l l o �
	}
}

空接口

你认为这段代码会输出什么?

package main

import "fmt"

type Animal interface {
	Speak()
}

type Dog struct{}

func (d *Dog) Speak() {}

func main() {
	var d *Dog = nil
	var a Animal = d
	fmt.Println(a == nil)
}
 

答案是:false!

这是因为尽管 为nil,但变量的类型是 非空接口

Go 将该值“装箱”到接口中,而接口本身并非 nil。若函数返回接口类型,此特性可能带来严重问题。一旦返回值(即使为 nil)被声明为接口类型,其 nil 检查将不再按预期工作。例如:

package main

import "fmt"

type Car interface {
	Honk()
}

type Honda struct{}

func (h *Honda) Honk() {
	fmt.Println("Beep!")
}

func giveCar() Car {
	var h *Honda // h is nil
	return h     // nil *Honda wrapped in Car interface
}

func main() {
	c := giveCar()
	if c == nil {
		fmt.Println("This will never print!")
	}
}

在此示例中,c是包装的nil值,因此c == nil检查永远返回false。

对nil值调用方法

相关地,你实际上可以对nil结构体调用方法。以下Go代码是有效的:

package main

import "fmt"

type Foo struct {
	Val string
}

func (f *Foo) Hello() {
	fmt.Println("hi from nil pointer receiver")
}

func main() {
	var f *Foo = nil
	f.Hello() // This is fine!

	fmt.Println(f.Val) // This is not!
}

当然,尝试访问此结构体的属性仍会引发panic。

遍历映射时的变量引用

在循环内更新映射时,无法保证更新会在当前迭代中生效。

唯一保证是循环结束时映射已包含更新内容。虽然这种写法显然不可取(属于不良代码),但理解其机制仍有价值。

func main() {
	m := map[int]int{1: 1, 2: 2, 3: 3}

	for key, value := range m {
		fmt.Printf("%d = %dn", key, value)
		if key == 1 {
			for i := 10; i < 20; i++ {
				m[i] = i * 10 // Add many entries
			}
		}
	}
}

例如在上方代码中,你可能看到也可能看不到循环内添加的值被打印出来。

这源于Go内部对象管理机制。当添加新键值对时,语言会对键进行哈希处理并放入存储桶。若Go的迭代已“查阅”过对象中的该存储桶,则新条目不会在循环中被访问。

这与Python等语言不同——后者采用“稳定插入顺序”确保此类情况不会发生。Go如此设计的根本原因在于:速度!

返回自定义错误

在Go中,将意外错误以类型化错误形式返回往往很有帮助,这样就能为调试或其他上游用途提供额外上下文。通过定义类型,可借助 errors.As 传递结构化数据并实现自定义逻辑,同时满足 error 接口要求。

package main

import (
	"errors"
	"fmt"
)

type MyError struct {
	Message string
	Code    int
}

func (e *MyError) Error() string {
	return fmt.Sprintf("Error %d: %s", e.Code, e.Message)
}

func someFunction() error {
	return &MyError{Message: "something went wrong", Code: 404}
}

func main() {
	err := someFunction()
	if err != nil {
		var myErr *MyError
		if errors.As(err, &myErr) {
			fmt.Printf("Handled typed error: %sn", myErr.Error())
		} else {
			fmt.Printf("Unhandled error: %sn", err)
		}
	}
}

感知上下文的 Go 函数

在感知上下文的函数中,应同时根据上下文和通道进行选择。否则可能出现上下文已被取消却仍无谓等待操作完成的情况。

例如下例中,当time.After结束时,我们会向通道发送“操作完成”消息;若上下文被取消则提前退出。

由于sendSignal函数能检测上下文取消状态,因此能够实现提前退出。

package main

import (
	"context"
	"fmt"
	"time"
)

func sendSignal(ctx context.Context, ch chan<- string) {
	select {
	case <-time.After(5 * time.Second): // Fake operation takes five seconds...
		ch <- "operation complete"
	case <-ctx.Done(): // But we can short-circuit it with a cancellation. Without this we'd ignore the context cancellation!
		ch <- "operation cancelled"
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) // Only 1 second timeout
	defer cancel()

	ch := make(chan string)
	go sendSignal(ctx, ch)

	msg := <-ch
  close(ch)

	fmt.Println(msg)

}

这是在通道操作中必须选择上下文的典型示例——若不这样做,即使上下文在1秒后被取消,函数仍会等待完整的5秒。

补充说明:Go语言的上下文会在HTTP处理器完成且响应完全发送后被取消,即使是成功响应也是如此。因此必须谨慎处理上下文传播。例如,若将HTTP请求的上下文传递给事件发布器,快速响应可能取消传播的上下文并阻止事件发布,从而引发竞争条件。

空结构体

Go开发者常传递空结构体,而非布尔值。为何如此?

在Go中,空结构体占用零字节空间。Go运行时会将所有零大小分配(包括空结构体)处理为返回单个特殊内存地址,该地址不占用任何空间。

因此当无需实际传输数据时,它们常被用于通道信号传递。相比之下,布尔值仍需占用一定空间。

Go编译器与range关键字

Go编译器会在进一步编译前将range关键字“降级”为基础循环。具体实现因降级对象而异,例如映射、切片或迭代包中的序列。

有趣的是,对于迭代包,它会将范围内的break调用转换为yield函数通常返回的“false”值来终止迭代。

隐藏接口满足性

隐藏接口满足性会导致结构体嵌套问题。

例如,当你将 time.Time 结构体嵌入 JSON 响应字段并尝试序列化父结构时:

嵌套结构体时,其包含的方法会隐式提升。由于 time.Time 具有 MarshalJSON() 方法,编译器将优先执行该方法而非常规序列化行为。

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

type Event struct {
	Name      string `json:"name"`
	time.Time `json:"timestamp"`
}

func main() {
	event := Event{
		Name: "Launch",
		Time: time.Date(2023, time.November, 10, 23, 0, 0, 0, time.UTC),
	}

	jsonData, _ := json.Marshal(event)
	fmt.Println(string(jsonData)) // "2023-11-10T23:00:00Z" weird right?

}

在此示例中,Event结构体嵌入了time.Time字段。当将Event结构体序列化为JSON时,time.Time类型的MarshalJSON()方法会被自动调用,用于格式化 整个结果,最终导致输出结果与预期不符。

其他方法同样如此,这可能导致难以追踪的怪异错误。嵌套结构体时务必谨慎!

JSON的”-”标签

序列化JSON时使用”-”标签可省略字段。当字段包含私有数据且需从API响应中排除时,此功能尤为实用。

package main

import (
	"encoding/json"
	"fmt"
)

type User struct {
	Name     string `json:"name"`
	Password string `json:"-"`
	Email    string `json:"email"`
}

func main() {
	user := User{
		Name:     "John Doe",
		Password: "supersecret",
		Email:    "john.doe@example.com",
	}

	data, err := json.Marshal(user)
	if err != nil {
		fmt.Println(err)
		return
	}

	fmt.Println(string(data)) // Only {"name":"John Doe","email":"john.doe@example.com"}, not password!
}

此为刻意设计的示例,实际操作中显然不会如此粗心处理密码。但该特性仍颇具实用价值。

时间比较

将Go中的Time类型转换为字符串时,字符串化器会自动附加时区信息,因此字符串比较无法准确判断时间值。此时应使用.Equal()方法进行时间比较:“Equal用于判断t和u是否表示相同的时间点。即使两个时间点位于不同时区,它们也可能被判定为相等。”

package main

import (
	"fmt"
	"time"
)

func main() {
	t1 := time.Date(2024, 1, 1, 15, 0, 0, 0, time.UTC)
	t2 := t1.In(time.FixedZone("EST", -5*3600)) // Adds timezone info

	fmt.Println(t1.String() == t2.String()) // prints false
	fmt.Println(t1.Equal(t2))               // prints true!

}

此特性在测试和持续集成场景中尤为重要。

wg.Go 函数

Go 1.25 引入的 waitgroup.Go 函数可更便捷地向等待组添加协程。它替代了 go 关键字的使用,示例如下:

wg.Go(func() {
    // your goroutine code here
})

其实现本质是以下代码的封装:

func (wg *WaitGroup) Go(f func()) {
    wg.Add(1)
    go func() {
        defer wg.Done()
        f()
    }()
}

本文文字及图片出自 15 Go Subtleties You May Not Already Know

共有 183 条评论

  1. > wg.Go 函数

    > Go 1.25 引入了 waitgroup.Go 函数,可更便捷地将 Go 协程添加至等待组。它取代了使用 go 关键字的 […] 写法

    99% 的情况下,您不应使用 sync.WaitGroup,而应选择 errgroup.Group。该组件本质上是带错误处理功能的 sync.WaitGroup,同时支持可选的上下文/取消机制。详见 https://pkg.go.dev/golang.org/x/sync/errgroup

    虽然它不属于标准库,但包含在http://golang.org/x/包中。坦白说,golang.org/x/里的内容本该纳入标准库,却因某些原因未能实现。

    • 我完全赞同。几乎每个Go项目我都会用errgroup,它能实现你通常需要手动完成的功能,而且实现方式更简洁。

      有趣的是,我发现它时已经写了完全相同的工具,两段代码几乎逐行相同。不过这倒是绝佳机会——无需重构就能从仓库删掉冗余代码!

      • > 代码几乎完全相同,这实在很有趣。

        Go语言的核心优势之一在于契合Python的“禅意”——“实现方式应当只有一种,最好是唯一且显而易见的”,而它完美践行了这一理念。

    • 扩展标准库确实很棒,但显然无法保证Go语言的兼容性承诺,因此将其独立出来是明智之举。

    • >golang.org/x/目录收录了本应纳入标准库却因故未被采纳的内容

      可将其视为并入稳定版标准库前的测试/预发布阶段

      • 我认为这关乎向后兼容性与API演进

      • 只是这种分离过于便利,导致像errgroup这类极具价值的功能仍滞留于此,未能被标准库采纳。

    • 天啊我怎么之前不知道这个?!

      当提供的上下文被取消时,如何终止正在运行的goroutine?

      • 所有goroutine都必须使用特殊上下文。

        • 只需具备上下文感知能力,或调用具备感知能力的组件即可。

          • 明白了,没有魔法般的goroutine中断机制,全程依赖上下文控制。

            不过这总比每次手动实现等待组要优雅。

    • 看来无法用于带参数的函数。这种情况需要使用wg.Add。

    • 我从未用过errgroup,但意识到这本质上就是我最终实现的方案。

      使用标准等待组时,我总将状态封装为结构体,包含嵌套的*data结构和err属性,再通过通道传递。但这种方式将错误处理移至读取之后,而非直接在Wait()调用处处理。

  2. 文中提及 len() 返回的是字节数而非“字符数”。更微妙的是:从用户可见的显示角度看,rune(码点)未必等同于“字符”——真正的“字符”应称为“字形”。

    字形可能包含多个码点,并带有修饰符、连接符等元素。

    这是所有语言的特性,属于Unicode规范而非Go语言特性。在此推荐一个Go语言的字符组合符分词器:https://github.com/clipperhouse/uax29/tree/master/graphemes

    • 这是我最喜欢的专题文章https://adam-p.ca/blog/2025/04/string-length/

      • 终于看到一篇不把字形集群奉为Unicode处理终极解决方案的文章。

        我收藏了这篇。虽然表达方式与我不同,但足够简明,可以分享给现任同事而不会误导他们。

    • Go语言中len()函数返回的是整型而非无符号整型/64位无符号整型。

      虽然我不写Go,但前几天给Rust项目写Go封装层时遇到了这个问题,当时完全摸不着头脑。

  3. 之前不知道基于索引的字符串插值功能,很实用!

    不过关于迭代时修改映射的描述有误。结果不确定的原因在于Go故意采用随机顺序迭代。这与速度无关,而是为了防止用户依赖迭代顺序。它随机选择起始桶,然后以环形顺序遍历,并在每个桶内随机生成0..7的排列。因此,如果你的修改操作落入已访问过的桶或槽位,则修改不会生效。

    另外,Python并非相反的例子。在迭代过程中修改Python字典会引发RuntimeError: 迭代期间字典大小发生变化错误。

  4. 这份清单完美诠释了人们为何既爱又恨Go语言。我确实享受编写Go代码的过程,但由于nil相关的微妙行为,你永远无法确信自己的代码是否真正健壮。

    • 作为Go语言学习者,最让我豁然开朗的解释是:接口类型本质上只是组合了两个指针:

      P1:类型及其方法虚函数表
      P2:实际值

      理解这一点后,我就能直观明白为什么空值Foo既不是空值Bar,也不是无类型的空值

      • 啊当然——nil的核心语义完全应该取决于我所用语言运行时的底层实现细节。

        willem-dafoe-head-tap.gif

    • 由此推论,Go语言确实鼓励编写最朴素的代码。无需高深的类型技巧,不必滥用接口,也无需复杂的类型组合。最终你将获得一个永不停歇、运行迅捷且资源消耗极低的系统。

      • 而完全不依赖花哨技巧(某些人所谓的“表达力”)的代码,即便面对陌生的代码库甚至语言,依然清晰易读。

        当系统崩溃需要火速定位错误时,这种特性尤为珍贵。你只需追踪流程,无需费力解读原始程序员用超表达性语言埋下的花哨技巧。

        没错,错误在第42行,但这一行竟做了二十几件事…

        • 我明白这并非Go或任何语言的专属问题,但用它确实能写出令人费解的代码。若真要说,恰恰是表达力和恰当的抽象能让你免于此类困境。

          我认为人们常因表达型语言中的糟糕抽象而吃亏,但这并非语言本身的问题,而是作者对可用工具的不熟悉。

          若有人在未掌握基础原理前就试图在抽象上耍小聪明,往往会导致抽象设计失当。

          因此我认为,掌握的工具越少,越能早日设计出优质抽象。

          根据我的经验,若能投入时间真正理解现有工具,表达力强的语言往往能极大提升代码理解与维护效率。

          当然,职业生涯早期我也经历过理解不足就贸然抽象,或是被迫应对他人糟糕抽象的困境。

          • 我常说:“唯有我的代码才允许耍小聪明”

            但认真讲,我认同你的观点。Go语言功能严重不足,尤其在类型系统方面,导致大量本可通过静态检查轻松避免的问题(及停机时间)。

            • 形式化证明语言固然精妙,但现实世界中无人真正使用。在日常使用的语言中,即便那些宣称拥有强大类型系统的语言,仍需依赖测试来验证绝大多数内容——既然最终要依赖测试,类型系统也就失去了真正的救赎意义。

              开发者群体中或许存在这样一类人:他们自认无懈可击,拒绝编写测试,直到类型系统揭穿其先入为主的谬误,才开始感激类型系统为其指明方向——尽管他们始终无视类型系统无法静态验证的诸多缺陷。这般境地着实诡异。

              • TypeScript的类型系统相较JavaScript实现了巨大飞跃。

                功能性测试(确保函数按预期工作)依然必要,但类型系统能自动消除大量错误场景。

                • 它极大提升了开发者体验。

                  但并不会改变你需要编写的测试,而这些测试恰好会覆盖类型系统同样能捕获的错误,因此类型系统本身并不能让软件更可靠。

                  更强大的类型系统或许能实现可靠性,但现实中没有语言能做到这一点。

            • 出于好奇(绝非讽刺),能否举例说明弱类型系统曾导致过严重问题?

              • 几乎所有空指针解引用错误都算吗?

                但问题根源极少在于弱类型系统本身,若善用强类型系统本可避免这类问题。

                一旦开始强制“无效状态不可呈现”并在类型系统边界执行检查,许多诡异错误便会消失。

                • 不过许多“强类型”语言同样存在空指针异常。你是在拿特定语言做对比吗?

          • >问题不在于语言本身,而在于作者对可用工具的陌生。

            当需要与技能水平参差不齐的大型团队共享代码库时,限制开发者出错的可能性绝对是语言应具备的特性——而这恰恰是某些语言所欠缺的。

            一如既往,这涉及权衡取舍。你更愿意拥有使用优质、富有表现力的抽象概念的能力,还是剥夺团队编写劣质抽象的能力?这可能取决于你的具体情况和目标。

          • > 绝对能用它写出令人费解的代码

            我曾竭力尝试编写难以解读的Go代码却未能成功。你有具体示例吗?

        • > 那些完全无法施展花哨技巧(某些人所谓的“表达力”)的代码,即便面对陌生的代码库甚至语言,依然清晰易读。

          大学时有个学计算机的朋友,当时大一还在教Turbo Pascal,他给我看过些代码——那时我还在用ZX Spectrum BASIC和Z80汇编写高中作业。虽然语法有点陌生,但代码逻辑瞬间就明白了。

          反观某些案例结构中,有人为炫技而堆砌三元运算符链,还依赖穿透机制——我不得不耗费时间逐行拆解这类代码。

          那位用Pascal的朋友称此类代码为“英格威·马尔姆斯汀式编程”。三十多年过去,这个比喻仍烙印在我脑海。

          别搞什么“WEEDLYWEEDLYWEEDLY”的把戏。那纯属炫技。

          • 我总告诫初级程序员:想写多精妙的代码都行。

            但请用在私人时间。

            当你为工作编写代码——那些最终需要他人阅读理解的代码——务必做到枯燥乏味。抛弃所有花哨技巧,让代码可读而非可爱的。或许有人要在凌晨三点火烧眉毛时理解并修复它。

              > 编写代码时永远假设:最终维护你代码的人是个知道你住址的暴力精神病患者。
            
            • 我曾共事过一位厌恶注释的同事。每周约有两天,他总以各种借口“居家办公”,整天埋头从庞杂混乱的PHP代码库中删除所有注释。那还是PHP4时代的事,足以让你体会这段往事有多久远。

              他的论调是:“代码本该一目了然!无需注释解释功能!”

              罗伯特,没错,但你需要注释说明代码要操作什么对象,以及操作原因。

              结果证明,撤销这位自封的“开发经理”对Subversion仓库的写入权限,竟在现实世界中引发了层层涟漪,一直波及到高管层。不过我手握铁证:他制造的问题远多于解决的问题,因此我的决定站得住脚。

        • 没错,几乎所有大型Go项目都存在并发缺陷。因为泛型出现前,编写并行for循环必然会自掘坟墓。

          Go的简单如同汇编语言的简单。

      • 任何语言都存在类似情况。编写“笨拙”的代码更易理解,尤其在小规模项目中。但Go语言中函数接受函数、返回函数,加上通道和泛型机制,其复杂度很快就能与其他语言比肩。

      • 哎呀,我跨协程的RW互斥锁(用于映射)可不这么认为。

        • 使用 sync.Map 或创建简单封装器来控制访问。

          • … sync.Map 的文档明确写道:

            Map 类型是特化的。大多数代码应改用普通 Go 映射,配合独立锁定或协调机制,以获得更强的类型安全,并便于维护映射内容之外的其他不变量。

            文档本质上表明它针对特定场景进行了优化,而这些场景并不影响上述问题。

            • 若追求严谨,可使用https://github.com/puzpuzpuz/xsync,该库支持泛型且比原生 sync.Map 更快

            • sync.Map基于原子操作实现,其本身存在局限性——必须使用特定(且可能冲突)的哈希键。

              我先前评论的核心观点在于:sync.Map并未采用基于资源的互斥锁机制,而是使用单一互斥锁处理所有操作,这必然导致最慢的执行场景。

              Go语言中并不存在类似Rust的真正借用机制。若主张应优先考虑简洁性,那么普通map[]本应默认线程安全,但这可能因泛型特性而需要编译时展开。

              Go语言“追求简洁”的核心理念始终像种伪装,因为其设计中存在太多例外和陷阱。这些陷阱几乎都是刻意为之——开发者本可打破旧有行为以追求更纯粹的简洁,却选择维持现状,这与最初的语言设计承诺背道而驰,实属诡谲。

      • > Go语言真正奖励的是编写最愚蠢的代码

        简约是种难事。你或许视其为愚蠢,他人却视其为语言的无价特质。

      • 公平地说,检查接口是否为空确实是愚蠢的代码,而这种检查无法生效正是我对这门语言最大的不满之处。在此情境下,愚蠢的显然是语言(创造者)本身

        • 接口本质是行为。这正是它与其他语言的核心区别。Go关注“做什么”而非“谁做”。因此检查空值时,你本质上是在询问变量是否具备可执行的逻辑。而这种逻辑只有在提供行为时才存在——即变量不为空。

  5. >额外复杂性在于:尽管字符串字面量采用UTF-8编码,本质上仍是任意字节集合,这意味着理论上可能存在包含无效数据的字符串。此时Go会将无效UTF-8数据替换为占位符字符。

    不,这只是常规的“打印时替换不可打印字符”行为。数据本身未改变,你完全无法保证UTF-8的有效性:https://go.dev/play/p/IpYjcMqtmP0

  6. 作为仅从远处观察Go语言的人,我认为这份清单既包含“这有什么大不了”的质疑,也夹杂着“请别这样”的呼吁。

    • 这类帖子让我忍俊不禁,因为它表明Go终于正从一种非人体工学式的简化语言(其最初的独特卖点?)逐渐转变,开始接纳现代语言本应具备的特性。

      我的开发经历始终让我觉得,语言设计者们看着C语言时想的是:“只要加上垃圾回收,就能得到完美的语言了”。

      我认为开发者在Go语言编程时感受到的高效感,很大程度上源于其海量代码产出——得益于完善的语言特性和标准库功能,他们只需几行代码就能实现其他语言需要冗长代码才能完成的功能。

      • 遗憾的是,鉴于作者们也参与了C语言的创建,这揭示了一个普遍模式,包括C为何成为不安全的语言。

        > 虽然我们偶尔考虑过实现当时主流语言(如Fortran、PL/I或Algol 68),但这类项目对我们的资源而言过于庞大:当时更需要简单轻量级的工具。这些语言都影响了我们的工作,但自主开发更有趣。

        摘自https://www.nokia.com/bell-labs/about/dennis-m-ritchie/chist

        Go语言源于Plan 9系统中Alef设计的失败尝试,后在Inferno系统通过Limbo获得重生。

        https://en.wikipedia.org/wiki/Alef_(programming_language)

        > 罗布·派克后来解释Alef失败的原因在于其缺乏自动内存管理机制——尽管派克等人曾多次敦促温特博顿为该语言添加垃圾回收功能;

        https://doc.cat-v.org/inferno/4th_edition/limbo_language/lim

        你会发现Limbo与Go存在诸多相似之处,其中点缀着Oberon-2的方法语法,而SYSTEM被unsafe所取代。

        https://ssw.jku.at/Research/Papers/Oberon2.pdf

      • 记得刚毕业做后端Java开发时,我曾因需要大量编写代码而自诩效率惊人。

        直到接触前端JS后才猛然醒悟——原来自己不过是在编写冗长的模板代码。

        那还是Java 6时代,许多便捷特性尚未出现。例如实现简单回调就得创建一个实现接口的类(意味着要输入3个独立名称和大量冗余代码),若使用匿名类则最多能省去2个名称。

      • 作为Go开发者,我确实发现初期代码量有所增加——不仅因为缺乏语法糖和“语言魔法”,更源于社区倡导“宁可多抄写代码,也避免过早抽象化”的哲学。

        最终产出的代码却极易理解和维护,因为归根结底它就是控制流清晰的纯粹代码。在我接触过的所有语言中,Go代码的调试体验最为愉悦,其他语言根本无法望其项背。

        考虑到我更多时间用于维护阶段,这种取舍我完全乐意接受。

        (当然这纯属个人经验;非常主观)

        • 所谓“过早”具体指多长时间?10年?20年?还是30年?

          • 对我而言,关键在于:这两者是本质相同还是偶然一致?若是本质相同,那么逻辑抽象化/集中化才是正确选择;若是偶然一致,则保持分离更为妥当。

            以现有信息判断尚为时过早——这在我初次编写新用例集时屡见不鲜。

            若某事物出现第三份副本,则很可能需要抽象化处理(届时我对该事物的理解也更深入)。若未出现第三份副本,那么无论上述问题的答案如何,该事物在两处被复制都是可接受的。

      • 包装接口问题如何提升Go的易用性?我认为这里列举的大多数怪癖并未让语言变得更好。

        • 在该博客中或许只有“直接遍历整数”算数,但我更普遍指的是泛型等特性引入带来的影响。

      • C语言至少还有const指针。在Go里我见过指针在调用栈深达七层时被修改,而后续的意大利面代码当然都依赖这些副作用。

        C语言的限制迫使你避免修改和复杂数据结构。

        Go的“强大”反而让你更容易自掘坟墓。

        若Go能引入const和NonNull(必要时可称之为引用),将变得优雅得多

      • 更像是:让我们抛弃过去75年的编程语言理论进步,再费尽周折重新发现这些成果。

        • 听起来像是“告诉我Go的泛型特性,但别提Go的泛型特性”

          • 既然连Go无法实现类型安全的枚举这种基础特性都无法讨论,何必谈泛型?

      • 若你认为Go和C如此相似,说明你两者都不懂。

        • 它们的相似之处在于抽象层极少,依赖程序员自行重构常见模式并规避逻辑错误。

          你必须思考诸如:

          – 我是否为函数调用可能返回的所有错误添加了显式检查?

          – 所有资源(如文件句柄)在各种场景下都清理妥当了吗?是否遗漏了“defer file.Close()”?(C++等语言早在1980年代就用RAII解决了这个问题)
          – 我的Go通道是否通过正确的信号量和错误处理,实现了规范的作业池系统?

          • > 我是否为函数调用可能返回的所有错误添加了显式检查?

            这可通过代码检查工具轻松验证。

            > 所有资源(如文件句柄)在所有场景下都得到正确清理了吗?还是遗漏了“defer file.Close()”?(C++等语言早在1980年代就通过RAII解决了这个问题)

            在Python中可能忘记使用with语句,现在C语言也这样了吧?

            > 我的Go通道是否正确实现了带正确信号量和错误处理的工作者池系统?

            那就别写面条式代码了,改用更高阶的抽象结构如x/sync/errgroup.Group吧。

            • >我是否为函数调用可能返回的所有错误添加了显式检查?

              代码检查工具能验证任何内容,但更理想的是让语言本身禁止你犯错。

              >在Python中你可能忘记使用with语句,现在C语言也这样了吧?

              在Python中使用with时,无需考虑具体清理逻辑,任何错误都会自动触发清理。对比Go语言的http.Get

              resp, err := http.Get(url)

              if err == nil { resp.Body.Close() }

              return err

              这里需要特别记住调用resp.Body.Close(),还得记住何时调用。毫无必要的复杂。

              >那就别写意大利面代码了,用更高层次的抽象库,比如x/sync/errgroup.Group

              为什么标准库里没有这个?为什么不实现收集结果这类基本功能?

              • 在我看来这是个设计周全的API。

                调用resp.Body.Close()前无需检查err是否为nil

                https://pkg.go.dev/net/http#Get

                > 当err为nil时,resp始终包含非空的resp.Body。调用方读取完毕后应关闭 resp.Body。

                https://pkg.go.dev/net/http#Response

                > HTTP客户端和传输层保证Body始终非空,即使在无正文或正文为零长度的响应中亦然。关闭Body是调用方的责任。

                调用 http.Get() 返回一个象征响应的对象。响应主体本身可能达到数千兆字节,因此 http.Get() 不应直接读取它,而应提供某种形式的 Reader。

                那么问题来了:何时关闭该读取器?答案应是“当调用方完成操作时”。这不能在响应对象超出作用域时自动处理,否则将限制调用方将响应传递给其他协程处理、存入数组等操作。

                Go的工具链会明确告知你:返回结构体中存在io.ReadCloser对象,且在所属结构体作用域结束前,该对象既未被调用Close()方法,也未被存储或传递至其他位置。

        • Go与C语言具有部分共同渊源。Go的三位创始人中有两位(Ken Thompson和Rob Pike)参与过C语言的早期开发。Ken Thompson更是C语言前身B语言的创造者。虽然两者存在显著差异,但更微妙的层面其实颇为相似:正如楼主所言,C是“非人体工学式的极简语言”,这恰恰与Go的定位不谋而合。

          • 派克并未参与C语言设计,但他参与开发的Newsqueak和Limbo语言启发了Go的并发模型。

    • 若你从未接触Go语言,本文对你毫无价值,你亦非目标读者。面对特定编程语言的文章,缺乏专业见解实属正常!

  7. 此处使用的“微妙之处”表述颇为怪异/不当。我未见任何微妙之处,这些全是合格Go程序员应掌握的基础知识。

    Go语言中存在诸多真正的微妙之处,即便许多专业Go程序员也未曾察觉。例如:https://go101.org/blog/2025-10-22-some-real-go-subtleties.ht

    • 相较于本文主题,你链接中的示例似乎并不实用。

      “for true {…} 与 for {…} 并非等价”

      那又如何?当你首次尝试运行这种“for true”的怪异代码时,编译器就会告知这是无效代码。

  8. 看到文章将格式字符串称为“字符串插值”时我有些犹豫,但这里已有数条评论沿用此说法。难道是我跟不上时代,如今都这么称呼了吗?

    以下表述也让我困惑:

    在循环内更新映射时,无法保证更新会在当前迭代中生效。唯一保证是循环结束时映射已包含更新内容。

    这完全错误吧?听起来像魔法。虽然有简明解释,但我认为更清晰的说法应该是:更新当然是立即生效的,但“范围”迭代器可能无法立即看到。

    • “难道是我跟不上时代,现在都叫字符串插值了吗?”

      这纯粹是命名差异。你的编译器会将

          x = “I want ${number/12|round} dozen eggs, ${name|namecase}”
      

      转换为

          x = StrCon(“I want ”, round(number/12), “ dozen eggs, ”, namecase(name))
      

      这并非重大转换。

      我觉得人们总在不同语言间的细微差别上纠结得离谱…不过话说回来,我认为过度使用字符串插值本身就是种代码异味,所以我的观点可能在多个层面都偏离主流。

      • > 这并非重大改动。

        写代码?或许如此。但试着修改它?享受匹配引号和调整逗号的乐趣吧。简直糟透了,这就是为何大家都改用 fmt.Sprintf() 的原因。

        字符串插值如今已是必备功能,真希望Go开发者能意识到这一点。

        • 语法高亮功能早在二十年前就解决了这个问题。

          不过正如我所说,我认为过度使用这种特性充其量只是代码异味。若你频繁使用到这成为实际问题,那很可能说明你的做法有误。我见过的字符串插值用法大多存在问题,而这些问题往往涉及安全隐患。

          • 日志记录中这种用法无处不在。其特性决定了它比关键代码更容易出现预期外的行为——直到你需要查看日志调试运行时问题时才发现。相比格式字符串,字符串插值在这方面具有压倒性优势。

            格式字符串本身也存在崩溃甚至更严重问题的历史*,历来是极具合理性的安全隐患。至少Go语言没有继承这个缺陷。

            *当然,若仍在C或C++中使用那些危险函数,该隐患依然存在。

    • 迭代过程中修改映射表是重大警示信号。

      • 没错。正因如此,我更倾向于准确描述为:迭代器可能遇到也可能不会遇到新添加的值。

        这能清晰定位问题根源,而解决方案也在此:提前获取待处理的键列表,在遍历这些键时修改映射。

    • 确实,我向来将这类技术称为“字符串格式化”,而语言内置的局部变量隐式字符串格式化糖语法则被称为“字符串插值”。

      在Python中,调用“{}”.format(x)属于字符串格式化,而使用f“{x}”这类语言特性实现相同功能则称为字符串插值。据我所知,Go语言不支持字符串插值,仅通过fmt包提供便捷的字符串格式化函数。

      简而言之:若使用语言特性格式化字符串,即为插值;若借助库实现格式化,则称为字符串格式化。

      • 我认为实际应用中通常如此区分,但即便语言本身直接提供格式化字符串功能,我仍会称其为格式化字符串;即使库实现了字符串插值,我依然会称之为字符串插值。

        区别在于:格式字符串是带有插值指示符的字符串(通常作为附加参数传递在字符串之后),而字符串插值将参数嵌入字符串内部,通过上下文提取值。

      • 不尽然。

        插值是指将值直接嵌入字符串而非作为参数附加。

        例如:“我今年 $age 岁”。

        这种写法确实带来副作用:插值通常是语言特性而非库特性。但这并不妨碍开发插值库——前提是需要具备良好反射能力的语言,或天生支持动态特性的语言。

  9. > 当需多次插入相同值时,此法可减少重复并提升代码可读性。

    基于索引的字符串插值是否更易理解?我认为当变量名直接出现在字符串中时更易理解,而非需要逐个计数参数来定位对应变量。

  10. Go语言那些隐蔽的陷阱无疑是其最糟糕的特性。作为一名“Go狂热爱好者”(我承认),我认为更值得探讨的是:为何这些陷阱从早期版本延续至今?答案在于Go对版本管理的极端严谨性——尤其对1.x主版本的固守。

    这种教条主义带来的积极影响是:长期维护的Go项目开发相对轻松。若我加入一个拥有老旧Go项目的团队,极大概率能直接在IDE中加载代码,并立即启用Go语言出色的LSP、调试、代码检查、测试等工具链。当我开始阅读代码时,其结构很可能与今日新建的Go项目并无二致。

    (顺便感谢楼主指出这些细节,让我学到了不少新知识)

  11. 精彩的清单!提醒我该深入了解1.25版本的新特性了。

    我最希望Go能实现的功能是只读切片(类似C#)。

    而我最希望其他语言借鉴Go的特性是结构化类型(任何带Foo()方法的类型都能作为interface { Foo() }使用)。

    • 没错,可选的可变性会很棒。这还能让大量数据通过栈传递而非堆——Go语言里充斥着毫无必要的指针(个人观点)。

      另一方面,既然Go现在支持迭代器,你可以创建一个[]byte的封装器,使其仅允许读取操作,同时仍可迭代。

      但这涉及抽象化操作,在Go中不可取,且当涉及自定义类型和自定义逻辑时会引发后续问题。

      • > 是的,可选可变性会很棒。这还能让大量数据通过栈传递而非堆——Go语言里充斥着毫无必要的指针(个人观点)。

        我猜这是因为许多开发者将其他语言的引用语义带入了Go,导致人们习惯用指针而非值来思考数据。

    • 在Go中,字符串本质上就是字节的只读切片。

      C#的ReadOnlySpan设计很棒!我认为Go从诞生起就内置了“span”理念。

      • 确实,C#团队添加Span时明显受到Go的影响。

        将字符串用作原始字节容器的做法很有意思,但当你基于[]byte创建字符串时,我认为它几乎总是(总是?)会创建副本,因此无法获得零成本的只读数据视图传递给其他函数。

        • 确实如此,双向转换通常都会分配内存。从语义上讲这是必须的。

          虽然可通过unsafe实现零拷贝转换,但这会破坏语义:字符串因底层字节可变而变得可变。

          或者!泛型常能实现字符串与字节的互通:https://github.com/clipperhouse/stringish

        • 如同Go语言中的其他特性,span概念早在此前数十年就已存在。

      • 1980年代的系统语言早已具备span概念。

        你可能会发现它们在某些语言中曾被称为开放数组。

  12. 在Go中处理panic()和recover()时我经历了“WTF”时刻

    设计上要求在延迟函数调用中嵌套recover的决策令我震惊。将错误处理与正常执行代码强行混杂实在荒谬。

    • 因为recover()本就不该用于常规错误处理。在小型代码库中,panic机制本就不该存在。大型代码库中,recover也只应出现在极其稀疏的位置(例如顶级HTTP处理器中间件中,用于捕获不可靠代码引发的panic)。但总体而言,返回错误才是所有异常的正确标记方式。我始终欣赏Go语言中panic与error的语义区分,这种设计比其他语言的“常规”异常处理(try…catch)清晰得多——后者在语法上将“文件不存在”这类常见情况与“物理内存损坏导致程序异常”混为一谈。我认为 panic 与 error 的区分让这条界限更加清晰,这点非常棒。

      假设 recover 必须存在,将其强制封装在 deferred 函数中堪称绝妙设计——这与 Go 中 defer 的工作机制完美契合。它确保在“函数返回时”执行,恰恰是捕获此类真正灾难性行为的最佳时机。

    • 语义层面。Go至少不会强制要求你包裹每个可能引发恐慌的调用。

      func Foo() { try { maybePanic() } catch (err any) { doSomething(err) }

        .. 更多代码
      

      }

      vs

      func Foo() { defer func() { if err := recover(); err != nil { doSomething(err) } }()

        maybePanic()
      
        .. 更多代码
      }
      
  13. 原文:“在Go语言中,空结构体占用零字节空间。Go运行时会将所有零大小分配(包括空结构体)处理为返回单个特殊内存地址,该地址不占用任何空间。

    这正是为何它们常被用于通道信号传递——当实际无需发送数据时。相比之下,布尔值仍需占用部分空间。”

    我本以为编译器会确保所有对 truefalse 的引用也指向单一地址。因此,更晦涩的代码最多可能节省 8 字节空间。我忽略了什么?

    • 若缓冲通道中存有 100 个 “true”,则实际占用 100 字节。

      若缓冲通道内存有100个“struct{}{}”结构体,只需存储长度即可,因元素类型为零尺寸。

      • 所以你有一个只能发送“struct{}{}”的通道?如果编译器能优化为单个 int,那么对于只能发送其他类型唯一值的通道,难道不能同样优化吗?这样能让代码更易读。

    • > 我认为编译器也应确保所有对 true 和 false 的引用都指向单一地址。

      为什么?若向通道发送常量 true,该 true 值不就存在于函数调用的栈帧中吗?这似乎比将常量 true 的指针存储在栈帧中,每次需要常量值时都进行解引用更合理。

      > 因此,更晦涩的代码最多可能节省8字节空间。我忽略了什么?

      循环构造通道可能导致内存使用量倍增

    • 这样做还能避免对值含义的混淆。

    • 这行不通,因为布尔变量可被修改(而零长度值不可修改)。

    • Go默认采用值传递。

      这意味着若*bool可行则可行,但实际不可行。

      • 若未显式将bool设为指针就实现该功能,那便成了语法糖,这违背了Go的核心精神——代码应清晰透明,通常不应出现任何元编程的玄学操作。

  14. 在专业使用Go约两年并反复遭遇诸如https://go.dev/blog/loopvar-preview这类陷阱后,我认为这根本不是一门好语言。

    许多人称赞它的“简洁”和“明确性”,但坦白说,光是搞清楚参数是按引用还是按值传递就常令人头疼。若你写的代码永远不必在意这些细节,那倒也无妨。但在任何实际项目中,它其实并不比C++或Python更优越或更简洁。

  15. 我最爱的Go技巧是用make(chan struct{}, CONCURRENCY)实现简单信号量,用于限流REST API调用及其他并发Goroutine。

    通过读取获取信号量,通过写入释放信号量,设计相当优雅。

    这能完美限制REST/HTTP爬虫的并发调用数,使其像网页浏览器一样保持8个并发请求。

  16. 啊,那些被包装成非空接口的旧式空值。即便每天写Go代码八年了,这仍会偶尔咬我一口。我从未见过实际使用这种写法的代码。我理解设计初衷,但就是讨厌它。

    • 批评类型化空值的话,做好被骂菜鸟的准备吧。

      我的帖子https://news.ycombinator.com/item?id=44982491招致大量攻击,有人为Go辩护说“那别这么做不就行了!”,还有人试图向我解释我自己的博客内容。

    • 多年前评估后我就放弃了Go语言。记得正是空指针的反直觉特性让我却步,还有异常处理机制。实在可惜,因为它的运行时环境和生态/社区本应相当出色。

      • 这门语言的精妙在于其简洁性,标准库由坚持“简单直观”理念的人主导…但正因如此,它存在“!= nil”不总符合预期这种明显陷阱就更显诡异。

        • Go语言的“简单性”不过是道德标榜。这种陷阱遍布语言各处,因为它本质上并不简单。

          • 没错。

            功能缺失意味着所有复杂性都转嫁给了程序员。而其他语言能为程序员分担部分复杂性负担。

            Go不是简单,是基础。

          • 作为一名近40年来用十多种不同语言编写商业软件的人,我完全不同意这种观点。

            Go语言确实存在缺陷。但说Go的简洁性“只是道德标榜”,这种无知程度之深,只能让我得出结论:你的观点不过是经验不足的开发者自鸣得意地抱持的典型伪宗教偏见。

            Go拥有最易上手的工具链之一。无需处理esconfig、virtualenv之类的繁琐配置,无需堆砌数十行use头文件来定义运行时版本,更不必在千头万绪的依赖中碰运气——这些依赖根本无法实际审核,因为没人愿意打包一个实用的标准库。你不会遭遇多页难以解读的模板错误,不必为解决简单问题钻研五十种实现方案,更不必在审查拉取请求时争论语言子集的适用范围。这里没有未定义行为,不同运行时实现间的微妙不兼容也不会导致语言碎片化。

            Go的问题在于它 枯燥,对开发者而言确实乏味。但这恰恰是它保持简洁的原因。

            所以这绝非道德标榜。它并非完美无缺,确实乏味。但这并不妨碍它同样简单。

            编辑:若有人指责我是狂热粉丝,我并非如此。我更偏爱ALGOL系语言而非B系。我确实不喜欢Go近期许多新增特性,特别是围绕范围迭代的部分。但这仅是个人偏好。

            • 你将Go与Python、JS和C++相提并论——这三者堪称最复杂的语言体系(JS本身并非 ,但开发前需面对大量看似任意的决策)。现实中存在许多易于构建、拥有合理标准库、且不将世界复杂性转嫁给程序员的语言。

              • > 你将Go与Python、JS和C++相提并论,这三者堪称最复杂的语言。

                不,我对比的是十余种实际商业应用过的语言。其中明确提及了Perl、Java、Pascal、过程化SQL等众多语言。

                > 有些语言既易于构建,又拥有合理的标准库

                确实。但它们的存在并不意味着Go就不是简单的语言。

                > 且不会将世界的复杂性转嫁给程序员。

                我不同意。每种语言都存在权衡取舍,而这些权衡最终必然转化为程序员必须应对的复杂性。在我四十年的语言中立实践和兼职语言设计工作中,这种现象 毫无例外 地存在。

          • > 因为它本质上并不简单

            请参考Rich Hickey的《简单之道》:https://www.youtube-nocookie.com/embed/SxdOUGdseq4 / https://ghostarchive.org/varchive/SxdOUGdseq4

      • > 以及异常处理

        若你经常阅读和编写Go代码,那些冗长的 error 处理逻辑终将淡出视野。

        不过需要说明的是,Go中的 error 并不能简单等同于通常意义上的 异常;而 panic 或许可以。

        error 处理机制的改进并非缺乏尝试:https://news.ycombinator.com/item?id=44171677

        > 空指针问题

        正因如此,多数API在可行时都力求采用非空的零值,因为结构体的方法仍可决定是否操作指针。不过我理解你所说的Go语言缺少 Optional/Maybe/? 运算符的问题,因为警示空指针类型的唯一替代方案是通过文档说明;例如:https://github.com/tailscale/tailscale/blob/afaa23c3b4/syncs…(近期偶然发现的案例)。

        静态代码分析工具如 nilaway (https://news.ycombinator.com/item?id=38300425)虽有帮助,但仍存在误报(烦人)和漏报(致命)问题。

        • 我经常阅读和编写Go代码,对其错误处理机制深恶痛绝

    • 是的,或许十年后才提出这个想法有些迟了,但我希望Go能引入空值类型,并将接口空值检查改为类型断言。其余情况则采用值比较,类似any(2) == 2的逻辑。

      不过这意味着nil标识符会被强制转换为有类型的nil,我们需要通过any(somepointer) == nil来检查接口内部内容的空值状态。

      就当前行为而言,保留未类型化的空值确实合理。但在许多其他场景中,我们确实存在自动推断/强制转换机制,例如将指针设为空值时:(p = nil)

      这确实很微妙,不过机会已经错过了。

      • 同意机会可能已经错过,但如果能解决的话,彻底移除空值接口岂不是很好?或许可以先让新接口类型声明/标注它们不封装空值?然后某天这就能成为默认设置。唉。

        • 哦,这应该可行。虽然引入这种机制与前文讨论的重点略有偏离,但确实可行。

          实现起来虽不简单,但我认为在考虑将联合接口提升为一等公民时,这会是需要探讨的方向。这需要在后端追踪非空类型状态/谓词,大概是类似这样的机制。

          • 再深入思考后…我认为应由结构体声明其不可作空值使用,进而告知运行时在空值状态下不进行装箱。这也能帮助编译器(及Copilot等工具)识别空指针调用并触发panic。

            • 但当赋值给可为空的接口变量/容器时,该信息就会消失。需要通过断言来恢复接口内部值非空的状态。

              本质上 if v.(nil){...}

              会形成两条分支:一条分支中我们知道 v 非空(位于 if 块外部),因此可以将其赋值给非可为空变量…

        • 问题并非“船已启航”,而是当你坐下来勾勒人们自以为想要的功能时,其逻辑本就自相矛盾。Go语言的设计是接口机制运作方式与“nil值并非无效”这一事实逻辑一致的结果。允许“nil”指针有效实现接口是完全合法的。例如参见https://go.dev/play/p/JBsa8XXxeJP,其中“*Repeater”类型的空指针完全符合io.Reader接口规范——它代表“完全不重复任何内容”的值。

          鉴于此,若接口规则试图禁止放入“nil”指针,只会徒增毫无意义的特殊限制。正确的解决方式是根本不要创建无效值[1],本质上就是“不要这么做”——但这并非“因为它本应按你预期工作却因故失效才禁止”,而是“因为你预期的行为本身就是错误的,你需要学会正确思考”。

          接口无法决定是否对空值进行装箱,因为接口本就不该“知晓”哪些值合法能实现其功能。向接口传递值的代码必须确保该值正确实现了接口。注意:在上例中,io.Reader 无法标记自己为“不包含 nil”,因为 io.Reader 无从“知晓”我的 Repeater 具体是什么类型。io.Reader值的职责是执行Read([]byte) (int error),若无法完成此操作,责任不在io.Reader本身,而在于代码错误地承诺某个值符合io.Reader接口却未能兑现。

          在Go语言中,nil与无效的[2]并非等价。若你仍固执地将其他语言的思维模式强加于Go,不仅在此处会遭遇困境,在切片、映射等结构的nil值行为中同样会陷入混乱。

          更合理的批评在于:Go语言中通常缺乏像总和类型那样明确声明“无效/无/空/NULL”值的便捷方式,甚至无法在单一类型中声明多种此类值(若语义需要)。但这属于独立议题,并不改变当前Go语言中“nil”即代表无效值的事实。Go既没有专属的“无效值”,也没有特定类型下无法调用方法的值。

          (当然可以要求Go增加更多特性来防止无效值进入接口,但若将此要求推至完全不可能实现的程度,最终只会陷入依赖类型语言的范畴——这类语言目前尚无实用实现方案。在任何主流语言中,都无法阻止开发者错误地将代码标记为实现某个接口/特质/方法集。因此这终究是权衡取舍的问题——毕竟“完全准确无误的接口”在现阶段甚至被认为是不可能实现的。)

          [1]: https://jerf.org/iri/post/2957/

          [2]: https://jerf.org/iri/post/2023/value_validity/

          • 我不明白为什么每次有人抱怨这个问题时,总有人默认我们根本不懂设计初衷。我完全明白x可以实现X,也理解x可以包含处理nil的方法。我自己也常写处理nil的方法,这确实是个很棒的功能。

            真正令人沮丧的是,99.99%的Go代码根本不遵循这种设计,导致人们总在自掘坟墓。到头来不得不承认:现有机制或许合乎逻辑,却违背直觉。对于标榜简洁性的语言而言,这实在令人失望。

            我也明白这个问题没有简单解决方案。我能想到的最佳方案是声明类型 x 的方法 Y 不能接受 nil,因此 (*x)(nil) 不应被视为满足接口中的该方法要求,从而避免自动装箱为该接口类型。但确实会很复杂。若能想到好方案,我会提交提案。

            • 因为过去十多次处理此类问题时,根本原因都源于对“为何如此”的认知缺失。归纳法表明下次仍会如此。很可能当前讨论的大多数读者仍处于未能正确理解问题的阵营。

              若你明白这问题本无解却仍渴望存在解决方案——尽管我对某些细节仍持异议,但这程度的分歧我不会计较。我完全理解这种渴望;回想自己长期使用的语言,似乎也存在类似“明知不可为却仍盼望能实现”的愿景。或许某天我们会“有幸”迎来某种基于LLM的语言,实现这类功能…无论结果是好是坏。

              • 我实在想不出既能让程序员掌控装箱操作,又不增加冗余复杂性的好办法……但代码检查工具检测这类问题并非完全不可能。它应该能识别出非空安全的方法,并发现此类方法的接口中存在空值。这样你就少解释很多了!

                • 静态分析确实存在难度,因为这涉及流程敏感性分析。

                  你说的没错,这确实是个尖锐问题。完全从接口中移除空值不可行,原因在于:1. 缺乏向后兼容性

                  不过我想稍作补充。空接口(即无类型空值)确实有用。而接口中包含有类型空值则值得商榷——因为所有带方法的值类型都可能生成指针,这意味着当指针而非值本身传递给接口变量时,可能引发解引用操作。

                  允许某些接口保留空值会很有用。

                  你的观点没错。通常而言,在有类型空指针上实现方法的价值有限。

                  若从类型理论的底层角度思考,确实应实现所有类型。但这更接近无类型nil,而Go的类型系统并非如此运作。虽然两者相近,但我们没有“可为空整数”的语言概念——因变量默认初始化为0,且纯粹虚拟地编码此类信息难度较大。但理论上这并非不可实现,只是缺乏机械同情心。言归正传,关键在于我认为代码检查工具难以轻松解决此问题,尽管已有不错的尝试。值得深思,你说的没错。

                • 完美的代码检查器终将遭遇停机问题,但我认为一个相当出色的检查器——能捕捉到指针类型方法中对nil值未作合理处理的情况——会非常有趣。这或许比任何其他方式更能有效引导社区关注此类问题。

                  不过我手头任务已满;即便要为Go编写检查器,这也不会是当前首要目标——尽管整体而言确实很有趣。

          • 你几周前发布的博文和此处的评论,是我读过关于这个主题的最佳阐释。你不仅剖析了问题表象,更从语义层面深入探究了其本质成因。尤其赞赏你在结论中提及依赖类型系统。

      • > 在其他所有情况下,如 any(2) == 2,我们进行值比较。

        但 any(nil) == nil 返回 true 符合预期。

        any((*int)(nil)) == nil 为 false 的原因,与 any(uint(2)) == 2 为 false 的原理相同:接口比较的是值 * 和类型*。

        • 这正是修复困难的另一原因。此处情况相同。2作为无类型常量本应返回真值(即使短赋值默认选择int类型)。

          但any(uint(2)) == int(2)确实应返回假。

          • 未类型化的常量在语言的微妙之处列表中绝对值得单独列出。

            关键在于,无类型常量在运行时并不存在,且接口等非基本类型也不属于常量范畴。因此若想让 any(uint(2)) == 2 按预期行为,必须对语言语义进行重大改动。要么为无类型常量赋予运行时表示形式——此时等值比较将引入繁重的反射机制 ——或者接口必须被提升到语言的常量部分——这相当棘手——最终你将陷入这样的困境:当运行时 x 变成 any(uint(2)) 时,any(uint(2)) == 2 能成立,但 x == 2 却不成立。

            • 不确定是否需要反射。它们仅出现在右侧。但你说得对,它们需要具备独立类型而非底层直接采用整数类型。类型转换本就不需要反射机制。或者你想到的是我忽略的某个细节?无论如何,这种改动的可能性不大。

              • 假设采用运行时表示方案,因其最具灵活性。需要进行赋值性检查来与有类型数值比较。保持左侧作为接口,右侧作为无类型常量。

                这意味着追踪左侧的类型指针,根据其底层类型(15种有效可能性[1])进行切换,然后将右侧转换为左侧类型或将左侧转换为无类型表示,最后执行相等性检查。具体实现可参考以下示例(具体表示形式及优化方案可酌情调整):

                  import (“math/big”; “reflect”)
                  type untypedInt struct { i *big.Int }
                  func (x untypedInt) equals(y any) bool {
                    val := reflect.ValueOf(y)
                    if val.Type() == reflect.TypeOf(x) {
                      return x.i.Cmp(val.Interface().(untypedInt).i) == 0
                    } else if val.CanInt() {
                      if !x.i.IsInt64() { return false }
                      return x.i.Int64() == val.Int()
                    } else if val.CanUint() {
                      if !x.i.IsUint64() { return false }
                      return x.i.Uint64() == val.Uint()
                    } else {
                      var yf float64
                      if val.CanFloat() {
                        yf = val.Float()
                      } else if val.CanComplex() {
                        yc := val.Complex()
                        if imag(yc) != 0 { return false }
                        yf = real(yc)
                      } else { return false }
                      xf, acc := x.i.Float64()
                      if acc != big.Exact { return false }
                      return xf == yf
                    }
                  }
                

                [1]: 未类型化的整数常量可与uint8..uint64、int8..int64、int、uint、uintptr、float32、float64、complex64或complex128进行比较

                • 若因溢出导致,设想是在编译时引入大小类。类似子类型/超类型机制,但针对数值类型。简单的类型指针检查即可实现。

                  • 大小类能节省空间并加速 同类型 比较,但对异类型比较(尤其与浮点数或复数)帮助有限。仅检查类型指针无法处理自定义类型(如 type Foo int);需注意未指定类型的整数常量可与这些类型比较。若要在运行时实现与编译时相同语义,就高级逻辑而言,我认为比我提出的方案更简洁的实现方式已不存在。虽然无疑存在优化空间——一方面Go编译器更注重编译速度而非生成代码的效率,另一方面若这是真实代码,它可能涉及内部实现细节,而我的示例(可能有效)只能依赖抽象的公开reflect包。

                    • 但当右侧被判定为无类型常量时,左侧能否自行决定如何与之比较?或者说(我之前说错了),既然比较是对称的,不如称其为“有类型的一侧”?这类似于为类型附加专用于比较的特殊方法?若我没理解错,这比类型切换的开销要小得多,还能处理自定义类型。若能从底层类型进行类型提升,甚至不会增加可执行文件体积。除非我理解有误…

                    • 明白了。你建议将比较逻辑从无类型侧移至有类型侧。在代码中,复用导入语句和untypedInt声明,但替换原有方法后应为:

                        type intType interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 }
                        func equals[I intType](x I, y any) bool {
                          switch val := y.(type) {
                              case I: return x == val
                              case untypedInt: return val.i.IsInt64() && val.i.Int64() == int64(x)
                              default: return false
                          }
                      }
                      

                      这需要为无符号整数、浮点数和复数分别实现专门的实现方案。这种方法避免了在运行时检查底层类型,但示例并不完整。我们还存在浮点型和复数型的无类型常量,因此每个具体类型现在都必须遍历所有待比较的无类型常量形式。尽管如此,这种方案可能更高效——虽然实际能减少多少代码膨胀尚不确定(不过无需依赖reflect包确实很理想)。

                      [编辑:附注,我曾尝试完整编写所需代码,发现泛型类型无法调用 real 或 imag 方法:https://github.com/golang/go/issues/50937]

    • 我遵循的建议是:函数始终应返回值而非接口,并对返回值进行空值检测。根据我的经验,这能有效杜绝绝大多数空接口问题。

      • 在下游的正常流程中确实如此,且效果极佳。但错误回传至上游时情况截然相反——错误常以嵌套接口形式返回。

        对于遇到问题就退出的通用代码而言这不成问题。但当错误成为长期运行的进程(如API)的预期组成部分时,围绕错误构建逻辑并进行条件处理就变得痛苦不堪。

        errors.Is 和 errors.As 的操作体验相当糟糕,且似乎没有明确指示何时应预期哨兵值、具体错误或指向具体错误的指针。

        总而言之,Go语言的错误处理机制充分体现了“返回具体值而非接口”的优势。不过针对错误处理本身,我认为若想改进它,恐怕需要在灵活性方面做出相当大的妥协。

      • 返回具体类型,接受接口。返回接口会隐藏行为并阻碍演进;接受接口则允许调用方替换实现。测试时应模拟依赖项而非返回值。

        反对返回具体类型的最强烈论点来自 Terraform 核心团队,其借口是便于测试。我对此持反对意见。

        • 所谓“返回具体类型”的建议,在多数情况下是阻碍演进的糟糕反模式,因其缺乏信息隐藏。标准库中多处已刻意打破此规则。此“建议”绝非普适法则。刻意打破该规则的实例:

             net.Dial (Conn, error) 
             image.Decode(r io.Reader) (Image, string, error)
             sha256.NewXXX() hash.Hash
             flate.NewReader(r io.Reader) io.ReadCloser
             http.NewFileTransport(fs FileSystem) RoundTripper
          

          关于os.File,Go团队甚至坦言:“若从头设计,我们可能会采用不同方案。”

          正因如此,Go后来才引入了fs.FS和fs.File等抽象层。

             embed/fs.Open再次刻意打破了这种规范。
          

          反观其对应接口net.Conn,堪称Go标准库中最成功的接口之一。它支撑着 net、net/http、tls 和 net/rpc 包,自 Go 1.0 起便保持稳定。它从未需要被 fs.Fs 取代。

          若你确信某个实现将永远唯一且永恒存在,且永远不需要模拟/伪造/替代实现,请返回具体类型。否则,请考虑返回接口是否更合理。

          • 相反地,近期提案评审中已明确不鼓励返回接口——除非是实现fs.FS这类通用接口,或用于调度函数如net.Dial/image.Decoder。

            建议返回具体类型时,需在消费者端同步定义接口。

            正是返回接口阻碍了良好的演进——标准库不会为接口添加方法,只能通过文档说明:所有现有标准库实现同时满足XXX接口。

            • 现实实现世界与假设建议世界似乎存在二元对立。

              由于Go语言缺乏对可选方法默认值的原生支持,许多接口通过进化过程添加了可选方法的权宜之计。

              Value接口包含`IsBoolFlag()`可选方法,该方法并不属于接口签名的一部分

              另一种演进方式是添加子接口。例如io.WriterToio.ReaderFrom本质上只是io.Writerio.Reader的扩展,分别提供WriteToReadFrom方法——这些方法会被io.Copy等消费者检查。

              总之,我讨论的重点在于泛型接口与替代实现方案,看来你认同这个观点。

          • 任何规则都有例外。引用标准库作为论据并不充分,因为其规范本身就存在前后不一致的情况。

            Go标准库中的接口(如net.Conn)确有其存在的价值。

            过早定义接口会固化错误,而该指南正是针对这种情况提出的。

      • 这种做法在以下两种情况失效:1) 不希望导出值类型 2) 返回值并非简单结构体而是切片或映射,因为[]x并非[]X类型,即使x实现了X接口。

        • 针对1/的情况,可通过不导出结构体值类型来实现返回。只要其满足接收接口要求,便不会引发问题。

          这正是我进行Go开发时最常采用的模式

          • 但这会影响可发现性。未导出的类型不会有公开文档。因此你最终仍需发布接口(即使不返回该类型),或通过文字说明方法集的具体形态。

        • > 返回值并非简单结构体,而是切片或映射,因为[]x并非[]X类型——即使x实现了X接口。

          我推测这是因为 on 是结构体指针数组,而另一边是胖指针数组——毕竟 Go 采用实例化接口(不同于高级语言)。

      • 完全正确。

        我发现人们总试图像使用面向对象语言那样使用接口。Go 并非面向对象语言。

  17. > 字符串的 len() 方法及 UTF-8 陷阱

    建议使用 utf8.RuneCountInString()。

    • 问题在于该方法需逐字节或逐符遍历整个字符串,而 len() 则无需如此操作——其长度值直接存储于底层类型中。

  18. Go语言近期最酷的功能之一是基于协程的新函数式迭代器,你可以通过iter.Pull函数巧妙利用这个特性 🙂

  19. 使用time.After实现超时是否属于反模式?这种方式无法取消已启动的计算任务。

  20. > 这与Python不同——Python的“稳定插入顺序”能确保此类情况不会发生。Go采用这种设计的原因是:速度!

    在Python中这里会抛出RuntimeError,因为Python会检测到你在迭代字典时对其进行修改。

  21. 还有人把标题看成“Go字幕”吗?

  22. Go语言已远超其初始定位——罗伯·派克为简单同事设计的简易语言。

        type User struct {
            Name     string `json:“name”`
            Password string `json:“-”`
            Email    string `json:“email”`
        }
    

    原来如此,原来可以使用包含任意元数据的原始字符串字面量来指定结构体的json序列化方式。json:“X”表示序列化为X,但特殊值“-”表示“省略此项”,而“-,‘则表示其名称为’-”。明白了。

    • 我向来不喜欢结构体标签的概念,这本质上是种字符串类型编程——X或-的含义完全取决于json包的定义。

      替代方案是引入注解机制,但肯定会遭遇抵触,毕竟这会让语言更接近Java之类的风格。

      但我的观点是:若真需要如此严格的类型系统,不如直接转用Java或C#之类的语言。

      • Java最初抵制官方支持注解功能。这项特性在2000年代初曾引发激烈争议。

        支持注解这类元编程/元数据功能,本就是语言设计的实用属性。

      • “字符串化编程”正是我想要的表述。这相当于语言设计师耸肩表示:“对此束手无策,干脆加个魔术字符串让别人去解决吧。”

        文章中这个例子及其他一两个案例让我隐约嗅到PHP的气息:功能堆砌源于即时需求而非系统设计。对于一个以多年拒绝添加泛型著称(后来又做得糟糕,我个人认为)的语言而言,这似乎有违其品牌调性。

    • 在所有可能批评Go语言不够简洁的方面,我不确定这点是否算数。我从未需要将“-”序列化为JSON键值,但考虑到字段标签的通用模式(如json:“name,omitempty”),-确实有其合理性。

    • 注解在编程领域早已存在,这并非新奇或异常的功能。

  23.   time.After函数创建的通道会在x秒后接收消息。
    

    真的吗?https://github.com/golang/go/issues/24595

      ...即使值为nil,变量的类型仍是非nil接口...Go会将该值封装在非nil的接口中。若从函数中返回接口,这会让你吃大亏
    

    新手时期就吃过这个亏。如今若 ireturn 失败,我的构建就会报错。

    go install github.com/butuzov/ireturn/cmd/ireturn@latest
    ireturn ./…

    Go 1.25引入的waitgroup.Go函数,能更便捷地将Go协程加入等待组。
    

    sync.WaitGroup(n) 在调用 Add(x) 后若遇到 Done(-1) 且 n 不为零时会引发 panic。不确定“等待组”和“简单”能否共存。或许可以,但我更倾向于用 Go 重现 Java 的 CountDownLatch 和 CyclicBarrier API。

      嵌入结构体时,其包含的方法也会被隐式提升...例如将 time.Time 结构体嵌入 JSON 响应字段并尝试序列化父结构...由于 time.Time 方法包含 MarshalJSON(),编译器会优先执行该方法而非常规序列化行为
    

    #£@&+!

    • waitgroup的缺陷正是我最终采用结构化并发方案的原因,例如https://github.com/sourcegraph/conc

      • 该库最后一次发布已是2.5年前,且处于1.0之前版本…真的适合生产环境使用吗?

        真心求教,我刚接触Go语言不久,希望能更清楚哪些生态组件值得深入学习。

        • 我在多个生产系统中实际使用过,确实很棒!

          不过两年半后标准库已大幅改进(如waitGroup.Go),我认为今后不再需要它了。

  24. 原文:

    > Runes对应Go语言中的码点,长度介于1至4字节之间。

    这是本月读到最愚蠢的表述。为何偏要用错误术语制造混乱¹?其他编程语言和Unicode标准明明都采用正确的“code point”表述。

    ¹ https://codepoints.net/runic已存在相关讨论

    • > 采用正确的“code point”表述

      实际上并非如此,这些是Unicode标量而非码点;它们排除了代理字符类别。

      我同意“符文”(rune)是个非常糟糕的命名。它既误解了符文的本质,又与符文字符集冲突。但C#出于某种原因采用了Rune这个名称。

      Rust直接称其为char,OCaml则用uchar(unicode字符),这些命名要好得多。

      • 是我疏忽了,刚复核发现Go再次犯了低级错误:它们确实像完整码点而非像其他语言那样作为标量存在。

    • 鉴于两位作者参与设计了utf8(或至少与他人并行开发),我认为在此处采纳他们的专业见解和命名规范是安全的。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号