先弄清楚 %v 到底在做什么
很多 Go 开发者对 fmt 包的最初印象,就是 Printf 里放几个占位符,再把变量传进去。这当然没错,但真正开始追查线上日志时,会发现同样一个结构体,用 %v、%+v、%#v 打出来的结果完全不同。有人习惯从头到尾只认 %v,直到某天面对一个多层嵌套的聚合结构体时,才发现自己根本分辨不出哪个字段是哪个。也有人听说 %#v 很厉害,就在日志里全换成 %#v,结果输出又长又乱,反而影响排查效率。理解这几个动词的差异,不是为了应付面试,而是为了在日常开发中做出更合理的选择。

%v 是 format verb 里最通用的一个,v 代表 value,输出的是变量的默认表示。对基础类型来说,比如 int、float、string,%v 的输出值和常见写法差别不大;对结构体,它会将字段值依次放进一串花括号中,不带字段名。看下面这个例子:
type User struct { Name string; Age int }
u := User{Name: "Alice", Age: 30}
fmt.Printf("%v\n", u) // {Alice 30}
单个小型结构体这样输出没问题,但真实的业务结构体往往包含用户、订单、商品列表,甚至还有嵌套的子结构体。用 %v 打印时,你会得到类似 {{张三 28} [{手机 5999 1}] 已支付} 的内容,看起来就像一团没有注释的大括号。这时候你会立即明白,自己需要的可能不是一个通用动词,而是能带出更多信息的动词。
fmt 格式化动词的底层逻辑
为什么 %v、%+v、%#v 会有不同效果?这要从 fmt 的工作方式说起。fmt 在解析格式化字符串后,会对每个参数做动态类型检查,再根据动词选择对应的格式化路径。v 动词本身没有固定格式,它代表“查看值”而不是“转换类型”。对不同 Go 类型,v 内部有一套默认规则。而 + 和 # 只是修饰符,修改的是默认规则的具体策略。
理解这一点,就不会困惑于“%+v 为什么没有让 map 带字段名”。加号修饰的是结构体字段名的显示策略,map 本身有键名,不存在字段名概念;切片也没有字段。同样,%#v 表示“Go 语法表示”,它试图重建一段接近源码的文本,所以字符串会加上双引号,map 会带上完整的类型信息。
这三个动词本质上不是简单递进的信息量关系,而是三种不同视角:%v 看默认值,%+v 看字段上下文,%#v 看语法结构。
%+v:给结构体输出加上字段名
在 v 前加一个加号,%+v 会在结构体输出的每个字段前带上字段名。同样是上面的 User,%+v 输出 {Name:Alice Age:30}。这个变化对调试的友好度提升是巨大的,也是很多团队在日志规范里要求用 %+v 的原因。
但注意,+ 修饰符不是对所有类型都有同样的效果。map 本身就有键名,slice 没有字段的概念,所以 %v 和 %+v 对这两类类型的输出几乎完全一样。如果你处理的是自定义类型,并且它实现了 String() 方法,那么 %+v 会和 %v 一样去调用 String(),而不是强制展开字段。
另一个容易忽略的细节是,%+v 对指针也有处理:如果指针指向一个结构体,它会自动解引用,再按带字段名的方式输出。这让你不需要手动判断传过来的是结构体还是结构体指针,直接一段 %+v 就能看到完整内容。
在我自己的排查经验里,遇到多层嵌套时 %+v 的递归输出很实用。比如一个 User 结构体里嵌入了 Profile 字段,%+v 会递归地为内部结构体也加上字段名,输出形如 {User:{Name:Alice Age:30} Profile:{City:BeiJing}}。虽然有点长,但每级关系都能一眼对上。
%#v:输出 Go 语法表示,信息最全也最“重”
%#v 中的 # 修饰符,在大部分格式化动词里都用来输出备用格式。对 v 来说,它输出的是该值的 Go 语法表示,也就是尽量让输出看起来像一段合法的 Go 字面量。回到 User 结构体:
fmt.Printf("%#v\n", u) // main.User{Name:"Alice", Age:30}
和 %v、%+v 相比,它多了包名 main.User,字符串也加了双引号。这段输出已经比较接近自己在代码里手动写的结构体初始化表达式。如果包名正确、字段可导出,你甚至可以直接复制它回去作为测试输入。
更有价值的是,%#v 能区分那些表面相同、本质不同的值。最典型的例子就是 nil 切片和空切片:
var s []string
fmt.Printf("%v\n", s) // []
fmt.Printf("%#v\n", s) // []string(nil)
在实际接口返回或数据库查询场景中,空切片和 nil 切片往往对应不同的业务含义。如果一直用 %v 打日志,看到两次输出都是 [],很容易误判。换成 %#v,一眼就能看出区别。
当然,代价是输出更长,包含更多类型信息,在性能上也更重。fmt 在输出 %#v 时需要反射获取类型名和字段结构,因此在非常热点的路径中使用需要权衡。它不是用来取代 %v 的,而是用来做精确诊断的。
一张表看懂三者的输出差异
不同类型的差异,用表格总结更直观。下面的示例中,结构体是 main.User{Name:”Alice”, Age:30},map 是 map[string]int{“age”:30},切片是 []int{1,2,3}。
| 类型 | %v | %+v | %#v |
|---|---|---|---|
| 结构体 | {Alice 30} | {Name:Alice Age:30} | main.User{Name:”Alice”, Age:30} |
| map | map[age:30 name:Alice] | map[age:30 name:Alice] | map[string]int{“age”:30,”name”:”Alice”} |
| 切片 | [1 2 3] | [1 2 3] | []int{1, 2, 3} |
| nil 切片 | [] | [] | []string(nil) |
| 字符串 | hello | hello | “hello” |
| 指针(指向结构体) | &{Alice 30} | &{Name:Alice Age:30} | &main.User{Name:”Alice”, Age:30} |
另外提醒一句,map 的键值顺序在 Go 中不受控制,示例顺序只是为了阅读方便。不要依赖 fmt 输出的 map 顺序做任何逻辑判断。
容易踩的坑:Stringer、指针和未导出字段
使用这些动词时,有几个问题很容易踩到,我分别说一下。
String() 方法的优先级
如果一个类型实现了 fmt.Stringer,那么 %v 和 %+v 都会调用它的 String() 方法来获取输出。但 %#v 不是这样,它首先检查是否实现了 fmt.GoStringer,如果没有,就继续走默认语法表示,而不是调用 String()。举个例子:
type User struct { Name string; Age int }
func (u User) String() string { return fmt.Sprintf("user(%s)", u.Name) }
u := User{Name: "Alice", Age: 30}
fmt.Printf("%v\n", u) // user(Alice)
fmt.Printf("%+v\n", u) // user(Alice)
fmt.Printf("%#v\n", u) // main.User{Name:"Alice", Age:30}
这不算 bug,但如果你不知道这个规则,很可能在调试时被 %#v 突然暴露的内部字段搞懵。反过来,如果某个类型本身不希望被打印内部结构,实现 String() 只能约束 %v,约束不了 %#v。
指针的 nil 输出
指针类型在使用不同动词时的形态也不一样。nil 指针用 %v 输出 <nil>,用 %#v 输出 (*main.User)(nil)。后者信息更准确,但每次看到 (*main.User)(nil) 都需要在脑子里翻译一下。如果你关心的是指针是否为空,建议直接在代码里判断,而不是去看格式化文本。
未导出字段的“伪可用”输出
%#v 的 Go 语法表示只保证接近语法,不保证可编译。尤其是跨包打印一个带未导出字段的结构体,输出里会包含小写字段名,但这些小写字段名无法从包外引用。你可以把它当作一个高保真快照来理解,但别把它当作序列化手段。要在系统间传递数据,还是使用 JSON、gob 这类明确格式。
怎么选:给日常开发一个简单判断标准
讲了这么多,真正落到快捷键上的建议其实很明确:
- 快速打印一个变量的普通值,用
%v,它最简洁,也能覆盖大多数基础类型。 - 调试结构体、在日志中保留字段上下文,用
%+v,它让日志可读性提升一个档次。 - 需要区分 nil 与空值、复现测试数据、精确分析某个值的内存表示时,用
%#v。
从工程角度看,还有个隐藏的开销问题。%v、%+v、%#v 在遇到复杂类型时需要反射,而 %#v 的反射路径更复杂一些。在正常业务日志频率下感知不明显,但在超高吞吐的日志或微秒级代码路径里,%v 依然是更稳的选择。可以在 Debug 级别或诊断模式里用 %#v,在常规信息日志里使用 %+v。
另外,如果是为了输出给下游系统解析,encoding/json 或对应序列化库永远是更靠谱的答案。格式化动词解决的是人眼阅读的问题,不是机器解析的问题。
写在最后
%v、%+v、%#v 是 Go 格式化系统里最常用的三个动词,但它们的差异远比“加号”和“井号”这两个符号本身要深。%v 给你一个快速视图,%+v 加上字段名,%#v 给你一个接近语法级的快照。理解这些差异,本质上是理解信息密度和使用成本的权衡。
下次调试时,试着有意识地从 %v 切换到 %+v,再到 %#v,观察输出的变化。你会慢慢发现,选择一个合适的格式化动词,比对着日志猜字段名节省的时间要多得多。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/736/