Biasanya, artikel tentang perancangan tipe berisi contoh-contoh dalam bahasa fungsional - Haskell, F #, dan lainnya. Konsep ini mungkin tampaknya tidak berlaku untuk bahasa berorientasi objek, tetapi tidak.
Pada artikel ini, saya akan menerjemahkan contoh-contoh dari artikel oleh Scott Vlaschin Type Design: Cara membuat status yang tidak valid tidak dapat diekspresikan dalam C # idiomatik . Saya juga akan mencoba menunjukkan bahwa pendekatan ini berlaku tidak hanya sebagai percobaan, tetapi juga dalam kode kerja.
Buat jenis domain
Pertama, Anda perlu mem-port tipe dari artikel sebelumnya dalam seri , yang digunakan dalam contoh di F #.
Bungkus tipe primitif dalam domain
Contoh F # menggunakan jenis domain, bukan primitif untuk alamat email, kode pos AS, dan kode negara. Mari kita coba bungkus tipe primitif dalam 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);
Saya memindahkan validasi alamat dari fungsi pabrik ke konstruktor, karena implementasi seperti itu lebih tipikal untuk C #. Kami juga harus menerapkan perbandingan dan konversi ke string, yang pada F # akan dilakukan oleh kompiler.
Di satu sisi, implementasinya terlihat cukup produktif. Di sisi lain, kekhususan alamat email diekspresikan di sini hanya dengan memeriksa konstruktor dan, mungkin, dengan logika perbandingan. Sebagian besar dari ini adalah kode infrastruktur, yang, apalagi, tidak mungkin berubah. Jadi, Anda bisa membuat templat , atau, paling buruk, menyalin kode umum dari kelas ke kelas.
Perlu dicatat bahwa penciptaan jenis domain dari nilai-nilai primitif bukanlah kekhususan pemrograman fungsional. Sebaliknya, penggunaan tipe primitif dianggap sebagai tanda kode buruk di OOP . Anda dapat melihat contoh pembungkus tersebut, misalnya, di NLog dan NBitcoin , dan tipe standar TimeSpan adalah, pada kenyataannya, pembungkus jumlah kutu.
Menciptakan Objek Nilai
Sekarang kita perlu membuat analog dari entri :
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")}"; }
Sekali lagi, butuh lebih banyak kode daripada F #, tetapi sebagian besar pekerjaan dapat dilakukan melalui refactoring di IDE .
Seperti EmailAddress
, EmailContactInfo
adalah objek nilai (dalam arti DDD , bukan tipe nilai dalam .NET ), yang telah lama dikenal dan digunakan dalam pemodelan objek.
Jenis lain - StateCode
, ZipCode
, PostalAddress
dan PersonalName
porting ke C # dengan cara yang sama.
Buat kontak
Jadi, kode harus menyatakan aturan "Kontak harus berisi alamat email atau alamat pos (atau kedua alamat)." Diperlukan untuk mengekspresikan aturan ini sehingga kebenaran negara terlihat dari definisi tipe dan diperiksa oleh kompiler.
Ekspresikan berbagai status kontak
Ini berarti bahwa kontak adalah objek yang berisi nama orang tersebut dan alamat email, atau alamat pos, atau keduanya. Jelas, satu kelas tidak dapat berisi tiga set properti yang berbeda, oleh karena itu, tiga kelas yang berbeda harus didefinisikan. Ketiga kelas harus mengandung nama kontak dan pada saat yang sama harus memungkinkan untuk memproses kontak dari berbagai jenis dengan cara yang sama, tidak tahu alamat mana yang berisi kontak. Oleh karena itu, kontak akan diwakili oleh kelas dasar abstrak yang berisi nama kontak, dan tiga implementasi dengan set bidang yang berbeda.
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; } }
Anda dapat berargumen bahwa Anda harus menggunakan komposisi , bukan warisan, dan umumnya Anda perlu mewarisi perilaku, bukan data. Pernyataan itu adil, tetapi, menurut pendapat saya, penggunaan hirarki kelas dibenarkan di sini. Pertama, subclass tidak hanya mewakili kasus khusus dari kelas dasar, seluruh hierarki adalah satu konsep - kontak. Tiga implementasi kontak sangat akurat mencerminkan tiga kasus yang ditetapkan oleh aturan bisnis. Kedua, hubungan kelas dasar dan ahli warisnya, pembagian tanggung jawab di antara mereka mudah dilacak. Ketiga, jika hierarki benar-benar menjadi masalah, Anda dapat memisahkan status kontak menjadi hierarki terpisah, seperti yang dilakukan dalam contoh asli. Dalam F #, pewarisan catatan tidak mungkin, tetapi tipe baru dinyatakan cukup sederhana, sehingga pemisahan dilakukan segera. Dalam C #, solusi yang lebih alami adalah menempatkan bidang Nama di kelas dasar.
Buat kontak
Membuat kontak cukup sederhana.
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");
Jika alamat email salah, kode ini akan mengeluarkan pengecualian, yang dapat dianggap sebagai analog dari pengembalian None
dalam contoh aslinya.
Pembaruan kontak
Memperbarui kontak juga mudah - Anda hanya perlu menambahkan metode abstrak ke jenis 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);
Seperti opsi.Nilai dalam F #, melemparkan pengecualian dari konstruktor dimungkinkan jika alamat email, kode pos, atau negara bagian salah, tetapi untuk C # ini adalah praktik umum. Tentu saja, kode pengecualian harus disediakan dalam kode kerja di sini atau di suatu tempat dalam kode panggilan.
Menangani kontak di luar hierarki
Adalah logis untuk menempatkan logika untuk memperbarui kontak dalam hierarki Contact
itu sendiri. Tetapi bagaimana jika Anda ingin mencapai sesuatu yang tidak sesuai dengan bidang tanggung jawabnya? Misalkan Anda ingin menampilkan kontak pada antarmuka pengguna.
Anda tentu saja dapat menambahkan metode abstrak ke kelas dasar lagi dan terus menambahkan metode baru setiap kali Anda perlu memproses kontak. Tapi kemudian prinsip tanggung jawab tunggal akan dilanggar, hierarki Contact
akan berantakan, dan logika pemrosesan akan kabur antara implementasi Contact
dan tempat-tempat yang bertanggung jawab, pada kenyataannya, memproses kontak. Tidak ada masalah seperti itu di F #, saya ingin kode C # tidak menjadi lebih buruk!
Setara terdekat dengan pencocokan pola dalam C # adalah konstruk saklar. Kami dapat menambahkan properti tipe enumerasi ke Contact
yang memungkinkan kami menentukan jenis kontak yang sebenarnya dan melakukan konversi. Dimungkinkan juga untuk menggunakan fitur-fitur C # yang lebih baru dan melakukan pergantian sebagai instance Contact
. Tapi kami ingin kompiler meminta dirinya sendiri ketika status Contact
benar baru ditambahkan, di mana tidak ada cukup pemrosesan kasus baru, dan beralih tidak menjamin pemrosesan semua kasus yang mungkin.
Tetapi OOP juga memiliki mekanisme yang lebih nyaman untuk memilih logika tergantung pada jenisnya, dan kami hanya menggunakannya saat memperbarui kontak. Dan karena sekarang pilihan tergantung pada jenis panggilan, itu juga harus polimorfik. Solusinya adalah templat Pengunjung. Ini memungkinkan Anda untuk memilih penangan tergantung pada implementasi Contact
, melepaskan ikatan metode pemrosesan kontak dari hierarki mereka, dan jika jenis kontak baru ditambahkan, dan, karenanya, metode baru dalam antarmuka Pengunjung, Anda harus menulisnya di semua implementasi antarmuka. Semua persyaratan terpenuhi!
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_); } }
Sekarang Anda dapat menulis kode untuk menampilkan kontak. Untuk kesederhanaan, saya akan menggunakan antarmuka konsol.
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);
Perbaikan lebih lanjut
Jika Contact
dideklarasikan di perpustakaan dan penampilan ahli waris baru di klien perpustakaan tidak diinginkan, Anda dapat mengubah ruang lingkup konstruktor Contact
ke internal
, atau bahkan membuat kelas bersarang pewarisnya, menyatakan visibilitas implementasi dan konstruktor private
, dan membuat contoh melalui metode pabrik statis saja.
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); }
Dengan demikian dimungkinkan untuk mereproduksi non-ekstensibilitas dari tipe-jumlah, meskipun, sebagai suatu peraturan, ini tidak diperlukan.
Kesimpulan
Saya harap saya bisa menunjukkan bagaimana membatasi keadaan logika bisnis yang benar menggunakan tipe dengan alat OOP. Kode tersebut ternyata lebih banyak daripada di F #. Di suatu tempat hal ini disebabkan oleh banyaknya kesulitan keputusan OOP, di suatu tempat karena verbositas bahasa, tetapi solusi tidak dapat disebut tidak praktis.
Menariknya, dimulai dengan solusi murni fungsional, kami datang dengan rekomendasi pemrograman berorientasi subjek dan pola OOP. Sebenarnya, ini tidak mengherankan, karena kesamaan jenis-jumlah dan pola Pengunjung telah dikenal cukup lama . Tujuan artikel ini adalah untuk menunjukkan tidak banyak trik nyata untuk menunjukkan penerapan ide dari "menara gading" dalam pemrograman imperatif. Tentu saja, tidak semuanya dapat ditransfer dengan mudah, tetapi dengan munculnya lebih banyak fungsi dalam bahasa pemrograman mainstream, batas-batas yang berlaku akan berkembang.
→ Kode sampel tersedia di GitHub