ما هو المُكرِّر فعليًا
كل حاوية قياسية - vector وstring وmap وset وlist - تخزّن عناصرها داخليًا بطريقة مختلفة. فالـ vector كتلة متجاورة، والـ map شجرة متوازنة، والـ list عُقَد مترابطة. ومع ذلك يمكنك المرور عليها كلها بالطريقة نفسها. والذي يجعل ذلك ممكنًا هو المُكرِّر: كائن صغير "يشير إلى" عنصر واحد ويعرف كيف يخطو إلى التالي.
تخيَّل المُكرِّر بوصفه مؤشرًا معمَّمًا. تحصل على واحد من begin()، وتقرأ العنصر الذي يشير إليه بـ *، وتُحرِّكه إلى الأمام بـ ++. تتلاءم القطع هكذا:
تُعيد v.begin() مُكرِّرًا إلى العنصر الأول؛ و*it تعطيك ذلك العنصر؛ و++it تنتقل إلى التالي. هذا الثلاثي - فكّ الإسناد، والتقدّم، والمقارنة - هو النموذج الذهني بأكمله.
begin() وend() والمجال نصف المفتوح
النصف الآخر من الصورة هو end(). والأمر الحاسم: end() لا يشير إلى العنصر الأخير، بل يشير إلى الموضع الذي يلي العنصر الأخير بموضع واحد. وهذا مجال "نصف مفتوح" مقصود [begin, end): فـ begin مُضمَّن، وend إشارة التوقف.
هذا التصميم يجعل الحلقة القياسية نظيفة - تمشي حتى يساوي المُكرِّر end():
لاحظ it != v.end()، وليس it < v.end(). فمعظم مُكرِّرات الحاويات (مثل map أو list) لا تدعم <، بل تدعم == و!= فقط، لذا فإن != هو الخيار القابل للنقل. كما أن auto يوفّر عليك كتابة vector<int>::iterator يدويًا - فالمُترجِم يستنتجه.
تنحلّ حالة الحاوية الفارغة بطبيعتها: عندما تكون الحاوية فارغة يكون begin() == end()، فلا يُنفَّذ جسم الحلقة أبدًا. لا حاجة إلى أي معالجة خاصة.
لا تفُكَّ إسناد end() أبدًا
أكثر أخطاء المُكرِّرات شيوعًا هو فكّ إسناد end(). وبما أنه يشير إلى موضع واحد بعد العنصر الأخير، فإن *v.end() يقرأ ذاكرة ليست ملكًا لك - سلوك غير مُعرَّف، أي انهيار أو قمامة صامتة، لا رسالة خطأ ودودة:
vector<int> v = {1, 2, 3};
cout << *v.end(); // سلوك غير معرَّف - end() ليس عنصرًا
والفخ نفسه يصيب دوال البحث. تُعيد std::find القيمة end() عندما لا تجد القيمة، لذا يجب أن تتحقق قبل فكّ الإسناد:
قارن دائمًا المُكرِّر المُعاد بـ end() قبل أن تفُكَّ إسناده. ونسيان جملة if هذه من أكثر مصادر الانهيار شيوعًا في شيفرة الـ STL لدى المبتدئين.
const وcbegin والمُكرِّرات العكسية (reverse)
تمنحك الحاويات نكهات مختلفة من المُكرِّرات بحسب ما تحتاج إليه:
begin()/end()- مُكرِّرات عادية للقراءة/الكتابة (*it = ...يعمل).cbegin()/cend()- مُكرِّراتconst_iterator؛ يمكنك القراءة من خلالها لكن لا يمكنك تعديل العنصر.rbegin()/rend()- مُكرِّرات عكسية تمشي من النهاية نحو البداية؛ و++يتحرّك فعليًا في الاتجاه المعاكس.
المُكرِّرات العكسية هي الطريقة النظيفة للمرور بالاتجاه المعاكس دون حسابات فهرسة معقّدة:
مع المُكرِّرات العكسية ما زلت تكتب ++it للتقدّم - فالمُكرِّر يتولّى اتجاه "الرجوع" داخليًا. استخدم cbegin()/cend() (أو مرجعًا const إلى الحاوية) عندما يُفترض ألا تقرأ الحلقة سوى القراءة، حتى يمنعك المُترجِم من الكتابة عن طريق الخطأ.
مُكرِّرات map تعطي pairs
ليس كل مُكرِّر غلافًا رقيقًا حول مؤشر. فمُكرِّر std::map يمشي عبر شجرة، وفكّ إسناده يعطيك std::pair من المفتاح والقيمة، يُوصَل إليهما عبر ->first و->second (تمامًا كالمؤشر، يدعم المُكرِّر ->):
حلقة for المعتمِدة على المجال مبنية مباشرة على begin()/end()، لذا فإنك في التكرار الأمامي البسيط ستلجأ إليها عادةً. أما المُكرِّرات الصريحة فتُثبت جدارتها حين تحتاج إلى مرور عكسي، أو إلى موضع عنصر، أو إلى تمرير مجال إلى خوارزمية.
الفخ الكبير: إبطال المُكرِّرات
هذا هو المزلق الذي يقع فيه الجميع عاجلًا أم آجلًا. عندما تغيّر بنية حاوية، قد تصبح المُكرِّرات الموجودة مُبطَلة - تشير إلى ذاكرة حُرِّرت أو نُقِلت. واستخدام أحدها سلوك غير مُعرَّف.
بالنسبة إلى vector، قد يُعيد push_back تخصيص المخزن المؤقت بأكمله ليُكبِّره، مُبطِلًا كل مُكرِّر قائم. أما الحذف أثناء المرور فأشدّ شهرةً - وهذا انهيار كلاسيكي:
vector<int> v = {1, 2, 3, 4};
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it % 2 == 0)
v.erase(it); // خطأ - erase يُبطِل it، ثم ++it سلوك غير معرَّف
}
الحل أن erase يُعيد مُكرِّرًا صالحًا إلى العنصر الذي يلي المحذوف. تقدَّم فقط حين لا تحذف:
لاحظ أن ترويسة for لا تتضمّن ++it - فالجسم هو من يقرّر متى يتقدّم. (في الشيفرة الواقعية، يُنجِز اصطلاح erase-remove أو std::erase_if في C++20 هذا في سطر واحد.) والقاعدة التي ينبغي تذكّرها: أي عملية تضيف عناصر أو تزيلها قد تُبطِل المُكرِّرات، فلا تتمسّك بمُكرِّر قديم عبر مثل هذا التغيير.
التالي: الخوارزميات
الآن وقد صرت قادرًا على وصف المجال بوصفه زوجًا من begin/end، فقد فتحت مكتبة خوارزميات الـ STL بأكملها. فدوال مثل sort وfind وcount وaccumulate لا تأبه بنوع الحاوية التي بين يديك - فهي تعمل على مجالات المُكرِّرات، بحيث يعمل الاستدعاء نفسه على vector، أو مصفوفة، أو شريحة من إحداها. وفيما يلي سنُشغِّل هذه المُكرِّرات وندَع المكتبة القياسية تتولّى التكرار نيابةً عنك.
الأسئلة الشائعة
ما هو المُكرِّر في C++؟
المُكرِّر كائن يشير إلى عنصر داخل حاوية ويعرف كيف ينتقل إلى التالي. تحصل على الأول عبر container.begin()، وعلى علامة الموضع الذي يلي الأخير عبر container.end(). فُكَّ إسناده بـ *it لقراءة العنصر أو الكتابة فيه، وقدِّمه بـ ++it. المُكرِّرات هي الواجهة المشتركة التي تتيح لخوارزميات الـ STL أن تعمل مع أي حاوية.
ما الفرق بين المُكرِّر والمؤشر في C++؟
بالنسبة إلى vector أو المصفوفة، يتصرف المُكرِّر تقريبًا كالمؤشر تمامًا - تفُكّ الإسناد بـ *، وتُقدِّم بـ ++، وتقارن بـ ==/!=. لكن المُكرِّر مفهوم، وليس بالضرورة مؤشرًا خامًا: مُكرِّر map أو list يمشي عبر شجرة أو عُقَد مترابطة، فهو نوع صنف (class) يُعيد تعريف * و++. المؤشرات نوع واحد من المُكرِّرات؛ والمُكرِّرات تعمِّم الفكرة لتشمل كل حاوية.
ما الذي يسبّب إبطال المُكرِّرات في C++؟
تعديل بنية الحاوية قد يترك المُكرِّرات الموجودة تشير إلى ذاكرة محرَّرة أو منقولة. بالنسبة إلى vector، قد يُعيد push_back التخصيص ويُبطِل كل المُكرِّرات؛ بينما يُبطِل erase المُكرِّرات عند العنصر المحذوف وما بعده. استخدام مُكرِّر مُبطَل سلوك غير مُعرَّف. للبقاء آمنًا، استخدم المُكرِّر الذي يُعيده erase، أو احجز السعة مسبقًا.