التصميم حسب الأنواع: كيفية جعل الحالات غير الصالحة غير قابلة للتعبير عنها في C #

عادةً ، تحتوي المقالات حول تصميم الكتابة على أمثلة باللغات الوظيفية - Haskell و F # وغيرها. قد لا يبدو أن هذا المفهوم ينطبق على اللغات الموجهة للكائنات ، ولكنه ليس كذلك.


في هذه المقالة ، سأترجم أمثلة من مقالة كتبها سكوت فلاشين نوع التصميم: كيفية جعل الحالات غير الصالحة غير قابلة للتعبير في لغة C # الاصطلاحية . سأحاول أيضًا إظهار أن هذا النهج قابل للتطبيق ليس فقط كتجربة ، ولكن أيضًا في كود العمل.


إنشاء أنواع المجال


تحتاج أولاً إلى نقل الأنواع من المقالة السابقة في السلسلة ، والتي يتم استخدامها في الأمثلة في F #.


التفاف الأنواع البدائية في المجال


تستخدم الأمثلة F # أنواع النطاق بدلاً من الأساسيات لعنوان البريد الإلكتروني والرمز البريدي للولايات المتحدة ورمز الولاية. دعونا نحاول لف نوع بدائي في C #:


public sealed class EmailAddress { public string Value { get; } public EmailAddress(string value) { if (value == null) { throw new ArgumentNullException(nameof(value)); } if (!Regex.IsMatch(value, @"^\S+@\S+\.\S+$")) { throw new ArgumentException("Email address must contain an @ sign"); } Value = value; } public override string ToString() => Value; public override bool Equals(object obj) => obj is EmailAddress otherEmailAddress && Value.Equals(otherEmailAddress.Value); public override int GetHashCode() => Value.GetHashCode(); public static implicit operator string(EmailAddress address) => address?.Value; } 

 var a = new EmailAddress("a@example.com"); var b = new EmailAddress("b@example.com"); var receiverList = String.Join(";", a, b); 

لقد قمت بنقل التحقق من العنوان من وظيفة المصنع إلى المُنشئ ، لأن هذا التنفيذ أكثر شيوعًا لـ C #. كان علينا أيضًا تنفيذ مقارنة وتحويل إلى سلسلة ، والتي على F # سيتم إجراؤها بواسطة المترجم.


من ناحية ، يبدو التنفيذ ضخمًا جدًا. من ناحية أخرى ، يتم التعبير عن خصوصية عنوان البريد الإلكتروني هنا فقط عن طريق الشيكات في المنشئ ، وربما عن طريق منطق المقارنة. معظم هذا هو رمز البنية التحتية ، والذي ، علاوة على ذلك ، من غير المرجح أن يتغير. لذلك ، يمكنك إما إنشاء قالب ، أو ، في أسوأ الأحوال ، نسخ الرمز العام من فئة إلى أخرى.


وتجدر الإشارة إلى أن إنشاء أنواع المجال من القيم البدائية ليس خصوصية البرمجة الوظيفية. على العكس من ذلك ، يعتبر استخدام الأنواع البدائية علامة على وجود رمز سيئ في OOP . يمكنك أن ترى أمثلة على هذه الأغلفة ، على سبيل المثال ، في NLog و NBitcoin ، والنوع القياسي من TimeSpan هو في الواقع غلاف فوق عدد علامات التجزئة.


إنشاء كائنات القيمة


الآن نحن بحاجة إلى إنشاء تناظرية للدخول :


 public sealed class EmailContactInfo { public EmailAddress EmailAddress { get; } public bool IsEmailVerified { get; } public EmailContactInfo(EmailAddress emailAddress, bool isEmailVerified) { if (emailAddress == null) { throw new ArgumentNullException(nameof(emailAddress)); } EmailAddress = emailAddress; IsEmailVerified = isEmailVerified; } public override string ToString() => $"{EmailAddress}, {(IsEmailVerified ? "verified" : "not verified")}"; } 

مرة أخرى ، استغرق الأمر كودًا أكثر من F # ، ولكن يمكن إنجاز معظم العمل من خلال إعادة الهيكلة في IDE .


مثل EmailAddress ، EmailContactInfo هو كائن قيم (بمعنى DDD ، وليس أنواع القيم في .NET ) ، معروف منذ فترة طويلة ويستخدم في نمذجة الكائن.


يتم نقل الأنواع الأخرى - StateCode و ZipCode و PostalAddress و PersonalName إلى C # بطريقة مماثلة.


إنشاء جهة اتصال


لذا ، يجب أن تعبر الشفرة عن القاعدة "يجب أن تحتوي جهة الاتصال على عنوان بريد إلكتروني أو عنوان بريدي (أو كلا العنوانين)." مطلوب للتعبير عن هذه القاعدة بحيث تكون صحة الحالة مرئية من تعريف النوع والتحقق من قبل المترجم.


التعبير عن حالات الاتصال المختلفة


وهذا يعني أن جهة الاتصال هي كائن يحتوي على اسم الشخص وعنوان بريد إلكتروني أو عنوان بريدي أو كليهما. من الواضح أنه لا يمكن أن تحتوي فئة واحدة على ثلاث مجموعات مختلفة من الخصائص ؛ وبالتالي ، يجب تحديد ثلاث فئات مختلفة. يجب أن تحتوي جميع الفصول الثلاثة على اسم جهة الاتصال وفي نفس الوقت يجب أن يكون من الممكن معالجة جهات الاتصال من أنواع مختلفة بنفس الطريقة ، دون معرفة العناوين التي تحتويها جهة الاتصال. لذلك ، سيتم تمثيل جهة الاتصال بفئة أساسية مجردة تحتوي على اسم جهة الاتصال ، وثلاثة تطبيقات مع مجموعة مختلفة من الحقول.


 public abstract class Contact { public PersonalName Name { get; } protected Contact(PersonalName name) { if (name == null) { throw new ArgumentNullException(nameof(name)); } Name = name; } } public sealed class PostOnlyContact : Contact { private readonly PostalContactInfo post_; public PostOnlyContact(PersonalName name, PostalContactInfo post) : base(name) { if (post == null) { throw new ArgumentNullException(nameof(post)); } post_ = post; } } public sealed class EmailOnlyContact : Contact { private readonly EmailContactInfo email_; public EmailOnlyContact(PersonalName name, EmailContactInfo email) : base(name) { if (email == null) { throw new ArgumentNullException(nameof(email)); } email_ = email; } } public sealed class EmailAndPostContact : Contact { private readonly EmailContactInfo email_; private readonly PostalContactInfo post_; public EmailAndPostContact(PersonalName name, EmailContactInfo email, PostalContactInfo post) : base(name) { if (email == null) { throw new ArgumentNullException(nameof(email)); } if (post == null) { throw new ArgumentNullException(nameof(post)); } email_ = email; post_ = post; } } 

يمكنك القول أنه يجب عليك استخدام التكوين ، وليس الميراث ، وعمومًا تحتاج إلى وراثة السلوك ، وليس البيانات. الملاحظات عادلة ، لكن في رأيي ، فإن استخدام التسلسل الهرمي للصف له ما يبرره هنا. أولاً ، الفئات الفرعية لا تمثل فقط حالات خاصة من الفئة الأساسية ، فالتسلسل الهرمي بأكمله هو مفهوم واحد - الاتصال. تعكس ثلاثة تطبيقات اتصال بدقة بالغة الحالات الثلاث المنصوص عليها في قاعدة العمل. ثانياً ، يمكن تتبع علاقة الطبقة الأساسية وورثتها وتقسيم المسؤوليات بينهما بسهولة. ثالثًا ، إذا أصبح التسلسل الهرمي مشكلة بالفعل ، يمكنك فصل حالة الاتصال إلى تسلسل هرمي منفصل ، كما حدث في المثال الأصلي. في F # ، يكون وراثة السجلات مستحيلا ، ولكن يتم الإعلان عن أنواع جديدة بكل بساطة ، لذلك تم إجراء التقسيم على الفور. في C # ، يكون الحل الأكثر طبيعية هو وضع حقول الاسم في الفئة الأساسية.


إنشاء جهة اتصال


إنشاء جهة اتصال أمر بسيط للغاية.


 public abstract class Contact { public static Contact FromEmail(PersonalName name, string emailStr) { var email = new EmailAddress(emailStr); var emailContactInfo = new EmailContactInfo(email, false); return new EmailOnlyContact(name, emailContactInfo); } } 

 var name = new PersonalName("A", null, "Smith"); var contact = Contact.FromEmail(name, "abc@example.com"); 

إذا كان عنوان البريد الإلكتروني غير صحيح ، فسيؤدي هذا الرمز إلى استثناء ، والذي يمكن اعتباره تناظريًا لإرجاع None في المثال الأصلي.


تحديث الاتصال


يعد تحديث جهة الاتصال أمرًا مباشرًا أيضًا - ما عليك سوى إضافة طريقة مجردة إلى نوع Contact .


 public abstract class Contact { public abstract Contact UpdatePostalAddress(PostalContactInfo newPostalAddress); } public sealed class EmailOnlyContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new EmailAndPostContact(Name, email_, newPostalAddress); } public sealed class PostOnlyContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new PostOnlyContact(Name, newPostalAddress); } public sealed class EmailAndPostContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new EmailAndPostContact(Name, email_, newPostalAddress); } 

 var state = new StateCode("CA"); var zip = new ZipCode("97210"); var newPostalAddress = new PostalAddress("123 Main", "", "Beverly Hills", state, zip); var newPostalContactInfo = new PostalContactInfo(newPostalAddress, false); var newContact = contact.UpdatePostalAddress(newPostalContactInfo); 

كما هو الحال مع option.Value في F # ، من الممكن طرح استثناء من المنشئين إذا كان عنوان البريد الإلكتروني أو الرمز البريدي أو الحالة غير صحيح ، ولكن بالنسبة لـ C # فهذه ممارسة شائعة. بالطبع ، يجب توفير رمز الاستثناء في رمز العمل هنا أو في مكان ما في رمز الاتصال.


معالجة جهات الاتصال خارج التسلسل الهرمي


من المنطقي وضع منطق لتحديث جهة اتصال في التسلسل الهرمي لجهة Contact نفسها. ولكن ماذا لو كنت تريد تحقيق شيء لا يتناسب مع مجال مسؤوليتها؟ افترض أنك تريد عرض جهات الاتصال على واجهة المستخدم.


يمكنك بالطبع إضافة الطريقة المجردة إلى الفئة الأساسية مرة أخرى والاستمرار في إضافة طريقة جديدة في كل مرة تحتاج فيها إلى معالجة جهات الاتصال بطريقة أو بأخرى. ولكن بعد ذلك سيتم انتهاك مبدأ المسؤولية الوحيدة ، وسيتم تشويش التسلسل الهرمي لجهة Contact وسوف يتم تعتيم منطق المعالجة بين عمليات تنفيذ جهات Contact والأماكن المسؤولة في الواقع عن معالجة جهات الاتصال. لم يكن هناك مثل هذه المشكلة في F # ، وأود أن يكون رمز C # ليس أسوأ!


أقرب ما يعادل مطابقة النمط في C # هو تكوين التبديل. يمكننا إضافة خاصية نوع تعداد إلى Contact التي تسمح لنا بتحديد نوع الاتصال الفعلي وإجراء التحويل. سيكون من الممكن أيضًا استخدام الميزات الأحدث لـ C # وإجراء تبديل كمثيل لجهة Contact . لكننا أردنا من المحول البرمجي أن يطالب نفسه عند إضافة حالات Contact جديدة صحيحة ، حيث لا توجد معالجة كافية للحالات الجديدة ، والتبديل لا يضمن معالجة جميع الحالات المحتملة.


لكن OOP لديه أيضًا آلية أكثر ملاءمة لاختيار المنطق اعتمادًا على النوع ، وقد استخدمناه للتو عند تحديث جهة اتصال. وبما أن الاختيار يعتمد الآن على نوع الاستدعاء ، فيجب أن يكون متعدد الأشكال أيضًا. الحل هو قالب الزائر. يسمح لك بتحديد معالج اعتمادًا على تنفيذ Contact ، ويفصل طرق معالجة جهات الاتصال من التسلسل الهرمي الخاص بهم ، وإذا تمت إضافة نوع جديد من جهات الاتصال ، وبالتالي ، طريقة جديدة في واجهة الزائر ، ستحتاج إلى كتابتها في جميع تطبيقات الواجهة. يتم استيفاء جميع المتطلبات!


 public abstract class Contact { public abstract void AcceptVisitor(IContactVisitor visitor); } public interface IContactVisitor { void Visit(PersonalName name, EmailContactInfo email); void Visit(PersonalName name, PostalContactInfo post); void Visit(PersonalName name, EmailContactInfo email, PostalContactInfo post); } public sealed class EmailOnlyContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, email_); } } public sealed class PostOnlyContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, post_); } } public sealed class EmailAndPostContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, email_, post_); } } 

الآن يمكنك كتابة رمز لعرض جهات الاتصال. من أجل البساطة ، سأستخدم واجهة وحدة التحكم.


 public sealed class ContactUi { private sealed class Visitor : IContactVisitor { void IContactVisitor.Visit(PersonalName name, EmailContactInfo email) { Console.WriteLine(name); Console.WriteLine("* Email: {0}", email); } void IContactVisitor.Visit(PersonalName name, PostalContactInfo post) { Console.WriteLine(name); Console.WriteLine("* Postal address: {0}", post); } void IContactVisitor.Visit(PersonalName name, EmailContactInfo email, PostalContactInfo post) { Console.WriteLine(name); Console.WriteLine("* Email: {0}", email); Console.WriteLine("* Postal address: {0}", post); } } public void Display(Contact contact) => contact.AcceptVisitor(new Visitor()); } 

 var ui = new ContactUi(); ui.Display(newContact); 

مزيد من التحسينات


إذا Contact الإعلان عن Contact في المكتبة وكان ظهور الورثة الجدد في عملاء المكتبة أمرًا غير مرغوب فيه ، يمكنك تغيير نطاق مُنشئ Contact إلى internal ، أو حتى جعل ورثتها متداخلين ، وإعلان رؤية عمليات التنفيذ والمنشئ private ، وإنشاء مثيلات من خلال طرق المصنع الثابتة فقط.


 public abstract class Contact { private sealed class EmailOnlyContact : Contact { public EmailOnlyContact(PersonalName name, EmailContactInfo email) : base(name) { } } private Contact(PersonalName name) { } public static Contact EmailOnly(PersonalName name, EmailContactInfo email) => new EmailOnlyContact(name, email); } 

وبالتالي ، من الممكن إعادة إنتاج عدم توسيع نطاق النوع ، على الرغم من ذلك ، كقاعدة عامة ، هذا غير مطلوب.


الخاتمة


آمل أن أكون قد تمكنت من إظهار كيفية تحديد الحالة الصحيحة لمنطق الأعمال باستخدام الأنواع باستخدام أدوات OOP. تبين أن الرمز أكبر حجمًا من F #. في مكان ما هذا يرجع إلى الإرهاق النسبي لقرارات OOP ، في مكان ما بسبب لغوية اللغة ، ولكن لا يمكن أن تسمى الحلول غير عملية.


ومن المثير للاهتمام ، بدءًا من حل وظيفي بحت ، توصلنا إلى توصية البرمجة الموجهة للموضوع وأنماط OOP. في الواقع ، هذا ليس مفاجئًا ، لأن التشابه بين أنواع المبالغ ونمط الزائر معروف منذ بعض الوقت . لم يكن الغرض من هذه المقالة إظهار حيلة ملموسة بقدر ما هو إثبات مدى قابلية تطبيق الأفكار من "برج العاج" في البرمجة الحتمية. بالطبع ، لا يمكن نقل كل شيء بهذه السهولة ، ولكن مع ظهور المزيد والمزيد من الوظائف في لغات البرمجة السائدة ، ستتوسع حدود التطبيق المعمول به.




→ يتوفر نموذج التعليمات البرمجية على GitHub

Source: https://habr.com/ru/post/ar431186/


All Articles