枚举是网络应用的一个重要部分。Go并不支持它们,但有一些方法可以模拟它们。
许多显而易见的解决方案都远非理想。下面是我们使用的一些想法,通过设计使枚举更加安全。
iota
Go让你用iota
来使用枚举。
const ( Guest = iota Member Moderator Admin )
虽然Go是明确的,但iota
似乎相对模糊。如果你以任何其他方式对const
组进行排序,你会引入副作用。在上面的例子中,你仅仅对第一个参数Guest
赋值了。你可以显式地给每个值分配一个数字来避免这个问题,但这使iota
变得过时。
iota
对于用位运算定义的参数也很有效。
const ( Guest = 1 << iota // 1 Member // 2 Moderator // 4 Admin // 8 ) // ... user.Roles = Member | Moderator // 6
位掩码是有效的,有时也很有帮助。然而,在大多数Web
应用程序中,它的使用情况与枚举不同。通常情况下,你可以将所有的角色存储在一个列表中。它也会更容易阅读。
iota
的主要问题是它在整数上工作,没有防止传递无效的值。
func CreateUser(role int) error { fmt.Println("Creating user with role", role) return nil } func main() { err := CreateUser(-1) if err != nil { fmt.Println(err) } err = CreateUser(42) if err != nil { fmt.Println(err) } }
CreateUser
会很乐意接受-1
或42
,即使没有相应的角色。
当然,我们可以在函数中验证这一点。但我们使用的是一种具有强类型的语言,所以让我们利用它。在我们应用程序的上下文中,用户角色远不止是一个模糊的数字。
反模式:整数枚举
不要使用基于
iota
的整数来表示不是连续的数字或标志的枚举。
我们可以引入一个类型来改进解决方案。
type Role uint const ( Guest Role = iota Member Moderator Admin )
它看起来更好,但仍有可能传递任何任意的整数来代替Role
。Go编译器在这里并没有帮助我们。
func CreateUser(role Role) error { fmt.Println("Creating user with role", role) return nil } func main () { err := CreateUser(0) if err != nil { fmt.Println(err) } err = CreateUser(role.Role(42)) if err != nil { fmt.Println(err) } }
这个类型是对裸整数的改进,但它仍然是一种幻觉。它并没有给我们提供任何保证,说明这个角色是有效的。
哨兵值
因为iota
从0开始,Guest
也是角色的零值。这使得我们很难检测到角色是空的还是有人传递了一个Guest
值。
你可以通过从1开始计算来避免这种情况。甚至更好的是,保留一个明确的哨兵值,你可以进行比较,不能误认为是一个实际的角色。
const ( Unknown Role = iota Guest Member Moderator Admin )
func CreateUser(r role.Role) error { if r == role.Unknown { return errors.New("no role provided") } fmt.Println("Creating user with role", r) return nil }
策略:明确的哨兵 为枚举的零值保留一个显式变量。
更准确的描述
枚举似乎是关于连续的整数,但它很少是有效的表示。在网络应用中,我们使用枚举来分组某种类型的可能变体。它们并不能很好地映射到数字上。
当你在API响应、数据库表或日志中看到一个3
时,很难理解其背景。你必须检查源码或过时的文档才能知道它是怎么回事。
在大多数情况下,字符串比整数更有意义。无论你在哪里看到它,一个有明确意义的表达都是显而易见的。既然iota
无论如何也帮不了我们,我们还可以使用人类可读的字符串。
type Role string const ( Unknown Role = "unknown" Guest Role = "guest" Member Role = "member" Moderator Role = "moderator" Admin Role = "admin" )
策略:使用字符串值而不是整数。
避免使用空白,以方便解析和记录。使用camelCase、snake_case或kebab-case。
这样的表达对错误代码特别有用。{"error":"user-not-found"}
与{"error":4102}
相比是显而易见的.
然而,该类型仍然可以容纳任何任意的字符串。
err = CreateUser("super-admin") if err != nil { fmt.Println(err) }
基于结构的枚举
最后的迭代使用了结构体。它可以让我们在设计上保证代码的安全性。我们不需要检查传递的值是否正确。
type Role struct { slug string } func (r Role) String() string { return r.slug } var ( Unknown = Role{"unknown"} Guest = Role{"guest"} Member = Role{"member"} Moderator = Role{"moderator"} Admin = Role{"admin"} )
因为slug
字段是私有的,所以不可能从包的外部引用它。你能构建的唯一无效的角色是空的:Role{}
。
我们可以添加一个构造函数来创建一个基于slug
的有效角色:
func FromString(s string) (Role, error) { switch s { case Guest.slug: return Guest, nil case Member.slug: return Member, nil case Moderator.slug: return Moderator, nil case Admin.slug: return Admin, nil } return Unknown, errors.New("unknown role: " + s) }
策略:基于结构的枚举 将枚举封装在结构中以获得额外的编译时安全性。
这种方法在你处理业务逻辑时是完美的。保持结构在内存中始终处于有效状态,使你的代码更容易操作和理解。检查枚举类型是否为空就足够了,而且你可以确定它是一个正确的值。
这种方法有一个潜在的问题。如果我们用的是全局性的常亮,这样的做法需要不断的赋值,如下:
roles.Guest = role.Admin
这样的话,这个值说不准在哪里变化了都不知道。
校验方法
竟然上面的方法都无法满足我们的需求,那么我们就加上一个校验方法,避免运行时传入了非法的值即可:
type Role string const ( Unknown Role = "unknown" Guest Role = "guest" Member Role = "member" Moderator Role = "moderator" Admin Role = "admin" ) var roleSet = []Role{Unknown, Guest, Member, Moderator, Admin} func (role Role) Valid() bool { for _, r := range roleSet { if role == r { return true } } return false }
这样的做法就可以满足我们对枚举更安全的需求了。