想在终端显示一行 hello,平时写 fmt.Println("hello") 就够了。它默认写到标准输出 os.Stdout。下面第二行把这个位置显式写了出来,效果和第一行一样。
go
fmt.Println("hello")
fmt.Fprintln(os.Stdout, "hello")
换掉 Fprintln 的第一个参数,就能让同一句 hello 先存到内存里。两种写入位置各有自己的 Write 方法。要看懂 Fprintln 怎样调用它们,先从一次简单的方法调用开始。
同样的点号,两种读法
一笔订单的商品价格是 30 元,运费是 5 元。把下面的代码保存为 main.go,运行 go run main.go。
go
package main
import "fmt"
type Order struct {
Price int
}
func (o Order) Total(shipping int) int {
return o.Price + shipping
}
func main() {
order := Order{Price: 30}
total := order.Total(5)
fmt.Println(total)
}
text
35
| 调用 | 点号左边是什么 | 哪些值传进去了 |
|---|---|---|
order.Total(5) |
order 是一笔订单 |
order 传给 o,5 传给 shipping |
fmt.Println(total) |
fmt 是 import "fmt" 引入的包名 |
total 传给 Println |
Total 前面的 (o Order) 叫接收者 。这里的 o 收到订单值的一份副本,所以 o.Price 取到 30。加上 shipping 的 5,结果存进 total,再交给 fmt 包里的 Println 显示。
把同一段计算写成普通函数
同样的计算也可以用普通函数表达。把下面的函数加在 main 外,再把计算总价的那行改成 total := calculateTotal(order, 5),运行后仍然得到 35。
go
func calculateTotal(order Order, shipping int) int {
return order.Price + shipping
}
order.Total(5) 把订单放在点号左边,calculateTotal(order, 5) 把订单放进参数列表。两种写法都能算出总价。把 Total 写成 Order 的方法,可以直接读作「算这笔订单的总价」。
先存起来,再显示
把 main.go 换成下面的程序,再运行 go run main.go。
go
package main
import (
"bytes"
"fmt"
"os"
)
func main() {
var buf bytes.Buffer
fmt.Fprintln(os.Stdout, "hello")
fmt.Fprintln(&buf, "hello")
fmt.Print("从内存取出:", buf.String())
}
text
hello
从内存取出:hello
第一条 Fprintln 直接把 hello 写到标准输出。第二条把 hello 存到 buf。var buf bytes.Buffer 声明了一个空的缓冲区,可以用来暂存文字。最后一行用 buf.String() 取出文字,再用 fmt.Print 连同前缀一起显示出来。
为什么把 &buf 传给 Fprintln
最后一行读的是原来的 buf。第二次调用 Fprintln 时,写入的代码得找到这同一个缓冲区。&buf 里的 & 表示取地址 ,得到一个指向 buf 的值。拿到这个值,写入的代码就能修改原来的 buf。
这个指向 buf 的值叫指针 。buf 的类型是 bytes.Buffer。在类型名前加上 *,*bytes.Buffer 就表示「指向缓冲区的指针」这种类型。&buf 是取地址的写法,取到的值属于 *bytes.Buffer 类型。
Fprintln 在哪一步把文字写出去
下面是标准库中 Fprintln 的实现。
go
func Fprintln(w io.Writer, a ...any) (n int, err error) {
p := newPrinter()
p.doPrintln(a)
n, err = w.Write(p.buf)
p.free()
return
}
把声明和 fmt.Fprintln(&buf, "hello") 对起来看。w io.Writer 声明了第一个参数,&buf 传给 w。a ...any 接收后面的参数,这次只有 "hello"。p.doPrintln(a) 把待写入的字节存进 p.buf,再由 w.Write(p.buf) 把它们写出去。
每次调用都走到同一行 w.Write(p.buf)。图里的两条路取决于 w 这次收到的值。
走到图中「写进 buf」这一步,调用的是 bytes 包里的 Write。
go
func (b *Buffer) Write(p []byte) (n int, err error)
方法名前的 (b *Buffer) 是接收者。b 收到的是 &buf 得到的指针,因此可以修改原来的 buf。*Buffer 是这个指针类型在 bytes 包里的写法,在包外写作 *bytes.Buffer。
方法名后面的 p []byte 是要写入的数据。Write 返回写入的字节数 n 和错误 err,供调用者判断写入是否成功。
为什么 Fprintln 能接收两种值
Fprintln 的第一个参数类型是 io.Writer。这是 io 包定义的接口,规定了传进来的值必须能做什么。在这里,只要求它有这样一个 Write 方法。
go
type Writer interface {
Write(p []byte) (n int, err error)
}
os.Stdout 的类型是 *os.File,&buf 的类型是 *bytes.Buffer。它们都提供了符合要求的 Write 方法,所以都可以传给 Fprintln。
把两次写入都改到内存
把示例里的 fmt.Fprintln(os.Stdout, "hello") 改成 fmt.Fprintln(&buf, "hello"),再从 import 中删掉 "os"。改完后,main 里的写入和显示代码如下。
go
fmt.Fprintln(&buf, "hello")
fmt.Fprintln(&buf, "hello")
fmt.Print("从内存取出:", buf.String())
重新运行。两次 Fprintln 现在都往 buf 里写,每次存入一行 hello。最后的 fmt.Print 把两行一次显示出来,所以「从内存取出」这个前缀只出现一次。
text
从内存取出:hello
hello
延伸阅读
为什么读取时可以写 buf.String()
String 也接收缓冲区的指针。直接写 buf.String() 时,Go 可以自动取这个变量的地址,效果相当于 (&buf).String()。调用 Fprintln 时,传进去的第一个值必须满足 io.Writer。Write 的接收者是 *bytes.Buffer,所以这里要传 &buf。
Go 里什么时候用方法,什么时候用函数
Go 和 Python 都可以在同一个程序里混用不同的编程风格。Go 常用包组织代码,用普通函数串起处理步骤,把一个类型自己的行为写成方法,再用小接口让同一段代码使用不同的实现。这里 fmt.Fprintln 负责整理文字和发起写入,Write 是各个输出位置自己的方法,io.Writer 让两者能接在一起。
Total 是订单的方法,也是一段只读取输入并返回结果的计算。给它同样的订单价格和运费,就会得到同样的总价。这是函数式写法里常见的做法。把计算改写成 calculateTotal,也可以保留这个性质。
从其他语言的 this 和 self 看接收者
如果在 JavaScript 对象里用 total(shipping) { ... } 定义方法,调用 order.total(5) 时,就可以在方法里用 this 访问当前订单。Python 通常把当前对象写成参数列表里的 self。Go 例子里的 o 也让方法访问这次调用的订单,名字和类型直接写在方法名前的 (o Order) 里。
Rust 也有类似的点号调用。它用 impl 块写明方法属于哪个类型,方法声明的第一个参数 self 表示这次操作的值。
把接收者放在方法名前的写法,在更早的 Oberon-2 里就能看到。Go 共同设计者 Robert Griesemer 在《The Evolution of Go》中用两种语言展示了同一个树节点查找方法。Oberon-2 用 PROCEDURE (t: Tree) Lookup,Go 对应写成 func (t *Node) Lookup。