بحثًا عن الإلهام ، وكيفية تجديد محفظة دار النشر حول موضوع C ++ ، صادفنا مدونة Arthur O'Dwyer ، الذي كتب بالفعل كتابًا واحدًا عن C ++ ، والذي ظهر من العدم . تدوينة اليوم حول كود نظيف . نأمل أن تكون القضية نفسها والمؤلف محل اهتمامك.
كلما تعاملت مع الكود الكلاسيكي متعدد الأشكال ، زاد تقديري للتعابير "الحديثة" التي نمت أكثر منها - لغة الواجهة غير الافتراضية ، ومبدأ استبدال Liskov ، وقاعدة سكوت مايرز التي تقضي بأن تكون جميع الفئات إما مجردة أو نهائية ، وهي القاعدة التي يجب أن يكون التسلسل الهرمي من مستويين تمامًا ، والقاعدة التي تنص على أن الفئات الأساسية تعبر عن وحدة الواجهة ، ولا تعيد استخدام التطبيق.
أريد اليوم أن أعرض عليكم مقتطفًا من التعليمات البرمجية يوضح كيف أدت "وراثة التنفيذ" إلى حدوث مشكلات وما هي الأنماط التي استخدمتها للكشف عنها. لسوء الحظ ، كان الرمز الذي أزعجني غير مقروء لدرجة أنني قررت هنا إظهاره في شكل مبسط ومخصص قليلاً للمجال. سأحاول تحديد المشكلة برمتها على مراحل.
المرحلة 1: المعاملات
لنفترض أن مجال موضوعنا هو "المعاملات المصرفية". لدينا واجهة هرمية كلاسيكية متعددة الأشكال.
class Txn { ... };
class DepositTxn : public Txn { ... };
class WithdrawalTxn : public Txn { ... };
class TransferTxn : public Txn { ... };
تحتوي مجموعة متنوعة من المعاملات على واجهات برمجة تطبيقات مشتركة معينة ، ولكل نوع من المعاملات أيضًا واجهة برمجة تطبيقات خاصة به.
class Txn {
public:
AccountNumber account() const;
std::string name_on_account() const;
Money amount() const;
private:
//
};
class DepositTxn : public Txn {
public:
std::string name_of_customer() const;
};
class TransferTxn : public Txn {
public:
AccountNumber source_account() const;
};
المرحلة 2: مرشحات المعاملات
لكن في الواقع ، لا ينفذ برنامجنا المعاملات ، بل يتتبعها من أجل الإبلاغ عن المعاملات المشبوهة. يمكن للمشغل البشري تعيين عوامل التصفية التي تفي بمعايير معينة ، مثل "وضع علامة على جميع المعاملات التي تزيد قيمتها عن 10000 دولار" أو "وضع علامة على جميع المعاملات التي تم إجراؤها نيابة عن الأشخاص الموجودين في قائمة التحقق W". داخليًا ، نحن نمثل الأنواع المختلفة من المرشحات القابلة للتكوين بواسطة المشغل كتسلسل هرمي كلاسيكي متعدد الأشكال.
class Filter { ... };
class AmountGtFilter : public Filter { ... };
class NameWatchlistFilter : public Filter { ... };
class AccountWatchlistFilter : public Filter { ... };
class DifferentCustomerFilter : public Filter { ... };
class AndFilter : public Filter { ... };
class OrFilter : public Filter { ... };
API.
class Filter {
public:
bool matches(const Txn& txn) const {
return do_matches(txn);
}
private:
virtual bool do_matches(const Txn&) const = 0;
};
فيما يلي مثال على مرشح بسيط:
class AmountGtFilter : public Filter {
public:
explicit AmountGtFilter(Money x) : amount_(x) { }
private:
bool do_matches(const Txn& txn) const override {
return txn.amount() > amount_;
}
Money amount_;
};
المرحلة الثالثة: التعثر لأول مرة
اتضح أن بعض المرشحات تحاول بالفعل الوصول إلى واجهات برمجة التطبيقات تلك الخاصة بمعاملات محددة - تمت مناقشة واجهات برمجة التطبيقات هذه أعلاه. لنفترض أنها
DifferentCustomerFilterتحاول وضع علامة على أي معاملة يختلف فيها اسم منفذها عن الاسم المحدد في الفاتورة. على سبيل المثال ، لنفترض أن البنك منظم بشكل صارم: فقط مالك هذا الحساب يمكنه سحب الأموال من الحساب. لذلك ، لا DepositTxnيهتم سوى الفصل بتسجيل اسم العميل الذي أجرى المعاملة.
class DifferentCustomerFilter : public Filter {
bool do_matches(const Txn& txn) const override {
if (auto *dtxn = dynamic_cast<const DepositTxn*>(&txn)) {
return dtxn->name_of_customer() != dtxn->name_on_account();
} else {
return false;
}
}
};
هذا هو إساءة استخدام الكلاسيكية من dynamic_cast! (كلاسيكي - لأنه موجود في كل وقت). لإصلاح هذا الرمز ، يمكن للمرء محاولة تطبيق الطريقة من " زيارة كلاسيكية متعددة الأشكال " (2020-09-29) ، ولكن ، للأسف ، لم تتم ملاحظة أي تحسينات:
class DifferentCustomerFilter : public Filter {
bool do_matches(const Txn& txn) const override {
my::visit<DepositTxn>(txn, [](const auto& dtxn) {
return dtxn.name_of_customer() != dtxn.name_on_account();
}, [](const auto&) {
return false;
});
}
};
لذلك تعرض مؤلف الكود (السخرية!) بفكرة. دعنا نطبق حساسية حالة الأحرف في بعض المرشحات. دعونا نعيد كتابة الفئة الأساسية
Filterمثل هذا:
class Filter {
public:
bool matches(const Txn& txn) const {
return my::visit<DepositTxn, WithdrawalTxn, TransferTxn>(txn, [](const auto& txn) {
return do_generic(txn) && do_casewise(txn);
});
}
private:
virtual bool do_generic(const Txn&) const { return true; }
virtual bool do_casewise(const DepositTxn&) const { return true; }
virtual bool do_casewise(const WithdrawalTxn&) const { return true; }
virtual bool do_casewise(const TransferTxn&) const { return true; }
};
class LargeAmountFilter : public Filter {
bool do_generic(const Txn& txn) const override {
return txn.amount() > Money::from_dollars(10'000);
}
};
class DifferentCustomerFilter : public Filter {
bool do_casewise(const DepositTxn& dtxn) const override {
return dtxn.name_of_customer() != dtxn.name_on_account();
}
bool do_casewise(const WithdrawalTxn&) const override { return false; }
bool do_casewise(const TransferTxn&) const override { return false; }
};
يقلل هذا الأسلوب الذكي من مقدار التعليمات البرمجية التي تحتاج إلى كتابتها
DifferentCustomerFilter. لكننا ننتهك أحد مبادئ OOP ، ألا وهو حظر الإرث للتنفيذ. الوظيفة Filter::do_generic(const Txn&)ليست نقية ولا نهائية. سيعود هذا ليطاردنا.
الخطوة 4: تعثرت للمرة الثانية
الآن ، دعونا نجعل مرشحًا يتحقق مما إذا كان صاحب الحساب مدرجًا في أي قائمة سوداء. نريد اختبار العديد من هذه القوائم.
class NameWatchlistFilter : public Filter {
protected:
bool is_flagged(std::string_view name) const {
for (const auto& list : watchlists_) {
if (std::find(list.begin(), list.end(), name) != list.end()) {
return true;
}
}
return false;
}
private:
bool do_generic(const Txn& txn) const override {
return is_flagged(txn.name_on_account());
}
std::vector<std::list<std::string>> watchlists_;
};
أوه ، نحتاج إلى عمل مرشح آخر يرسم شبكة أوسع - سيتحقق من كل من صاحب الحساب واسم المستخدم. مرة أخرى ، مؤلف الكود الأصلي لديه فكرة ذكية (سخرية!). لماذا المنطق المكرر
is_flagged، دعونا نرثه بشكل أفضل. الميراث هو مجرد إعادة استخدام للكود ، أليس كذلك؟
class WideNetFilter : public NameWatchlistFilter {
bool do_generic(const Txn& txn) const override {
return true;
}
bool do_casewise(const DepositTxn& txn) const override {
return is_flagged(txn.name_on_account()) || is_flagged(txn.name_of_customer());
}
bool do_casewise(const WithdrawalTxn& txn) const override {
return is_flagged(txn.name_on_account());
}
bool do_casewise(const TransferTxn& txn) const override {
return is_flagged(txn.name_on_account());
}
};
لاحظ كيف أن العمارة الناتجة مشوشة بشكل رهيب.
NameWatchlistFilterيلغي do_genericفقط التحقق من صحة الاسم الأخير لصاحب الحساب ، ثم WideNetFilterيتخطاه مرة أخرى إلى طريقة العرض الأصلية. (إذا WideNetFilterلم أقم بإعادة تعريفه مرة أخرى ، WideNetFilterلكان قد تم تشغيله بشكل غير صحيح لأي معاملة إيداع حيث name_on_account()لم يتم وضع علامة عليها ، ولكن تم name_of_customer()تمييزها.) هذا أمر محير ، لكنه يعمل ... في الوقت الحالي.
المرحلة الخامسة: سلسلة من الأحداث غير السارة
لقد دفعنا هذا الدين الفني في مثل هذا الاتجاه غير المتوقع ، حيث احتجنا إلى إضافة نوع جديد من المعاملات. دعونا ننشئ فئة لتمثيل العمولات ومدفوعات الفائدة التي بدأها البنك نفسه.
class FeeTxn : public Txn { ... };
class Filter {
public:
bool matches(const Txn& txn) const {
return my::visit<DepositTxn, WithdrawalTxn, TransferTxn, FeeTxn>(txn, [](const auto& txn) {
return do_generic(txn) && do_casewise(txn);
});
}
private:
virtual bool do_generic(const Txn&) const { return true; }
virtual bool do_casewise(const DepositTxn&) const { return true; }
virtual bool do_casewise(const WithdrawalTxn&) const { return true; }
virtual bool do_casewise(const TransferTxn&) const { return true; }
virtual bool do_casewise(const FeeTxn&) const { return true; }
};
أهم خطوة: لقد نسينا التحديث
WideNetFilterوإضافة تجاوز لـ WideNetFilter::do_casewise(const FeeTxn&) const. في مثال "اللعبة" هذا ، قد يبدو مثل هذا الخطأ على الفور أمرًا لا يُغتفر ، ولكن في الكود الحقيقي ، حيث لا تتم ملاحظة الغيرة من معيد تعريف إلى عشرات سطور التعليمات البرمجية ، وتعبير واجهة غير افتراضية - أعتقد أنه لن يكون من الصعب العثور على خطأ class WideNetFilter : public NameWatchlistFilterيتجاوز مثل do_genericهذا و do_casewise، والتفكير، "أوه، شيء من التشويش هنا. سأعود إلى هذا لاحقًا "(ولن أعود إلى هذا أبدًا).
على أي حال ، آمل أن تكون مقتنعًا بالفعل أنه بحلول هذه المرحلة لدينا وحش. كيف نتخلص منه الآن؟
إعادة الهيكلة والتخلص من إرث التنفيذ. الخطوة 1
للتخلص من تنفيذ الميراث في
Filter::do_casewise، دعنا نطبق افتراض سكوت مايرز بأن أي طريقة افتراضية يجب أن تكون إما خالصة أو نهائية (منطقيًا). في هذه الحالة ، هذا حل وسط ، لأننا هنا ننتهك القاعدة القائلة بأن التسلسلات الهرمية يجب أن تكون ضحلة. نقدم فئة متوسطة:
class Filter {
public:
bool matches(const Txn& txn) const {
return do_generic(txn);
}
private:
virtual bool do_generic(const Txn&) const = 0;
};
class CasewiseFilter : public Filter {
bool do_generic(const Txn&) const final {
return my::visit<DepositTxn, WithdrawalTxn, TransferTxn>(txn, [](const auto& txn) {
return do_casewise(txn);
});
}
virtual bool do_casewise(const DepositTxn&) const = 0;
virtual bool do_casewise(const WithdrawalTxn&) const = 0;
virtual bool do_casewise(const TransferTxn&) const = 0;
};
يمكن الآن ببساطة أن ترث عوامل التصفية التي توفر معالجة حساسة لحالة الأحرف لكل معاملة ممكنة
CasewiseFilter. لا تزال المرشحات التي تكون تطبيقاتها عامة ترث مباشرة من Filter.
class LargeAmountFilter : public Filter {
bool do_generic(const Txn& txn) const override {
return txn.amount() > Money::from_dollars(10'000);
}
};
class DifferentCustomerFilter : public CasewiseFilter {
bool do_casewise(const DepositTxn& dtxn) const override {
return dtxn.name_of_customer() != dtxn.name_on_account();
}
bool do_casewise(const WithdrawalTxn&) const override { return false; }
bool do_casewise(const TransferTxn&) const override { return false; }
};
إذا أضاف شخص ما نوع معاملة جديدًا وتغييرات
CasewiseFilterلإدراج حمل زائد جديد do_casewise، فسنرى أننا DifferentCustomerFilterأصبحنا فئة مجردة: علينا التعامل مع معاملة من نوع جديد. يساعد المترجم الآن على الامتثال لقواعد هندستنا ، حيث اعتاد على إخفاء أخطائنا بهدوء.
لاحظ أيضًا أنه من المستحيل الآن تحديد
WideNetFilterالمصطلحات NameWatchlistFilter.
إعادة الهيكلة والتخلص من إرث التنفيذ. الخطوة 2
للتخلص من إرث التنفيذ في
WideNetFilter، ينطبق مبدأ المسؤولية الفردية . في الوقت الحالي ، يقوم NameWatchlistFilterبحل مشكلتين: يعمل كمرشح كامل ولديه القدرة is_flagged. لنجعلها is_flaggedفئة قائمة بذاتها WatchlistGroupلا تحتاج إلى التوافق مع واجهة برمجة التطبيقات class Filter، لأنها ليست مرشحًا ، ولكنها مجرد فئة مساعدة مفيدة.
class WatchlistGroup {
public:
bool is_flagged(std::string_view name) const {
for (const auto& list : watchlists_) {
if (std::find(list.begin(), list.end(), name) != list.end()) {
return true;
}
}
return false;
}
private:
std::vector<std::list<std::string>> watchlists_;
};
WideNetFilterيمكن
الآن استخدامها is_flaggedدون وراثة NameWatchlistFilter. في كلا الفلترين ، يمكنك اتباع التوصية المعروفة وتفضل التركيب على الوراثة ، لأن التركيب أداة لإعادة استخدام الكود ، لكن الوراثة ليست كذلك.
class NameWatchlistFilter : public Filter {
bool do_generic(const Txn& txn) const override {
return wg_.is_flagged(txn.name_on_account());
}
WatchlistGroup wg_;
};
class WideNetFilter : public CasewiseFilter {
bool do_casewise(const DepositTxn& txn) const override {
return wg_.is_flagged(txn.name_on_account()) || wg_.is_flagged(txn.name_of_customer());
}
bool do_casewise(const WithdrawalTxn& txn) const override {
return wg_.is_flagged(txn.name_on_account());
}
bool do_casewise(const TransferTxn& txn) const override {
return wg_.is_flagged(txn.name_on_account());
}
WatchlistGroup wg_;
};
مرة أخرى ، إذا أضاف شخص ما نوعًا جديدًا من المعاملات وتعديله
CasewiseFilterليشمل زيادة جديدة do_casewise، فسنعمل على التأكد من أن WideNetFilterنصبح فئة مجردة: علينا التعامل مع معاملات من نوع جديد في WideNetFilter(ولكن ليس في NameWatchlistFilter). يبدو الأمر كما لو أن المترجم يحتفظ بقائمة مهام لنا!
الاستنتاجات
لقد جعلت هذا المثال مجهولاً ومبسطًا للغاية مقارنةً بالرمز الذي كان علي العمل معه. لكن المخطط العام للتسلسل الهرمي للطبقة كان كذلك بالضبط ، كما كان المنطق الضعيف
do_generic(txn) && do_casewise(txn)من الكود الأصلي. أعتقد أنه مما سبق ليس من السهل فهم مدى إخفاء الخطأ بشكل غير محسوس في الهيكل القديم FeeTxn. الآن بعد أن قدمت لك هذه النسخة المبسطة (حرفيًا تمضغها من أجلك!) ، أنا شخصياً فوجئت بنفسي ، هل كانت النسخة الأصلية من الكود سيئة للغاية؟ ربما لا ... بعد كل شيء ، نجح هذا الرمز لفترة من الوقت.
لكنني آمل أن توافق على أن نسخة إعادة البناء التي تستخدم
CasewiseFilterوتحديدا WatchlistGroupتبين أنها أفضل بكثير. إذا اضطررت إلى اختيار أي من هذين الكودين للعمل بهما ، فلن أتردد في أخذ الثاني.