Menu

Enum ב-Golang: איך בונים enums עם const ו-iota

ל-Go אין מילת מפתח enum. הדף הזה מראה את התחליף המקובל: טיפוס בעל שם יחד עם בלוק const ו-iota, ואיך מוסיפים String(), ולידציה, פענוח, דגלי ביטים ותמיכה ב-JSON.

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

ל-Go אין מילת מפתח enum. בונים enum משני חלקים: טיפוס בעל שם, ובלוק const של ערכים מאותו טיפוס, ממוספרים עם iota.

Sunday הוא 0, וכל שורה אחריו גדולה באחד. הטיפוס בעל השם Weekday הוא מה שהופך את זה ל-enum ולא לרשימת מספרים: isWeekend אומרת בחתימה שלה מה היא מצפה לקבל, ואפשר להוסיף מתודות לטיפוס. הפלט מדפיס 1 5 6 כי עדיין שום דבר לא אומר ל-Go איך להציג Weekday כטקסט. זה מגיע בהמשך.

איך iota עובד

iota הוא מונה שהקומפיילר מספק בתוך בלוק const. שני כללים מסבירים כל טריק שנבנה עליו:

  1. iota שווה לאינדקס של השורה הנוכחית בבלוק, החל מ-0, ומתאפס ל-0 בכל בלוק const חדש.
  2. שורה בלי = expression חוזרת על הביטוי והטיפוס של השורה הקודמת, ומחשבת אותם עם ה-iota החדש.

לכן Monday שלמעלה הוא קיצור של Monday Weekday = iota, כש-iota הוא עכשיו 1. מכיוון שהביטוי חוזר, הוא יכול להיות כל ביטוי קבוע, לא רק iota:

iota סופר שורות, לא שמות: שני קבועים באותה שורה חולקים ערך iota אחד, כפי ש-X ו-Y מראים.

התחלה מ-1, ולמה אולי לא כדאי

משתנה מטיפוס enum שאף אחד לא הציב בו ערך מחזיק 0, ערך האפס שלו. אם 0 הוא ערך אמיתי כמו Sunday, אי אפשר להבחין בין "המשתמש בחר ביום ראשון" ל"השדה אף פעם לא מולא". שלושה פתרונות נפוצים:

אפשרות 1 היא הנפוצה ביותר בקוד production, ו-enums של Go שנוצרים מ-protobuf הולכים לפיה (..._UNSPECIFIED = 0). ערך האפס אז אומר משהו כן.

דילוג על ערכים

המזהה הריק _ צורך ערך iota בלי ליצור שם. השתמשו בו כדי להשאיר פערים, למשל כדי להתאים למספרים שמוגדרים בפרוטוקול או כדי להוציא ערך משימוש בלי למספר מחדש את השאר:

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
)

כשהמספרים נקבעים על ידי מפרט חיצוני, כמו ב-opcodes האלה של WebSocket, כתיבה מפורשת שלהם (OpClose Opcode = 8) ברורה לעיתים קרובות יותר מספירת מזהים ריקים. iota מיועד לערכים שהמספרים המדויקים שלהם לא משנים לכם.

אף פעם אל תשנו סדר ואל תכניסו ערכים לרשימת iota שהמספרים שלה נשמרים במסד נתונים, בקובץ, או נשלחים ברשת. הוספת שורה באמצע מזיזה כל ערך שאחריה. הוסיפו ערכים חדשים בסוף, או הציבו מספרים במפורש.

הוספת מתודת String

תנו לטיפוס מתודת String() string ו-fmt תשתמש בה עבור %v, %s ו-Println:

שני פרטים במתודה הזאת חשובים:

  • בדיקת הגבולות. בלעדיה, Weekday(9).String() נכנסת ל-panic עם index out of range, וזה יקרה בסופו של דבר, כי שום דבר לא מונע ממי שקורא ליצור Weekday(9).
  • ה-int(d) בתוך Sprintf. עיצוב של d עצמו עם %d תקין, אבל עיצוב שלו עם %v היה קורא שוב ל-String() ונכנס לרקורסיה עד שהמחסנית מתפוצצת.

%d עדיין מדפיס את המספר, כך שמקבלים את שתי הצורות: Wednesday is day 3.

יצירת String עם stringer

לרשימות ארוכות, הכלי stringer כותב את המתודה בשבילכם:

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

הוא יוצר את weekday_string.go עם מימוש קומפקטי של String(), ועוד בדיקה בזמן קומפילציה ששוברת את הבנייה אם הקבועים משתנים בלי לייצר מחדש. ה-flag -linecomment משתמש בהערה שבסוף השורה כשם, מה שנוח לשמות עם רווחים.

ולידציה של ערכים

enum ב-Go אינו סגור. כל ערך של טיפוס הבסיס ניתן להמרה אליו, וקבועים בלי טיפוס מומרים באופן מרומז:

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

לכן בדקו ערכים שמגיעים מחוץ לקוד שלכם (JSON, מסדי נתונים, flags, חבילות אחרות):

ה-sentinel הלא מיוצא colorCount בסוף הבלוק שומר על IsValid נכונה כשמוסיפים צבעים חדשים, כי הוא תמיד נמצא מקום אחד אחרי הערך האמיתי האחרון.

switch על enum

enums נצרכים בדרך כלל על ידי switch. Go לא בודקת ש-switch מכסה כל ערך, ולכן הוסיפו default שמדווח על ההפתעה:

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

ה-linter החיצוני exhaustive (שכלול ב-golangci-lint) מדווח על switches על טיפוסי enum שחסר בהם מקרה, וזה נותן לכם את רוב מה שבדיקת שלמות של enums נותנת בשפות אחרות.

enums של דגלי ביטים

כשערכים משתלבים, כמו הרשאות, השתמשו בביט אחד לכל ערך עם 1 << iota:

| משלב דגלים, & בודק אותם, ו-&^ (אופרטור ה-AND NOT של Go) מנקה אותם. טיפוס בסיס לא מסומן הוא הבחירה הנכונה כאן: uint8 מחזיק 8 דגלים, uint64 מחזיק 64.

enums של מחרוזות

כשהערך נשמר או נשלח כטקסט בכל מקרה, טיפוס שמבוסס על מחרוזת חוסך את שכבת ההמרה:

type Env string

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

הערכים מודפסים ומוסדרים (serialized) בצורה קריאה בלי מתודת String(), ועמודה במסד הנתונים מחזיקה "prod" במקום מספר שתלוי בסדר ההצהרה. המחיר: השוואות הן השוואות מחרוזות, דגלי ביטים בלתי אפשריים, והוולידציה עדיין עליכם, כי גם Env("banana") מתקמפל.

Enums ו-JSON

enum של מספר שלם מוסדר כמספר כברירת מחדל. כדי לקרוא ולכתוב שמות במקום, ממשו את encoding.TextMarshaler ואת encoding.TextUnmarshaler. encoding/json משתמשת בהם עבור ערכים ועבור מפתחות של map:

ל-MarshalText יש receiver מסוג ערך, כך שהיא עובדת גם על Level וגם על *Level; UnmarshalText צריכה receiver מסוג מצביע כי היא משנה את הערך. אותן שתי מתודות גורמות לטיפוס לעבוד עם TextVar של חבילת flag ועם רוב ספריות הקונפיגורציה.

מלכודות

  • המרה מרומזת של ליטרלים. פונקציה שמקבלת Weekday מקבלת גם את הקבוע בלי הטיפוס 42. רק ערכים עם טיפוס אחר נדחים.
  • שכחת הטיפוס בשורה הראשונה. ב-const ( Red = iota; Green; Blue ) שלושתם קבועים שלמים בלי טיפוס, לא ערכי Color, ולכן מתודות של Color לא חלות עליהם. כתבו Red Color = iota כדי שהביטוי החוזר יישא את הטיפוס.
  • שינוי סדר של enums שנשמרים. הכנסת ערך באמצע בלוק iota משנה בשקט את המספרים שכבר נשמרו במקום אחר.
  • רקורסיה ב-String. בתוך String(), אף פעם אל תעצבו את ה-receiver עם %v או %s. המירו קודם לטיפוס הבסיס.

שאלות נפוצות

האם יש enums ב-Go?

לא כתכונה של השפה. אין מילת מפתח enum. התחליף המקובל הוא טיפוס בעל שם יחד עם בלוק של קבועים עם טיפוס, בדרך כלל ממוספרים עם iota:

type Color int

const (
	Red Color = iota
	Green
	Blue
)

הטיפוס נותן לכם חתימות קריאות ומקום לתלות עליו מתודות כמו String(). הוא לא מונע ממישהו לכתוב Color(42), ולכן בדקו ערכים שמגיעים מבחוץ.

מה זה iota ב-Go?

iota הוא מזהה מוצהר מראש ששווה לאינדקס של השורה הנוכחית (הגדרת הקבוע) בתוך בלוק const, החל מ-0. הוא מתאפס ל-0 בכל בלוק const חדש. כששורה משמיטה את הביטוי שלה, Go חוזרת על הביטוי הקודם עם ה-iota הבא, וזה מה שגורם ל-Red = iota; Green; Blue לייצר 0, 1, 2.

איך גורמים ל-iota להתחיל מ-1?

כתבו First Kind = iota + 1 בשורה הראשונה, או דלגו על האפס עם המזהה הריק: _ = iota ואחריו First. הרבה מתכנתי Go מעדיפים לשמור את ה-0 ולקרוא לו Unknown או Invalid, כך שמשתנה שלא אותחל (שערך האפס שלו הוא 0) בבירור אינו בחירה אמיתית.

איך מדפיסים enum כמחרוזת ב-Go?

תנו לטיפוס מתודת String() string. fmt קוראת לה עבור %v, %s ו-Println, כך ש-fmt.Println(Green) מדפיס Green במקום 1. אפשר לכתוב את המתודה ידנית עם switch או מערך, או לייצר אותה עם go run golang.org/x/tools/cmd/stringer@latest -type=Color.

איך ממירים מחרוזת ל-enum ב-Go?

כתבו פונקציית פענוח שמחפשת את המחרוזת, בדרך כלל ב-map[string]Color או ב-switch, ומחזירה שגיאה עבור קלט לא מוכר: func ParseColor(s string) (Color, error). מימוש UnmarshalText עם אותה לוגיקה גורם ל-JSON, ל-flags ולטועני קונפיגורציה להשתמש בה אוטומטית.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל