Go 自定义类型与类型别名:读懂类型身份、底层类型和可赋值性
面向有 Java 经验的开发者,基于 Go 1.26。
Go 的 type 声明只有一个等号之差,含义却完全不同:
type UserID int64 // 定义一个新类型
type RawID = int64 // 给已有类型起别名UserID 和 int64 是不同的类型,RawID 与 int64 则只是同一个类型的两种写法。这个等号会影响赋值、转换、方法、泛型约束、反射和 API 兼容性。类型边界设计得当,编译器可以直接拦住“把订单号当用户号”这类错误;若类型没有承载任何规则,自定义类型只会留下满篇的强制转换。
这一章把定义类型、别名、底层类型和可赋值性放进同一套判断模型。以后再遇到 “cannot use X as Y”,不必凭感觉加转换,可以顺着类型身份和底层类型逐步定位。
目录
1. 先分清定义与别名
规范把 type 声明分为两类:
// type definition
type Celsius float64
// alias declaration
type Temperature = float64两种声明的差异可以先从这张表看:
| 问题 | type Celsius float64 | type Temperature = float64 |
|---|---|---|
| 是否产生新类型 | 是 | 否 |
| 与右侧类型是否相同 | 否 | 是 |
| 是否通常需要显式转换 | 是 | 否 |
| 能否为它定义本包方法 | 可以 | 别名本身不是新的接收者基类型 |
| 反射中的名称 | Celsius | 仍是被别名类型 |
| 主要用途 | 领域语义、封装、方法 | 迁移、兼容、缩短复杂名称 |
别名不是 Java 的子类,也不是 typedef 后得到的名义新类型。它只是让同一个类型在一个作用域中多了一个名字。
2. 定义类型
2.1 从已有类型定义新类型
type UserID int64
type OrderID int64
var uid UserID = 42
var oid OrderID = 42
// uid = oid // 编译错误:UserID 和 OrderID 是不同的已定义类型它们的底层类型都是 int64,类型身份却不同。领域类型的价值正在这里:即使数据形状完全一样,编译器仍能区分它们所表达的含义。
类型定义右侧可以是任意类型表达式:
type Tags []string
type Header map[string][]string
type Handler func(Request) Response
type Point struct{ X, Y float64 }
type IntChannel chan int定义类型不会“继承”右侧已定义类型的方法:
type Base string
func (Base) Label() string { return "base" }
type Child Base
// Child 没有 Label 方法。Child 只继承了底层表示和可用运算,不继承 Base 的方法集。这和 Java extends 完全不同。
2.2 操作由底层类型决定
type Celsius float64
func main() {
var a Celsius = 20
var b Celsius = 5
fmt.Println(a + b) // 允许,底层类型支持 +
}可用运算来自底层类型,但运算结果的类型遵循表达式规则:
var c Celsius = 20
fmt.Printf("%T\n", c+5) // main.Celsius;5 是可表示为 Celsius 的无类型常量若另一侧是已经具有类型的变量,则不能随意混算:
var f float64 = 5
// _ = c + f // 类型不匹配
_ = c + Celsius(f)2.3 声明与字面量
底层类型为结构体、切片、map 等时,可以使用相应复合字面量:
type Names []string
type Scores map[string]int
type Point struct{ X, Y int }
names := Names{"Alice", "Bob"}
scores := Scores{"Alice": 90}
p := Point{X: 1, Y: 2}命名类型本身总有零值,等于其底层表示的零值。零值是否具有业务意义,则由 API 决定。
3. 类型别名
3.1 别名表示同一类型
type Byte = uint8
var a Byte = 1
var b uint8 = a // 不需要转换
fmt.Printf("%T\n", a) // uint8标准库中 byte 是 uint8 的别名,rune 是 int32 的别名。它们分别强调字节和 Unicode 码点语义,但没有产生新类型。
3.2 别名的典型用途
最重要的用途是跨包迁移时保持源码兼容。假设类型从 oldpkg 移到 newpkg:
// package oldpkg
import "example.com/project/newpkg"
type Client = newpkg.Client调用方暂时仍可写 oldpkg.Client,但它与 newpkg.Client 完全相同。迁移完成后可以在新的主版本移除旧入口。
别名也可以缩短当前包内部的复杂类型:
type cancelFunc = func(error)不过,公开 API 中出现大量别名后,类型究竟定义在哪里会越来越难判断。一个概念最好只有一个权威名称,别名应服务于明确的迁移计划。
3.3 别名不能增加封装
type Password = string这不会阻止普通字符串被当作密码,也不能为其建立独立方法集。要表达领域边界,应写:
type Password string然后通过构造函数、未导出字段或方法建立规则。
4. 底层类型
每个类型 T 都有一个底层类型:
- 预声明类型
int的底层类型是它自己; - 字面类型的底层类型是它自己;
- 已定义类型的底层类型,是不断沿定义右侧追溯,直到遇到非已定义类型;
- 类型参数的底层类型由规范通过类型集规则处理,不能简单当作某一个具体类型。
type A int
type B A
type C BA、B、C 是三个不同的定义类型,底层类型都是 int。
type Header map[string][]stringHeader 的底层类型是 map[string][]string,因此可索引、可 range、可传给 len,零值也和 map 一样是 nil。
底层类型主要参与:
- 判断可用操作;
- 判断一些赋值和转换是否合法;
- 泛型中
~T的类型集合; - 结构、接口和复合类型的身份规则。
需要反复确认的一条边界是:底层类型相同,不代表类型相同,也不表示两边总能直接赋值。
5. 类型身份
5.1 已定义类型总是不同
每次类型定义都会创建新的已定义类型:
type A int
type B int即使名字、底层类型和方法碰巧相似,A 与 B 仍不同。别名则不创建类型:
type C = A // C 与 A 相同5.2 未命名类型按结构判断
数组长度、元素类型,结构体字段顺序、名称、类型、标签和嵌入属性,函数参数和结果,channel 方向等都会参与类型身份。
var a struct {
X int `json:"x"`
}
var b struct {
X int `json:"value"`
}
// a 和 b 的类型不相同,因为标签不同。切片 []int 在任何出现位置都表示同一个未命名类型;map[string]int 也是如此。命名不会渗透:
type MyInt int
// []MyInt 与 []int 不同,因为元素类型不同。不要用反射字符串或格式化输出猜类型身份。需要编译期判断时,让赋值、泛型约束或接口断言交给编译器。
6. 可赋值性
可赋值性回答的是:表达式 x 能否不写转换,直接放进类型为 T 的变量、参数、返回值或 channel。
6.1 类型相同时可赋值
type ID int64
var a ID
var b ID = a别名因为类型相同,自然也可赋值:
type Alias = ID
var c Alias = a6.2 底层类型相同且至少一方不是命名类型
规范允许:V 和 T 的底层类型相同,并且两者不是类型参数,且至少有一方不是命名类型时直接赋值。
type Names []string
var a Names
var b []string
a = b // 允许:底层类型相同,[]string 是未命名类型
b = a // 也允许但两个不同的命名类型不行:
type A []string
type B []string
var a A
var b B
// a = b // 不允许
a = A(b)这一规则经常被误记成“底层类型相同就能赋值”,漏掉了“至少一方不是命名类型”。
6.3 nil 可赋给特定类型
预声明标识符 nil 可以赋给指针、函数、切片、map、channel 和接口类型:
var p *int = nil
var s []int = nil
var m map[string]int = nil
var ch chan int = nil
var fn func() = nil
var v any = nilnil 没有默认类型,不能写 x := nil。
6.4 接口可赋值性
非接口值实现接口时可赋给接口;接口之间则取决于类型集:
type Stringer interface {
String() string
}
type Name string
func (n Name) String() string { return string(n) }
var s Stringer = Name("Alice")这里没有 implements 声明,具体规则在接口章节展开。
6.5 channel 的特殊规则
双向 channel 可以赋给相同元素类型的单向 channel;在规范规定的条件下,至少一方不是命名类型:
ch := make(chan int)
var recv <-chan int = ch
var send chan<- int = ch方向收窄是安全的,不能反向恢复:
// ch = recv // 不允许6.6 类型参数
泛型代码中的赋值要对类型集合里的每一种可能类型都成立。看到涉及类型参数的错误时,不要只拿某个实例推理,应检查约束中的全部类型。
7. 显式转换
类型转换写成 T(x):
type UserID int64
raw := int64(42)
id := UserID(raw)
back := int64(id)转换创建目标类型的值,不是在运行时给对象“换标签”。对数值转换尤其要注意范围和精度:
n := int64(300)
b := uint8(n) // 44;不会自动报溢出错误运行时数值转换通常按规范截断或扩展,业务代码应先校验范围:
if n < 0 || n > math.MaxUint8 {
return fmt.Errorf("out of uint8 range: %d", n)
}
b := uint8(n)字符串与字节、rune 切片的转换具有数据复制和编码语义:
bytes := []byte("Go") // UTF-8 字节
runes := []rune("你好") // Unicode 码点
s1 := string(bytes)
s2 := string(runes)string(65) 得到 "A",不是 "65"。数字格式化应使用 strconv.Itoa、FormatInt 或 fmt.
7.1 底层为指针的定义类型
指针类型的转换规则容易被误用。即使某些具有相同底层基类型的指针转换合法,也不要用它绕过封装。跨类型共享可变内存会让不变量失效;若目的是 reinterpret cast,就已经进入 unsafe 领域。
7.2 转换不是断言
T(x)是编译期认可的类型转换;x.(T)是接口值的运行时类型断言;- 泛型约束不是运行时转换。
三者解决的问题不同,错误表现也不同。
8. 无类型常量
常量可以处于“无类型”状态:
const timeout = 5
const ratio = 0.125只要值能由目标类型表示,就可以直接赋给定义类型:
type RetryCount uint8
var r RetryCount = 5
// var bad RetryCount = 300 // 编译期溢出但变量一旦有具体类型,就按普通赋值规则处理:
n := 5 // n 是 int
// r = n
r = RetryCount(n)这解释了为什么字面量 5 能和许多定义数值类型配合,而变量 n 不行。无类型常量拥有任意精度的语义值,转换到具体类型时才检查可表示性。
无类型布尔、字符串、rune、整数、浮点和复数常量都有各自默认类型;它们进入短变量声明或接口时,通常会取得默认类型。
9. 方法与接收者
可以为当前包声明的定义类型添加方法:
type UserID int64
func (id UserID) Valid() bool {
return id > 0
}
func (id UserID) String() string {
return strconv.FormatInt(int64(id), 10)
}接收者基类型必须是当前包定义的非指针、非接口类型。不能给另一个包的类型添加方法:
// func (time.Time) MyMethod() {} // 不允许解决方式是定义新类型、嵌入或写普通函数。定义新类型不会带来原类型的方法:
type MyTime time.Time
// MyTime 不自动拥有 time.Time 的方法。若想保留行为并增加组合能力,常用嵌入:
type EventTime struct {
time.Time
}9.1 别名与方法
别名没有创建新的定义类型,因此不能把它当成新的方法接收者基类型。若别名指向当前包的定义类型,通过别名当然能调用原类型已有方法,因为它们是同一类型;这不等于方法“定义在别名上”。
尤其不要通过别名试图给外部类型补方法:
type LocalTime = time.Time
// func (LocalTime) Foo() {} // 不允许9.2 方法集和转换
从 Old 转为 New 后,值只拥有 New 的方法集:
type Old string
func (Old) OldMethod() {}
type New Old
func (New) NewMethod() {}
v := New(Old("x"))
v.NewMethod()底层数据没有变化,静态类型决定可调用的方法和接口满足关系。
10. 泛型约束中的 T 与 ~T
下面的约束只包含精确类型 int:
type ExactInt interface {
int
}定义类型 UserID 即便底层类型是 int,也不属于该类型集合。若希望接受所有底层类型为 int 的类型,要写近似元素 ~int:
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
func Add[T Integer](a, b T) T {
return a + b
}type Score int
fmt.Println(Add(Score(1), Score(2)))波浪号读作“底层类型为”。~T 中的 T 本身必须是它自己的底层类型,不能随意写:
type MyInt int
// interface{ ~MyInt } 非法,因为 MyInt 的底层类型不是 MyInt。约束定义了可用操作。若类型集合里所有类型都支持 +,泛型函数才可使用 +。不要把约束接口当作普通运行时接口使用:含类型元素的接口通常只能作为约束,不能作为普通变量类型。
11. 泛型类型别名
从 Go 1.24 开始,别名声明可以有类型参数;Go 1.26 中这是正式语言特性:
type Set[E comparable] = map[E]struct{}
type Pair[A, B any] = struct {
First A
Second B
}使用泛型别名时必须实例化:
var ids Set[int64]
p := Pair[string, int]{First: "age", Second: 18}Set[int64] 与 map[int64]struct{} 是同一个类型,并没有产生新的封装边界,也不能获得一组专属方法。
泛型别名适合:
- 迁移泛型类型的包路径;
- 给冗长但稳定的实例化模式命名;
- 对外保持类型身份兼容。
不适合:
- 想增加方法;
- 想禁止调用方直接构造;
- 想区分两个领域概念;
- 想改变赋值兼容性。
若需要这些能力,应定义泛型类型:
type Set[E comparable] map[E]struct{}
func (s Set[E]) Add(v E) {
s[v] = struct{}{}
}注意方法不能绑定到一个实例化的泛型别名上。接收者基类型必须满足规范对本包定义类型的要求。
Go 1.26 还放宽了泛型类型在其类型参数列表中自引用的限制,可以表达 F-bounded 风格约束:
type Adder[A Adder[A]] interface {
Add(A) A
}这项变化与“泛型别名”不是同一件事:前者扩展约束表达力,后者保持类型身份。
12. 领域建模
12.1 用类型阻止参数调换
type UserID int64
type OrderID int64
func LoadOrder(userID UserID, orderID OrderID) {}如果两个参数都写 int64,调用 LoadOrder(orderID, userID) 仍能编译。使用定义类型后,这类错误在编译期暴露。
12.2 用方法集中规则
type Percentage uint8
func NewPercentage(v int) (Percentage, error) {
if v < 0 || v > 100 {
return 0, fmt.Errorf("percentage out of range: %d", v)
}
return Percentage(v), nil
}
func (p Percentage) Float64() float64 {
return float64(p) / 100
}需要诚实面对一个限制:如果类型导出且底层类型可见,其他包仍可写 Percentage(200)。Go 的定义类型提供静态区分,却不是密封构造。要求强不变量时,可用含未导出字段的结构体:
type Percentage struct {
value uint8
}这样调用方不能直接构造非法字段值。
12.3 不要为每个字段造类型
类型有维护成本。以下情况值得定义:
- 参数容易调换;
- 单位不同,如米和秒;
- 有明确校验与行为;
- 要实现接口;
- 需要控制公开 API;
- 编译期区分能消除真实错误。
仅仅为了把 string 换个名字,却在所有边界做来回转换,收益可能很低。先问“它阻止了哪类错误”。
12.4 枚举式类型
type Status uint8
const (
StatusUnknown Status = iota
StatusPending
StatusRunning
StatusDone
)Go 枚举不是封闭集合,调用方仍可构造 Status(99)。处理外部输入时要校验,并在 String、序列化和 switch 的默认分支中兼容未知值。协议演进时,未知值往往比 panic 更常见。
13. 别名与 API 演进
13.1 跨包移动
安全迁移通常分阶段:
- 在新包定义权威类型;
- 旧包用别名重新导出;
- 文档标记旧名称废弃;
- 调用方逐步迁移 import;
- 在允许破坏兼容的主版本移除旧别名。
// Deprecated: use model.User.
type User = model.User因为类型身份不变,函数参数、接口满足关系和方法集仍兼容。若旧包写 type User model.User,就会创建新类型,迁移成本完全不同。
13.2 别名的依赖方向
旧包依赖新包,通常比新包反向依赖旧包合理。要检查 import cycle:如果新包又依赖旧包,添加别名会直接造成循环。此时需要提取更底层的独立包,而不是用别名掩盖架构问题。
13.3 兼容不等于永远保留
别名会增加 API 表面积、文档入口和用户选择。迁移期应写清:
- 权威名称;
- 废弃时间线;
- 是否只保证源码兼容;
- 序列化名称是否变化;
- 反射、日志或代码生成器是否依赖包路径。
14. 序列化、反射与数据库
定义类型通常沿用底层类型的默认编码行为:
type UserID int64默认 JSON 会编码为数字。但可以通过实现接口改变外部表示:
func (id UserID) MarshalText() ([]byte, error) {
return []byte(strconv.FormatInt(int64(id), 10)), nil
}实现 encoding.TextMarshaler、json.Marshaler、sql.Scanner 或 driver.Valuer 后,类型就不仅是名字,而成为边界规则的集中点。
反射会看到定义类型的名称和包路径:
t := reflect.TypeOf(UserID(1))
fmt.Println(t.Name(), t.PkgPath(), t.Kind())
// UserID, 当前包路径, int64Kind 描述底层类别,不等于完整类型身份。两个不同定义类型都可能是 reflect.Int64。
别名不会产生新的反射类型。若日志、注册表或序列化框架用反射名称作为协议标识,类型迁移需要额外测试,不能只依据编译成功判断兼容。
15. 性能、内存与并发
定义一个新类型通常不会引入运行时包装:
type UserID int64它与 int64 具有相同底层表示,普通转换通常不需要分配。真正可能有成本的是:
- 转为接口导致装箱或逃逸;
- 字符串与
[]byte的数据转换; - 自定义序列化;
- 反射;
- 方法中额外校验或同步。
不要通过 unsafe 消除普通数值转换;编译器已能处理无成本表示转换。用基准测试确认热点:
go test -bench . -benchmem类型定义也不改变并发性质。type SafeMap map[string]int 并不会让 map 安全;别名更不会。并发安全来自状态所有权和同步协议,应在类型 API 中明确。
对包含锁的结构体再定义一个新类型时尤其小心:底层布局虽然相同,转换和复制仍可能破坏锁语义。不可复制约束是语义要求,不会因为换了类型名就消失。
16. 与 Java 对照
| Go | Java 中较接近的概念 | 差异 |
|---|---|---|
type UserID int64 | 小型 value object / record | Go 可保留底层运算,无继承 |
type X = Y | import 重命名或迁移入口 | Java 没有完全对应的公开类型别名 |
| 底层类型 | primitive representation 的一部分 | 直接参与 Go 赋值、转换和泛型类型集 |
~int | 接受一族底层为 int 的类型 | Java 泛型没有对应的底层类型集合 |
| 接收者方法 | 实例方法 | 定义在类型外,只能绑定本包定义类型 |
| 无类型常量 | 编译期常量 | 可在可表示范围内适配目标类型 |
Java 常用包装类为原始值增加名义类型;Go 的定义类型更轻量,但封装强度也较低。若要强制构造规则,使用带未导出字段的结构体,而不是只换一个名字。
17. 常见误区
- 把定义类型和别名当同义语法。 等号决定是否产生新类型。
- 认为底层类型相同就能直接赋值。 两边都是不同命名类型时通常仍需转换。
- 认为新类型继承原类型方法。 方法集不会沿定义链继承。
- 想通过外部类型别名增加方法。 别名不能绕过接收者必须在本包定义的限制。
- 用别名做领域隔离。 别名完全保持类型身份,无法阻止混用。
- 把
~int写成“int 的子类”。 它只用于类型集合,表示底层类型为int。 - 用转换代替输入校验。
uint8(300)不会替你返回错误。 - 假定定义类型能密封取值范围。 导出的数值定义类型仍能被显式构造任意可表示值。
- 泛型别名上设计专属方法。 它没有创建可绑定方法的新类型。
- 把
reflect.Kind当类型。 Kind 相同不表示类型相同。 - 无意义地包装每个字符串。 若不能阻止错误或承载规则,只会增加转换噪声。
- 把源码兼容等同协议兼容。 JSON、数据库、反射注册名仍需单独验证。
18. 判断流程与速查表
遇到赋值错误时,按顺序检查:
- 两边是否完全相同类型,或只是别名的不同名字?
- 若不同,底层类型是否相同?
- 底层相同时,两边是否都是命名类型?
- 是否涉及无类型常量,且常量值能否由目标类型表示?
- 是否是 nil、接口或 channel 方向的特殊规则?
- 是否涉及类型参数,规则能否覆盖约束内所有类型?
- 若不直接可赋值,规范是否允许显式转换?
- 即便能转换,转换是否符合业务语义并处理范围?
| 目标 | 写法 |
|---|---|
| 定义新类型 | type UserID int64 |
| 声明别名 | type ID = int64 |
| 显式转换 | UserID(raw) |
| 接受底层为 int64 的定义类型 | interface{ ~int64 } |
| 泛型别名 | type Set[E comparable] = map[E]struct{} |
| 泛型定义类型 | type Set[E comparable] map[E]struct{} |
| 编译期范围检查 | 将无类型常量赋给目标类型 |
| 运行时范围检查 | 先比较边界,再转换 |
| 类型迁移兼容入口 | 旧包 type T = newpkg.T |
| 强不变量 | 含未导出字段的结构体 + 构造函数 |
可运行示例
自定义类型不只是让编译器显示另一个名字。更实际的用途,是把领域边界、合法状态和转换规则收进类型系统。
示例一:defined type 和 alias 的真实区别
先验证这个等号。 type UserID string 与 type Text = string 看起来只差一个等号,行为却不同。程序会从赋值、格式化类型名和方法三个角度进行对比。
package main
import "fmt"
type UserID string
// String 属于新定义的 UserID;它不能定义在 string 或其别名上。
func (id UserID) String() string {
return "user:" + string(id)
}
// Text 是 string 的别名:两者是同一个类型,主要用于迁移和兼容。
type Text = string
func main() {
var raw string = "42"
var alias Text = raw // 别名与 string 完全相同,可以直接赋值。
// UserID 是独立类型,需要显式转换。这道边界能阻止把普通文本误当用户标识。
id := UserID(raw)
fmt.Printf("alias=%s type=%T\n", alias, alias)
fmt.Printf("id=%s type=%T\n", id.String(), id)
}运行:
go run ./examples/ch10/defined-alias预期输出:
alias=42 type=string
id=user:42 type=main.UserID运行结果说明了三件事。
Text与string是同一类型,赋值不需要转换,也不能承载独立方法集。UserID是新的 defined type,必须显式接过原始字符串。- 显式转换是有价值的审查点:从“任意文本”进入“用户标识”时,可以顺手完成校验。
进一步验证。 尝试把 raw 直接赋给 UserID,阅读编译错误;再给 Text 定义方法,观察编译器为什么拒绝。最后把 UserID 与另一个 OrderID 混用,验证领域类型能否阻止串号。
示例二:让构造函数守住领域不变量
怎样守住合法状态。 如果订单号只是一段公开字符串,任何调用者都能制造空值和非法格式。这个示例用未导出字段、解析函数和构造函数,把合法性集中到入口处。
package main
import (
"fmt"
"strings"
)
type OrderID string
func ParseOrderID(raw string) (OrderID, error) {
// 在领域边界统一规范化输入,避免数据库中同时存在带空格和不带空格的同一标识。
normalized := strings.TrimSpace(raw)
if !strings.HasPrefix(normalized, "ORD-") || len(normalized) <= len("ORD-") {
return "", fmt.Errorf("订单号 %q 必须以 ORD- 开头且包含编号", raw)
}
return OrderID(normalized), nil
}
type Order struct {
// 字段不导出,迫使包外调用者通过构造函数建立合法对象。
id OrderID
}
func NewOrder(rawID string) (Order, error) {
// 构造函数只在校验成功后创建 Order,让后续方法不必反复防御非法 ID。
id, err := ParseOrderID(rawID)
if err != nil {
return Order{}, err
}
return Order{id: id}, nil
}
func (o Order) ID() OrderID {
return o.id
}
func main() {
order, err := NewOrder(" ORD-2026 ")
fmt.Printf("合法订单:id=%s err=%v\n", order.ID(), err)
_, err = NewOrder("2026")
fmt.Printf("非法订单:%v\n", err)
}运行:
go run ./examples/ch10/order-domain预期输出:
合法订单:id=ORD-2026 err=<nil>
非法订单:订单号 "2026" 必须以 ORD- 开头且包含编号这个边界由三部分组成。
ParseOrderID负责清理输入并验证格式。Order.id不导出,包外不能绕开构造过程直接写字段。NewOrder成功返回后,调用方可以依赖“订单号已经合法”这一不变量。
继续扩展规则。 增加长度上限和只允许数字编号的规则,并为每条失败路径返回不同的 sentinel error。然后再判断:从数据库反序列化时是否允许绕过构造函数,以及应在哪一层重新校验。
示例三:把状态转换规则放进领域类型
把分散的判断收回来。 订单状态散落为字符串和 if 时,拼写错误与非法跳转很难统一阻止。这个程序用 defined type、常量和方法描述有限状态机。
package main
import "fmt"
type OrderStatus uint8
const (
StatusPending OrderStatus = iota + 1
StatusPaid
StatusShipped
StatusCancelled
)
func (s OrderStatus) String() string {
switch s {
case StatusPending:
return "pending"
case StatusPaid:
return "paid"
case StatusShipped:
return "shipped"
case StatusCancelled:
return "cancelled"
default:
return fmt.Sprintf("unknown(%d)", s)
}
}
func (s OrderStatus) CanTransitionTo(next OrderStatus) bool {
// 状态转换规则集中在领域类型中,避免各业务入口各写一套 if。
switch s {
case StatusPending:
return next == StatusPaid || next == StatusCancelled
case StatusPaid:
return next == StatusShipped
default:
return false // 已发货或已取消均视为终态。
}
}
func main() {
current := StatusPending
for _, next := range []OrderStatus{StatusPaid, StatusShipped, StatusCancelled} {
fmt.Printf("%s -> %s: %t\n", current, next, current.CanTransitionTo(next))
if current.CanTransitionTo(next) {
current = next
}
}
}运行:
go run ./examples/ch10/status-transition预期输出:
pending -> paid: true
paid -> shipped: true
shipped -> cancelled: false规则落在这三处。
- 常量提供统一状态集合,
String只负责展示。 CanTransitionTo集中保存转换规则,调用端无需复制状态判断。default对未知值采取保守策略,因为从数据库或协议读取的整数仍可能越界。
再验证未知状态。 增加退款状态及 paid -> refunded 规则;再构造 OrderStatus(99),确认日志不会空白、转换不会误放行。若规则需要说明失败原因,可以把布尔结果改成 error。
19. 练习
- 定义
UserID和OrderID,写一个容易调换参数的函数,比较使用int64和定义类型时的编译结果。 - 写
type A []string、type B []string和[]string三组赋值,逐个解释哪些需要转换。 - 为
Percentage实现构造、String、文本编解码,并处理未知或越界输入。 - 将一个类型从
oldpkg移到newpkg,用别名保持兼容,再故意改为类型定义观察错误。 - 写一个接受
~int64的泛型求和函数,让UserID可以使用;再把约束改成int64比较。 - 定义泛型别名
Set[E]和泛型定义类型OwnedSet[E],比较赋值、方法和反射结果。 - 检查
uint8(-1)、uint8(300)在常量和运行时变量两种情况下的差异。 - 为领域数值实现
sql.Scanner与driver.Valuer,写测试覆盖 NULL、格式错误和溢出。