Skip to content

Go 自定义类型与类型别名:读懂类型身份、底层类型和可赋值性

面向有 Java 经验的开发者,基于 Go 1.26。

Go 的 type 声明只有一个等号之差,含义却完全不同:

go
type UserID int64  // 定义一个新类型
type RawID = int64 // 给已有类型起别名

UserIDint64 是不同的类型,RawIDint64 则只是同一个类型的两种写法。这个等号会影响赋值、转换、方法、泛型约束、反射和 API 兼容性。类型边界设计得当,编译器可以直接拦住“把订单号当用户号”这类错误;若类型没有承载任何规则,自定义类型只会留下满篇的强制转换。

这一章把定义类型、别名、底层类型和可赋值性放进同一套判断模型。以后再遇到 “cannot use X as Y”,不必凭感觉加转换,可以顺着类型身份和底层类型逐步定位。

目录

1. 先分清定义与别名

规范把 type 声明分为两类:

go
// type definition
type Celsius float64

// alias declaration
type Temperature = float64

两种声明的差异可以先从这张表看:

问题type Celsius float64type Temperature = float64
是否产生新类型
与右侧类型是否相同
是否通常需要显式转换
能否为它定义本包方法可以别名本身不是新的接收者基类型
反射中的名称Celsius仍是被别名类型
主要用途领域语义、封装、方法迁移、兼容、缩短复杂名称

别名不是 Java 的子类,也不是 typedef 后得到的名义新类型。它只是让同一个类型在一个作用域中多了一个名字。

2. 定义类型

2.1 从已有类型定义新类型

go
type UserID int64
type OrderID int64

var uid UserID = 42
var oid OrderID = 42

// uid = oid // 编译错误:UserID 和 OrderID 是不同的已定义类型

它们的底层类型都是 int64,类型身份却不同。领域类型的价值正在这里:即使数据形状完全一样,编译器仍能区分它们所表达的含义。

类型定义右侧可以是任意类型表达式:

go
type Tags []string
type Header map[string][]string
type Handler func(Request) Response
type Point struct{ X, Y float64 }
type IntChannel chan int

定义类型不会“继承”右侧已定义类型的方法:

go
type Base string
func (Base) Label() string { return "base" }

type Child Base

// Child 没有 Label 方法。

Child 只继承了底层表示和可用运算,不继承 Base 的方法集。这和 Java extends 完全不同。

2.2 操作由底层类型决定

go
type Celsius float64

func main() {
	var a Celsius = 20
	var b Celsius = 5
	fmt.Println(a + b) // 允许,底层类型支持 +
}

可用运算来自底层类型,但运算结果的类型遵循表达式规则:

go
var c Celsius = 20
fmt.Printf("%T\n", c+5) // main.Celsius;5 是可表示为 Celsius 的无类型常量

若另一侧是已经具有类型的变量,则不能随意混算:

go
var f float64 = 5
// _ = c + f // 类型不匹配
_ = c + Celsius(f)

2.3 声明与字面量

底层类型为结构体、切片、map 等时,可以使用相应复合字面量:

go
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 别名表示同一类型

go
type Byte = uint8

var a Byte = 1
var b uint8 = a // 不需要转换
fmt.Printf("%T\n", a) // uint8

标准库中 byteuint8 的别名,runeint32 的别名。它们分别强调字节和 Unicode 码点语义,但没有产生新类型。

3.2 别名的典型用途

最重要的用途是跨包迁移时保持源码兼容。假设类型从 oldpkg 移到 newpkg

go
// package oldpkg
import "example.com/project/newpkg"

type Client = newpkg.Client

调用方暂时仍可写 oldpkg.Client,但它与 newpkg.Client 完全相同。迁移完成后可以在新的主版本移除旧入口。

别名也可以缩短当前包内部的复杂类型:

go
type cancelFunc = func(error)

不过,公开 API 中出现大量别名后,类型究竟定义在哪里会越来越难判断。一个概念最好只有一个权威名称,别名应服务于明确的迁移计划。

3.3 别名不能增加封装

go
type Password = string

这不会阻止普通字符串被当作密码,也不能为其建立独立方法集。要表达领域边界,应写:

go
type Password string

然后通过构造函数、未导出字段或方法建立规则。

4. 底层类型

每个类型 T 都有一个底层类型:

  • 预声明类型 int 的底层类型是它自己;
  • 字面类型的底层类型是它自己;
  • 已定义类型的底层类型,是不断沿定义右侧追溯,直到遇到非已定义类型;
  • 类型参数的底层类型由规范通过类型集规则处理,不能简单当作某一个具体类型。
go
type A int
type B A
type C B

ABC 是三个不同的定义类型,底层类型都是 int

go
type Header map[string][]string

Header 的底层类型是 map[string][]string,因此可索引、可 range、可传给 len,零值也和 map 一样是 nil。

底层类型主要参与:

  • 判断可用操作;
  • 判断一些赋值和转换是否合法;
  • 泛型中 ~T 的类型集合;
  • 结构、接口和复合类型的身份规则。

需要反复确认的一条边界是:底层类型相同,不代表类型相同,也不表示两边总能直接赋值。

5. 类型身份

5.1 已定义类型总是不同

每次类型定义都会创建新的已定义类型:

go
type A int
type B int

即使名字、底层类型和方法碰巧相似,AB 仍不同。别名则不创建类型:

go
type C = A // C 与 A 相同

5.2 未命名类型按结构判断

数组长度、元素类型,结构体字段顺序、名称、类型、标签和嵌入属性,函数参数和结果,channel 方向等都会参与类型身份。

go
var a struct {
	X int `json:"x"`
}
var b struct {
	X int `json:"value"`
}

// a 和 b 的类型不相同,因为标签不同。

切片 []int 在任何出现位置都表示同一个未命名类型;map[string]int 也是如此。命名不会渗透:

go
type MyInt int

// []MyInt 与 []int 不同,因为元素类型不同。

不要用反射字符串或格式化输出猜类型身份。需要编译期判断时,让赋值、泛型约束或接口断言交给编译器。

6. 可赋值性

可赋值性回答的是:表达式 x 能否不写转换,直接放进类型为 T 的变量、参数、返回值或 channel。

6.1 类型相同时可赋值

go
type ID int64
var a ID
var b ID = a

别名因为类型相同,自然也可赋值:

go
type Alias = ID
var c Alias = a

6.2 底层类型相同且至少一方不是命名类型

规范允许:VT 的底层类型相同,并且两者不是类型参数,且至少有一方不是命名类型时直接赋值。

go
type Names []string

var a Names
var b []string

a = b // 允许:底层类型相同,[]string 是未命名类型
b = a // 也允许

但两个不同的命名类型不行:

go
type A []string
type B []string

var a A
var b B
// a = b // 不允许
a = A(b)

这一规则经常被误记成“底层类型相同就能赋值”,漏掉了“至少一方不是命名类型”。

6.3 nil 可赋给特定类型

预声明标识符 nil 可以赋给指针、函数、切片、map、channel 和接口类型:

go
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 = nil

nil 没有默认类型,不能写 x := nil

6.4 接口可赋值性

非接口值实现接口时可赋给接口;接口之间则取决于类型集:

go
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;在规范规定的条件下,至少一方不是命名类型:

go
ch := make(chan int)
var recv <-chan int = ch
var send chan<- int = ch

方向收窄是安全的,不能反向恢复:

go
// ch = recv // 不允许

6.6 类型参数

泛型代码中的赋值要对类型集合里的每一种可能类型都成立。看到涉及类型参数的错误时,不要只拿某个实例推理,应检查约束中的全部类型。

7. 显式转换

类型转换写成 T(x)

go
type UserID int64

raw := int64(42)
id := UserID(raw)
back := int64(id)

转换创建目标类型的值,不是在运行时给对象“换标签”。对数值转换尤其要注意范围和精度:

go
n := int64(300)
b := uint8(n) // 44;不会自动报溢出错误

运行时数值转换通常按规范截断或扩展,业务代码应先校验范围:

go
if n < 0 || n > math.MaxUint8 {
	return fmt.Errorf("out of uint8 range: %d", n)
}
b := uint8(n)

字符串与字节、rune 切片的转换具有数据复制和编码语义:

go
bytes := []byte("Go")  // UTF-8 字节
runes := []rune("你好") // Unicode 码点
s1 := string(bytes)
s2 := string(runes)

string(65) 得到 "A",不是 "65"。数字格式化应使用 strconv.ItoaFormatIntfmt.

7.1 底层为指针的定义类型

指针类型的转换规则容易被误用。即使某些具有相同底层基类型的指针转换合法,也不要用它绕过封装。跨类型共享可变内存会让不变量失效;若目的是 reinterpret cast,就已经进入 unsafe 领域。

7.2 转换不是断言

  • T(x) 是编译期认可的类型转换;
  • x.(T) 是接口值的运行时类型断言;
  • 泛型约束不是运行时转换。

三者解决的问题不同,错误表现也不同。

8. 无类型常量

常量可以处于“无类型”状态:

go
const timeout = 5
const ratio = 0.125

只要值能由目标类型表示,就可以直接赋给定义类型:

go
type RetryCount uint8

var r RetryCount = 5
// var bad RetryCount = 300 // 编译期溢出

但变量一旦有具体类型,就按普通赋值规则处理:

go
n := 5 // n 是 int
// r = n
r = RetryCount(n)

这解释了为什么字面量 5 能和许多定义数值类型配合,而变量 n 不行。无类型常量拥有任意精度的语义值,转换到具体类型时才检查可表示性。

无类型布尔、字符串、rune、整数、浮点和复数常量都有各自默认类型;它们进入短变量声明或接口时,通常会取得默认类型。

9. 方法与接收者

可以为当前包声明的定义类型添加方法:

go
type UserID int64

func (id UserID) Valid() bool {
	return id > 0
}

func (id UserID) String() string {
	return strconv.FormatInt(int64(id), 10)
}

接收者基类型必须是当前包定义的非指针、非接口类型。不能给另一个包的类型添加方法:

go
// func (time.Time) MyMethod() {} // 不允许

解决方式是定义新类型、嵌入或写普通函数。定义新类型不会带来原类型的方法:

go
type MyTime time.Time
// MyTime 不自动拥有 time.Time 的方法。

若想保留行为并增加组合能力,常用嵌入:

go
type EventTime struct {
	time.Time
}

9.1 别名与方法

别名没有创建新的定义类型,因此不能把它当成新的方法接收者基类型。若别名指向当前包的定义类型,通过别名当然能调用原类型已有方法,因为它们是同一类型;这不等于方法“定义在别名上”。

尤其不要通过别名试图给外部类型补方法:

go
type LocalTime = time.Time
// func (LocalTime) Foo() {} // 不允许

9.2 方法集和转换

Old 转为 New 后,值只拥有 New 的方法集:

go
type Old string
func (Old) OldMethod() {}

type New Old
func (New) NewMethod() {}

v := New(Old("x"))
v.NewMethod()

底层数据没有变化,静态类型决定可调用的方法和接口满足关系。

10. 泛型约束中的 T~T

下面的约束只包含精确类型 int

go
type ExactInt interface {
	int
}

定义类型 UserID 即便底层类型是 int,也不属于该类型集合。若希望接受所有底层类型为 int 的类型,要写近似元素 ~int

go
type Integer interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}

func Add[T Integer](a, b T) T {
	return a + b
}
go
type Score int
fmt.Println(Add(Score(1), Score(2)))

波浪号读作“底层类型为”。~T 中的 T 本身必须是它自己的底层类型,不能随意写:

go
type MyInt int
// interface{ ~MyInt } 非法,因为 MyInt 的底层类型不是 MyInt。

约束定义了可用操作。若类型集合里所有类型都支持 +,泛型函数才可使用 +。不要把约束接口当作普通运行时接口使用:含类型元素的接口通常只能作为约束,不能作为普通变量类型。

11. 泛型类型别名

从 Go 1.24 开始,别名声明可以有类型参数;Go 1.26 中这是正式语言特性:

go
type Set[E comparable] = map[E]struct{}
type Pair[A, B any] = struct {
	First  A
	Second B
}

使用泛型别名时必须实例化:

go
var ids Set[int64]
p := Pair[string, int]{First: "age", Second: 18}

Set[int64]map[int64]struct{} 是同一个类型,并没有产生新的封装边界,也不能获得一组专属方法。

泛型别名适合:

  • 迁移泛型类型的包路径;
  • 给冗长但稳定的实例化模式命名;
  • 对外保持类型身份兼容。

不适合:

  • 想增加方法;
  • 想禁止调用方直接构造;
  • 想区分两个领域概念;
  • 想改变赋值兼容性。

若需要这些能力,应定义泛型类型:

go
type Set[E comparable] map[E]struct{}

func (s Set[E]) Add(v E) {
	s[v] = struct{}{}
}

注意方法不能绑定到一个实例化的泛型别名上。接收者基类型必须满足规范对本包定义类型的要求。

Go 1.26 还放宽了泛型类型在其类型参数列表中自引用的限制,可以表达 F-bounded 风格约束:

go
type Adder[A Adder[A]] interface {
	Add(A) A
}

这项变化与“泛型别名”不是同一件事:前者扩展约束表达力,后者保持类型身份。

12. 领域建模

12.1 用类型阻止参数调换

go
type UserID int64
type OrderID int64

func LoadOrder(userID UserID, orderID OrderID) {}

如果两个参数都写 int64,调用 LoadOrder(orderID, userID) 仍能编译。使用定义类型后,这类错误在编译期暴露。

12.2 用方法集中规则

go
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 的定义类型提供静态区分,却不是密封构造。要求强不变量时,可用含未导出字段的结构体:

go
type Percentage struct {
	value uint8
}

这样调用方不能直接构造非法字段值。

12.3 不要为每个字段造类型

类型有维护成本。以下情况值得定义:

  • 参数容易调换;
  • 单位不同,如米和秒;
  • 有明确校验与行为;
  • 要实现接口;
  • 需要控制公开 API;
  • 编译期区分能消除真实错误。

仅仅为了把 string 换个名字,却在所有边界做来回转换,收益可能很低。先问“它阻止了哪类错误”。

12.4 枚举式类型

go
type Status uint8

const (
	StatusUnknown Status = iota
	StatusPending
	StatusRunning
	StatusDone
)

Go 枚举不是封闭集合,调用方仍可构造 Status(99)。处理外部输入时要校验,并在 String、序列化和 switch 的默认分支中兼容未知值。协议演进时,未知值往往比 panic 更常见。

13. 别名与 API 演进

13.1 跨包移动

安全迁移通常分阶段:

  1. 在新包定义权威类型;
  2. 旧包用别名重新导出;
  3. 文档标记旧名称废弃;
  4. 调用方逐步迁移 import;
  5. 在允许破坏兼容的主版本移除旧别名。
go
// Deprecated: use model.User.
type User = model.User

因为类型身份不变,函数参数、接口满足关系和方法集仍兼容。若旧包写 type User model.User,就会创建新类型,迁移成本完全不同。

13.2 别名的依赖方向

旧包依赖新包,通常比新包反向依赖旧包合理。要检查 import cycle:如果新包又依赖旧包,添加别名会直接造成循环。此时需要提取更底层的独立包,而不是用别名掩盖架构问题。

13.3 兼容不等于永远保留

别名会增加 API 表面积、文档入口和用户选择。迁移期应写清:

  • 权威名称;
  • 废弃时间线;
  • 是否只保证源码兼容;
  • 序列化名称是否变化;
  • 反射、日志或代码生成器是否依赖包路径。

14. 序列化、反射与数据库

定义类型通常沿用底层类型的默认编码行为:

go
type UserID int64

默认 JSON 会编码为数字。但可以通过实现接口改变外部表示:

go
func (id UserID) MarshalText() ([]byte, error) {
	return []byte(strconv.FormatInt(int64(id), 10)), nil
}

实现 encoding.TextMarshalerjson.Marshalersql.Scannerdriver.Valuer 后,类型就不仅是名字,而成为边界规则的集中点。

反射会看到定义类型的名称和包路径:

go
t := reflect.TypeOf(UserID(1))
fmt.Println(t.Name(), t.PkgPath(), t.Kind())
// UserID, 当前包路径, int64

Kind 描述底层类别,不等于完整类型身份。两个不同定义类型都可能是 reflect.Int64

别名不会产生新的反射类型。若日志、注册表或序列化框架用反射名称作为协议标识,类型迁移需要额外测试,不能只依据编译成功判断兼容。

15. 性能、内存与并发

定义一个新类型通常不会引入运行时包装:

go
type UserID int64

它与 int64 具有相同底层表示,普通转换通常不需要分配。真正可能有成本的是:

  • 转为接口导致装箱或逃逸;
  • 字符串与 []byte 的数据转换;
  • 自定义序列化;
  • 反射;
  • 方法中额外校验或同步。

不要通过 unsafe 消除普通数值转换;编译器已能处理无成本表示转换。用基准测试确认热点:

bash
go test -bench . -benchmem

类型定义也不改变并发性质。type SafeMap map[string]int 并不会让 map 安全;别名更不会。并发安全来自状态所有权和同步协议,应在类型 API 中明确。

对包含锁的结构体再定义一个新类型时尤其小心:底层布局虽然相同,转换和复制仍可能破坏锁语义。不可复制约束是语义要求,不会因为换了类型名就消失。

16. 与 Java 对照

GoJava 中较接近的概念差异
type UserID int64小型 value object / recordGo 可保留底层运算,无继承
type X = Yimport 重命名或迁移入口Java 没有完全对应的公开类型别名
底层类型primitive representation 的一部分直接参与 Go 赋值、转换和泛型类型集
~int接受一族底层为 int 的类型Java 泛型没有对应的底层类型集合
接收者方法实例方法定义在类型外,只能绑定本包定义类型
无类型常量编译期常量可在可表示范围内适配目标类型

Java 常用包装类为原始值增加名义类型;Go 的定义类型更轻量,但封装强度也较低。若要强制构造规则,使用带未导出字段的结构体,而不是只换一个名字。

17. 常见误区

  1. 把定义类型和别名当同义语法。 等号决定是否产生新类型。
  2. 认为底层类型相同就能直接赋值。 两边都是不同命名类型时通常仍需转换。
  3. 认为新类型继承原类型方法。 方法集不会沿定义链继承。
  4. 想通过外部类型别名增加方法。 别名不能绕过接收者必须在本包定义的限制。
  5. 用别名做领域隔离。 别名完全保持类型身份,无法阻止混用。
  6. ~int 写成“int 的子类”。 它只用于类型集合,表示底层类型为 int
  7. 用转换代替输入校验。 uint8(300) 不会替你返回错误。
  8. 假定定义类型能密封取值范围。 导出的数值定义类型仍能被显式构造任意可表示值。
  9. 泛型别名上设计专属方法。 它没有创建可绑定方法的新类型。
  10. reflect.Kind 当类型。 Kind 相同不表示类型相同。
  11. 无意义地包装每个字符串。 若不能阻止错误或承载规则,只会增加转换噪声。
  12. 把源码兼容等同协议兼容。 JSON、数据库、反射注册名仍需单独验证。

18. 判断流程与速查表

遇到赋值错误时,按顺序检查:

  1. 两边是否完全相同类型,或只是别名的不同名字?
  2. 若不同,底层类型是否相同?
  3. 底层相同时,两边是否都是命名类型?
  4. 是否涉及无类型常量,且常量值能否由目标类型表示?
  5. 是否是 nil、接口或 channel 方向的特殊规则?
  6. 是否涉及类型参数,规则能否覆盖约束内所有类型?
  7. 若不直接可赋值,规范是否允许显式转换?
  8. 即便能转换,转换是否符合业务语义并处理范围?
目标写法
定义新类型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 stringtype Text = string 看起来只差一个等号,行为却不同。程序会从赋值、格式化类型名和方法三个角度进行对比。

go
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)
}

运行:

bash
go run ./examples/ch10/defined-alias

预期输出:

text
alias=42 type=string
id=user:42 type=main.UserID

运行结果说明了三件事。

  • Textstring 是同一类型,赋值不需要转换,也不能承载独立方法集。
  • UserID 是新的 defined type,必须显式接过原始字符串。
  • 显式转换是有价值的审查点:从“任意文本”进入“用户标识”时,可以顺手完成校验。

进一步验证。 尝试把 raw 直接赋给 UserID,阅读编译错误;再给 Text 定义方法,观察编译器为什么拒绝。最后把 UserID 与另一个 OrderID 混用,验证领域类型能否阻止串号。

示例二:让构造函数守住领域不变量

怎样守住合法状态。 如果订单号只是一段公开字符串,任何调用者都能制造空值和非法格式。这个示例用未导出字段、解析函数和构造函数,把合法性集中到入口处。

go
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)
}

运行:

bash
go run ./examples/ch10/order-domain

预期输出:

text
合法订单:id=ORD-2026 err=<nil>
非法订单:订单号 "2026" 必须以 ORD- 开头且包含编号

这个边界由三部分组成。

  1. ParseOrderID 负责清理输入并验证格式。
  2. Order.id 不导出,包外不能绕开构造过程直接写字段。
  3. NewOrder 成功返回后,调用方可以依赖“订单号已经合法”这一不变量。

继续扩展规则。 增加长度上限和只允许数字编号的规则,并为每条失败路径返回不同的 sentinel error。然后再判断:从数据库反序列化时是否允许绕过构造函数,以及应在哪一层重新校验。

示例三:把状态转换规则放进领域类型

把分散的判断收回来。 订单状态散落为字符串和 if 时,拼写错误与非法跳转很难统一阻止。这个程序用 defined type、常量和方法描述有限状态机。

go
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
		}
	}
}

运行:

bash
go run ./examples/ch10/status-transition

预期输出:

text
pending -> paid: true
paid -> shipped: true
shipped -> cancelled: false

规则落在这三处。

  • 常量提供统一状态集合,String 只负责展示。
  • CanTransitionTo 集中保存转换规则,调用端无需复制状态判断。
  • default 对未知值采取保守策略,因为从数据库或协议读取的整数仍可能越界。

再验证未知状态。 增加退款状态及 paid -> refunded 规则;再构造 OrderStatus(99),确认日志不会空白、转换不会误放行。若规则需要说明失败原因,可以把布尔结果改成 error

19. 练习

  1. 定义 UserIDOrderID,写一个容易调换参数的函数,比较使用 int64 和定义类型时的编译结果。
  2. type A []stringtype B []string[]string 三组赋值,逐个解释哪些需要转换。
  3. Percentage 实现构造、String、文本编解码,并处理未知或越界输入。
  4. 将一个类型从 oldpkg 移到 newpkg,用别名保持兼容,再故意改为类型定义观察错误。
  5. 写一个接受 ~int64 的泛型求和函数,让 UserID 可以使用;再把约束改成 int64 比较。
  6. 定义泛型别名 Set[E] 和泛型定义类型 OwnedSet[E],比较赋值、方法和反射结果。
  7. 检查 uint8(-1)uint8(300) 在常量和运行时变量两种情况下的差异。
  8. 为领域数值实现 sql.Scannerdriver.Valuer,写测试覆盖 NULL、格式错误和溢出。

20. 官方资料

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。