Pointers vs. Values في Go: الحد الفاصل بين الأداء والأمان
\n\nتخيل موقفًا: أنت تكتب بلغة Go، كل شيء يسير بسلاسة، لكن فجأة تلاحظ أن التطبيق يبدأ في "التباطؤ" عند معالجة كميات كبيرة من البيانات. تفتح أداة التنميط (profiler) وترى أن allocs/op (الذاكرة المخصصة لكل عملية) مرتفعة جدًا. هل هذا مألوف؟ على الأرجح، المشكلة تكمن في كيفية تمرير البيانات إلى الدوال وإرجاعها. سؤال "Pointers vs. values in parameters and return values" هو واحد من أكثر الأسئلة إثارة للجدل على Stack Overflow (آلاف المشاهدات!)، وسنقوم اليوم بتحليله بالعربية، بشكل حيوي ومع أمثلة حقيقية.
\n\nGo هي لغة تعشق البساطة. لكن هذه البساطة خادعة: الاختيار بين تمرير القيمة (value) والمؤشر (pointer) يمكن أن يغير بشكل جذري سرعة تشغيل البرنامج واستهلاكه للذاكرة. دعونا نفهم متى نستخدم كل منهما، بحيث يكون كودك سريعًا وليس بطيئًا.
\n\nلماذا هذا الموضوع مهم جدًا؟
\n\nعلى عكس Java أو Python، حيث كل شيء هو مرجع لكائن، في Go تختار بوضوح: إما تمرير نسخة من الهيكل أو عنوانه. قد يبدو هذا أمرًا بسيطًا. لكنه في الواقع قرار أساسي يؤثر على:
\n\n- \n
- سرعة التنفيذ — نسخ الهياكل الكبيرة يستغرق وقتًا. \n
- استهلاك الذاكرة — النسخ الزائدة = تخصيص ذاكرة زائد. \n
- أمان البيانات — المؤشرات تسمح بتعديل الأصل، القيم لا تسمح بذلك. \n
- التعامل مع الواجهات (interfaces) — الواجهات في Go هي آلية معقدة تعمل بشكل مختلف مع المؤشرات والقيم. \n
دعنا ننتقل مباشرة إلى العمل ونلقي نظرة على الكود.
\n\nمثال رقم 1: هيكل صغير
\n\nلنبدأ بشيء بسيط. لنفترض أن لدينا هيكل Point بإحداثيتين:
type Point struct {\n X, Y float64\n}\n\n// تمرير بالقيمة\nfunc MoveByValue(p Point, dx, dy float64) Point {\n p.X += dx\n p.Y += dy\n return p\n}\n\n// تمرير بمؤشر\nfunc MoveByPointer(p *Point, dx, dy float64) {\n p.X += dx\n p.Y += dy\n}\n\nفي الحالة الأولى، ننشئ نسخة من الهيكل، نعدلها، ونعيد نسخة جديدة. في الحالة الثانية، نعدل الأصل مباشرة. أي الخيارين أسرع؟ لمثل هذا الهيكل الصغير جدًا (16 بايت) — لا يوجد فرق تقريبًا. نسخ 16 بايت هي عملية ذرية، و GC (جامع القمامة) لا يخلق حملًا إضافيًا. بل إن التمرير بالقيمة قد يكون أسرع، لأن المؤشر يتطلب إلغاء مرجعية إضافي (dereference).
\n\nالخلاصة: للهياكل الصغيرة (حتى 4-8 حقول من الأنواع الأساسية)، استخدم القيم بثقة. هذا أكثر أمانًا ولا يسبب أي حمل زائد.
\n\nمثال رقم 2: هيكل كبير
\n\nوالآن، لنتخيل أننا نعمل مع بيانات المستخدم:
\n\ntype User struct {\n ID int64\n Name string\n Email string\n Avatar []byte // صورة بحجم 1 ميجابايت\n CreatedAt time.Time\n UpdatedAt time.Time\n}\n\nهنا، Avatar هو شريحة (slice)، وهي في Go نوع مرجعي (رأس: مؤشر للبيانات، طول، سعة). لكن الهيكل نفسه عند نسخه سينسخ فقط رأس الشريحة (24 بايت)، وليس البايتات نفسها. ومع ذلك، إذا كان الهيكل يحتوي على العديد من الحقول، فإن نسخ 100+ بايت في كل استدعاء دالة يصبح ملحوظًا.
لنقم بتشغيل معيار أداء بسيط:
\n\nfunc BenchmarkPassByValue(b *testing.B) {\n u := User{ID: 1, Name: "John", Avatar: make([]byte, 1024*1024)}\n for i := 0; i < b.N; i++ {\n _ = processUserByValue(u)\n }\n}\n\nfunc BenchmarkPassByPointer(b *testing.B) {\n u := &User{ID: 1, Name: "John", Avatar: make([]byte, 1024*1024)}\n for i := 0; i < b.N; i++ {\n _ = processUserByPointer(u)\n }\n}\n\nالنتيجة (على جهاز حقيقي):
\n- \n
- PassByValue: ~120 ns/op, 0 تخصيصات ذاكرة \n
- PassBy