Menu

Enum trong Golang: cách tạo enum với const và iota

Go không có từ khóa enum. Trang này giới thiệu cách thay thế chuẩn mực: một kiểu có tên cùng khối const dùng iota, kèm cách thêm String(), kiểm tra hợp lệ, phân tích chuỗi, bit flag và hỗ trợ JSON.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Go không có từ khóa enum. Bạn tạo một enum từ hai thành phần: một kiểu có tên, và một khối const gồm các giá trị thuộc kiểu đó, được đánh số bằng iota.

Sunday bằng 0, và mỗi dòng sau lớn hơn một đơn vị. Chính kiểu có tên Weekday biến danh sách này thành một enum thay vì một dãy số: chữ ký của isWeekend cho biết hàm cần gì, và bạn có thể thêm phương thức cho kiểu. Đầu ra in 1 5 6 vì chưa có gì cho Go biết cách hiển thị một Weekday dưới dạng văn bản. Phần đó có ở bên dưới.

Cách iota hoạt động

iota là một bộ đếm do trình biên dịch cung cấp bên trong khối const. Hai quy tắc giải thích mọi mẹo dựa trên nó:

  1. iota bằng chỉ số của dòng hiện tại trong khối, bắt đầu từ 0, và đặt lại về 0 trong mỗi khối const mới.
  2. Một dòng không có = expression sẽ lặp lại biểu thức và kiểu của dòng trước, được tính với giá trị iota mới.

Vì vậy Monday ở trên là cách viết tắt của Monday Weekday = iota, với iota lúc này bằng 1. Vì biểu thức được lặp lại, nó có thể là bất kỳ biểu thức hằng nào, không chỉ riêng iota:

iota đếm dòng, không đếm tên: hai hằng số trên cùng một dòng dùng chung một giá trị iota, như X và Y cho thấy.

Bắt đầu từ 1, và lý do có thể không nên

Một biến kiểu enum chưa được gán sẽ mang giá trị 0, tức giá trị zero của nó. Nếu 0 là một giá trị thật như Sunday, bạn không phân biệt được "người dùng đã chọn Chủ Nhật" với "trường này chưa bao giờ được điền". Có ba cách khắc phục phổ biến:

Cách 1 phổ biến nhất trong code production, và các enum Go sinh từ protobuf cũng theo cách này (..._UNSPECIFIED = 0). Khi đó giá trị zero mang một ý nghĩa trung thực.

Bỏ qua giá trị

Định danh trống _ dùng hết một giá trị iota mà không tạo tên nào. Dùng nó để chừa khoảng trống, chẳng hạn để khớp với các số do một giao thức quy định hoặc để bỏ một giá trị mà không phải đánh số lại phần còn lại:

type Opcode byte

const (
	OpContinue Opcode = iota // 0
	OpText                   // 1
	OpBinary                 // 2
	_                        // 3, reserved
	_                        // 4, reserved
	_                        // 5, reserved
	_                        // 6, reserved
	_                        // 7, reserved
	OpClose                  // 8
	OpPing                   // 9
	OpPong                   // 10
)

Khi các con số đã được một đặc tả bên ngoài cố định, như các opcode WebSocket này, viết rõ từng số (OpClose Opcode = 8) thường dễ hiểu hơn việc đếm các chỗ trống. iota dành cho những giá trị mà bạn không quan tâm đến con số chính xác.

Không bao giờ sắp xếp lại hay chèn thêm vào một danh sách iota có các con số đã được lưu trong cơ sở dữ liệu, trong tệp hoặc gửi qua mạng. Thêm một dòng ở giữa sẽ dịch chuyển mọi giá trị phía sau. Hãy thêm giá trị mới vào cuối, hoặc gán số một cách tường minh.

Thêm phương thức String

Thêm cho kiểu một phương thức String() string và fmt sẽ dùng nó cho %v, %s và Println:

Có hai chi tiết quan trọng trong phương thức đó:

  • Phần kiểm tra giới hạn. Thiếu nó, Weekday(9).String() sẽ panic vì chỉ số vượt phạm vi, và sớm muộn chuyện đó cũng xảy ra, vì không có gì ngăn người gọi tạo ra Weekday(9).
  • int(d) bên trong Sprintf. Định dạng chính d bằng %d thì không sao, nhưng định dạng nó bằng %v sẽ gọi lại String() và đệ quy cho đến khi tràn stack.

%d vẫn in ra con số, nên bạn có cả hai dạng: Wednesday is day 3.

Sinh String bằng stringer

Với danh sách dài, công cụ stringer sẽ viết phương thức thay bạn:

//go:generate go run golang.org/x/tools/cmd/stringer@latest -type=Weekday
go generate ./...

Công cụ tạo ra weekday_string.go với một bản cài đặt String() gọn gàng, kèm một phép kiểm tra lúc biên dịch làm build thất bại nếu các hằng số thay đổi mà chưa sinh lại. Cờ -linecomment dùng chú thích cuối dòng làm tên, rất tiện cho những tên có dấu cách.

Kiểm tra giá trị hợp lệ

Enum trong Go không đóng. Mọi giá trị thuộc kiểu nền đều chuyển được sang nó, và hằng số không kiểu được chuyển ngầm định:

var d Weekday = 42     // compiles
d = Weekday(userInput) // compiles

Vì vậy hãy kiểm tra các giá trị đến từ bên ngoài code của bạn (JSON, cơ sở dữ liệu, flag, package khác):

Hằng canh gác không export colorCount ở cuối khối giúp IsValid luôn đúng khi bạn thêm màu mới, vì nó luôn đứng sau giá trị thật cuối cùng một đơn vị.

Dùng switch với enum

Enum thường được xử lý bằng switch. Go không kiểm tra xem một switch có bao hết mọi giá trị hay không, vì vậy hãy thêm default để báo trường hợp bất ngờ:

func (c Color) Hex() string {
	switch c {
	case Red:
		return "#ff0000"
	case Green:
		return "#00ff00"
	case Blue:
		return "#0000ff"
	default:
		return "#000000"
	}
}

Linter bên thứ ba exhaustive (có trong golangci-lint) báo các switch trên kiểu enum bị thiếu case, giúp bạn có gần như đầy đủ những gì phép kiểm tra enum toàn diện mang lại ở các ngôn ngữ khác.

Enum dạng bit flag

Khi các giá trị có thể kết hợp với nhau, như quyền truy cập, hãy dùng mỗi giá trị một bit với 1 << iota:

| kết hợp các flag, & kiểm tra chúng, và &^ (toán tử AND NOT của Go) xóa chúng. Kiểu nền không dấu là lựa chọn phù hợp ở đây: uint8 chứa được 8 flag, uint64 chứa được 64.

Enum dạng chuỗi

Khi giá trị vốn được lưu hoặc gửi dưới dạng văn bản, một kiểu dựa trên chuỗi giúp bỏ đi lớp chuyển đổi:

type Env string

const (
	EnvDev     Env = "dev"
	EnvStaging Env = "staging"
	EnvProd    Env = "prod"
)

Giá trị được in và tuần tự hóa dễ đọc mà không cần phương thức String(), và một cột trong cơ sở dữ liệu chứa "prod" thay vì một con số phụ thuộc vào thứ tự khai báo. Đổi lại: phép so sánh là so sánh chuỗi, không thể dùng bit flag, và việc kiểm tra hợp lệ vẫn là của bạn, vì Env("banana") cũng biên dịch được.

Enum và JSON

Theo mặc định, một enum số nguyên được tuần tự hóa thành số. Để đọc và ghi bằng tên, hãy cài đặt encoding.TextMarshaler và encoding.TextUnmarshaler. encoding/json dùng chúng cho giá trị và cho khóa của map:

MarshalText có receiver kiểu giá trị nên hoạt động với cả Level lẫn *Level; UnmarshalText cần receiver con trỏ vì nó thay đổi giá trị. Cũng hai phương thức đó giúp kiểu hoạt động với TextVar của package flag và với hầu hết thư viện cấu hình.

Những lỗi thường gặp

  • Chuyển đổi ngầm định của literal. Một hàm nhận Weekday cũng chấp nhận hằng số không kiểu 42. Chỉ những giá trị có kiểu khác mới bị từ chối.
  • Quên khai báo kiểu ở dòng đầu. Trong const ( Red = iota; Green; Blue ) cả ba đều là hằng số nguyên không kiểu, không phải giá trị Color, nên các phương thức của Color không áp dụng cho chúng. Hãy viết Red Color = iota để biểu thức được lặp lại mang theo kiểu.
  • Sắp xếp lại enum đã được lưu. Chèn một giá trị vào giữa khối iota sẽ âm thầm làm thay đổi các con số đã lưu ở nơi khác.
  • Đệ quy trong String. Bên trong String(), đừng bao giờ định dạng receiver bằng %v hoặc %s. Hãy chuyển sang kiểu nền trước.

Câu hỏi thường gặp

Go có enum không?

Không, enum không phải một tính năng của ngôn ngữ. Go không có từ khóa enum. Cách thay thế chuẩn mực là một kiểu có tên cùng một khối hằng số có kiểu, thường được đánh số bằng iota:

type Color int

const (
	Red Color = iota
	Green
	Blue
)

Kiểu này giúp chữ ký hàm dễ đọc và là nơi để gắn các phương thức như String(). Nó không ngăn ai đó viết Color(42), vì vậy hãy kiểm tra các giá trị đến từ bên ngoài.

iota trong Go là gì?

iota là một định danh khai báo sẵn, có giá trị bằng chỉ số của dòng hiện tại (constant spec) trong khối const, bắt đầu từ 0. Nó đặt lại về 0 trong mỗi khối const mới. Khi một dòng bỏ trống biểu thức, Go lặp lại biểu thức của dòng trước với giá trị iota tiếp theo, nhờ vậy Red = iota; Green; Blue cho ra 0, 1, 2.

Làm sao để iota bắt đầu từ 1?

Viết First Kind = iota + 1 ở dòng đầu tiên, hoặc bỏ qua số 0 bằng định danh trống: _ = iota rồi đến First. Nhiều lập trình viên Go lại giữ số 0 và đặt tên là Unknown hoặc Invalid, để một biến chưa khởi tạo (có giá trị zero là 0) rõ ràng không phải một lựa chọn thật.

Làm sao in enum ra dạng chuỗi trong Go?

Thêm cho kiểu một phương thức String() string. fmt gọi phương thức này với %v, %s và Println, nên fmt.Println(Green) in ra Green thay vì 1. Bạn có thể tự viết phương thức bằng switch hoặc một mảng, hoặc sinh tự động bằng go run golang.org/x/tools/cmd/stringer@latest -type=Color.

Làm sao chuyển chuỗi thành enum trong Go?

Viết một hàm phân tích tra cứu chuỗi, thường trong một map[string]Color hoặc một switch, và trả về lỗi khi gặp đầu vào lạ: func ParseColor(s string) (Color, error). Cài đặt UnmarshalText với cùng logic đó thì JSON, flag và các trình tải cấu hình sẽ tự động dùng nó.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu