العودة إلى المدونة

الكلمة المفتاحية struct في Go: الحقول والوسوم والتضمين والدوال التابعة

كيف تعمل الكلمة المفتاحية struct في Go: التصريح عن الحقول، والقيم الصفرية والقيم المركّبة الحرفية، والحقول المُصدَّرة و JSON، ووسوم struct مع omitempty و omitzero، والمستقبِل بالقيمة مقابل المستقبِل بالمؤشر، والتضمين، والمقارنة والنسخ، و struct الفارغة، وترتيب الحقول.

الكلمة المفتاحية struct في Go: الحقول والوسوم والتضمين والدوال التابعة

تجمع الكلمة المفتاحية struct حقولًا مُسمّاة في نوع واحد. والتصريح type Order struct { ... } هو الطريقة التي تصف بها في Go طلبًا أو مستخدمًا أو جسم طلب HTTP أو ملف إعدادات. ليس للـ structs دوال إنشاء (constructors) ولا وراثة. تضيف السلوك بالدوال التابعة (methods)، وتعيد استخدام الكود بتضمين struct داخل أخرى، وتتحكم في الترميز بوسوم struct (struct tags) (مواصفات Go).

الخلاصة

  • الصيغة type Order struct { ... } تصرّح عن نوع struct. لكل حقل اسم ونوع، ويمكن للحقول التي تشترك في النوع نفسه أن تُكتب في سطر واحد.
  • في القيمة الصفرية للـ struct يأخذ كل حقل قيمته الصفرية الخاصة، لذا يصبح var o Order جاهزًا للاستخدام دون أي تهيئة.
  • اكتب القيم المركّبة الحرفية (composite literals) بأسماء الحقول، كما في Order{ID: 1001, Total: 4999}. ويُنبّه go vet على القيم الحرفية التي لا تذكر أسماء الحقول حين يكون نوعها من حزمة أخرى.
  • الحقول المُصدَّرة (التي تبدأ بحرف كبير) وحدها مرئية لـ encoding/json وللحزم الأخرى. أما الحقل الذي يبدأ بحرف صغير فيُتجاهَل دون أي خطأ.
  • تتحكم وسوم struct مثل json:"email,omitempty" في الترميز. استخدم omitzero (Go 1.24) مع time.Time وغيره من الـ structs، لأن omitempty لا يحذفها أبدًا.
  • الدوال التابعة ذات المستقبِل بالمؤشر (pointer receiver) يمكنها تعديل الـ struct. أما ذات المستقبِل بالقيمة (value receiver) فتعمل على نسخة.
  • تضمين نوع ما يُرقّي (promote) حقوله ودواله التابعة إلى الـ struct الخارجية. وبهذا تعيد Go استخدام الكود دون وراثة.
  • لا يمكن مقارنة الـ structs باستخدام == إلا إذا كانت كل حقولها قابلة للمقارنة. وإسناد struct ينسخها، لكن الـ slices والـ maps بداخلها تبقى مشتركة.

كيف تصرّح عن struct في Go؟

اكتب type ثم الاسم ثم struct ثم الحقول بين قوسين معقوفين. كل حقل هو اسم يليه نوع:

example.gogo
package main

import (
	"fmt"
	"time"
)

type Order struct {
	ID         int64
	CustomerID int64
	Status     string
	Total      int64 // cents
	CreatedAt  time.Time
}

type Address struct {
	Street, City, Country string
}

func main() {
	var o Order
	fmt.Printf("%+v\n", o)
	fmt.Println(o.Status == "", o.CreatedAt.IsZero())
}
example.texttext
{ID:0 CustomerID:0 Status: Total:0 CreatedAt:0001-01-01 00:00:00 +0000 UTC}
true true

يوضّح Address الصيغة المختصرة. فالحقول التي لها النوع نفسه يمكن أن تشترك في سطر واحد، مفصولة بفواصل. ومع ذلك تضع معظم قواعد الكود حقلًا واحدًا في كل سطر، لأن هذا يُبقي الفروقات (diffs) صغيرة ويترك مكانًا لوسم أو تعليق.

لا يحتاج var o Order إلى دالة إنشاء. فكل حقل يبدأ بقيمته الصفرية: 0 للأرقام، و "" للسلاسل النصية، و nil للمؤشرات والـ slices والـ maps، وقيمة time.Time الصفرية لـ CreatedAt. ويشرح مقال الكلمة المفتاحية var في Go القيم الصفرية بالتفصيل.

وكأي نوع آخر، يمكن التصريح عن struct داخل دالة حين لا يحتاجها غير تلك الدالة. ويعرض مقال الكلمة المفتاحية type في Go: الأنواع المُعرَّفة والأسماء البديلة والدوال التابعة struct محلية باسم loginRequest تُستخدم لفك ترميز جسم JSON.

كيف تنشئ قيمة struct؟

ستستخدم في الغالب القيمة الصفرية من var، أو قيمة مركّبة حرفية بأسماء الحقول، أو مؤشرًا إلى قيمة حرفية باستخدام &:

example.gogo
keyed := Order{ID: 1001, CustomerID: 42, Status: "paid", Total: 4999}
partial := Order{ID: 1002, Status: "pending"}
positional := Order{1003, 42, "paid", 1250}
p := &Order{ID: 1004}
p.Status = "shipped"
example.texttext
{ID:1001 CustomerID:42 Status:paid Total:4999}
{ID:1002 CustomerID:0 Status:pending Total:0}
{ID:1003 CustomerID:42 Status:paid Total:1250}
{ID:1004 CustomerID:0 Status:shipped Total:0}

القيمة الحرفية ذات الأسماء يمكن أن تذكر أي مجموعة من الحقول بأي ترتيب، وتأخذ بقية الحقول قيمتها الصفرية. أما القيمة الحرفية الموضعية (positional) فيجب أن تذكر كل الحقول بترتيب التصريح عنها، وهذا ما يجعلها هشّة. فإذا أضفت حقل Currency إلى Order توقفت كل قيمة حرفية موضعية عن الترجمة، بينما تستمر القيم ذات الأسماء في العمل.

ومع أنواع struct القادمة من حزم أخرى، يُبلغ go vet عن القيم الحرفية الموضعية عبر المحلّل composites:

example.gogo
addr := net.TCPAddr{net.ParseIP("10.0.0.5"), 5432, ""}
example.texttext
main.go:9:10: net.TCPAddr struct literal uses unkeyed fields

يعمل p.Status على المؤشر دون أن تكتب (*p).Status. فـ Go تتبع المؤشر إلى الـ struct تلقائيًا حين تصل إلى أحد حقولها.

كيف تضبط حقل مؤشر اختياريًا في سطر واحد؟

استخدم new مع قيمة، وهذا يعمل منذ Go 1.26. حقول المؤشرات هي الطريقة المعتادة للتمييز بين «لم يُرسَل» و«أُرسل بقيمة صفرية» في جسم طلب PATCH، وقبل Go 1.26 كنت تحتاج إلى متغير مؤقت أو دالة مساعدة لكل حقل منها:

example.gogo
type UpdateUser struct {
	Name  *string `json:"name,omitempty"`
	Email *string `json:"email,omitempty"`
	Age   *int    `json:"age,omitempty"`
}

func main() {
	patch := UpdateUser{
		Email: new("[email protected]"),
		Age:   new(31),
	}
	out, _ := json.Marshal(patch)
	fmt.Println(string(out))
}
example.texttext
{"email":"[email protected]","age":31}

يحجز new(31) قيمة int ويخزّن فيها 31 ثم يعيد المؤشر إليها (ملاحظات إصدار Go 1.26). ومع go 1.25 في go.mod يرفضه المترجم:

example.texttext
./main.go:16:10: new("[email protected]") requires go1.26 or later (-lang was set to go1.25; check go.mod)

لماذا تختفي حقول struct التي تبدأ بحرف صغير من JSON؟

لأنها غير مُصدَّرة، ولا تستطيع الحزم الأخرى رؤية الحقول غير المُصدَّرة. و encoding/json حزمة أخرى، لذا تتجاهلها دون أي خطأ:

example.gogo
type User struct {
	ID       int64
	Email    string
	password string
	role     string
}

func main() {
	u := User{ID: 7, Email: "[email protected]", password: "hunter2", role: "admin"}
	out, err := json.Marshal(u)
	fmt.Println(string(out), err)

	var in User
	err = json.Unmarshal([]byte(`{"ID":8,"Email":"[email protected]","role":"admin"}`), &in)
	fmt.Printf("%+v %v\n", in, err)
}
example.texttext
{"ID":7,"Email":"[email protected]"} <nil>
{ID:8 Email:[email protected] password: role:} <nil>

يعيد الاستدعاءان كلاهما خطأ nil، ويضيع role الموجود في المدخلات دون أي تنبيه. فإذا أعادت واجهة API مكتوبة بـ Go القيمة {} أو بقي حقل فارغًا بعد فك الترميز، فابحث عن هذا السبب أولًا. والحل أن تجعل الحرف الأول من اسم الحقل كبيرًا، وتستخدم وسمًا للإبقاء على الاسم بحروف صغيرة في JSON.

وتنطبق القاعدة نفسها على كل حزمة تقرأ الحقول عبر الانعكاس (reflection): encoding/xml، وأدوات القراءة في database/sql، و GORM، ومكتبات YAML، وتنفيذ القوالب. ويمكنك أيضًا الاستفادة منها عن قصد. فالحقل غير المُصدَّر مكان آمن للبيانات التي يجب ألا تُرمَّز أبدًا، مثل مقبض قاعدة بيانات أو ذاكرة تخزين مؤقت.

ما وسوم struct في Go؟

وسم struct سلسلة نصية حرفية تأتي بعد نوع الحقل. تتجاهله Go نفسها، وتقرؤه الحزم عبر الانعكاس لتقرر كيف تتعامل مع ذلك الحقل. ووسم JSON هو أكثر ما ستكتبه:

example.gogo
type User struct {
	ID           int64     `json:"id"`
	Email        string    `json:"email"`
	Nickname     string    `json:"nickname,omitempty"`
	PasswordHash string    `json:"-"`
	DeletedAt    time.Time `json:"deleted_at,omitempty"`
	LastLoginAt  time.Time `json:"last_login_at,omitzero"`
}

func main() {
	u := User{ID: 7, Email: "[email protected]", PasswordHash: "$2a$10$..."}
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	enc.Encode(u)
}
example.texttext
{
  "id": 7,
  "email": "[email protected]",
  "deleted_at": "0001-01-01T00:00:00Z"
}

وإليك المخرجات حقلًا بحقل:

  • يغيّر json:"id" اسم الحقل في المخرجات.
  • يحذف omitempty الحقل حين تكون قيمته false أو 0 أو "" أو nil أو slice أو map فارغة. والحقل Nickname فارغ، لذا اختفى.
  • لا يرمّز json:"-" الحقل أبدًا. فيبقى PasswordHash خارج كل استجابة.
  • لا يعمل omitempty مع الـ structs. فالحقل DeletedAt قيمة time.Time صفرية، ومع ذلك يظهر على شكل 0001-01-01T00:00:00Z، وتميل تطبيقات العملاء إلى قراءته على أنه تاريخ حقيقي.
  • يحذف omitzero، الذي أُضيف في Go 1.24، الحقل حين يحمل القيمة الصفرية لنوعه. ومع الأنواع التي لها دالة تابعة IsZero() bool، مثل time.Time، يستدعي تلك الدالة. لذا اختفى LastLoginAt (ملاحظات إصدار Go 1.24).

في الكود الجديد، استخدم omitzero مع حقول struct ومع time.Time. أما omitempty فما زال مناسبًا للسلاسل النصية والأرقام والـ slices والـ maps.

يمكن أن يحمل الحقل وسومًا لعدة حزم، مفصولة بمسافات: `json:"email" db:"email" validate:"required,email"`. وكل حزمة لا تقرأ إلا مفتاحها الخاص باستخدام reflect.StructTag.Get:

example.gogo
f, _ := reflect.TypeFor[User]().FieldByName("Nickname")
fmt.Println(f.Tag.Get("json"))
example.texttext
nickname,omitempty

لماذا يُتجاهَل وسم struct الذي كتبته؟

السبب في الغالب مسافة أو علامة اقتباس ناقصة. فالصيغة صارمة: key:"value"، دون مسافة بعد النقطتين. يبدو json: "email" سليمًا للقارئ، لكن Tag.Get("json") يعيد سلسلة فارغة، فيُرمَّز الحقل باسم Email. ويقبل المترجم أي سلسلة نصية وسمًا، لذا لن يعترض. أما go vet فسيعترض عبر المحلّل structtag، ويكتشف أيضًا حقلين لهما اسم JSON نفسه:

example.gogo
type User struct {
	Email string `json: "email"`
	Name  string `json:"name" db:"name"`
	ID    int64  `json:"id"`
	Ref   int64  `json:"id"`
}
example.texttext
main.go:4:2: struct field tag `json: "email"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
main.go:7:2: struct field Ref repeats json tag "id" also at main.go:6

والمحررات التي تشغّل gopls تعرض التحذيرات نفسها أثناء الكتابة.

كيف تعمل الدوال التابعة مع الـ structs؟

الدالة التابعة دالة لها مستقبِل (receiver) يُكتب قبل اسمها. ويكون المستقبِل إما قيمة (c Cart) أو مؤشرًا (c *Cart)، وهذا الاختيار يحدد هل تستطيع الدالة تعديل الـ struct:

example.gogo
type Cart struct {
	Items []string
	Total int64
}

func (c Cart) AddValue(item string, price int64) {
	c.Items = append(c.Items, item)
	c.Total += price
}

func (c *Cart) Add(item string, price int64) {
	c.Items = append(c.Items, item)
	c.Total += price
}

func main() {
	var c Cart
	c.AddValue("keyboard", 4999)
	fmt.Println(len(c.Items), c.Total)

	c.Add("keyboard", 4999)
	fmt.Println(len(c.Items), c.Total)
}
example.texttext
0 0
1 4999

تُترجم AddValue وتعمل، لكن السلة تبقى فارغة. فالمستقبِل بالقيمة نسخة من الـ struct، لذا تعدّل الدالة النسخة ثم تتخلص منها. ويقع كود Go الجديد في هذا الخطأ كثيرًا، لأنه لا يوجد خطأ أو تحذير يشير إليه. أما Add فتأخذ مؤشرًا وتعدّل سلة المستدعي.

لا تحتاج إلى كتابة (&c).Add(...). فحين يكون c قابلًا لأخذ عنوانه، تأخذ Go العنوان نيابةً عنك. والعكس يعمل أيضًا: يمكن للمؤشر أن يستدعي الدوال التابعة ذات المستقبِل بالقيمة.

استخدم المستقبِل بالمؤشر حين تعدّل الدالة الـ struct، أو حين تكون الـ struct كبيرة، أو حين تحتوي على sync.Mutex أو حقل مشابه يجب ألا يُنسخ. واستخدم المستقبِل بالقيمة للقيم الصغيرة الثابتة مثل Money أو Point. وإذا احتاجت دالة تابعة واحدة على نوع ما إلى مستقبِل بالمؤشر، فاجعل كل دواله التابعة بمستقبِل بالمؤشر. فالخلط بين النوعين يجعل فهم مجموعة الدوال التابعة أصعب، لأن *Cart وحده عندها يملك كل الدوال، و *Cart وحده يحقق الـ interfaces التي تحتاج إليها جميعًا (مواصفات Go).

كيف يعمل تضمين الـ structs في Go؟

الحقل الذي يُكتب بنوعه فقط دون اسم هو حقل مُضمَّن (embedded field). وتُرقّى حقوله ودواله التابعة، أي يمكنك استخدامها كأنها مُصرَّح عنها في الـ struct الخارجية:

example.gogo
type Timestamps struct {
	CreatedAt time.Time
	UpdatedAt time.Time
}

func (t *Timestamps) Touch(now time.Time) {
	if t.CreatedAt.IsZero() {
		t.CreatedAt = now
	}
	t.UpdatedAt = now
}

type Order struct {
	ID int64
	Timestamps
}

type User struct {
	ID    int64
	Email string
	Timestamps
}

func main() {
	now := time.Date(2026, 9, 26, 9, 0, 0, 0, time.UTC)
	var o Order
	o.Touch(now)
	fmt.Println(o.CreatedAt.Format(time.DateOnly), o.Timestamps.UpdatedAt.Format(time.Kitchen))

	u := User{ID: 7, Email: "[email protected]", Timestamps: Timestamps{CreatedAt: now}}
	fmt.Println(u.CreatedAt.Year())
}
example.texttext
2026-09-26 9:00AM
2026

الاستدعاء o.Touch(now) اختصار لـ o.Timestamps.Touch(now). فالحقل المُضمَّن ما زال موجودًا تحت اسم نوعه، وبهذا الاسم أيضًا تضبطه في القيمة الحرفية: Timestamps: Timestamps{...}. وتُسطّح encoding/json الـ structs المُضمَّنة كذلك، لذا يُرمَّز Order أعلاه على شكل {"ID":0,"CreatedAt":"2026-09-26T09:00:00Z","UpdatedAt":"2026-09-26T09:00:00Z"}.

التضمين ليس وراثة. فالنوع Order ليس Timestamps، ولا يمكنك تمرير Order إلى دالة تأخذ Timestamps. وحين تعمل Touch يكون مستقبِلها هو Timestamps الداخلية. ولا سبيل لها إلى الوصول إلى Order المحيطة بها أو استدعاء دوال تابعة يعرّفها Order. أما الصنف الفرعي (subclass) في Java أو Python فيستطيع ذلك، ومن هنا تأتي أسئلة «أي دالة مُعاد تعريفها ستعمل؟». وهذه الأسئلة لا تُطرح في Go.

وحين تصرّح الـ struct الخارجية عن حقل أو دالة تابعة بالاسم نفسه، تكون لها الأولوية، ويبقى الحقل أو الدالة المُضمَّنة متاحًا عبر اسم النوع:

example.gogo
type Base struct{ ID int64 }

func (Base) Describe() string { return "base" }

type Order struct {
	Base
	ID string
}

func (Order) Describe() string { return "order" }

func main() {
	o := Order{Base: Base{ID: 42}, ID: "ord_42"}
	fmt.Println(o.ID, o.Base.ID)
	fmt.Println(o.Describe(), o.Base.Describe())
}
example.texttext
ord_42 42
order base

هل يمكنك تضمين interface داخل struct؟

نعم، وهذه طريقة شائعة لتغليف نوع مع إعادة تعريف دالة تابعة واحدة. فالـ middleware في HTTP الذي يسجّل رمز الحالة يضمّن http.ResponseWriter، فيحصل على Header و Write دون أي جهد، ولا يستبدل إلا WriteHeader:

example.gogo
type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (r *statusRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}

func logStatus(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, req)
		slog.Info("request", "path", req.URL.Path, "status", rec.status)
	})
}
example.texttext
2026/09/26 09:04:44 INFO request path=/orders/99 status=404

ولأن *statusRecorder يملك الدوال التابعة الثلاث كلها، فهو يحقق http.ResponseWriter ويمكن تمريره إلى المعالج التالي. وإذا كانت الـ interface المُضمَّنة nil، فإن استدعاء إحدى دوالها التابعة يسبب panic، لذا اضبطها دائمًا في القيمة الحرفية.

هل ينبغي تضمين sync.Mutex داخل struct؟

ليس في نوع مُصدَّر. فالتضمين يُرقّي Lock و Unlock إلى الواجهة العامة للنوع، فيستطيع أي مستدعٍ أن يقفل ذاكرتك المؤقتة من الخارج:

example.gogo
type Cache struct {
	sync.Mutex
	items map[string]string
}

أما الحقل المُسمّى غير المُصدَّر فيُبقي القفل تفصيلًا داخليًا في التنفيذ:

example.gogo
type SafeCache struct {
	mu    sync.Mutex
	items map[string]string
}

func (c *SafeCache) Get(key string) (string, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	v, ok := c.items[key]
	return v, ok
}

القيمة الصفرية لـ sync.Mutex قفل غير مقفول، لذا لا تحتاج SafeCache إلى أي تهيئة للقفل. لكنها ما زالت تحتاج إلى إنشاء الـ map قبل أول عملية كتابة.

هل يمكنك مقارنة الـ structs ونسخها في Go؟

يمكنك مقارنة struct بأخرى باستخدام == حين تكون كل الحقول قابلة للمقارنة. وتجري المقارنة حقلًا بحقل. وهذا يجعل الـ structs الصغيرة أنواع قيم جيدة ومفاتيح map جيدة:

example.gogo
type Money struct {
	Amount   int64
	Currency string
}

type RouteKey struct {
	Method string
	Path   string
}

func main() {
	fmt.Println(Money{4999, "EUR"} == Money{4999, "EUR"})

	hits := map[RouteKey]int{}
	hits[RouteKey{"GET", "/orders"}]++
	hits[RouteKey{"GET", "/orders"}]++
	hits[RouteKey{"POST", "/orders"}]++
	fmt.Println(hits[RouteKey{"GET", "/orders"}], len(hits))
}
example.texttext
true
2 2

المفتاح المركّب مثل RouteKey أنظف من لصق السلاسل النصية معًا على شكل "GET /orders"، ولا يمكن أن يتصادم مع مفتاح آخر حين يحتوي المسار على مسافة. والقيم الحرفية الموضعية مناسبة هنا، لأن RouteKey مُصرَّح عنه في الحزمة نفسها وله حقلان واضحان.

الـ slices والـ maps والدوال غير قابلة للمقارنة، لذا فالـ struct التي تحتوي على أحدها غير قابلة للمقارنة أيضًا. ويخبرك المترجم بذلك:

example.gogo
type Order struct {
	ID    int64
	Items []string
}

func main() {
	a := Order{ID: 1}
	b := Order{ID: 1}
	fmt.Println(a == b)
}
example.texttext
./main.go:13:14: invalid operation: a == b (struct containing []string cannot be compared)

قارن مثل هذه الـ structs حقلًا بحقل، مع slices.Equal للـ slice، أو باستخدام reflect.DeepEqual في الاختبارات.

هل يؤدي إسناد struct إلى نسخها؟

نعم، لكن النسخة سطحية. فكل حقل يُنسخ، والحقل من نوع slice أو map يُنسخ على شكل ترويسة (header) ما زالت تشير إلى البيانات نفسها:

example.gogo
type Order struct {
	ID    int64
	Items []string
	Meta  map[string]string
}

func main() {
	original := Order{ID: 1, Items: []string{"keyboard"}, Meta: map[string]string{"source": "web"}}
	copied := original
	copied.ID = 2
	copied.Items[0] = "mouse"
	copied.Meta["source"] = "api"
	fmt.Println(original.ID, original.Items, original.Meta)
}
example.texttext
1 [mouse] map[source:api]

تغيير copied.ID لم يمسّ الأصل. أما تغيير عنصر في copied.Items أو مفتاح في copied.Meta فقد غيّر الأصل أيضًا. وللحصول على نسخة مستقلة، انسخ هذه الحقول بنفسك باستخدام slices.Clone و maps.Clone.

ويحدث النسخ نفسه حين تمرّر struct بالقيمة إلى دالة. ومع struct تحتوي على sync.Mutex يكون هذا النسخ خطأً برمجيًا، لأن النسخة تحصل على قفل خاص بها. ويُبلغ go vet عن ذلك عبر المحلّل copylocks:

example.gogo
type Counter struct {
	mu   sync.Mutex
	hits int
}

func report(c Counter) {
	fmt.Println(c.hits)
}
example.texttext
main.go:13:15: report passes lock by value: shop.Counter contains sync.Mutex
main.go:19:9: call of report copies lock value: shop.Counter contains sync.Mutex

والحل هو func report(c *Counter).

ما الـ struct المجهولة (anonymous struct) في Go؟

هي نوع struct يُكتب في مكانه دون اسم. تستخدمها حين تحتاج إلى شكل ما مرة واحدة. وأكثر مكانين ستراها فيهما هما الاختبارات المبنية على الجداول (table-driven tests) وفك ترميز جزء من استجابة JSON:

example.gogo
var resp struct {
	Data struct {
		Status string `json:"status"`
	} `json:"data"`
}
body := `{"data":{"status":"shipped","carrier":"DHL"},"meta":{"request_id":"abc"}}`
if err := json.Unmarshal([]byte(body), &resp); err != nil {
	panic(err)
}
fmt.Println(resp.Data.Status)

tests := []struct {
	name  string
	input string
	want  string
}{
	{"trims spaces", "  [email protected] ", "[email protected]"},
	{"already clean", "[email protected]", "[email protected]"},
}
for _, tt := range tests {
	got := strings.ToLower(strings.TrimSpace(tt.input))
	fmt.Println(tt.name, got == tt.want)
}
example.texttext
shipped
trims spaces true
already clean true

لا يملأ فك الترميز إلا الحقول التي تصرّح عنها الـ struct، لذا يُتجاهَل carrier و meta. ولا تحتاج إلى نوع مُسمّى لاستجابة لا تقرأ منها إلا حقلًا واحدًا. وحين يظهر الشكل نفسه في مكانين، أعطه اسمًا.

ما استخدام struct{}؟

النوع struct{} هو struct بلا حقول. حجمها صفر بايت، لذا فهي نوع القيمة المفضّل حين لا يهم إلا وجود الشيء. والحالتان الشائعتان هما مجموعة (set) مبنية على map، وقناة (channel) تُستخدم للإشارة فقط:

example.gogo
seen := map[string]struct{}{}
for _, id := range []string{"evt_1", "evt_2", "evt_1"} {
	if _, dup := seen[id]; dup {
		fmt.Println("skipping duplicate", id)
		continue
	}
	seen[id] = struct{}{}
}
fmt.Println(len(seen), unsafe.Sizeof(struct{}{}))

done := make(chan struct{})
go func() {
	defer close(done)
}()
<-done
fmt.Println("worker finished")
example.texttext
skipping duplicate evt_1
2 0
worker finished

يقول map[string]struct{} «مجموعة من السلاسل النصية» بوضوح أكبر من map[string]bool، التي يضطر القارئ معها إلى السؤال عن معنى false. ويقول chan struct{} إنه لا توجد بيانات تنتقل عبر القناة، وإنما مجرد حقيقة أنها أُغلقت. وللسبب نفسه تعيد ctx.Done() القيمة <-chan struct{}.

هل يغيّر ترتيب الحقول حجم الـ struct؟

نعم. فكل حقل يُحاذى (aligned) بحسب حجمه، لذا يُدخل المترجم حشوًا (padding) بين حقل صغير وحقل أكبر يليه. والحقول الخمسة نفسها تشغل ضعف الذاكرة إذا رُتّبت ترتيبًا سيئًا على جهاز 64 بت:

example.gogo
type EventLoose struct {
	Active   bool
	ID       int64
	Retried  bool
	Attempts int32
	Urgent   bool
}

type EventPacked struct {
	ID       int64
	Attempts int32
	Active   bool
	Retried  bool
	Urgent   bool
}

func main() {
	fmt.Println(unsafe.Sizeof(EventLoose{}), unsafe.Sizeof(EventPacked{}))
}
example.texttext
32 16

يضيف EventLoose حشوًا من 7 بايتات بعد Active كي يبدأ ID عند حدّ من 8 بايتات، ثم حشوًا إضافيًا بعد Retried و Urgent. أما EventPacked فيرتّب الحقول من الأكبر إلى الأصغر ولا يهدر إلا بايتًا واحدًا في النهاية.

في معظم الـ structs لا يهم هذا، وتجميع الحقول بحسب معناها أسهل في القراءة. لكنه يبدأ في الأهمية حين تحتفظ بملايين القيم في slice أو في ذاكرة تخزين مؤقت. ويُبلغ المحلّل fieldalignment في golang.org/x/tools عن الـ structs التي يمكن أن تكون أصغر.

الـ struct في Go مقابل الصنف (class): ما الفرق؟

تحمل الـ struct في Go البيانات ويمكن أن تكون لها دوال تابعة، وهذا يغطي معظم ما يفعله الصنف في Java أو C# أو Python. وما عدا ذلك مختلف عن قصد:

الـ struct في Goالصنف (Java و C# و Python)
دالة الإنشاءلا توجد. القيمة الصفرية، أو دالة NewX بحكم العُرفدالة إنشاء خاصة
إعادة الاستخدامالتضمين (التركيب)الوراثة، إضافةً إلى التركيب
تعدد الأشكالالـ interfaces، وتتحقق ضمنيًاالأصناف الأساسية والـ interfaces المُصرَّح عنها
الظهورعلى مستوى الحزمة، بحسب حالة الحرف الأولpublic و private و protected لكل عضو
الدوال التابعةيُصرَّح عنها خارج النوع، في أي مكان من الحزمةيُصرَّح عنها داخل جسم الصنف
دلالة النسخبالقيمة افتراضيًا، و & للحصول على مؤشربالمرجع افتراضيًا

حين تحتاج struct إلى تحقق من صحة القيم أو إلى قيم افتراضية، اكتب دالة عادية تعيدها. والعُرف أن تسمّيها NewX، ومن الطبيعي أن تعيد معها خطأً:

example.gogo
type Client struct {
	baseURL string
	http    *http.Client
	retries int
}

func NewClient(baseURL string) (*Client, error) {
	if baseURL == "" {
		return nil, errors.New("baseURL is required")
	}
	return &Client{
		baseURL: baseURL,
		http:    &http.Client{Timeout: 10 * time.Second},
		retries: 3,
	}, nil
}

الحقول غير المُصدَّرة تعني أن المستدعين خارج الحزمة لا يستطيعون الحصول على Client إلا عبر NewClient، مع تطبيق القيم الافتراضية. ولأن Go لا تعرف super ولا إعادة تعريف الدوال، فلن تضطر إلى صعود شجرة أصناف لتعرف ما تفعله دالة ما. فسلوك الـ struct هو دوالها التابعة الخاصة إضافةً إلى الدوال التابعة التي تضمّنها، ويمكنك قراءتها كلها في حزمة واحدة. وهذا يساعد كثيرًا حين تكبر قاعدة كود مكتوبة بـ Go.

أين يأتي دور LevelUpGo

يعلّم LevelUpGo لغة Go عبر تمارين تشغّل كود Go حقيقيًا في المتصفح. تبني دورة Composite Types أول struct لك ودوال الإنشاء (constructor functions) والدوال التابعة والتضمين خطوة بخطوة، ثم تستخدمها في مشروع صغير. وتغطي دورة Pointers & Memory المؤشرات إلى الـ structs و new والمستقبِل بالقيمة مقابل المستقبِل بالمؤشر، وتنتهي بمخزن لمفاتيح الميزات (feature flags) يعدّل الـ structs في مكانها. وتوضّح دورة Interfaces & Polymorphism كيف تحقق الـ structs الـ interfaces دون التصريح بذلك. ويقدّم Training Ground تمارين قصيرة مستقلة للتدرّب خارج أي دورة. وللتعرّف على الكلمات المحجوزة الـ 24 الأخرى، راجع الكلمات المفتاحية في Go: شرح جميع الكلمات الـ 25.

الأسئلة الشائعة

هل struct كلمة مفتاحية في Go؟

نعم. struct واحدة من الكلمات المفتاحية المحجوزة الـ 25 في Go، لذا لا يمكنك استخدامها اسمًا لمتغير أو دالة. وهي تبدأ نوع struct، يُسمّى في أغلب الأحوال باستخدام type، كما في type Order struct { ... }. وتظهر أيضًا في الـ structs المجهولة وفي الـ struct الفارغة struct{}.

هل في Go أصناف (classes)؟

لا. في Go بدلًا منها structs مع دوال تابعة، و interfaces، وتضمين. تحمل الـ struct البيانات، وتضيف الدوال التابعة السلوك، وتوفّر الـ interfaces تعدد الأشكال، ويعيد التضمين استخدام الكود عبر التركيب. لا توجد وراثة ولا كلمة مفتاحية لدالة الإنشاء. وتؤدي دالة NewX هذا الدور بحكم العُرف.

هل أستخدم المستقبِل بالقيمة أم المستقبِل بالمؤشر؟

استخدم المستقبِل بالمؤشر إذا كانت الدالة تعدّل الـ struct، أو إذا كانت الـ struct كبيرة، أو إذا كانت تحتوي على sync.Mutex. واستخدم المستقبِل بالقيمة للقيم الصغيرة التي لا تتغير أبدًا، مثل Money أو Point. وإذا احتاجت دالة تابعة واحدة على نوع ما إلى مستقبِل بالمؤشر، فاجعل كل الدوال التابعة بمستقبِل بالمؤشر كي يتصرف النوع بشكل متّسق.

لماذا يعيد json.Marshal كائنًا فارغًا للـ struct الخاصة بي؟

لأن حقول الـ struct غير مُصدَّرة. فالحزمة encoding/json لا ترى إلا الحقول التي تبدأ بحرف كبير، وتتجاهل غيرها دون أي خطأ. غيّر اسم email إلى Email وأضف `json:"email"` كي يبقى الاسم بحروف صغيرة في JSON.

ما الفرق بين omitempty و omitzero؟

يحذف omitempty القيم false و 0 و "" و nil والـ slices والـ maps الفارغة، لكنه لا يحذف struct أبدًا، لذا تظهر قيمة time.Time الصفرية على شكل 0001-01-01T00:00:00Z. أما omitzero، الذي أُضيف في Go 1.24، فيحذف أي حقل يحمل القيمة الصفرية لنوعه، ويستخدم الدالة التابعة IsZero الخاصة بالنوع إن وُجدت.

المصادر

اكتب Go كما يكتبها مهندس أول

دروس تفاعلية في متصفحك. الدروس الأولى مجانية.

جرّب درسًا مجانيًاأو أنشئ حسابًا مجانيًا