KembaliKembali ke artikel

Memahami C# Generics: Type Safety, Constraints, Factory Pattern, dan Penggunaan Nyata

10 Agustus 2026

1 jam baca

2 pembaca

Memahami C# Generics: Type Safety, Constraints, Factory Pattern, dan Penggunaan Nyata

Memahami Generic di C# dan .NET secara menyeluruh, mulai dari konsep dasar, constraint, collection, delegate, hingga penerapannya dalam pola desain dan library .NET.

C#Advanced C#

Bagian 1: Pendahuluan

Hampir setiap developer .NET mungkin pernah menuliskan kode seperti ini tanpa perlu terlalu memikirkan apa yang terjadi di baliknya:

C#

List<string> names = new List<string>();
Dictionary<int, string> users = new Dictionary<int, string>();
Task<int> result = GetDataAsync();

Tanda <T> yang sering muncul dalam kode C# bukan sekadar bagian dari sintaks. Tanda tersebut berkaitan dengan Generic, salah satu fitur fundamental dalam C# yang digunakan di berbagai bagian .NET.

Beberapa contoh yang paling mudah ditemui antara lain:

  • List<T> — koleksi dinamis yang dapat digunakan untuk berbagai tipe data

  • Dictionary<TKey, TValue> — struktur data key-value dengan tipe data yang jelas

  • IEnumerable<T> — salah satu fondasi penting dalam LINQ

  • Task<T> — representasi hasil dari operasi asynchronous

  • Nullable<T> — memungkinkan value type seperti int atau bool memiliki nilai null

Ada satu hal yang sama dari semua contoh tersebut: tipe data tidak ditentukan ketika class atau interface dibuat, tetapi ditentukan ketika digunakan.

Sebagai contoh, List<T> dapat digunakan untuk berbagai kebutuhan:

C#

List<int> numbers = new List<int>();
List<string> names = new List<string>();
List<Product> products = new List<Product>();

Tidak perlu membuat IntegerList, StringList, dan ProductList secara terpisah. Satu implementasi List<T> dapat digunakan untuk berbagai tipe data.

Lalu muncul pertanyaan sederhana: mengapa banyak collection di .NET menggunakan Generic?

Jawabannya berkaitan dengan dua kebutuhan penting dalam pengembangan software: type safety dan code reusability.

Sebelum Generic digunakan secara luas, mendapatkan keduanya sekaligus tidak selalu mudah. Salah satu pendekatan adalah membuat class yang berbeda untuk setiap tipe data. Cara ini memang memberikan type safety, tetapi menghasilkan banyak kode yang sebenarnya memiliki logika hampir sama.

Pendekatan lainnya adalah menggunakan object sehingga satu class dapat menerima berbagai tipe data:

C#

List<object> values = new List<object>();
values.Add(10);
values.Add("Hello");
values.Add(new Product());

Cara tersebut memang lebih fleksibel, tetapi tipe data menjadi kurang jelas. Nilai yang diambil dari collection juga sering kali perlu dikonversi kembali ke tipe yang diharapkan. Kesalahan tipe baru dapat diketahui ketika program berjalan.

Generic menawarkan pendekatan yang lebih praktis. Tipe data tidak perlu ditentukan secara hardcode di dalam class, tetapi dapat diberikan sebagai parameter tipe.

Konsepnya mirip dengan method yang menerima parameter berupa nilai:

C#

void Print(string message)
{
// ...
}

Method tersebut menerima message sebagai parameter. Generic menggunakan ide yang serupa, tetapi parameter yang diberikan adalah tipe data:

C#

class Box<T>
{
public T Value { get; set; }
}

Saat class digunakan, tipe T dapat ditentukan:

C#

Box<int> numberBox = new Box<int>();
Box<string> textBox = new Box<string>();

Dengan pendekatan ini, satu class dapat digunakan untuk berbagai tipe data sekaligus tetap memberikan pemeriksaan tipe oleh compiler. Melalui memahami konsep tersebut, Generic tidak lagi sekadar terlihat sebagai tanda <T> yang muncul di berbagai bagian kode C#. Generic dapat dipahami sebagai cara untuk membuat kode yang dapat digunakan kembali, tetap type-safe, dan fleksibel terhadap berbagai tipe data.

Sebelum masuk ke definisi Generic secara formal, ada baiknya melihat terlebih dahulu bagaimana masalah yang sama biasanya diselesaikan sebelum Generic digunakan di C#. Dengan memahami masalah tersebut, alasan dan manfaat penggunaan Generic akan lebih mudah terlihat.

Bagian 2: Masalah Sebelum Generic

Sebelum Generic diperkenalkan di C# 2.0 pada tahun 2005, salah satu cara untuk membuat collection yang dapat menyimpan berbagai jenis data adalah menggunakan object sebagai tipe dasarnya. Salah satu contoh yang cukup terkenal adalah ArrayList.

ArrayList dapat menyimpan berbagai macam tipe data dalam satu collection. Di balik layar, setiap nilai yang dimasukkan akan diperlakukan sebagai object. Sekilas, pendekatan ini terlihat praktis. Satu collection bisa menyimpan angka, teks, bahkan object dari class yang berbeda. Namun, ada beberapa konsekuensi yang baru terlihat ketika data tersebut mulai digunakan kembali.

Contoh Kode: ArrayList

Berikut contoh sederhana penggunaan ArrayList:

C#

using System.Collections;
ArrayList list = new ArrayList();
list.Add(10);
list.Add("dua puluh");
list.Add(30.5);
foreach (var item in list)
{
Console.WriteLine(item);
}

Kode tersebut dapat berjalan tanpa masalah. ArrayList menerima int, string, dan double dalam collection yang sama. Masalahnya muncul ketika data tersebut diproses dengan asumsi tipe tertentu. Misalnya, seluruh isi collection dianggap sebagai angka:

C#

int total = 0;
foreach (var item in list)
{
total += (int)item;
}

Kode tersebut tetap berhasil dikompilasi. Namun, ketika program berjalan dan mencapai nilai "dua puluh", program akan gagal dengan InvalidCastException. Alasannya sederhana: "dua puluh" adalah string, sedangkan kode mencoba mengubahnya menjadi int. Di sinilah salah satu kelemahan utama ArrayList terlihat. Compiler tidak memiliki informasi yang cukup untuk memastikan tipe data setiap elemen. Semua elemen hanya terlihat sebagai object. Akibatnya, kesalahan seperti ini baru diketahui ketika program dijalankan, bukan ketika kode dikompilasi.

Idealnya, kesalahan tipe data dapat diketahui sedini mungkin. Semakin awal sebuah kesalahan ditemukan, semakin mudah pula memperbaikinya. Jika kesalahan baru muncul ketika aplikasi sedang digunakan, dampaknya tentu bisa menjadi lebih besar.

Boxing dan Unboxing

Selain masalah type safety, penggunaan object juga membawa konsekuensi lain, terutama ketika collection menyimpan value type seperti int, double, atau bool. Di sinilah istilah boxing dan unboxing mulai muncul. Boxing adalah proses ketika sebuah value type dikonversi menjadi object. Sebaliknya, unboxing adalah proses mengambil kembali value type dari object.

Contohnya:

C#

int number = 10;
object boxed = number; // boxing
int unboxed = (int)boxed; // unboxing

Pada saat number dimasukkan ke dalam object, nilai int tersebut perlu dibungkus menjadi sebuah object. Proses inilah yang disebut boxing. Ketika nilai tersebut ingin digunakan kembali sebagai int, diperlukan proses unboxing.

Secara sederhana, gambaran prosesnya seperti ini:

Plain Text

int
│ boxing
object
│ unboxing
int

Untuk satu atau dua nilai, overhead tersebut mungkin tidak terlalu terasa. Namun, jika proses yang sama terjadi dalam jumlah besar dan berulang, alokasi object tambahan dan pekerjaan garbage collector dapat ikut memengaruhi performa.

Misalnya, sebuah collection berisi ribuan atau bahkan jutaan angka. Jika setiap angka harus melalui proses boxing ketika dimasukkan ke collection, akan ada lebih banyak object yang perlu dialokasikan dan nantinya dikelola oleh garbage collector.

Ini bukan berarti setiap penggunaan object atau boxing otomatis menyebabkan masalah performa. Dalam banyak kasus, dampaknya kecil. Namun, pada operasi yang sangat sering atau pemrosesan data dalam jumlah besar, overhead tersebut dapat menjadi pertimbangan penting.

Pembahasan mengenai value type, reference type, boxing, dan unboxing secara lebih mendalam dapat dilihat pada artikel Mengupas Value Type dan Reference Type di C#.

Casting yang Berlebihan

Masalah lain muncul ketika nilai dari ArrayList ingin digunakan kembali. Karena setiap elemen dianggap sebagai object, compiler tidak mengetahui tipe asli dari nilai tersebut.

Misalnya:

C#

string name = (string)list[1];
double price = (double)list[2];

Casting seperti ini diperlukan agar nilai object dapat digunakan sebagai tipe yang diharapkan. Masalahnya, casting juga menjadi tanggung jawab kode yang menggunakan collection. Jika tipe sebenarnya tidak sesuai dengan tipe yang digunakan dalam casting, program dapat mengalami InvalidCastException ketika berjalan.

Contohnya:

C#

ArrayList list = new ArrayList();
list.Add(100);
string value = (string)list[0];

Nilai 100 sebenarnya adalah int, tetapi kode mencoba melakukan casting menjadi string. Compiler tidak dapat mendeteksi kesalahan tersebut karena dari sudut pandangnya, list[0] memang bertipe object. Kesalahan baru terlihat saat program dijalankan. Semakin sering casting seperti ini dilakukan, semakin besar pula kemungkinan terjadi kesalahan tipe data.

Dari sini terlihat bahwa pendekatan berbasis object memang memberikan fleksibilitas, tetapi harus dibayar dengan beberapa konsekuensi:

  • Type safety berkurang karena compiler tidak mengetahui tipe elemen sebenarnya.

  • Casting menjadi lebih sering karena nilai harus dikembalikan ke tipe aslinya secara manual.

  • Kesalahan tipe dapat muncul saat runtime, misalnya melalui InvalidCastException.

  • Value type dapat mengalami boxing dan unboxing, yang menambah overhead tertentu.

  • Kode menjadi lebih sulit dipahami, karena tipe data tidak terlihat dengan jelas dari deklarasi collection.

Lalu muncul kebutuhan yang cukup jelas: bagaimana jika sebuah collection tetap dapat digunakan untuk berbagai tipe data, tetapi compiler juga mengetahui tipe data tersebut sejak awal?

Misalnya, daripada menggunakan:

C#

ArrayList numbers = new ArrayList();

dan kemudian harus melakukan casting:

C#

int number = (int)numbers[0];

akan jauh lebih nyaman jika tipe datanya sudah diketahui sejak collection dibuat:

C#

List<int> numbers = new List<int>();
int number = numbers[0];

Pada contoh terakhir, List<int> memberi tahu compiler bahwa collection tersebut hanya berisi int. Tidak diperlukan casting ketika mengambil nilainya, dan compiler juga dapat menolak data dengan tipe yang tidak sesuai. Inilah masalah yang kemudian dijawab oleh Generic.

Bagian 3: Apa Itu Generic?

Setelah melihat beberapa masalah pada ArrayList dan penggunaan object, sekarang saatnya melihat pendekatan yang digunakan C# untuk mengatasinya: Generic. Secara sederhana, Generic adalah cara membuat class, method, interface, atau struct yang dapat bekerja dengan berbagai tipe data tanpa harus menentukan tipe tersebut secara langsung ketika kode dibuat. Tipe data tersebut nantinya diberikan ketika kode digunakan.

Konsepnya mirip dengan parameter pada method. Sebuah method dapat menerima nilai sebagai parameter:

C#

void Print(string message)
{
Console.WriteLine(message);
}

message merupakan parameter yang nilainya baru diketahui ketika method dipanggil.

Generic menggunakan konsep yang serupa, tetapi parameter yang diberikan bukan nilai, melainkan tipe data.

Contoh Sederhana: Class Box<T>

Berikut contoh class sederhana menggunakan Generic:

C#

public class Box<T>
{
public T Value { get; set; }
}

T pada Box<T> disebut type parameter.

Type parameter dapat dianggap sebagai tempat untuk menyimpan informasi tentang tipe data yang akan digunakan nanti. Huruf T sendiri bukan kata khusus yang diwajibkan oleh C#. T hanyalah konvensi penamaan yang umum digunakan karena berasal dari kata Type.

Nama lain juga diperbolehkan:

C#

public class Box<TItem>
{
public TItem Value { get; set; }
}

Secara konsep, Box<T> dan Box<TItem> bekerja dengan cara yang sama. Saat class tersebut digunakan, tipe data ditentukan di antara tanda < dan >:

C#

Box<int> intBox = new Box<int>();
intBox.Value = 100;
Box<string> stringBox = new Box<string>();
stringBox.Value = "Halo Generic";
Console.WriteLine(intBox.Value); // Output: 100
Console.WriteLine(stringBox.Value); // Output: Halo Generic

Pada Box<int>, T menjadi int. Sedangkan pada Box<string>, T menjadi string. Dengan demikian, satu definisi class dapat digunakan untuk berbagai tipe data.

Secara sederhana, hubungan tersebut dapat digambarkan seperti ini:

Plain Text

Box<T>
┌────────┴────────┐
│ │
Box<int> Box<string>
│ │
Value: int Value: string

Tidak perlu membuat class terpisah seperti IntBox dan StringBox. Cukup ada satu Box<T>, lalu tipe data ditentukan ketika class tersebut digunakan.

Type Safety Tetap Terjaga

Salah satu manfaat penting Generic adalah compiler tetap mengetahui tipe data yang digunakan.

Misalnya:

C#

Box<int> intBox = new Box<int>();
intBox.Value = 100;

Karena intBox merupakan Box<int>, compiler mengetahui bahwa property Value bertipe int. Jika ada nilai dengan tipe yang tidak sesuai:

C#

Box<int> intBox = new Box<int>();
intBox.Value = "ini string";

Kode tersebut akan menghasilkan compile error.

Kesalahan dapat diketahui sebelum program dijalankan karena compiler sudah mengetahui bahwa Value hanya menerima int.

Hal yang sama berlaku untuk Box<string>:

C#

Box<string> stringBox = new Box<string>();
stringBox.Value = "Hello";

Kode tersebut valid karena Value pada instance tersebut bertipe string. Perbedaan ini cukup penting jika dibandingkan dengan pendekatan menggunakan object.

Pada ArrayList, compiler hanya mengetahui bahwa setiap elemen bertipe object:

C#

ArrayList list = new ArrayList();
list.Add(100);
int number = (int)list[0];

Developer perlu memastikan sendiri bahwa nilai yang diambil memang dapat di-cast menjadi int.

Dengan Generic, informasi tipe tersebut sudah menjadi bagian dari definisi collection:

C#

List<int> numbers = new List<int>();
numbers.Add(100);
int number = numbers[0];

Tidak diperlukan casting karena compiler sudah mengetahui bahwa numbers hanya berisi int.

Bagaimana dengan T?

Pada saat menulis class:

C#

public class Box<T>
{
public T Value { get; set; }
}

T belum mewakili tipe tertentu.

Tipe tersebut baru ditentukan ketika Generic digunakan:

C#

Box<int> intBox = new Box<int>();
Box<string> stringBox = new Box<string>();

Setelah Box<int> digunakan, compiler dan runtime memperlakukan Value sebagai int untuk instance tersebut. Sementara pada Box<string>, Value bertipe string.

Hal pentingnya adalah informasi tipe tetap dipertahankan sepanjang penggunaan Generic tersebut. Compiler dapat menggunakan informasi ini untuk melakukan pemeriksaan tipe dan memberikan bantuan seperti IntelliSense.

Inilah yang membuat Generic berbeda dari sekadar menggunakan object.

Dengan object, tipe data yang sebenarnya dapat menjadi tidak jelas:

C#

object value = 100;
int number = (int)value;

Dengan Generic, tipe data menjadi bagian dari tipe itu sendiri:

C#

Box<int> box = new Box<int>();
int number = box.Value;

Bukan Hanya untuk Class

Generic tidak hanya dapat digunakan pada class.

Konsep yang sama juga tersedia pada:

  • Method — membuat method yang dapat bekerja dengan berbagai tipe data

  • Interface — membuat kontrak yang tetap type-safe untuk berbagai tipe

  • Struct — membuat value type yang dapat bekerja dengan tipe data tertentu

  • Delegate — memungkinkan delegate bekerja dengan parameter dan return type yang bersifat Generic

Contohnya, Generic method dapat ditulis seperti ini:

C#

public static T GetValue<T>(T value)
{
return value;
}

Method tersebut dapat digunakan dengan berbagai tipe:

C#

int number = GetValue(100);
string text = GetValue("Hello");
bool status = GetValue(true);

Dalam contoh tersebut, compiler dapat menentukan tipe T berdasarkan argument yang diberikan. Konsep dasarnya tetap sama: tipe data dijadikan sebagai parameter. Dengan pendekatan ini, satu definisi kode dapat digunakan untuk berbagai tipe data tanpa harus membuat implementasi yang sama berulang kali.

Lebih penting lagi, fleksibilitas tersebut tetap disertai dengan type safety. Compiler mengetahui tipe yang digunakan dan dapat mendeteksi banyak kesalahan sebelum program dijalankan.

Bagian 4: Mengapa Generic Lebih Baik?

Setelah melihat cara kerja Generic secara mendasar, pertanyaan berikutnya adalah: mengapa Generic banyak digunakan di .NET?

Jawabannya bukan hanya karena sintaksnya lebih ringkas. Generic membantu menyelesaikan beberapa masalah yang muncul ketika kode harus bekerja dengan berbagai tipe data, terutama masalah type safety, casting, penggunaan ulang kode, dan dalam kondisi tertentu, performa.

Type Safety

Type safety berarti compiler dapat memeriksa apakah tipe data yang digunakan sudah sesuai dengan aturan yang ditentukan dalam kode.

Misalnya, sebuah collection memang dirancang untuk menyimpan angka int. Idealnya, string atau tipe data lain tidak dapat dimasukkan ke dalam collection tersebut secara tidak sengaja.

Sebelum Generic, pendekatan berbasis ArrayList memungkinkan berbagai tipe data masuk ke dalam collection yang sama:

C#

ArrayList list = new ArrayList();
list.Add(10);
list.Add("dua puluh");

Tidak ada error pada saat kode dikompilasi. Kedua nilai tersebut diterima karena ArrayList menyimpan elemen sebagai object. Masalah baru muncul ketika seluruh isi collection kemudian diperlakukan sebagai int.

Dengan Generic, tipe data dapat ditentukan sejak awal:

C#

List<int> list = new List<int>();
list.Add(10);
list.Add("dua puluh");

Pada contoh ini, baris kedua akan menghasilkan compile error. List<int> sudah secara jelas menyatakan bahwa collection tersebut hanya dapat menyimpan nilai bertipe int.

Kesalahan tipe data yang sebelumnya mungkin baru diketahui ketika aplikasi berjalan, sekarang dapat ditemukan lebih awal.

Compile-Time Checking

Generic bekerja bersama compiler untuk melakukan pemeriksaan tipe sebelum program dijalankan. Proses ini dikenal sebagai compile-time checking.

Misalnya:

C#

List<int> numbers = new List<int>();
numbers.Add(10);
numbers.Add(20);
numbers.Add("tiga puluh");

Compiler dapat langsung mengetahui bahwa "tiga puluh" tidak sesuai dengan tipe int. Error dapat ditemukan saat proses penulisan atau build kode, tanpa harus menunggu aplikasi dijalankan.

Hal ini berbeda dengan pendekatan berbasis object, di mana banyak kesalahan tipe baru terlihat ketika sebuah nilai di-cast:

C#

object value = "Hello";
int number = (int)value;

Kode tersebut dapat dikompilasi, tetapi akan gagal ketika dijalankan.

Semakin banyak kesalahan yang dapat ditemukan pada tahap compile-time, semakin kecil kemungkinan kesalahan tipe data tersebut muncul ketika aplikasi sudah berjalan.

Generic tidak dapat mencegah semua jenis bug, tentu saja. Namun, Generic memberikan compiler lebih banyak informasi tentang tipe data yang digunakan sehingga lebih banyak kesalahan dapat dideteksi sejak awal.

Code Reusability

Keuntungan lain dari Generic adalah kemampuan untuk menggunakan kembali kode yang sama untuk berbagai tipe data. Tanpa Generic, sebuah class yang memiliki logika serupa untuk beberapa tipe data biasanya membutuhkan implementasi terpisah atau menggunakan object.

Misalnya, sebuah repository sederhana dapat dibuat menggunakan Generic:

C#

public class Repository<T>
{
private List<T> _items = new List<T>();
public void Add(T item)
{
_items.Add(item);
}
}

Class tersebut dapat digunakan untuk berbagai tipe:

C#

Repository<Product> productRepository = new Repository<Product>();
Repository<Customer> customerRepository = new Repository<Customer>();

Satu Repository<T> dapat digunakan untuk Product, Customer, atau tipe lainnya.

Tidak perlu menulis ulang logika yang sama:

C#

ProductRepository
CustomerRepository
OrderRepository

selama perbedaan antar repository hanya terletak pada tipe data yang dikelola.

Dengan Generic, kode yang memiliki pola atau perilaku sama dapat ditulis sekali dan digunakan kembali untuk berbagai tipe.

Performa

Generic juga dapat memberikan keuntungan dari sisi performa, terutama ketika bekerja dengan value type seperti int, double, atau struct. Seperti yang sudah dibahas sebelumnya, collection lama seperti ArrayList menyimpan elemen sebagai object.

Ketika sebuah int dimasukkan ke dalam ArrayList, terjadi proses boxing:

C#

ArrayList list = new ArrayList();
list.Add(10);

Secara konsep, nilai tersebut harus diperlakukan sebagai object agar dapat disimpan di dalam collection. Ketika nilainya diambil kembali sebagai int, proses unboxing diperlukan:

C#

int number = (int)list[0];

Pada Generic, tipe data sudah diketahui:

C#

List<int> numbers = new List<int>();
numbers.Add(10);
int number = numbers[0];

List<int> menyimpan dan mengembalikan nilai sebagai int, sehingga penggunaan value type dalam collection seperti ini tidak memerlukan boxing dan unboxing seperti pada ArrayList.

Perbedaan tersebut mungkin tidak terasa untuk beberapa data saja. Namun, pada operasi yang dilakukan berulang kali atau pemrosesan data dalam jumlah besar, berkurangnya alokasi dan proses tambahan dapat memberikan keuntungan pada penggunaan memori dan performa.

Karena itu, Generic bukan sekadar fitur untuk membuat kode terlihat lebih rapi. Pada kasus tertentu, Generic juga dapat membantu menghasilkan kode yang lebih efisien.

Mengurangi Casting

Pada ArrayList, setiap nilai yang diambil memiliki tipe object. Akibatnya, casting biasanya diperlukan:

C#

ArrayList list = new ArrayList();
list.Add("Andi");
string name = (string)list[0];

Dengan Generic, tipe data hasil sudah diketahui:

C#

List<string> names = new List<string>();
names.Add("Andi");
string name = names[0];

Tidak diperlukan (string) karena names[0] memang sudah diketahui bertipe string. Selain membuat kode lebih ringkas, berkurangnya casting juga mengurangi kemungkinan terjadinya kesalahan seperti:

C#

string name = (string)someObject;

ketika someObject ternyata bukan string.

Informasi tipe sudah tersedia sejak awal dan dapat diperiksa oleh compiler.

Ringkasan Perbandingan

Perbedaan antara pendekatan berbasis ArrayList atau object dan Generic dapat diringkas seperti berikut:

AspekArrayList / objectGeneric
Type safetyTipe asli sering tidak diketahui secara langsungTipe data menjadi bagian dari deklarasi
Pemeriksaan tipeBanyak kesalahan baru muncul saat runtimeBanyak kesalahan dapat ditemukan saat compile-time
Boxing/unboxingDapat terjadi saat menyimpan value type sebagai objectUmumnya tidak diperlukan pada collection Generic seperti List<int>
CastingSering diperlukan saat mengambil nilaiBiasanya tidak diperlukan
Code reusabilitySering menggunakan object atau implementasi terpisahSatu definisi dapat digunakan untuk berbagai tipe
PerformaDapat memiliki overhead tambahan, terutama pada value typeDapat lebih efisien karena menghindari overhead tertentu

Semua keuntungan tersebut saling berkaitan. Type safety membantu mengurangi kesalahan penggunaan tipe data. Compile-time checking membantu menemukan kesalahan lebih awal. Generic juga memungkinkan kode dengan logika yang sama digunakan kembali untuk berbagai tipe, sehingga duplikasi dapat dikurangi.

Di sisi lain, ketika digunakan dengan tepat, Generic juga dapat menghindari overhead seperti boxing dan unboxing pada value type. Kombinasi inilah yang membuat Generic menjadi salah satu fondasi penting dalam library modern .NET.

Bagian 5: Generic Class

Setelah melihat contoh sederhana seperti Box<T>, sekarang saatnya melihat Generic Class dalam contoh yang lebih dekat dengan kebutuhan aplikasi.

Salah satu contoh yang mudah digunakan untuk memahami konsep ini adalah Repository<T>.

Repository pada contoh ini bertugas menyimpan dan mengelola sekumpulan data. Karena logika yang dibutuhkan cukup umum, tipe data yang dikelola dapat dijadikan sebagai parameter Generic.

Studi Kasus: Repository<T>

Bayangkan sebuah aplikasi yang memiliki beberapa jenis entitas:

  • Product

  • Customer

  • Order

Masing-masing entitas tentu memiliki data yang berbeda. Namun, beberapa operasi dasarnya bisa saja sama, misalnya:

  • menambahkan data

  • menghapus data

  • mencari data

  • mengambil seluruh data

Tanpa Generic, bisa saja dibuat repository terpisah untuk setiap entitas:

Plain Text

ProductRepository
CustomerRepository
OrderRepository

Masalahnya, jika isi ketiga repository tersebut hampir sama, akan ada banyak kode yang sebenarnya hanya berbeda pada tipe data yang digunakan.

Generic memungkinkan logika tersebut ditulis satu kali:

C#

public class Repository<T>
{
private readonly List<T> _items = new List<T>();
public void Add(T item)
{
_items.Add(item);
}
public bool Remove(T item)
{
return _items.Remove(item);
}
public T? Find(Func<T, bool> predicate)
{
return _items.FirstOrDefault(predicate);
}
public IEnumerable<T> GetAll()
{
return _items;
}
}

Sekilas, mungkin terlihat banyak T pada class tersebut. Sebenarnya semuanya mengacu pada tipe data yang sama.

Jika digunakan sebagai Repository<Product>, maka semua T di dalam repository tersebut mengacu pada Product.

Jika digunakan sebagai Repository<Customer>, semua T mengacu pada Customer.

Secara sederhana:

Plain Text

Repository<T>
├── Repository<Product>
│ └── T = Product
├── Repository<Customer>
│ └── T = Customer
└── Repository<Order>
└── T = Order

List<T>

Perhatikan property _items:

C#

private readonly List<T> _items = new List<T>();

Karena List juga merupakan Generic Class, T dapat digunakan sebagai tipe elemennya.

Jika repository dibuat sebagai:

C#

Repository<Product> productRepo = new Repository<Product>();

maka _items pada repository tersebut menjadi sebuah List<Product>.

Begitu juga jika dibuat:

C#

Repository<Customer> customerRepo = new Repository<Customer>();

maka _items menjadi List<Customer>.

Dengan begitu, repository tidak perlu mengetahui sejak awal apakah data yang akan dikelola berupa Product, Customer, atau tipe lainnya.

Method Add

Method Add menggunakan T sebagai parameter:

C#

public void Add(T item)
{
_items.Add(item);
}

Artinya, tipe parameter item selalu mengikuti tipe T.

Pada Repository<Product>:

C#

var productRepo = new Repository<Product>();
productRepo.Add(new Product());

Add() menerima Product.

Sedangkan pada Repository<Customer>:

C#

var customerRepo = new Repository<Customer>();
customerRepo.Add(new Customer());

Add() menerima Customer.

Jika tipe yang salah diberikan:

C#

productRepo.Add(new Customer());

compiler akan menolaknya.

Informasi tipe yang diberikan ketika Repository<Product> dibuat tetap berlaku untuk penggunaan repository tersebut.

Method Find

Bagian berikutnya sedikit lebih menarik:

C#

public T? Find(Func<T, bool> predicate)
{
return _items.FirstOrDefault(predicate);
}

Method Find menerima Func<T, bool> sebagai parameter.

Func<T, bool> dapat dipahami secara sederhana sebagai sebuah fungsi yang menerima T dan menghasilkan bool.

Misalnya pada Repository<Product>:

C#

productRepo.Find(p => p.Name == "Mouse");

p merupakan sebuah Product, sehingga property seperti Name dapat langsung digunakan.

Pada Repository<Customer>:

C#

customerRepo.Find(c => c.Name == "Dewi");

c merupakan Customer.

Di sini Generic tidak hanya menentukan tipe data yang disimpan, tetapi juga membuat tipe tersebut mengalir ke bagian lain dari class.

T? pada Return Type

Method Find menggunakan:

C#

T? Find(...)

Tanda ? menunjukkan bahwa hasil pencarian mungkin tidak memiliki nilai.

Misalnya, ketika tidak ada Product dengan nama "Mouse", method Find dapat menghasilkan null.

Karena itu, hasilnya dapat digunakan dengan null-conditional operator:

C#

var found = productRepo.Find(p => p.Name == "Mouse");
Console.WriteLine(found?.Price);

?. memastikan property Price hanya diakses jika found tidak null.

Detail mengenai nullable reference types dan aturan T? pada Generic akan lebih mudah dipahami setelah konsep Generic dasarnya sudah dikuasai.

Entitas yang Digunakan

Agar contoh lebih mudah dibayangkan, misalkan aplikasi memiliki tiga entitas berikut:

C#

public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public decimal Total { get; set; }
}

Tidak ada hubungan khusus antara ketiga class tersebut dengan Repository<T>.

Justru di situlah letak manfaat Generic pada contoh ini. Repository<T> tidak perlu mengetahui detail masing-masing class.

Menggunakan Repository<T>

Repository dapat digunakan dengan tipe yang berbeda:

C#

var productRepo = new Repository<Product>();
productRepo.Add(new Product
{
Id = 1,
Name = "Keyboard",
Price = 250000
});
productRepo.Add(new Product
{
Id = 2,
Name = "Mouse",
Price = 120000
});
var customerRepo = new Repository<Customer>();
customerRepo.Add(new Customer
{
Id = 1,
Name = "Dewi"
});

Pencarian juga tetap mendapatkan manfaat dari informasi tipe tersebut:

C#

var found = productRepo.Find(p => p.Name == "Mouse");
Console.WriteLine(found?.Price);
// Output: 120000

Karena productRepo adalah Repository<Product>, compiler mengetahui bahwa p merupakan Product.

Seluruh data di dalam repository juga memiliki tipe yang sama:

C#

foreach (var product in productRepo.GetAll())
{
Console.WriteLine($"{product.Name} - {product.Price}");
}

Output:

Plain Text

Keyboard - 250000
Mouse - 120000

Tidak diperlukan casting ketika mengambil data dari repository.

Satu Class, Banyak Tipe

Pada akhirnya, tiga repository berikut semuanya berasal dari satu class:

C#

var productRepo = new Repository<Product>();
var customerRepo = new Repository<Customer>();
var orderRepo = new Repository<Order>();

Yang berbeda hanya tipe yang diberikan sebagai parameter.

Plain Text

Repository<Product> → T = Product
Repository<Customer> → T = Customer
Repository<Order> → T = Order
Tidak perlu membuat implementasi baru hanya karena tipe datanya berbeda.

Misalnya, ketika aplikasi membutuhkan repository untuk Invoice, cukup gunakan:

C#

var invoiceRepo = new Repository<Invoice>();

Selama operasi yang dibutuhkan memang sama, logika yang sudah ada dapat digunakan kembali.

Mengapa Ini Penting?

Contoh Repository<T> menunjukkan salah satu manfaat utama Generic Class: logika yang tidak bergantung pada tipe tertentu dapat dipisahkan dari tipe data yang digunakan.

Repository<T> tidak perlu mengetahui apakah T adalah Product, Customer, Order, atau Invoice.

Yang perlu diketahui hanyalah bahwa repository bekerja dengan sebuah tipe T.

Hal ini membuat satu implementasi dapat digunakan kembali untuk berbagai tipe data sekaligus tetap mempertahankan type safety.

Generic juga dapat membantu mengurangi duplikasi kode. Jika tiga repository memiliki implementasi yang hampir sama, cukup ada satu implementasi yang perlu diperbaiki dan diuji.

Namun, perlu diingat bahwa contoh Repository<T> di sini terutama digunakan untuk menjelaskan konsep Generic Class. Dalam aplikasi nyata, desain repository dapat membutuhkan pertimbangan lain seperti database access, query, transaction, unit of work, specification, dan kebutuhan arsitektur aplikasi.

Dengan memahami contoh sederhana ini, konsep Generic Class sudah mulai terlihat: class tidak harus mengetahui tipe data yang digunakan sejak awal. Tipe tersebut dapat diberikan ketika class digunakan, sementara compiler tetap menjaga aturan tipenya.

Setelah Generic Class, konsep yang sama dapat diterapkan pada level yang lebih kecil, yaitu Generic Method.

Bagian 6: Generic Method

Generic tidak selalu harus diterapkan pada seluruh class. Ada situasi ketika sebuah class hanya memiliki satu atau beberapa method yang perlu bekerja dengan berbagai tipe data. Jika seluruh class dibuat Generic hanya karena satu method, desainnya justru bisa menjadi lebih rumit dari yang diperlukan. Untuk kondisi seperti ini, Generic Method dapat digunakan. Dengan Generic Method, type parameter hanya berlaku pada method tersebut. Class yang menampungnya tetap dapat berupa class biasa.

Sintaks Dasar

Berikut contoh sederhana:

C#

public T Max<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}

Ada satu bagian yang menjadi ciri khas Generic Method:

C#

Max<T>

<T> diletakkan setelah nama method, bukan setelah nama class. Pada contoh tersebut, T digunakan sebagai tipe dari parameter a, parameter b, dan nilai yang dikembalikan oleh method. Method tersebut dapat digunakan untuk berbagai tipe data selama tipe tersebut memenuhi constraint yang diberikan.

Apa Fungsi where T : IComparable<T>?

Bagian berikut:

C#

where T : IComparable<T>

disebut constraint.

Constraint memberikan batasan mengenai tipe apa saja yang boleh digunakan sebagai T. Dalam contoh Max<T>, method perlu membandingkan a dan b menggunakan:

C#

a.CompareTo(b)

Karena itu, T harus memiliki CompareTo() yang sesuai. IComparable<T> digunakan untuk menyatakan kebutuhan tersebut kepada compiler.

Tanpa constraint:

C#

public T Max<T>(T a, T b)
{
return a.CompareTo(b) > 0 ? a : b;
}

compiler tidak dapat menjamin bahwa semua kemungkinan tipe T memiliki method CompareTo().

Pembahasan mengenai berbagai jenis constraint akan dibahas secara khusus pada bagian berikutnya. Untuk saat ini, cukup dipahami bahwa constraint digunakan untuk membatasi tipe yang dapat digunakan oleh Generic.

Menggunakan Max<T>

Method tersebut dapat digunakan dengan berbagai tipe:

C#

int maxInt = Max(10, 25);
Console.WriteLine(maxInt);
// Output: 25

Method yang sama juga dapat digunakan untuk string:

C#

string maxString = Max("apel", "jeruk");
Console.WriteLine(maxString);
// Output: jeruk

Tidak perlu membuat:

Plain Text

MaxInt()
MaxString()
MaxDouble()

Satu method Generic sudah cukup untuk menangani berbagai tipe yang memenuhi constraint.

Type Inference

Ada hal menarik pada pemanggilan berikut:

C#

Max(10, 25);

Tidak terlihat <int> di sana. Namun, compiler tetap mengetahui bahwa T adalah int.

Compiler dapat melihat tipe argument yang diberikan:

Plain Text

10 → int
25 → int

Kemudian menyimpulkan:

Plain Text

T = int

Proses ini disebut type inference.

Karena itu, penulisan:

C#

int result = Max(10, 25);

setara secara konsep dengan:

C#

int result = Max<int>(10, 25);

Tipe Generic juga dapat ditulis secara eksplisit jika diperlukan:

C#

int result = Max<int>(10, 25);

Dalam kebanyakan kasus, type inference membuat kode lebih ringkas tanpa kehilangan informasi tipe.

Contoh: Swap<T>

Contoh lain yang lebih sederhana adalah method Swap.

Method ini bertugas menukar nilai dari dua variabel:

C#

public void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}

Tanpa Generic, mungkin diperlukan method yang berbeda untuk setiap tipe:

Plain Text

SwapInt()
SwapString()
SwapDouble()

Dengan Generic, cukup ada satu method.

Untuk int:

C#

int x = 1;
int y = 2;
Swap(ref x, ref y);
Console.WriteLine($"{x}, {y}");
// Output: 2, 1

Method yang sama dapat digunakan untuk string:

C#

string first = "kiri";
string second = "kanan";
Swap(ref first, ref second);
Console.WriteLine($"{first}, {second}");
// Output: kanan, kiri

Compiler menentukan T berdasarkan argument yang diberikan.

Pada pemanggilan pertama:

Plain Text

T = int

Sedangkan pada pemanggilan kedua:

Plain Text

T = string

Tidak ada kode yang perlu ditulis ulang.

Contoh: Print<T>

Generic Method juga dapat digunakan untuk membuat method yang menerima nilai dengan tipe apa pun:

C#

public void Print<T>(T value)
{
Console.WriteLine(
$"Nilai: {value}, Tipe: {typeof(T).Name}"
);
}

Method tersebut dapat dipanggil dengan berbagai tipe:

C#

Print(100);
// Output: Nilai: 100, Tipe: Int32
Print("Halo");
// Output: Nilai: Halo, Tipe: String
Print(3.14);
// Output: Nilai: 3.14, Tipe: Double

Pada contoh ini terdapat:

C#

typeof(T)

typeof(T) digunakan untuk mendapatkan informasi Type dari tipe yang digunakan sebagai T.

Meskipun method ditulis secara Generic dan tidak mengetahui tipe konkretnya ketika kode dibuat, informasi tipe tetap tersedia ketika Generic tersebut digunakan.

Hal ini juga menunjukkan bahwa Generic tidak berarti tipe datanya "hilang" atau menjadi object. Informasi tipe tetap dipertahankan.

Contoh: Compare<T>

Generic Method juga dapat digunakan untuk melakukan perbandingan:

C#

public int Compare<T>(T a, T b)
where T : IComparable<T>
{
return a.CompareTo(b);
}

Contoh penggunaannya:

C#

int result = Compare(5, 10);
Console.WriteLine(result);
// Output: -1

Karena 5 lebih kecil daripada 10, CompareTo() menghasilkan nilai negatif.

Method ini mirip dengan Max<T>, tetapi hasilnya bukan salah satu dari kedua nilai tersebut. Hasil perbandingan dikembalikan langsung sehingga kode yang memanggil method dapat menentukan sendiri bagaimana hasil tersebut akan digunakan.

Misalnya:

C#

int result = Compare(5, 10);
if (result < 0)
{
Console.WriteLine("Nilai pertama lebih kecil");
}
else if (result > 0)
{
Console.WriteLine("Nilai pertama lebih besar");
}
else
{
Console.WriteLine("Kedua nilai sama");
}

Generic Method vs Generic Class

Perbedaan antara Generic Class dan Generic Method sebenarnya cukup sederhana. Generic Class menjadikan tipe sebagai bagian dari class tersebut.

Contohnya:

C#

Repository<Product> productRepository = new Repository<Product>();

Instance productRepository tersebut bekerja dengan Product. Jika ingin menggunakan Customer, dibuat instance lain:

C#

Repository<Customer> customerRepository = new Repository<Customer>();

Sementara itu, Generic Method menentukan tipe ketika method dipanggil.

Contohnya:

C#

public class Utility
{
public T Max<T>(T a, T b)
where T : IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}
}

Utility sendiri bukan Generic Class.

Namun, method Max di dalamnya adalah Generic Method.

Karena itu, satu object Utility dapat digunakan untuk berbagai tipe:

C#

var utility = new Utility();
int maxNumber = utility.Max(10, 20);
string maxText = utility.Max("apel", "jeruk");

Pada pemanggilan pertama:

Plain Text

T = int

Pada pemanggilan kedua:

Plain Text

T = string

Tipe T ditentukan secara independen pada setiap pemanggilan method.

Secara sederhana, perbedaannya dapat digambarkan seperti ini:

Plain Text

Generic Class
└── T ditentukan ketika instance dibuat
Repository<Product>
Repository<Customer>
Generic Method
└── T dapat ditentukan setiap kali method dipanggil
Max(10, 20) → T = int
Max("apel", "jeruk") → T = string

Pemilihan antara Generic Class dan Generic Method bergantung pada di mana kebutuhan terhadap tipe Generic tersebut berada.

Jika seluruh object perlu terikat pada suatu tipe, Generic Class mungkin lebih sesuai.

Jika hanya satu operasi yang perlu bekerja dengan berbagai tipe, Generic Method biasanya sudah cukup.

Konsep ini banyak digunakan dalam library .NET. Berbagai method seperti operasi collection, utility, dan algoritma umum dapat dibuat Generic tanpa membuat seluruh class menjadi Generic.

Setelah memahami Generic Class dan Generic Method, bagian berikutnya akan membahas Generic Interface. Di sana, konsep Generic tidak lagi hanya digunakan untuk implementasi, tetapi juga untuk mendefinisikan kontrak yang dapat digunakan oleh berbagai tipe.

Bagian 7: Generic Interface

Selain class dan method, interface juga bisa dibuat generic. Generic Interface berguna ketika sebuah kontrak perlu berlaku untuk berbagai tipe data, tapi tetap ingin memastikan bahwa setiap implementasi konsisten secara struktur.

Mendefinisikan IRepository<T>

Melanjutkan contoh Repository<T> dari bagian sebelumnya, kontrak yang mendasarinya bisa diekstrak menjadi sebuah interface:

C#

public interface IRepository<T>
{
void Add(T item);
bool Remove(T item);
T? Find(Func<T, bool> predicate);
IEnumerable<T> GetAll();
}

Interface ini mendefinisikan apa saja yang harus dimiliki oleh sebuah repository, tanpa menentukan bagaimana data sebenarnya disimpan. Detail penyimpanan diserahkan sepenuhnya kepada class yang mengimplementasikannya.

Implementasi: MemoryRepository<T>

C#

public class MemoryRepository<T> : IRepository<T>
{
private readonly List<T> _items = new List<T>();
public void Add(T item)
{
_items.Add(item);
}
public bool Remove(T item)
{
return _items.Remove(item);
}
public T? Find(Func<T, bool> predicate)
{
return _items.FirstOrDefault(predicate);
}
public IEnumerable<T> GetAll()
{
return _items;
}
}

MemoryRepository<T> menyimpan data di memori menggunakan List<T>, sesuai namanya. Karena mengimplementasikan IRepository<T>, class ini terikat kontrak untuk menyediakan keempat method tersebut, dengan tipe data yang konsisten di setiap methodnya.

Penggunaan Melalui Interface

C#

IRepository<Product> productRepo = new MemoryRepository<Product>();
productRepo.Add(new Product { Id = 1, Name = "Monitor", Price = 1500000 });
var product = productRepo.Find(p => p.Id == 1);
Console.WriteLine(product?.Name); // Output: Monitor

Kode di atas mendeklarasikan variabel bertipe IRepository<Product>, bukan MemoryRepository<Product> secara langsung. Ini adalah praktik umum yang dikenal sebagai program to an interface, not an implementation: kode yang bergantung pada IRepository<T> tidak perlu tahu atau peduli apakah data disimpan di memori, database, atau file — selama kontraknya dipenuhi.

Keuntungan Generic Interface

Ada beberapa keuntungan nyata dari pendekatan ini:

Fleksibilitas implementasi. Di kemudian hari, DatabaseRepository<T> atau CachedRepository<T> bisa dibuat sebagai implementasi alternatif dari IRepository<T>, tanpa mengubah kode yang sudah bergantung pada interface tersebut.

C#

public class DatabaseRepository<T> : IRepository<T>
{
// implementasi berbeda, kontrak tetap sama
}

Kemudahan testing. Saat menulis unit test, implementasi asli seperti DatabaseRepository<T> bisa digantikan dengan implementasi tiruan (mock) yang juga mengimplementasikan IRepository<T>, tanpa menyentuh kode yang diuji.

Konsistensi struktur. Setiap class yang mengimplementasikan IRepository<T> dijamin memiliki method Add, Remove, Find, dan GetAll dengan signature yang sama, sehingga developer lain yang membaca kode langsung tahu apa yang bisa diharapkan dari sebuah repository, tanpa perlu membaca detail implementasinya satu per satu.

Loose coupling. Kode yang bergantung pada IRepository<T> menjadi tidak terikat pada detail implementasi tertentu, sehingga perubahan pada cara data disimpan tidak merambat ke seluruh bagian aplikasi yang menggunakan repository tersebut.

Kombinasi Generic dan interface seperti ini adalah dasar dari banyak pola desain di .NET, termasuk dependency injection, di mana implementasi konkret dari sebuah interface generic bisa didaftarkan dan diganti sesuai kebutuhan tanpa mengubah kode yang menggunakannya.

Bagian 8: Generic Struct

Generic tidak hanya berlaku untuk reference type seperti class dan interface. Value type seperti struct juga bisa dibuat generic, dan penerapannya cukup umum ditemui — salah satu contoh paling dikenal di .NET adalah KeyValuePair<TKey, TValue>, yang sebenarnya adalah struct generic.

Studi Kasus: Pair<TFirst, TSecond>

Struct generic sering digunakan untuk membungkus dua nilai atau lebih yang saling berkaitan, tanpa perlu membuat class terpisah untuk setiap kombinasi tipe data.

C#

public struct Pair<TFirst, TSecond>
{
public TFirst First { get; set; }
public TSecond Second { get; set; }
public Pair(TFirst first, TSecond second)
{
First = first;
Second = second;
}
public override string ToString()
{
return $"({First}, {Second})";
}
}

Pair<TFirst, TSecond> menerima dua type parameter sekaligus, yang tidak harus bertipe sama. Ini memungkinkan penggabungan dua nilai dengan tipe data berbeda dalam satu unit data yang ringkas.

Penggunaan Pair<TFirst, TSecond>

C#

var coordinate = new Pair<int, int>(10, 20);
Console.WriteLine(coordinate); // Output: (10, 20)
var studentScore = new Pair<string, double>("Rani", 92.5);
Console.WriteLine(studentScore); // Output: (Rani, 92.5)
var idStatus = new Pair<int, bool>(101, true);
Console.WriteLine(idStatus); // Output: (101, True)

Ketiga variabel di atas menggunakan struct yang sama, hanya berbeda pada kombinasi tipe data yang diisikan ke TFirst dan TSecond.

Mengapa Struct, Bukan Class?

Perbedaan mendasar antara struct dan class terletak pada cara keduanya disimpan dan disalin. Class adalah reference type — variabel yang menampung instance class sebenarnya hanya menyimpan referensi ke lokasi data di heap. Struct adalah value type — variabel yang menampung instance struct langsung berisi datanya sendiri, biasanya dialokasikan di stack.

Untuk data kecil yang sering dibuat dan dibuang dalam jumlah besar, seperti koordinat, pasangan key-value, atau titik dalam ruang, struct cenderung lebih efisien karena tidak membebani garbage collector dengan alokasi heap yang berulang. Namun, karena struct disalin berdasarkan nilai (bukan referensi), setiap kali sebuah Pair<T1, T2> diteruskan ke method lain atau disalin ke variabel baru, seluruh isinya digandakan.

C#

var original = new Pair<int, int>(1, 2);
var copy = original;
copy.First = 99;
Console.WriteLine(original.First); // Output: 1 (tidak berubah)
Console.WriteLine(copy.First); // Output: 99

Perilaku ini berbeda dari class generic seperti Box<T> pada bagian sebelumnya, di mana menyalin variabel hanya menyalin referensinya, sehingga perubahan pada satu variabel ikut terlihat pada variabel lainnya.

Kapan Menggunakan Struct Generic

Struct generic paling cocok digunakan untuk data yang:

  • berukuran kecil, biasanya hanya berisi beberapa field sederhana
  • bersifat immutable atau jarang diubah setelah dibuat
  • sering dibuat dalam jumlah besar dan berumur pendek, misalnya dalam perhitungan atau iterasi
  • secara semantik memang lebih masuk akal diperlakukan sebagai "nilai" dibanding "objek", seperti koordinat atau rentang angka

Sebaliknya, jika data yang dibungkus cukup besar, sering diubah dan dibagikan antar bagian kode, atau butuh identitas referensi yang konsisten, Generic Class biasanya jadi pilihan yang lebih tepat dibanding Generic Struct.

Bagian selanjutnya akan membahas Multiple Generic Type — bagaimana sebuah class atau struct bisa menerima lebih dari satu type parameter sekaligus, seperti yang sudah terlihat sekilas pada Pair<TFirst, TSecond> di atas.

Bagian 9: Multiple Generic Type

Sejauh ini, sebagian besar contoh menggunakan satu type parameter (T). Namun, seperti yang sudah terlihat pada Pair<TFirst, TSecond> di bagian sebelumnya, sebuah class, struct, interface, atau method bisa menerima lebih dari satu type parameter sekaligus. Salah satu contoh paling umum ditemui di .NET adalah Dictionary<TKey, TValue>.

Dictionary<TKey, TValue>

C#

Dictionary<int, string> users = new Dictionary<int, string>();
users.Add(1, "Andi");
users.Add(2, "Budi");
Console.WriteLine(users[1]); // Output: Andi

Dictionary<TKey, TValue> menggunakan dua type parameter: TKey untuk tipe data kunci, dan TValue untuk tipe data nilai yang terasosiasi dengannya. Kedua tipe ini tidak harus sama — pada contoh di atas, TKey adalah int dan TValue adalah string.

Studi Kasus: Result<TSuccess, TError>

Pola ini sering dipakai untuk merepresentasikan hasil operasi yang bisa berhasil atau gagal, tanpa mengandalkan exception untuk kasus kegagalan yang memang diperkirakan bisa terjadi.

C#

public class Result<TSuccess, TError>
{
public bool IsSuccess { get; }
public TSuccess? Value { get; }
public TError? Error { get; }
private Result(bool isSuccess, TSuccess? value, TError? error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}
public static Result<TSuccess, TError> Success(TSuccess value) =>
new Result<TSuccess, TError>(true, value, default);
public static Result<TSuccess, TError> Failure(TError error) =>
new Result<TSuccess, TError>(false, default, error);
}

C#

Result<int, string> Divide(int a, int b)
{
if (b == 0)
return Result<int, string>.Failure("Tidak bisa membagi dengan nol");
return Result<int, string>.Success(a / b);
}
var result = Divide(10, 2);
if (result.IsSuccess)
Console.WriteLine($"Hasil: {result.Value}"); // Output: Hasil: 5
var failedResult = Divide(10, 0);
if (!failedResult.IsSuccess)
Console.WriteLine($"Error: {failedResult.Error}"); // Output: Error: Tidak bisa membagi dengan nol

Pola Result<TSuccess, TError> memungkinkan kegagalan operasi diperlakukan sebagai bagian normal dari alur program, bukan sebagai kondisi eksepsional yang harus ditangkap dengan try-catch. Pemanggil method secara eksplisit diarahkan untuk memeriksa IsSuccess sebelum mengakses Value, sehingga alur error-handling menjadi lebih eksplisit dan mudah diikuti.

Studi Kasus: Response<TData, TMetadata>

Pola serupa juga umum ditemukan pada respons API, di mana data utama perlu dipisahkan dari informasi tambahan seperti paginasi.

C#

public class Response<TData, TMetadata>
{
public TData Data { get; set; } = default!;
public TMetadata Metadata { get; set; } = default!;
}
public class PaginationInfo
{
public int Page { get; set; }
public int TotalPages { get; set; }
}

C#

var response = new Response<List<Product>, PaginationInfo>
{
Data = new List<Product> { new Product { Id = 1, Name = "Headset", Price = 300000 } },
Metadata = new PaginationInfo { Page = 1, TotalPages = 5 }
};
Console.WriteLine(response.Data[0].Name); // Output: Headset
Console.WriteLine(response.Metadata.TotalPages); // Output: 5

Pair<T1, T2>: Penamaan Type Parameter

Penamaan type parameter tidak harus selalu deskriptif seperti TKey dan TValue. Untuk kasus generik yang benar-benar tidak punya makna spesifik, penamaan sederhana seperti T1 dan T2 juga umum digunakan, terutama pada struct pembantu seperti Pair<T1, T2> yang sudah dibahas sebelumnya.

C#

public struct Pair<T1, T2>
{
public T1 Item1 { get; set; }
public T2 Item2 { get; set; }
}

Meski begitu, praktik terbaik umumnya menyarankan penamaan yang deskriptif ketika maknanya jelas — misalnya TKey/TValue untuk pasangan kunci-nilai, atau TSuccess/TError untuk hasil operasi. Penamaan yang jelas membuat kode lebih mudah dipahami tanpa perlu membaca implementasinya secara detail. Pembahasan lebih lanjut mengenai konvensi penamaan akan dibahas pada bagian Best Practices.

Tidak Ada Batasan Jumlah Type Parameter

Meskipun dua type parameter adalah yang paling umum, C# tidak membatasi jumlahnya secara ketat. Class atau method bisa saja memiliki tiga, empat, atau lebih type parameter jika memang dibutuhkan. Namun, semakin banyak type parameter yang digunakan, semakin kompleks juga kode tersebut untuk dibaca dan dipahami. Umumnya, jika sebuah class atau method sudah membutuhkan lebih dari dua atau tiga type parameter, ini bisa jadi tanda bahwa desainnya perlu dipertimbangkan ulang — topik ini akan disinggung lebih lanjut pada bagian Kesalahan Umum.

Setelah memahami bagaimana Generic bekerja dengan satu atau lebih type parameter, bagian selanjutnya akan membahas salah satu aspek terpenting dari Generic: Constraints — cara membatasi tipe data apa saja yang boleh digunakan sebagai type parameter.

Bagian 10: Generic Constraints

Sejauh ini, type parameter seperti T bisa diisi dengan tipe data apa pun tanpa batasan. Pada sebagian besar kasus, ini baik-baik saja. Tapi ada situasi di mana sebuah Generic Class atau Generic Method perlu melakukan sesuatu yang spesifik terhadap T dan tidak semua tipe data punya kemampuan itu.

Di sinilah constraints berperan. Constraint adalah cara membatasi tipe data apa saja yang boleh digunakan sebagai type parameter, sehingga compiler bisa menjamin bahwa T akan selalu punya kemampuan yang dibutuhkan.

Tanpa constraint, kode berikut tidak akan bisa dikompilasi:

C#

public T CreateInstance<T>()
{
return new T(); // Error: 'T' tidak dijamin punya constructor kosong
}

Compiler tidak tahu apakah setiap kemungkinan tipe T punya constructor tanpa parameter. Constraint where T : new() menyelesaikan masalah ini. Setiap constraint akan dibahas satu per satu berikut ini.

where T : class

Constraint ini membatasi T hanya boleh diisi dengan reference type: class, interface, delegate, atau array. Value type seperti int atau struct tidak diperbolehkan.

C#

public class Cache<T> where T : class
{
private T? _cached;
public void Set(T value) => _cached = value;
public T? Get() => _cached;
}

C#

var cache = new Cache<Product>(); // boleh, Product adalah class
// var invalidCache = new Cache<int>(); // Error: int adalah value type

Constraint ini cocok digunakan ketika logika di dalam class memang mengandalkan sifat reference type, misalnya perbandingan referensi (ReferenceEquals) atau kemungkinan nilai null.

where T : struct

Kebalikan dari class, constraint ini membatasi T hanya boleh diisi dengan value type — termasuk int, double, bool, enum, dan struct custom.

C#

public struct Wrapper<T> where T : struct
{
public T Value { get; set; }
}

C#

var wrapper = new Wrapper<int> { Value = 10 }; // boleh
// var invalidWrapper = new Wrapper<string> { Value = "teks" }; // Error: string adalah reference type

Constraint struct sering dikaitkan dengan Nullable<T>, yang sebenarnya didefinisikan dengan constraint ini di balik layar:

C#

public struct Nullable<T> where T : struct
{
// ...
}

Inilah sebabnya int? (yang merupakan singkatan dari Nullable<int>) valid, tapi string? bukan berasal dari Nullable<T> — karena string adalah reference type dan sudah secara alami bisa bernilai null tanpa perlu dibungkus Nullable<T>.

where T : unmanaged

Constraint ini lebih ketat dari struct, T harus berupa value type yang tidak mengandung reference type apa pun di dalamnya, baik secara langsung maupun tidak langsung (misalnya int, float, bool, atau struct yang seluruh fieldnya juga unmanaged).

C#

public unsafe void PrintBytes<T>(T value) where T : unmanaged
{
int size = sizeof(T);
Console.WriteLine($"Ukuran {typeof(T).Name}: {size} byte");
}

C#

PrintBytes(100); // Output: Ukuran Int32: 4 byte
PrintBytes(3.14); // Output: Ukuran Double: 8 byte

Constraint ini umumnya dibutuhkan pada operasi tingkat rendah yang berkaitan dengan memori, seperti interop dengan kode native atau operasi menggunakan pointer (sizeof(T) pada contoh di atas hanya valid jika T dijamin unmanaged).

where T : notnull

Constraint ini memastikan T tidak boleh berupa tipe nullable, baik reference type yang bisa null (string?) maupun Nullable<T> (int?).

C#

public class NonNullBox<T> where T : notnull
{
public T Value { get; set; }
public NonNullBox(T value)
{
Value = value;
}
}

Perbedaannya dengan where T : class cukup penting untuk dipahami: class membatasi T harus reference type (boleh null secara alami), sedangkan notnull membatasi T tidak boleh bernilai null, tapi tetap membolehkan baik reference type maupun value type selama nilainya dijamin tidak null. Constraint ini banyak dipakai bersamaan dengan nullable reference types, fitur yang membantu compiler memperingatkan potensi NullReferenceException sejak compile-time.

where T : new()

Constraint ini memastikan T punya constructor publik tanpa parameter, sehingga instance baru bisa dibuat langsung dari dalam Generic Class atau Generic Method menggunakan new T().

C#

public T CreateInstance<T>() where T : new()
{
return new T();
}

C#

public class Product
{
public string Name { get; set; } = "Produk Baru";
}
var product = CreateInstance<Product>();
Console.WriteLine(product.Name); // Output: Produk Baru

Tanpa constraint new(), baris return new T(); tidak akan lolos compile, karena compiler tidak bisa memastikan setiap kemungkinan tipe T memang punya constructor kosong yang bisa dipanggil.

where T : BaseClass

Constraint ini membatasi T harus berupa class tertentu atau turunannya (subclass).

C#

public abstract class Animal
{
public abstract string MakeSound();
}
public class Dog : Animal
{
public override string MakeSound() => "Guk!";
}
public class AnimalHandler<T> where T : Animal
{
public void Handle(T animal)
{
Console.WriteLine(animal.MakeSound());
}
}

C#

var handler = new AnimalHandler<Dog>();
handler.Handle(new Dog()); // Output: Guk!

Dengan constraint where T : Animal, method Handle bisa memanggil MakeSound() dengan aman, karena compiler menjamin bahwa T pasti merupakan Animal atau turunannya, sehingga method tersebut pasti tersedia.

where T : Interface

Mirip dengan constraint berbasis class, tapi membatasi T harus mengimplementasikan interface tertentu.

C#

public interface IEntity
{
int Id { get; }
}
public class EntityValidator<T> where T : IEntity
{
public bool IsValid(T entity)
{
return entity.Id > 0;
}
}

C#

public class Order : IEntity
{
public int Id { get; set; }
}
var validator = new EntityValidator<Order>();
Console.WriteLine(validator.IsValid(new Order { Id = 5 })); // Output: True
Console.WriteLine(validator.IsValid(new Order { Id = 0 })); // Output: False

Constraint berbasis interface ini sangat umum ditemui pada pola seperti Generic Repository, di mana entitas yang dikelola perlu dijamin memiliki properti tertentu, misalnya Id, tanpa memaksa entitas tersebut mewarisi class dasar tertentu.

Multiple Constraints

Lebih dari satu constraint bisa digabungkan sekaligus pada satu type parameter, dipisahkan dengan koma.

C#

public class EntityFactory<T> where T : class, IEntity, new()
{
public T CreateNew()
{
return new T();
}
}

Ada urutan penulisan yang perlu diikuti ketika constraint digabungkan:

  1. Constraint tipe utama lebih dulu: class atau struct, jika ada.
  2. Diikuti oleh satu atau lebih constraint interface atau base class.
  3. Constraint new() selalu ditulis paling akhir, jika digunakan.

C#

where T : class, IEntity, IComparable<T>, new()

Perlu dicatat, class dan struct tidak bisa digabungkan sekaligus pada type parameter yang sama, karena keduanya saling eksklusif, T tidak mungkin sekaligus reference type dan value type.

Ringkasan Constraints

ConstraintMembatasi T menjadi
where T : classReference type
where T : structValue type
where T : unmanagedValue type tanpa reference type di dalamnya
where T : notnullTipe apa pun yang dijamin tidak null
where T : new()Tipe dengan constructor publik tanpa parameter
where T : BaseClassBaseClass atau turunannya
where T : InterfaceTipe yang mengimplementasikan Interface

Constraint pada dasarnya adalah cara memberi tahu compiler "percayalah, T akan selalu punya kemampuan ini", sehingga Generic Class atau Generic Method bisa memanfaatkan kemampuan tersebut dengan aman, tanpa perlu casting atau pengecekan tipe secara manual saat runtime.

Bagian 11: Generic Delegates

Delegate adalah sebuah tipe yang dapat menyimpan referensi ke method. Dengan delegate, sebuah method dapat diperlakukan seperti sebuah nilai: disimpan dalam variabel, diberikan sebagai parameter ke method lain, atau dikembalikan sebagai hasil dari sebuah method.

Pembahasan mengenai konsep dasar Delegate, cara membuatnya, serta penggunaannya di C# sudah dibahas lebih dahulu dalam artikel Memahami C# Delegate: Konsep, Cara Kerja, dan Contoh Penggunaan. Bagian ini tidak mengulang seluruh konsep tersebut, tetapi berfokus pada bagaimana Generic dapat digunakan pada Delegate.

Sama seperti class, struct, dan interface, delegate juga dapat dibuat Generic. Dengan Generic Delegate, satu definisi delegate dapat digunakan untuk berbagai tipe data tanpa harus membuat delegate baru untuk setiap variasi tipe.

Mendefinisikan Generic Delegate

Sebagai contoh, buat sebuah delegate yang menerima string dan mengembalikan tipe T:

C#

public delegate T Converter<T>(string value);

Pada deklarasi tersebut:

Plain Text

Converter<T>
└── tipe return ditentukan kemudian

T merupakan type parameter yang belum ditentukan.

Ketika delegate digunakan sebagai:

C#

Converter<int>

maka T menjadi int.

Sedangkan:

C#

Converter<double>

berarti T menjadi double.

Contohnya:

C#

Converter<int> toInt =
value => int.Parse(value);
Converter<double> toDouble =
value => double.Parse(value);
int number = toInt("100");
double price = toDouble("99.5");
Console.WriteLine(number);
// Output: 100
Console.WriteLine(price);
// Output: 99.5

Kedua delegate tersebut berasal dari definisi yang sama:

C#

Converter<T>

Perbedaannya hanya pada tipe return:

Plain Text

Converter<int>
string → int
Converter<double>
string → double

Tanpa Generic, diperlukan delegate yang berbeda untuk setiap tipe hasil:

C#

public delegate int ConverterToInt(string value);
public delegate double ConverterToDouble(string value);

Untuk jumlah tipe yang banyak, pendekatan seperti ini tentu akan menghasilkan definisi yang berulang.

Generic Delegate Bawaan .NET

Dalam praktik sehari-hari, Generic Delegate seperti Converter<T> tidak selalu perlu dibuat sendiri.

.NET sudah menyediakan beberapa Generic Delegate yang mencakup banyak kebutuhan umum:

  • Func<>

  • Action<>

  • Predicate<T>

  • Comparison<T>

Memahami delegate-delegate ini penting karena semuanya sangat sering muncul dalam kode .NET, terutama ketika bekerja dengan collection, LINQ, event, dan berbagai API lainnya.

Func<>

Func<> digunakan untuk merepresentasikan method yang mengembalikan sebuah nilai.

Hal penting yang perlu diingat adalah:

Parameter terakhir pada Func<> selalu merupakan tipe return.

Contohnya:

C#

Func<int, int, int> add = (a, b) => a + b;
Console.WriteLine(add(5, 10));
// Output: 15

Cara membacanya:

Plain Text

Func<int, int, int>
│ │ │
│ │ └── return: int
│ └─────── parameter kedua: int
└──────────── parameter pertama: int

Jadi delegate tersebut merepresentasikan method dengan bentuk:

C#

int SomeMethod(int a, int b);

Contoh lainnya:

C#

Func<string, int> getLength =
text => text.Length;
Console.WriteLine(getLength("Generic"));
// Output: 7

Func<string, int> berarti:

Plain Text

Input : string
Return : int

Sehingga bentuk method yang direpresentasikan kurang lebih:

C#

int SomeMethod(string text);

Func<> dapat menerima beberapa parameter input. .NET menyediakan berbagai generic arity untuk Func, hingga 16 parameter input, dengan satu parameter tambahan untuk return type.

Action<>

Berbeda dengan Func<>, Action<> digunakan untuk method yang tidak mengembalikan nilai, atau dengan kata lain memiliki return type void. Semua type parameter pada Action<> merupakan parameter input.

Contohnya:

C#

Action<string> greet =
name => Console.WriteLine($"Halo, {name}!");
greet("Rani");
// Output: Halo, Rani!

Action<string> dapat dibaca sebagai:

Plain Text

Input : string
Return : void

Contoh lainnya:

C#

Action<int, int> printSum =
(a, b) => Console.WriteLine(a + b);
printSum(3, 4);
// Output: 7

Bentuk method yang direpresentasikan kurang lebih:

C#

void SomeMethod(int a, int b);

Seperti Func<>, Action<> juga tersedia untuk berbagai jumlah parameter.

Predicate<T>

Predicate<T> merupakan delegate yang menerima satu parameter bertipe T dan mengembalikan bool.

Dengan kata lain:

Plain Text

T → bool

Contohnya:

C#

Predicate<int> isEven = number => number % 2 == 0;
Console.WriteLine(isEven(4));
// Output: True
Console.WriteLine(isEven(7));
// Output: False

Delegate tersebut dapat dibaca sebagai:

C#

bool IsEven(int number);

Predicate<T> sering digunakan untuk merepresentasikan kondisi atau pemeriksaan terhadap sebuah nilai.

Misalnya:

C#

List<int> numbers =
new List<int> { 1, 2, 3, 4, 5 };
Predicate<int> isEven =
number => number % 2 == 0;
int firstEven = numbers.Find(isEven);
Console.WriteLine(firstEven);
// Output: 2

Predicate<T> vs Func<T, bool>

Secara fungsi, keduanya memiliki bentuk yang sama:

C#

Predicate<int> predicate = number => number > 10;
Func<int, bool> function = number => number > 10;

Keduanya menerima int dan mengembalikan bool. Perbedaan utamanya adalah nama dan konteks penggunaannya. Predicate<T> secara semantik menunjukkan bahwa delegate tersebut digunakan sebagai sebuah kondisi atau predikat.

Sementara Func<T, bool> lebih umum karena dapat merepresentasikan method apa pun yang menerima T dan menghasilkan bool.

Predicate<T> juga sudah lama menjadi bagian dari .NET dan digunakan oleh beberapa API seperti:

C#

List<T>.Find
List<T>.FindAll
List<T>.RemoveAll

Comparison<T>

Comparison<T> merupakan Generic Delegate yang digunakan untuk membandingkan dua nilai bertipe T. Bentuknya secara konsep adalah:

Plain Text

(T, T) → int

Hasil perbandingan memiliki aturan:

  • angka negatif → nilai pertama dianggap lebih kecil

  • 0 → kedua nilai dianggap setara

  • angka positif → nilai pertama dianggap lebih besar

Contohnya:

C#

Comparison<int> compareDescending = (a, b) => b.CompareTo(a);

Delegate tersebut kemudian dapat diberikan kepada List<T>.Sort:

C#

List<int> numbers =
new List<int> { 5, 1, 4, 2, 3 };
numbers.Sort(compareDescending);
Console.WriteLine(
string.Join(", ", numbers));
// Output:
// 5, 4, 3, 2, 1

Comparison<T> sangat berguna ketika urutan sorting tidak ingin menggunakan aturan default dan membutuhkan logika perbandingan sendiri.

Semua Ini Adalah Generic Delegate

Func<>, Action<>, Predicate<T>, dan Comparison<T> mungkin terlihat seperti fitur khusus bahasa C#, tetapi sebenarnya mereka adalah delegate yang sudah didefinisikan oleh .NET. Misalnya, secara konsep Func didefinisikan seperti:

C#

public delegate TResult Func<TResult>();
public delegate TResult Func<T, TResult>(
T arg);
public delegate TResult Func<T1, T2, TResult>(
T1 arg1,
T2 arg2);
// dan seterusnya

Definisi sebenarnya memiliki lebih banyak variasi untuk mendukung jumlah parameter yang berbeda. Hal yang sama berlaku pada Action<> dan delegate generic bawaan lainnya. Dengan memahami konsep ini, deklarasi seperti:

C#

Func<int, int, int>

tidak lagi terlihat seperti sintaks yang misterius.

Bentuk tersebut cukup dibaca sebagai:

Plain Text

Func<int, int, int>
│ │ │
│ │ └── return type
│ └─────── parameter
└──────────── parameter

Atau secara sederhana:

Method yang menerima dua int dan mengembalikan int.

Begitu pula:

C#

Action<string>

berarti:

Method yang menerima string dan tidak mengembalikan nilai.

Sedangkan:

C#

Predicate<Product>

berarti:

Method yang menerima Product dan mengembalikan bool.

Pola ini sebenarnya tidak berbeda dari Generic Class atau Generic Method yang sudah dibahas sebelumnya. Type parameter tetap digunakan untuk membuat satu definisi dapat bekerja dengan berbagai tipe data.

Bedanya, pada Generic Delegate, type parameter tersebut digunakan untuk mendeskripsikan signature sebuah method.

Plain Text

Generic Class
List<T>
└── T menentukan tipe data yang disimpan
Generic Method
Max<T>(T a, T b)
└── T menentukan tipe data yang diproses
Generic Delegate
Func<T, TResult>
│ │
│ └── tipe return
└─────────── tipe parameter

Inilah yang membuat Generic Delegate sangat fleksibel dan menjadi bagian penting dari API .NET modern.

Bagian 12: Generic Events

Event merupakan mekanisme di C# yang memungkinkan sebuah object memberi tahu bagian lain dari aplikasi bahwa sesuatu telah terjadi. Misalnya, ketika sebuah order berhasil dibuat, OrderService dapat memicu event OrderCreated. Bagian lain dari aplikasi kemudian dapat berlangganan event tersebut dan menjalankan tindakan tertentu, seperti mengirim notifikasi atau mencatat aktivitas.

Pembahasan mengenai konsep dasar Event, cara membuat event, serta mekanisme subscribe dan unsubscribe sudah dibahas lebih dahulu dalam artikel Memahami C# Event: Konsep, Cara Kerja, EventHandler, dan Observer Pattern. Bagian ini berfokus pada hubungan antara Event dan Generic.

Di balik mekanisme ini, event menggunakan delegate. Karena delegate dapat dibuat Generic, pola yang sama juga dapat diterapkan pada event. Salah satu Generic Delegate yang paling sering digunakan untuk event adalah EventHandler<TEventArgs>.

Dengan EventHandler<TEventArgs>, sebuah event dapat membawa data tambahan dengan tipe yang ditentukan secara Generic. Hal ini membuat satu pola event dapat digunakan untuk berbagai jenis data tanpa harus membuat delegate khusus untuk setiap kebutuhan.

EventHandler<TEventArgs>

Secara konsep, EventHandler<TEventArgs> memiliki bentuk:

C#

public delegate void EventHandler<TEventArgs>(
object? sender,
TEventArgs e);

Terdapat dua parameter:

  • sender — object yang memicu event

  • e — data yang dibawa oleh event

Type parameter TEventArgs menentukan jenis data yang dikirim bersama event.

Misalnya:

C#

EventHandler<OrderCreatedEventArgs>

berarti event tersebut menggunakan OrderCreatedEventArgs sebagai data event. Dengan pola ini, event yang berbeda dapat membawa data yang berbeda tanpa perlu membuat delegate baru untuk setiap jenis event.

Membuat Custom EventArgs

Data yang dibawa oleh sebuah event biasanya ditempatkan dalam class khusus yang mewarisi EventArgs. Misalnya, ketika sebuah order berhasil dibuat, event dapat membawa informasi berupa ID order dan total pembayaran:

C#

public class OrderCreatedEventArgs : EventArgs
{
public int OrderId { get; }
public decimal Total { get; }
public OrderCreatedEventArgs(
int orderId,
decimal total)
{
OrderId = orderId;
Total = total;
}
}

Dengan class tersebut, event nantinya dapat membawa dua informasi:

Plain Text

OrderCreatedEventArgs
├── OrderId
└── Total

Pendekatan ini lebih terstruktur dibanding mengirim beberapa nilai secara terpisah melalui parameter event.

Membuat Generic Event

Berikut contoh sebuah OrderService yang memicu event setelah order berhasil dibuat:

C#

public class OrderService
{
public event EventHandler<OrderCreatedEventArgs>? OrderCreated;
public void CreateOrder(int orderId, decimal total)
{
Console.WriteLine(
$"Order #{orderId} sedang diproses...");
// Logika pembuatan order di sini
OrderCreated?.Invoke(
this,
new OrderCreatedEventArgs(orderId, total));
}
}

Perhatikan deklarasi berikut:

C#

public event EventHandler<OrderCreatedEventArgs>? OrderCreated;

Bagian:

Plain Text

EventHandler<OrderCreatedEventArgs>
└── tipe data event

menentukan bahwa subscriber dari OrderCreated akan menerima:

C#

object? sender,
OrderCreatedEventArgs e

Karena e sudah diketahui bertipe OrderCreatedEventArgs, subscriber dapat langsung mengakses:

C#

e.OrderId
e.Total

tanpa casting.

Memicu Event

Event dipicu dengan:

C#

OrderCreated?.Invoke(
this,
new OrderCreatedEventArgs(orderId, total));

Ada dua nilai yang diberikan:

Plain Text

this
└── sender
new OrderCreatedEventArgs(...)
└── event data

this menunjukkan instance OrderService yang sedang memicu event.

Sementara OrderCreatedEventArgs berisi informasi mengenai order yang baru dibuat. Operator ?. memastikan Invoke hanya dijalankan jika terdapat subscriber.

Misalnya belum ada bagian kode yang berlangganan:

C#

OrderCreated?.Invoke(...);

maka event tidak melakukan apa-apa.

Tanpa pengecekan tersebut, pemanggilan event yang belum memiliki subscriber dapat menyebabkan NullReferenceException.

Berlangganan Event

Bagian lain dari aplikasi dapat berlangganan event menggunakan operator +=:

C#

var orderService = new OrderService();
orderService.OrderCreated += (sender, e) =>
{
Console.WriteLine(
$"Notifikasi: Order #{e.OrderId} " +
$"dengan total {e.Total} berhasil dibuat.");
};

Setelah subscriber terdaftar, ketika:

C#

orderService.CreateOrder(1001, 250000);

dijalankan, event akan dipicu dan subscriber tersebut ikut dijalankan.

Output:

Plain Text

Order #1001 sedang diproses...
Notifikasi: Order #1001 dengan total 250000 berhasil dibuat.

Yang menarik adalah tipe e sudah diketahui secara otomatis sebagai:

C#

OrderCreatedEventArgs

Sehingga property seperti:

C#

e.OrderId
e.Total

dapat langsung digunakan.

Tidak diperlukan:

C#

var args = (OrderCreatedEventArgs)e;

Inilah salah satu manfaat Generic pada event: informasi tipe tetap terjaga dari sumber event sampai ke subscriber.

Mengapa Tidak Membuat Delegate Sendiri?

Sebelum pola EventHandler<TEventArgs> digunakan secara luas, sebuah aplikasi dapat membuat delegate khusus untuk setiap jenis event:

C#

public delegate void OrderCreatedHandler(
object sender,
OrderCreatedEventArgs e);
public delegate void OrderCancelledHandler(
object sender,
OrderCancelledEventArgs e);

Jika jumlah event semakin banyak, jumlah delegate custom juga ikut bertambah:

Plain Text

OrderCreatedHandler
OrderCancelledHandler
OrderUpdatedHandler
OrderShippedHandler
OrderCompletedHandler
...

Padahal struktur delegate tersebut hampir selalu sama:

Plain Text

object sender
+
EventArgs tertentu

Perbedaannya hanya pada tipe EventArgs.

Generic menyelesaikan pengulangan tersebut.

Cukup gunakan:

C#

EventHandler<OrderCreatedEventArgs>

untuk satu event:

C#

public event EventHandler<OrderCreatedEventArgs>? OrderCreated;

dan:

C#

EventHandler<OrderCancelledEventArgs>

untuk event lainnya:

C#

public event EventHandler<OrderCancelledEventArgs>? OrderCancelled;

Tidak diperlukan delegate khusus untuk masing-masing event.

Generic Memberikan Pola yang Konsisten

Misalnya terdapat dua jenis event:

C#

public event EventHandler<OrderCreatedEventArgs>? OrderCreated;
public event EventHandler<OrderCancelledEventArgs>? OrderCancelled;

Meskipun data yang dibawa berbeda, pola penggunaannya tetap sama:

Plain Text

Event
├── sender
└── event data
├── OrderCreatedEventArgs
└── OrderCancelledEventArgs

Developer yang sudah memahami pola EventHandler<TEventArgs> tidak perlu mempelajari bentuk delegate baru setiap kali menemukan event lain. Cukup lihat tipe TEventArgs untuk mengetahui jenis data yang dibawa oleh event tersebut.

Apa Peran Generic di Sini?

Pada contoh ini, Generic bukan digunakan untuk membuat event menjadi "bisa menerima tipe apa pun" secara sembarangan. Justru sebaliknya. Generic digunakan untuk menjaga informasi tipe pada event.

Tanpa Generic:

C#

object sender,
object eventData

kode penerima harus mengetahui dan melakukan casting sendiri.

Dengan Generic:

C#

object sender,
OrderCreatedEventArgs e

compiler sudah mengetahui tipe data yang diterima.

Alurnya menjadi:

Plain Text

EventHandler<TEventArgs>
│ TEventArgs = OrderCreatedEventArgs
EventHandler<OrderCreatedEventArgs>
Subscriber menerima
OrderCreatedEventArgs
├── OrderId
└── Total

Jadi, Generic membantu mempertahankan type safety sekaligus membuat pola event menjadi konsisten dan reusable.

Bagian 13: Generic Collections

Setelah membahas Generic pada class, method, interface, struct, delegate, dan event, sekarang saatnya melihat salah satu penggunaan Generic yang paling sering ditemui dalam pengembangan .NET: Generic Collections.

Collection adalah struktur data yang digunakan untuk menyimpan dan mengelola sekumpulan nilai atau object. .NET menyediakan berbagai collection dengan karakteristik yang berbeda, sehingga pemilihan collection sebaiknya disesuaikan dengan kebutuhan.

Beberapa collection generic yang paling sering digunakan adalah:

  • List<T>

  • Dictionary<TKey, TValue>

  • HashSet<T>

  • Queue<T>

  • Stack<T>

Semuanya menggunakan Generic sehingga tipe data yang disimpan dapat ditentukan sejak awal.

List<T>

List<T> adalah collection dinamis yang ukurannya dapat bertambah atau berkurang sesuai jumlah elemen.

Collection ini cocok ketika:

  • urutan data penting

  • data perlu diakses berdasarkan index

  • jumlah elemen dapat berubah selama program berjalan

Contohnya:

C#

List<string> fruits = new List<string>();
fruits.Add("Apel");
fruits.Add("Jeruk");
fruits.Add("Mangga");
fruits.Remove("Jeruk");
foreach (var fruit in fruits)
{
Console.WriteLine(fruit);
}
// Output:
// Apel
// Mangga

Karena didefinisikan sebagai:

C#

List<string>

compiler memastikan bahwa collection tersebut hanya menerima string.

C#

fruits.Add("Apel"); // valid
fruits.Add("Jeruk"); // valid
// fruits.Add(100); // compile error

Elemen juga dapat diakses berdasarkan index:

C#

Console.WriteLine(fruits[0]);
// Output: Apel

Secara umum, akses berdasarkan index pada List<T> memiliki kompleksitas O(1).

Dictionary<TKey, TValue>

Dictionary<TKey, TValue> menyimpan data dalam bentuk pasangan key-value.

Setiap entry memiliki:

Plain Text

Key → Value

Misalnya:

Plain Text

"Keyboard" → 250000
"Mouse" → 120000

Contohnya:

C#

Dictionary<string, decimal> prices = new Dictionary<string, decimal>();
prices["Keyboard"] = 250000;
prices["Mouse"] = 120000;

Data kemudian dapat dicari berdasarkan key:

C#

if (prices.TryGetValue(
"Mouse",
out decimal mousePrice))
{
Console.WriteLine(mousePrice);
// Output: 120000
}

Dictionary<TKey, TValue> sangat berguna ketika data lebih sering dicari berdasarkan identifier daripada berdasarkan posisi.

Misalnya:

Plain Text

User ID → User
Product ID → Product
Order ID → Order
Code → Price
Username → User

Lookup berdasarkan key pada Dictionary<TKey, TValue> umumnya memiliki kompleksitas rata-rata O(1). Namun, Dictionary bukan berarti selalu lebih cepat daripada List<T> dalam semua situasi. Jika data hanya perlu diakses berdasarkan index atau jumlah datanya kecil, List<T> bisa lebih sesuai.

HashSet<T>

HashSet<T> digunakan untuk menyimpan sekumpulan elemen yang unik. Jika elemen yang sama ditambahkan lebih dari satu kali, collection tidak akan menyimpan duplikat tersebut.

C#

HashSet<int> uniqueNumbers = new HashSet<int>();
uniqueNumbers.Add(1);
uniqueNumbers.Add(2);
uniqueNumbers.Add(1);
Console.WriteLine(uniqueNumbers.Count);
// Output: 2

Angka 1 hanya tersimpan satu kali meskipun ditambahkan dua kali. HashSet<T> juga sangat berguna ketika kebutuhan utama adalah memeriksa apakah sebuah nilai sudah ada:

C#

Console.WriteLine(
uniqueNumbers.Contains(2));
// Output:
// True

Operasi Contains pada HashSet<T> umumnya memiliki kompleksitas rata-rata O(1). Contoh penggunaan dalam aplikasi:

Plain Text

ID yang sudah diproses
ID yang sudah dikirim
Kode yang harus unik
Daftar permission
Daftar tag unik

Jika kebutuhan utama adalah menjaga keunikan data dan melakukan pengecekan keberadaan, HashSet<T> biasanya lebih tepat dibanding List<T>.

Queue<T>

Queue<T> digunakan untuk membuat struktur data dengan prinsip:

FIFO — First In, First Out

Artinya, elemen yang pertama masuk akan menjadi elemen pertama yang keluar. Konsepnya mirip dengan antrean:

Plain Text

Masuk
[Tiket 1] → [Tiket 2] → [Tiket 3]
keluar

Contohnya:

C#

Queue<string> ticketQueue = new Queue<string>();
ticketQueue.Enqueue("Tiket 1");
ticketQueue.Enqueue("Tiket 2");
ticketQueue.Enqueue("Tiket 3");

Untuk mengambil elemen paling depan sekaligus mengeluarkannya dari queue, gunakan Dequeue():

C#

string nextTicket =
ticketQueue.Dequeue();
Console.WriteLine(nextTicket);
// Output:
// Tiket 1

Jika hanya ingin melihat elemen paling depan tanpa mengeluarkannya, gunakan Peek():

C#

Console.WriteLine(
ticketQueue.Peek());
// Output:
// Tiket 2

Queue<T> cocok digunakan ketika urutan pemrosesan harus mengikuti urutan kedatangan.

Contohnya:

  • antrean pekerjaan

  • pemrosesan task

  • sistem ticketing

  • message processing

  • antrean request tertentu

Stack<T>

Stack<T> menggunakan prinsip kebalikan dari Queue<T>:

LIFO — Last In, First Out

Artinya, elemen yang terakhir dimasukkan akan menjadi elemen pertama yang keluar. Konsepnya mirip dengan tumpukan buku atau piring:

Plain Text

[halaman-3] ← keluar lebih dulu
[halaman-2]
[halaman-1]

Contohnya:

C#

Stack<string> browserHistory = new Stack<string>();
browserHistory.Push("halaman-1");
browserHistory.Push("halaman-2");
browserHistory.Push("halaman-3");

Untuk mengambil sekaligus menghapus elemen paling atas:

C#

string lastPage =
browserHistory.Pop();
Console.WriteLine(lastPage);
// Output:
// halaman-3

Sedangkan Peek() hanya melihat elemen teratas tanpa menghapusnya:

C#

Console.WriteLine(
browserHistory.Peek());
// Output:
// halaman-2

Stack<T> cocok untuk berbagai skenario yang membutuhkan konsep "yang terakhir dilakukan, dibatalkan lebih dulu".

Contohnya:

  • undo/redo

  • browser history

  • parsing expression

  • backtracking

  • traversal tertentu pada struktur data

Memilih Collection yang Tepat

Kelima collection tersebut sama-sama Generic, tetapi masing-masing dirancang untuk kebutuhan yang berbeda.

CollectionKarakteristik utamaCocok untuk
List<T>Akses berdasarkan index, urutan terjagaData berurutan yang sering diakses berdasarkan posisi
Dictionary<TKey, TValue>Pencarian berdasarkan keyData yang perlu dicari berdasarkan identifier
HashSet<T>Elemen unik, pengecekan keberadaan cepatMenghindari duplikasi dan memeriksa keanggotaan
Queue<T>FIFOAntrean pemrosesan sesuai urutan kedatangan
Stack<T>LIFOUndo, history, dan pemrosesan dari elemen terbaru

Pemilihan collection sebaiknya dimulai dari cara data akan digunakan, bukan hanya dari tipe datanya. Misalnya, terdapat daftar Product. Jika kebutuhan utamanya adalah:

Plain Text

"Ambil product berdasarkan index"

List<Product> merupakan pilihan yang masuk akal.

Jika kebutuhan utamanya:

Plain Text

"Ambil product berdasarkan ProductId"

Dictionary<int, Product> bisa lebih sesuai.

Jika kebutuhan utamanya:

Plain Text

"Pastikan tidak ada ProductId yang duplikat"

HashSet<int> dapat menjadi pilihan yang lebih tepat.

Sedangkan jika kebutuhan utamanya:

Plain Text

"Proses product sesuai urutan masuk"

Queue<Product> lebih sesuai.

Dengan kata lain, tidak ada satu collection yang selalu paling baik. Collection yang tepat bergantung pada operasi yang paling sering dilakukan terhadap data tersebut.

Apa Hubungannya dengan Generic?

Semua collection di atas memanfaatkan Generic.

Misalnya:

C#

List<Product>
Dictionary<int, Product>
HashSet<int>
Queue<Order>
Stack<string>

Generic memungkinkan struktur collection yang sama digunakan untuk berbagai tipe data tanpa kehilangan type safety. Tanpa Generic, collection harus menyimpan elemen sebagai object, seperti yang terjadi pada ArrayList.

Dengan Generic:

C#

List<int>

compiler mengetahui bahwa semua elemen harus berupa int.

Dengan:

C#

List<Product>

compiler mengetahui bahwa semua elemen harus berupa Product.

Dengan demikian, collection dapat tetap reusable tanpa mengorbankan keamanan tipe data. Selain itu, untuk value type seperti int, collection generic seperti List<int> tidak memerlukan boxing setiap kali nilai dimasukkan seperti pada collection berbasis object. Inilah salah satu alasan Generic Collections menjadi fondasi penting dalam .NET.

Hampir setiap aplikasi .NET akan berinteraksi dengan collection dalam satu bentuk atau lainnya. Memahami karakteristik masing-masing collection akan membantu menghasilkan kode yang tidak hanya benar, tetapi juga lebih sesuai dengan kebutuhan aplikasi.

Bagian 14: Generic Factory

Setelah membahas berbagai bentuk Generic dan penggunaannya pada collection, sekarang saatnya melihat bagaimana Generic dapat diterapkan pada Design Pattern. Salah satu pola yang menarik untuk dibahas adalah Factory Pattern.

Factory digunakan ketika proses pembuatan object ingin dipisahkan dari kode yang menggunakan object tersebut. Dengan begitu, kode pemanggil tidak perlu mengetahui detail bagaimana sebuah object dibuat.

Generic dapat membuat pola Factory menjadi lebih sederhana dalam beberapa kasus, terutama ketika jenis object yang akan dibuat sudah diketahui melalui tipe data.

Masalah: Factory Berbasis if-else

Salah satu cara sederhana membuat Factory adalah memeriksa jenis object melalui sebuah nilai, misalnya string:

C#

public class NotificationFactory
{
public INotification Create(string type)
{
if (type == "email")
return new EmailNotification();
else if (type == "sms")
return new SmsNotification();
else if (type == "push")
return new PushNotification();
else
throw new ArgumentException(
"Tipe notifikasi tidak dikenal");
}
}

Kode seperti ini memang dapat bekerja dengan baik untuk jumlah tipe yang sedikit. Namun, seiring bertambahnya jenis notifikasi, method Create juga harus terus diperbarui.

Misalnya ingin menambahkan WhatsAppNotification:

C#

else if (type == "whatsapp")
return new WhatsAppNotification();

Setiap penambahan jenis baru berarti Factory yang sudah ada harus dimodifikasi.

Ada beberapa masalah yang muncul dari pendekatan ini.

1. Factory terus berubah

Semakin banyak jenis object yang didukung, semakin panjang percabangan di dalam Factory.

2. String rawan salah

Pemanggilan seperti:

C#

factory.Create("emial");

akan tetap lolos dari compiler karena "emial" adalah string yang valid. Kesalahannya baru diketahui ketika program dijalankan.

3. Tipe object tidak terlihat dari parameter

Method:

C#

Create("email")

tidak memberi informasi tipe secara langsung kepada compiler. Kode tersebut hanya mengatakan bahwa sebuah string dikirimkan ke Factory.

4. Penambahan tipe membutuhkan perubahan pada Factory

Setiap jenis baru harus dikenali oleh Factory melalui penambahan kondisi baru. Pada aplikasi yang memiliki banyak jenis object, pola seperti ini dapat membuat Factory semakin sulit dipelihara.

Solusi: Generic Factory

Generic menawarkan pendekatan yang berbeda. Daripada memberi tahu Factory melalui string:

C#

Create("email")

jenis object dapat langsung diberikan sebagai type parameter:

C#

Create<EmailNotification>()

Factory sederhananya dapat dibuat seperti berikut:

C#

public static class Factory
{
public static T Create<T>()
where T : new()
{
return new T();
}
}

Kemudian class yang ingin dibuat dapat digunakan secara langsung:

C#

public class EmailNotification
{
public string Send() => "Email terkirim";
}
public class SmsNotification
{
public string Send() => "SMS terkirim";
}

Penggunaannya:

C#

var email = Factory.Create<EmailNotification>();
Console.WriteLine(email.Send());
// Output:
// Email terkirim
var sms =
Factory.Create<SmsNotification>();
Console.WriteLine(sms.Send());
// Output:
// SMS terkirim

Perhatikan perbedaannya:

Plain Text

Factory berbasis string
Create("email")
if-else
new EmailNotification()
Generic Factory
Create<EmailNotification>()
new T()
EmailNotification

Pada Generic Factory, tidak diperlukan string untuk menentukan jenis object.

Peran where T : new()

Ada satu bagian penting dari Generic Factory:

C#

where T : new()

Constraint tersebut berarti T harus memiliki public parameterless constructor. Alasannya sederhana. Di dalam Factory terdapat:

C#

return new T();

Compiler hanya mengizinkan sintaks new T() jika ada jaminan bahwa tipe T memang dapat dibuat menggunakan constructor tanpa parameter.

Tanpa constraint:

C#

public static T Create<T>()
{
return new T(); // compile error
}

Compiler tidak dapat memastikan bahwa setiap tipe yang diberikan sebagai T memiliki constructor yang sesuai. Dengan:

C#

where T : new()

jaminan tersebut diberikan sejak compile-time.

Menggabungkan Generic dengan Interface Constraint

Generic Factory menjadi lebih berguna ketika digabungkan dengan interface constraint.

Misalnya seluruh notification harus memiliki method Send():

C#

public interface INotification
{
string Send();
}

Kemudian beberapa implementasi dibuat:

C#

public class EmailNotification : INotification
{
public string Send()
=> "Email terkirim";
}
public class PushNotification : INotification
{
public string Send()
=> "Push notification terkirim";
}

Factory dapat diberikan dua constraint:

C#

public static class NotificationFactory
{
public static T Create<T>()
where T : INotification, new()
{
var notification = new T();
Console.WriteLine(
$"Membuat notifikasi tipe: {typeof(T).Name}");
return notification;
}
}

Sekarang T harus memenuhi dua syarat:

Plain Text

T
├── harus mengimplementasikan INotification
└── harus memiliki public parameterless constructor

Dengan demikian, Factory dijamin hanya membuat object yang memenuhi kontrak tersebut.

Penggunaannya:

C#

INotification notification = NotificationFactory.Create<PushNotification>();
Console.WriteLine(notification.Send());

Output:

Plain Text

Membuat notifikasi tipe: PushNotification
Push notification terkirim

Apa yang Terjadi Jika Tipe Tidak Memenuhi Constraint?

Misalnya terdapat class:

C#

public class Product
{
}

Class tersebut tidak mengimplementasikan INotification.

Jika dipaksakan:

C#

var product = NotificationFactory.Create<Product>();

compiler akan menolaknya.

Hal yang sama terjadi jika sebuah class mengimplementasikan INotification, tetapi tidak memiliki public parameterless constructor yang dibutuhkan oleh constraint new().

Inilah salah satu keuntungan penting dari Generic:

Banyak aturan yang sebelumnya perlu diperiksa secara manual saat runtime dapat dinyatakan langsung melalui type system dan diperiksa oleh compiler.

Menambahkan Tipe Baru

Misalnya ingin menambahkan:

C#

public class SmsNotification : INotification
{
public string Send()
=> "SMS terkirim";
}

Tidak perlu mengubah NotificationFactory.

Object tersebut langsung dapat dibuat:

C#

var sms =
NotificationFactory.Create<SmsNotification>();
Console.WriteLine(sms.Send());

Factory tetap sama.

Ini berbeda dengan pendekatan if-else, di mana Factory harus mengetahui setiap jenis notification:

C#

if (type == "email")
...
else if (type == "sms")
...
else if (type == "push")
...

Pada Generic Factory, tipe konkret diberikan melalui type parameter:

C#

Create<EmailNotification>()
Create<SmsNotification>()
Create<PushNotification>()

Factory tidak perlu memiliki daftar if-else untuk setiap tipe tersebut.

Type Safety pada Generic Factory

Perbedaan lainnya dapat dilihat dari cara compiler memperlakukan kedua pendekatan. Dengan string:

C#

factory.Create("emial");

Compiler tidak mengetahui bahwa "emial" seharusnya "email". Kesalahan baru ditemukan ketika program berjalan.

Dengan Generic:

C#

NotificationFactory.Create<EmailNotification>();

nama tipe diperiksa langsung oleh compiler.

Jika type tersebut tidak memenuhi constraint:

C#

where T : INotification, new()

program tidak dapat di-build.

Jadi, Generic Factory memanfaatkan type system C# untuk memberikan validasi yang lebih kuat.

Generic Factory Tidak Selalu Menggantikan Semua Factory

Generic Factory bukan berarti setiap Factory sebaiknya dibuat menggunakan Generic. Contoh sebelumnya cocok karena object dapat dibuat secara langsung:

C#

new T()

dan kebutuhan utamanya memang hanya membuat instance berdasarkan tipe.

Namun, pada situasi tertentu, proses pembuatan object bisa lebih kompleks. Misalnya:

Plain Text

EmailNotification
membutuhkan SMTP configuration
SmsNotification
membutuhkan API key
PushNotification
membutuhkan Firebase configuration

Jika setiap object membutuhkan dependency atau konfigurasi yang berbeda, sekadar menggunakan:

C#

new T()

tentu tidak cukup.

Dalam kasus seperti itu, Factory dapat memiliki logic tambahan, menggunakan dependency injection, atau bahkan menggunakan pendekatan Factory Method, Abstract Factory, maupun pattern lainnya.

Jadi, manfaat utama Generic Factory bukan sekadar menghilangkan if-else, melainkan memberikan cara yang type-safe dan reusable ketika object dapat dibuat berdasarkan tipe yang sudah diketahui.

Bagian 15: Generic Repository

Generic Repository sudah diperkenalkan secara singkat pada Bagian 5 melalui Repository<T> dan pada Bagian 7 melalui IRepository<T> serta MemoryRepository<T>. Pada bagian ini, pembahasannya dilanjutkan dengan melihat manfaat praktisnya, sekaligus memahami kondisi ketika pola ini cocok digunakan dan ketika justru lebih baik dihindari.

Mengulas Kembali Implementasi

Berikut implementasi sederhana Generic Repository:

C#

public interface IRepository<T> where T : class
{
void Add(T entity);
void Remove(T entity);
T? GetById(int id);
IEnumerable<T> GetAll();
}
public class Repository<T> : IRepository<T> where T : class
{
private readonly List<T> _items = new List<T>();
public void Add(T entity) => _items.Add(entity);
public void Remove(T entity) => _items.Remove(entity);
public T? GetById(int id)
{
// Implementasi pencarian berdasarkan Id akan dibahas di bawah.
return default;
}
public IEnumerable<T> GetAll() => _items;
}

T menjadi bagian penting dari implementasi tersebut. Artinya, Repository<T> tidak dibuat khusus untuk satu jenis entitas tertentu. Tipe entitas ditentukan saat repository digunakan.

Digunakan untuk Berbagai Entitas

Sebagai contoh, repository yang sama dapat digunakan untuk Product maupun Customer:

C#

IRepository<Product> productRepository = new Repository<Product>();
productRepository.Add(new Product
{
Id = 1,
Name = "Webcam",
Price = 450000
});
IRepository<Customer> customerRepository = new Repository<Customer>();
customerRepository.Add(new Customer
{
Id = 1,
Name = "Fajar"
});

Tanpa Generic, kebutuhan tersebut biasanya mengarah pada pembuatan class terpisah seperti ProductRepository dan CustomerRepository. Padahal, jika operasi yang dilakukan hanya berupa penambahan, penghapusan, dan pengambilan data, logikanya hampir sama.

Generic Repository memungkinkan satu class Repository<T> digunakan untuk berbagai entitas tanpa harus menyalin implementasi yang sama ke banyak class.

Mengapa Generic Repository Banyak Digunakan?

Ada beberapa alasan pola ini cukup populer pada aplikasi .NET, terutama pada aplikasi yang memiliki banyak entitas dan operasi data yang serupa.

Mengurangi Duplikasi Kode

Operasi CRUD dasar seperti Create, Read, Update, dan Delete umumnya memiliki pola yang sama untuk berbagai entitas.

Daripada membuat:

Plain Text

ProductRepository
CustomerRepository
OrderRepository
CategoryRepository

dengan implementasi yang hampir sama, satu Repository<T> dapat menangani berbagai tipe:

Plain Text

Repository<Product>
Repository<Customer>
Repository<Order>
Repository<Category>

Perbedaan utamanya hanya terletak pada tipe data yang dikelola.

Membuat API Lebih Konsisten

Penggunaan repository juga membuat cara mengakses data menjadi lebih seragam.

Sebagai contoh:

C#

IRepository<Product> productRepository;
IRepository<Order> orderRepository;
IRepository<Customer> customerRepository;

Ketiganya memiliki operasi dasar yang sama karena mengikuti kontrak IRepository<T>.

Setelah memahami cara kerja IRepository<Product>, pola penggunaannya pada IRepository<Order> atau IRepository<Customer> menjadi relatif mudah dipahami.

Memudahkan Pengujian

Karena kode dapat bergantung pada IRepository<T>, implementasi repository dapat diganti dengan implementasi lain saat melakukan unit testing.

Sebagai contoh, aplikasi dapat menggunakan repository yang terhubung ke database pada kondisi normal, kemudian menggunakan repository sederhana berbasis memory saat pengujian.

Konsep penggunaan interface sebagai abstraksi untuk mempermudah pengujian ini sudah dibahas pada Bagian 7.

Menambahkan Constraint yang Lebih Berguna

Pada contoh sebelumnya, GetById belum dapat diimplementasikan dengan baik:

C#

public T? GetById(int id)
{
return default;
}

Masalahnya adalah Repository<T> belum mengetahui apakah T memiliki properti bernama Id.

C# tidak mengizinkan kode seperti berikut:

C#

public T? GetById(int id)
{
return _items.FirstOrDefault(item => item.Id == id);
}

Sebab, dari sudut pandang compiler, T bisa berupa tipe apa pun. Belum ada jaminan bahwa tipe tersebut memiliki properti Id.

Salah satu cara untuk memberikan jaminan tersebut adalah menggunakan generic constraint melalui interface:

C#

public interface IEntity
{
int Id { get; }
}

Kemudian Repository<T> dapat dibatasi hanya untuk tipe yang mengimplementasikan IEntity:

C#

public class Repository<T> : IRepository<T>
where T : class, IEntity
{
private readonly List<T> _items = new List<T>();
public void Add(T entity) => _items.Add(entity);
public void Remove(T entity) => _items.Remove(entity);
public T? GetById(int id) =>
_items.FirstOrDefault(item => item.Id == id);
public IEnumerable<T> GetAll() => _items;
}

Product kemudian dapat mengimplementasikan IEntity:

C#

public class Product : IEntity
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}

Dengan constraint:

C#

where T : class, IEntity

compiler sekarang mengetahui bahwa setiap T yang digunakan sebagai repository harus berupa reference type yang mengimplementasikan IEntity.

Karena IEntity memiliki properti Id, Repository<T> dapat mengakses item.Id dengan aman.

Contoh penggunaannya:

C#

var repository = new Repository<Product>();
repository.Add(new Product
{
Id = 1,
Name = "Speaker",
Price = 350000
});
repository.Add(new Product
{
Id = 2,
Name = "Charger",
Price = 150000
});
var product = repository.GetById(2);
Console.WriteLine(product?.Name);
// Output: Charger

Konsep ini menunjukkan salah satu manfaat generic constraint: Generic tetap fleksibel, tetapi memiliki batasan yang jelas mengenai tipe apa saja yang boleh digunakan.

Kapan Generic Repository Cocok Digunakan?

Generic Repository cukup masuk akal ketika sebagian besar entitas memang membutuhkan operasi data yang serupa. Misalnya, sebuah aplikasi memiliki banyak entitas yang pada dasarnya hanya membutuhkan operasi:

  • menambahkan data,

  • mengambil satu data,

  • mengambil seluruh data,

  • memperbarui data,

  • menghapus data.

Dalam kondisi seperti ini, Generic Repository dapat mengurangi pengulangan implementasi dan memberikan struktur yang konsisten.

Kapan Sebaiknya Tidak Digunakan?

Generic Repository bukan solusi yang selalu diperlukan. Ada kondisi tertentu ketika penggunaannya justru dapat membuat kode menjadi lebih kompleks.

Ketika Query Sangat Spesifik

Misalnya, Product membutuhkan pencarian berdasarkan kategori dan rentang harga:

C#

GetByCategoryAndPriceRange(...)

Sementara Order membutuhkan pencarian berdasarkan rentang tanggal dan status:

C#

GetByDateRangeAndStatus(...)

Jika semua kebutuhan tersebut dipaksakan masuk ke satu Repository<T>, interface dapat berkembang menjadi kumpulan method yang tidak semuanya relevan untuk setiap entitas.

Pada titik tertentu, repository khusus seperti ProductRepository dan OrderRepository justru dapat membuat desain lebih jelas.

Ketika Logika Bisnis Terlalu Kompleks

Generic Repository paling mudah dipahami ketika tanggung jawabnya sebatas operasi data umum. Namun, penyimpanan sebuah entitas terkadang melibatkan proses yang lebih kompleks, misalnya:

  • validasi aturan bisnis,

  • transaksi yang melibatkan beberapa entitas,

  • perubahan data pada beberapa tabel,

  • pemanggilan service lain,

  • pembuatan audit log,

  • atau efek samping lain yang berbeda untuk setiap jenis entitas.

Jika semua kebutuhan tersebut dimasukkan ke dalam Generic Repository, abstraction yang awalnya sederhana dapat berubah menjadi semakin rumit.

Ketika Abstraksi Tidak Memberikan Manfaat yang Jelas

Framework atau ORM yang digunakan terkadang sudah menyediakan abstraction untuk mengakses data. Pada Entity Framework Core, misalnya, DbSet<T> sudah menyediakan berbagai operasi untuk bekerja dengan entitas.

Dalam kondisi tertentu, membuat:

Plain Text

DbSet<T>
Generic Repository
Service

hanya untuk meneruskan operasi yang sebenarnya sudah tersedia dapat menambah satu lapisan abstraction tanpa memberikan manfaat yang berarti.

Situasi seperti ini sering disebut sebagai needless abstraction, yaitu abstraction yang dibuat tetapi tidak memberikan nilai yang cukup untuk membenarkan kompleksitas tambahannya.

Karena itu, penggunaan Generic Repository sebaiknya tidak dianggap sebagai aturan yang harus selalu diterapkan. Nilainya bergantung pada kebutuhan dan bentuk aplikasi yang sedang dikembangkan.

Bagian 16: Generic pada Library .NET

Setelah membahas Generic melalui berbagai bentuk, mulai dari class, method, interface, struct, delegate, event, hingga pola desain, bagian ini akan melihat penerapannya secara langsung di library .NET.

Generic bukan hanya fitur yang digunakan saat membuat kode sendiri. Banyak tipe dan API yang digunakan sehari-hari di .NET juga dibangun dengan Generic. Beberapa contohnya adalah Task<T>, Lazy<T>, Nullable<T>, IEnumerable<T>, Func<>, dan Dictionary<TKey, TValue>.

Task<T>

Task<T> merepresentasikan operasi asynchronous yang akan menghasilkan nilai bertipe T. Sebagai contoh:

C#

async Task<string> GetUserNameAsync(int userId)
{
await Task.Delay(100); // Simulasi operasi asynchronous
return "Pengguna-" + userId;
}
string name = await GetUserNameAsync(5);
Console.WriteLine(name);
// Output: Pengguna-5

Pada contoh tersebut, Task<string> menunjukkan bahwa operasi asynchronous tersebut akan menghasilkan string. Tanpa Generic, Task biasa hanya merepresentasikan operasi asynchronous yang tidak menghasilkan nilai secara langsung. Ketika sebuah operasi perlu mengembalikan hasil, Task<T> memungkinkan tipe hasil tersebut ditentukan sejak compile-time. Konsep ini membuat kode yang menggunakan asynchronous method menjadi lebih aman karena compiler dapat mengetahui tipe hasil yang akan diterima.

Lazy<T>

Lazy<T> digunakan ketika pembuatan sebuah objek ingin ditunda sampai objek tersebut benar-benar dibutuhkan. Hal ini dapat berguna ketika proses pembuatan objek cukup mahal, sementara belum ada kepastian bahwa objek tersebut akan digunakan.

C#

Lazy<List<int>> lazyNumbers = new Lazy<List<int>>(() =>
{
Console.WriteLine("Membuat data...");
return Enumerable.Range(1, 1000).ToList();
});
Console.WriteLine("Sebelum diakses");
var numbers = lazyNumbers.Value;
Console.WriteLine($"Total: {numbers.Count}");

Output:

Plain Text

Sebelum diakses
Membuat data...
Total: 1000

Perhatikan bahwa "Membuat data..." baru muncul ketika lazyNumbers.Value diakses. Sebelum titik tersebut, fungsi yang digunakan untuk membuat List<int> belum dijalankan. Lazy<T> memanfaatkan Generic untuk menyimpan informasi mengenai tipe objek yang nantinya akan dibuat. Pada contoh tersebut, tipe objeknya adalah List<int>.

Nullable<T>

Nullable<T> sudah diperkenalkan secara singkat pada Bagian 10.

Generic ini memungkinkan value type seperti int, bool, atau DateTime memiliki nilai null.

C#

Nullable<int> age = null;
if (age.HasValue)
{
Console.WriteLine(age.Value);
}
else
{
Console.WriteLine("Umur belum diisi");
}

Output:

Plain Text

Umur belum diisi

Dalam penggunaan sehari-hari, Nullable<int> biasanya ditulis dengan bentuk yang lebih singkat:

C#

int? age = null;

Keduanya memiliki arti yang sama.

Konsep ini berguna karena value type secara normal selalu memiliki nilai. int misalnya, tidak dapat berisi null. Dengan Nullable<T>, kondisi "belum memiliki nilai" dapat direpresentasikan secara eksplisit.

IEnumerable<T>, ICollection<T>, dan IList<T>

IEnumerable<T>, ICollection<T>, dan IList<T> merupakan beberapa interface generic yang sering digunakan ketika bekerja dengan koleksi di .NET. Ketiganya memiliki kemampuan yang berbeda:

C#

IEnumerable<int> numbers = new List<int> { 1, 2, 3 };
// Dapat diiterasi
foreach (var number in numbers)
{
Console.WriteLine(number);
}

IEnumerable<T> berfokus pada kemampuan untuk melakukan iterasi terhadap sekumpulan data. Sementara itu, ICollection<T> menyediakan kemampuan tambahan untuk memodifikasi koleksi:

C#

ICollection<int> collection = new List<int> { 1, 2, 3 };
collection.Add(4);
collection.Remove(2);

IList<T> menyediakan kemampuan yang lebih spesifik lagi, termasuk akses berdasarkan index:

C#

IList<int> list = new List<int> { 1, 2, 3 };
list[0] = 100;
Console.WriteLine(list[0]);
// Output: 100

Secara sederhana, hubungan kemampuannya dapat digambarkan seperti ini:

Plain Text

IEnumerable<T>
ICollection<T>
IList<T>

Semakin ke bawah, semakin banyak kemampuan yang tersedia.

Generic memungkinkan tipe elemen ditentukan secara eksplisit. Pada IEnumerable<int>, misalnya, compiler mengetahui bahwa setiap elemen yang dihasilkan bertipe int.

Hal yang sama berlaku untuk tipe lain:

C#

IEnumerable<Product>
IEnumerable<Customer>
IEnumerable<string>
IEnumerable<DateTime>

Satu interface dapat digunakan untuk berbagai tipe data tanpa kehilangan type safety.

Func<T> dan Action<T> dalam LINQ

Penerapan Generic juga sangat terlihat pada LINQ.

IEnumerable<T> menjadi salah satu fondasi utama operasi LINQ, sementara banyak method LINQ menggunakan Func<> untuk menerima fungsi sebagai parameter.

Sebagai contoh:

C#

List<Product> products = new List<Product>
{
new Product
{
Id = 1,
Name = "Kabel HDMI",
Price = 75000
},
new Product
{
Id = 2,
Name = "Adapter USB-C",
Price = 120000
},
new Product
{
Id = 3,
Name = "Power Bank",
Price = 250000
}
};
var affordable = products
.Where(p => p.Price < 200000)
.ToList();
var names = products
.Select(p => p.Name)
.ToList();
Console.WriteLine(string.Join(", ", names));
// Output: Kabel HDMI, Adapter USB-C, Power Bank

Pada contoh tersebut, Where membutuhkan fungsi yang menghasilkan bool:

C#

Func<Product, bool>

Sedangkan Select membutuhkan fungsi yang mengubah Product menjadi string:

C#

Func<Product, string>

Lambda expression:

C#

p => p.Price < 200000

dapat digunakan untuk memenuhi Func<Product, bool>.

Sementara:

C#

p => p.Name

memenuhi Func<Product, string>.

Di balik API LINQ yang terlihat sederhana, terdapat kombinasi antara Generic Type, Generic Method, dan Generic Delegate. Inilah salah satu contoh bagaimana beberapa konsep Generic yang telah dibahas sebelumnya bekerja bersama dalam library .NET.

EventHandler<T> dan Dictionary<TKey, TValue>

Beberapa contoh Generic lainnya sudah dibahas lebih mendalam pada bagian sebelumnya. EventHandler<T> digunakan untuk membawa data tambahan ketika sebuah event terjadi. Generic memungkinkan tipe data event tersebut ditentukan sesuai kebutuhan. Sementara itu, Dictionary<TKey, TValue> digunakan untuk menyimpan pasangan key-value:

C#

Dictionary<int, string> users = new Dictionary<int, string>
{
[1] = "Fajar",
[2] = "Rina"
};

Pada contoh tersebut:

  • TKey adalah int.

  • TValue adalah string.

Artinya, dictionary tersebut menggunakan int sebagai key dan string sebagai value. Kedua contoh ini menunjukkan bahwa Generic tidak hanya digunakan untuk koleksi dengan satu tipe data. Generic juga dapat menerima beberapa type parameter sekaligus, seperti pada Dictionary<TKey, TValue>.

Melihat Generic dari Sudut yang Lebih Luas

Dari berbagai contoh tersebut terlihat bahwa Generic bukan sekadar fitur tambahan untuk membuat class atau method menjadi lebih fleksibel.

Generic digunakan di banyak bagian penting .NET:

Plain Text

Asynchronous
└── Task<T>
Collections
├── IEnumerable<T>
├── ICollection<T>
├── IList<T>
└── Dictionary<TKey, TValue>
Delegates
├── Func<T>
├── Action<T>
└── Predicate<T>
Events
└── EventHandler<T>
Object Creation
└── Lazy<T>
Value Types
└── Nullable<T>

Pola yang sama terus muncul: sebuah tipe atau operasi dibuat agar dapat bekerja dengan berbagai jenis data, sementara informasi mengenai tipe tersebut tetap diketahui oleh compiler.

Inilah salah satu alasan Generic menjadi bagian penting dalam desain .NET. Banyak API yang digunakan sehari-hari sebenarnya memanfaatkan konsep yang sama dengan Generic yang telah dibahas pada bagian-bagian sebelumnya.

Dengan memahami Generic secara mendalam, penggunaan berbagai API tersebut menjadi lebih mudah dipahami karena struktur di baliknya tidak lagi terlihat sebagai sesuatu yang terpisah-pisah.

Bagian 17: Best Practices

Setelah memahami berbagai bentuk dan penggunaan Generic, bagian ini merangkum beberapa praktik yang dapat membantu menjaga kode generic tetap jelas, sederhana, dan mudah dipelihara dalam proyek nyata.

Tidak semua aturan berikut bersifat mutlak. Penamaan dan struktur Generic tetap perlu disesuaikan dengan konteks kode. Namun, beberapa konvensi umum dapat menjadi titik awal yang baik.

Gunakan Nama Generic yang Jelas

Konvensi penamaan type parameter di C# umumnya diawali dengan huruf T. Untuk Generic sederhana dengan satu type parameter yang tidak memiliki makna khusus, T biasanya sudah cukup:

C#

public class Box<T>
{
}

Pola yang sama dapat ditemukan pada banyak tipe bawaan .NET, seperti List<T> dan Task<T>. Namun, ketika sebuah class atau method memiliki beberapa type parameter, atau masing-masing tipe memiliki peran yang berbeda, nama yang lebih deskriptif akan membuat kode lebih mudah dipahami.

Contohnya:

C#

// Kurang jelas
public class Response<T1, T2>
{
}

Dibandingkan:

C#

// Lebih jelas
public class Response<TData, TMetadata>
{
}

Nama TData dan TMetadata langsung memberikan gambaran mengenai peran masing-masing type parameter tanpa harus melihat implementasi class terlebih dahulu.

Gunakan T untuk Kasus yang Sederhana

T cocok digunakan ketika type parameter benar-benar bersifat umum dan tidak memiliki peran khusus.

Contohnya:

C#

public class Box<T>
{
public T Value { get; set; } = default!;
}

Tidak ada makna khusus yang perlu dijelaskan dari T. T hanya berarti "tipe apa pun yang diberikan".

Hal yang sama berlaku untuk method yang bersifat umum:

C#

public void Print<T>(T value)
{
Console.WriteLine(value);
}

Namun, ketika type parameter memiliki peran yang lebih spesifik, nama yang lebih deskriptif biasanya lebih membantu.

Gunakan TResult untuk Hasil Operasi

TResult merupakan konvensi yang umum digunakan untuk type parameter yang merepresentasikan hasil dari sebuah operasi.

Konvensi ini juga digunakan oleh Func<> di .NET:

C#

Func<TInput, TResult>

Contoh penerapannya:

C#

public TResult Execute<TInput, TResult>(
TInput input,
Func<TInput, TResult> operation)
{
return operation(input);
}

Pada contoh tersebut:

  • TInput menunjukkan tipe data yang masuk.

  • TResult menunjukkan tipe data yang dihasilkan.

Penamaan tersebut membuat struktur method lebih mudah dipahami dibandingkan penggunaan nama seperti T1 dan T2.

Gunakan TValue untuk Nilai

TValue umum digunakan ketika type parameter merepresentasikan nilai dalam struktur data yang memiliki pasangan key-value. Konvensi ini juga digunakan oleh Dictionary<TKey, TValue>. Sebagai contoh, sebuah cache sederhana dapat dibuat seperti berikut:

C#

public class Cache<TKey, TValue>
where TKey : notnull
{
private readonly Dictionary<TKey, TValue> _store = new();
public void Set(TKey key, TValue value) =>
_store[key] = value;
public TValue? Get(TKey key) =>
_store.TryGetValue(key, out var value)
? value
: default;
}

Pada contoh tersebut:

  • TKey menunjukkan tipe yang digunakan sebagai kunci.

  • TValue menunjukkan tipe data yang disimpan.

Misalnya:

C#

var cache = new Cache<int, string>();
cache.Set(1, "Fajar");
string? name = cache.Get(1);

Penamaan TKey dan TValue membuat hubungan antara kedua type parameter tersebut langsung terlihat.

Gunakan TKey untuk Kunci

TKey biasanya digunakan berpasangan dengan TValue.

Type parameter ini cocok untuk struktur data yang menggunakan sebuah nilai sebagai kunci pencarian:

C#

Dictionary<TKey, TValue>

atau:

C#

Cache<TKey, TValue>

Contoh lain:

C#

public class Lookup<TKey, TValue>
{
// ...
}

Dengan konvensi ini, tidak perlu melihat implementasi class terlebih dahulu untuk mengetahui bahwa TKey digunakan sebagai kunci dan TValue sebagai nilai.

Hindari Generic yang Terlalu Kompleks

Generic memberikan fleksibilitas yang besar, tetapi terlalu banyak fleksibilitas dalam satu tempat dapat membuat kode sulit dipahami.

Sebagai contoh:

C#

public class Processor<
TInput,
TOutput,
TContext,
TValidator,
TLogger>
where TInput : class
where TOutput : class, new()
where TContext : IContext
where TValidator : IValidator<TInput>
where TLogger : ILogger
{
// ...
}

Secara teknis, kode tersebut mungkin valid. Namun, pembaca harus memahami lima type parameter sekaligus, beserta berbagai constraint yang saling berhubungan.

Masalahnya bukan sekadar jumlah Generic parameter. Yang lebih penting adalah seberapa banyak konsep yang harus dipahami sekaligus untuk menggunakan class tersebut.

Jika sebuah Generic Class membutuhkan banyak type parameter dan constraint yang saling berkaitan, kondisi tersebut dapat menjadi tanda bahwa beberapa tanggung jawab sebaiknya dipisahkan ke class atau abstraction yang lebih kecil.

Sebagai contoh, daripada satu class menangani input, output, validation, context, dan logging sekaligus, masing-masing tanggung jawab dapat dipertimbangkan untuk ditempatkan pada bagian yang lebih fokus.

Gunakan Constraint Bila Memang Dibutuhkan

Generic constraint berguna ketika implementasi membutuhkan kemampuan tertentu dari type parameter. Namun, constraint tidak perlu ditambahkan hanya untuk "berjaga-jaga".

Contohnya, class berikut hanya menyimpan dan mengembalikan nilai T:

C#

public class Box<T>
{
public T Value { get; set; } = default!;
}

Tidak ada kebutuhan untuk memberikan constraint tertentu.

T dapat berupa berbagai tipe:

C#

var numberBox = new Box<int>();
var stringBox = new Box<string>();
var productBox = new Box<Product>();

Berbeda dengan repository yang membutuhkan akses ke properti Id:

C#

public class Repository<T>
where T : class, IEntity
{
public T? FindById(int id)
{
// Mengakses T.Id
return default;
}
}

Pada kasus ini, constraint memang diperlukan karena implementasi membutuhkan sesuatu yang dijamin tersedia pada T.

Melalui:

C#

where T : class, IEntity

compiler mengetahui bahwa T harus mengimplementasikan IEntity, sehingga properti Id dapat digunakan dengan aman. Dengan kata lain, constraint sebaiknya mengikuti kebutuhan implementasi, bukan ditambahkan tanpa alasan yang jelas.

Ringkasan Konvensi Penamaan

Beberapa konvensi yang umum digunakan dapat dirangkum sebagai berikut:

KonvensiDigunakan untuk
TType parameter tunggal yang benar-benar generik
TKeyType parameter yang digunakan sebagai kunci
TValueType parameter yang digunakan sebagai nilai
TResultType parameter yang merepresentasikan hasil operasi
TDataData utama
TMetadataInformasi pendukung atau metadata
TSuccessTipe hasil ketika operasi berhasil
TErrorTipe hasil ketika operasi mengalami kegagalan

Konvensi tersebut bukan aturan yang harus diterapkan secara kaku. Yang paling penting adalah nama type parameter dapat membantu pembaca memahami peran tipe tersebut.

Menjaga Generic Tetap Sederhana

Pada akhirnya, beberapa praktik di atas memiliki tujuan yang sama: menjaga Generic tetap mudah dibaca tanpa menghilangkan fleksibilitas yang memang dibutuhkan.

Beberapa prinsip sederhananya:

  • Gunakan T ketika tidak ada makna khusus yang perlu dijelaskan.

  • Gunakan nama deskriptif ketika type parameter memiliki peran tertentu.

  • Gunakan konvensi seperti TKey, TValue, dan TResult ketika sesuai dengan konteksnya.

  • Hindari terlalu banyak type parameter dalam satu abstraction.

  • Tambahkan constraint ketika implementasi memang membutuhkannya.

  • Jangan menambahkan Generic atau constraint hanya karena secara teknis memungkinkan.

Generic yang baik bukan Generic yang paling fleksibel, melainkan Generic yang memberikan fleksibilitas secukupnya tanpa membuat kode menjadi sulit dipahami.

Bagian 18: Kesalahan Umum

Generic dapat membuat kode lebih fleksibel dan aman terhadap kesalahan tipe. Namun, seperti abstraction lainnya, Generic juga dapat menambah kompleksitas jika digunakan pada tempat yang kurang tepat. Bagian ini membahas beberapa pola penggunaan Generic yang sering kali perlu dipertimbangkan kembali.

Menggunakan Generic Padahal Tidak Diperlukan

Generic paling terasa manfaatnya ketika sebuah class atau method memang perlu bekerja dengan beberapa tipe data. Jika sebuah class hanya digunakan untuk satu tipe data dan tidak ada kebutuhan yang jelas untuk mendukung tipe lain, Generic justru dapat membuat kode terlihat lebih rumit.

Sebagai contoh:

C#

// Kurang diperlukan jika validator memang hanya digunakan untuk Product
public class Validator<T> where T : Product
{
public bool IsValid(T product) =>
product.Price > 0;
}

Jika hanya Product yang perlu divalidasi, bentuk yang lebih sederhana dapat lebih mudah dipahami:

C#

public class ProductValidator
{
public bool IsValid(Product product) =>
product.Price > 0;
}

Tidak ada keuntungan berarti dari Generic pada contoh pertama karena variasi tipe yang ingin didukung sebenarnya tidak ada.

Generic lebih baik diperkenalkan ketika kebutuhan untuk mendukung beberapa tipe memang sudah terlihat, bukan semata-mata sebagai persiapan untuk kemungkinan yang belum tentu terjadi.

Menambahkan Constraint yang Tidak Dibutuhkan

Constraint memberikan batasan pada type parameter. Batasan tersebut sebaiknya memiliki alasan yang jelas, terutama ketika berkaitan dengan logika di dalam Generic.

Contohnya:

C#

public class Container<T>
where T : class, IEntity, IComparable<T>, new()
{
private T? _item;
public void Set(T item) =>
_item = item;
public T? Get() =>
_item;
}

Container<T> pada contoh tersebut hanya menyimpan dan mengembalikan T.

Tidak ada bagian yang menggunakan:

  • IEntity,

  • IComparable<T>, atau

  • new().

Akibatnya, constraint tersebut hanya membatasi tipe yang dapat digunakan tanpa memberikan manfaat pada implementasi.

Misalnya, Container<string> menjadi tidak valid karena string tidak memenuhi seluruh constraint tersebut, padahal tidak ada alasan dari implementasi Container<T> yang mengharuskan hal itu.

Versi yang lebih sederhana sudah cukup:

C#

public class Container<T>
{
private T? _item;
public void Set(T item) =>
_item = item;
public T? Get() =>
_item;
}

Constraint sebaiknya ditambahkan ketika Generic memang membutuhkan kemampuan tertentu dari tipe tersebut.

Menggunakan Terlalu Banyak Generic Parameter

Satu class atau method dapat memiliki beberapa type parameter. Namun, semakin banyak type parameter yang digunakan, semakin banyak pula informasi yang perlu dipahami ketika membaca atau menggunakan kode tersebut.

Contohnya:

C#

public TResult Process<TInput, TOutput, TResult, TContext>(
TInput input,
Func<TInput, TContext, TOutput> transform,
Func<TOutput, TResult> finalize,
TContext context)
{
var output = transform(input, context);
return finalize(output);
}

Kode tersebut valid dan mungkin memiliki alasan yang masuk akal dalam konteks tertentu. Namun, pembaca perlu memahami empat type parameter sekaligus:

Plain Text

TInput
TOutput
TResult
TContext

Selain itu, hubungan antar tipe juga perlu diperhatikan melalui kedua Func<>.

Jika pola seperti ini mulai sulit diikuti, beberapa pendekatan dapat dipertimbangkan:

  • memecah proses menjadi beberapa method yang lebih kecil,

  • memindahkan sebagian logika ke class lain,

  • menggunakan class biasa untuk merepresentasikan context atau hasil proses,

  • atau mengurangi type parameter yang sebenarnya tidak diperlukan.

Tujuannya bukan sekadar mengurangi jumlah Generic, tetapi membuat hubungan antarbagian kode lebih mudah dipahami.

Penamaan Type Parameter yang Kurang Jelas

Nama type parameter menjadi semakin penting ketika sebuah Generic memiliki lebih dari satu tipe.

Contohnya:

C#

// Kurang jelas
public class Mapper<T, U>
{
public U Convert(
T input,
Func<T, U> converter) =>
converter(input);
}

Kode tersebut memang valid, tetapi T dan U tidak memberikan banyak informasi mengenai perannya.

Versi berikut lebih deskriptif:

C#

// Lebih jelas
public class Mapper<TSource, TDestination>
{
public TDestination Convert(
TSource input,
Func<TSource, TDestination> converter) =>
converter(input);
}

Dari namanya saja sudah terlihat bahwa:

  • TSource adalah tipe data sumber.

  • TDestination adalah tipe data tujuan.

Nama yang jelas membantu mengurangi kebutuhan untuk membuka implementasi hanya untuk memahami arti sebuah type parameter.

Menggunakan object Padahal Generic Lebih Tepat

Sebelum Generic tersedia, kode .NET sering menggunakan object untuk membuat struktur data yang dapat menyimpan berbagai tipe. Pendekatan seperti ini masih dapat ditemukan pada kode lama.

Contohnya:

C#

public class Stack
{
private readonly List<object> _items = new();
public void Push(object item) =>
_items.Add(item);
public object Pop()
{
var item = _items[^1];
_items.RemoveAt(_items.Count - 1);
return item;
}
}

Masalahnya, object tidak lagi memberikan informasi mengenai tipe data yang sebenarnya disimpan.

Contoh penggunaannya:

C#

var stack = new Stack();
stack.Push(100);
stack.Push("Hello");
int number = (int)stack.Pop();

Casting diperlukan secara manual karena compiler hanya mengetahui bahwa hasil Pop() bertipe object.

Generic dapat membuat struktur tersebut lebih aman:

C#

public class Stack<T>
{
private readonly List<T> _items = new();
public void Push(T item) =>
_items.Add(item);
public T Pop()
{
var item = _items[^1];
_items.RemoveAt(_items.Count - 1);
return item;
}
}

Sekarang tipe data ditentukan ketika Stack<T> dibuat:

C#

var stack = new Stack<int>();
stack.Push(100);
stack.Push(200);
int number = stack.Pop();

Tidak diperlukan casting karena compiler sudah mengetahui bahwa Stack<int> hanya berisi int.

Pendekatan Generic seperti ini memberikan type safety yang lebih baik dan mengurangi kebutuhan casting manual.

Terlalu Cepat Menggeneralisasi Kode

Selain menggunakan Generic ketika tidak diperlukan, ada bentuk lain yang cukup umum: membuat sebuah abstraction generic terlalu dini. Misalnya, sebuah aplikasi baru memiliki satu kebutuhan:

C#

public class ProductFormatter
{
public string Format(Product product)
{
// ...
return string.Empty;
}
}

Kemudian langsung diubah menjadi:

C#

public class Formatter<TInput, TOutput>
{
public TOutput Format(
TInput input,
Func<TInput, TOutput> formatter)
{
return formatter(input);
}
}

Versi kedua memang lebih fleksibel, tetapi fleksibilitas tersebut belum tentu memberikan manfaat jika hanya ada satu jenis formatting yang dibutuhkan. Generic akan lebih masuk akal ketika terdapat kebutuhan nyata untuk menggunakan abstraction yang sama pada beberapa tipe. Dengan kata lain, tidak semua kode yang terlihat memiliki pola serupa harus langsung digeneralisasi.

Cara Mengenali Generic yang Bermasalah

Beberapa pertanyaan berikut dapat membantu ketika mengevaluasi sebuah Generic:

Apakah Generic memang diperlukan?

Apakah type parameter benar-benar digunakan untuk mendukung beberapa tipe data, atau kebutuhan tersebut baru berupa kemungkinan di masa depan? Jika hanya ada satu tipe yang digunakan dan tidak ada alasan kuat untuk mendukung tipe lain, class atau method biasa mungkin sudah cukup.

Apakah Setiap Constraint Memiliki Alasan?

Untuk setiap constraint, coba lihat apakah implementasi memang membutuhkan kemampuan yang diberikan constraint tersebut. Jika tidak pernah digunakan, constraint tersebut mungkin tidak diperlukan.

Apakah Type Parameter Masih Mudah Dipahami?

Empat atau lima type parameter tidak otomatis berarti desainnya buruk. Namun, jika hubungan antar tipe mulai membutuhkan penjelasan panjang hanya untuk memahami cara memanggil sebuah method, abstraction tersebut mungkin sudah terlalu kompleks.

Apakah Nama Type Parameter Membantu?

Bandingkan:

C#

Process<T1, T2, T3>()

dengan:

C#

Process<TInput, TOutput, TContext>()

Nama yang lebih deskriptif biasanya membuat kode lebih mudah dibaca, terutama ketika Generic digunakan di banyak tempat.

Apakah object atau Casting Masih Diperlukan?

Jika sebuah struktur data sebenarnya dirancang untuk bekerja dengan satu tipe tertentu dalam satu penggunaan, Generic sering kali dapat memberikan type safety yang lebih baik dibandingkan object.

Generic yang Baik Tidak Harus yang Paling Fleksibel

Dari beberapa contoh di atas, terlihat bahwa masalah Generic sering kali bukan pada Generic itu sendiri, melainkan pada tingkat abstraction yang dipilih. Generic yang terlalu sedikit mungkin menyebabkan duplikasi kode. Sebaliknya, Generic yang terlalu banyak dapat membuat kode sulit dipahami.

Hal yang sama berlaku untuk constraint. Constraint yang tepat dapat membantu compiler menjaga keamanan tipe, sedangkan constraint yang tidak diperlukan hanya membatasi penggunaan.

Pada akhirnya, beberapa pertanyaan sederhana dapat menjadi panduan:

  • Apakah Generic benar-benar menyelesaikan masalah yang ada?

  • Apakah type parameter memiliki peran yang jelas?

  • Apakah constraint memang diperlukan?

  • Apakah abstraction masih mudah digunakan dan dipahami?

  • Apakah Generic memberikan manfaat yang lebih besar daripada kompleksitas yang ditambahkannya?

Jika jawabannya semakin tidak jelas, bentuk kode yang lebih sederhana mungkin lebih tepat.

Bagian 19: Ringkasan

Perjalanan artikel ini dimulai dari pertanyaan sederhana: mengapa hampir semua collection di .NET menggunakan Generic? Setelah membahas berbagai konsep dan penerapannya, jawabannya menjadi lebih jelas. Sebelum Generic tersedia, object dan ArrayList menjadi salah satu cara untuk membuat kode yang dapat bekerja dengan berbagai tipe data. Namun, pendekatan tersebut memiliki konsekuensi seperti hilangnya type safety, kebutuhan boxing/unboxing pada value type, dan casting yang harus dilakukan secara manual. Generic mengatasi masalah tersebut dengan menjadikan tipe data sebagai parameter, sehingga satu definisi kode dapat digunakan untuk berbagai tipe tanpa kehilangan keamanan tipe dan efisiensi yang ditawarkan oleh tipe data yang konkret.

Konsep Generic kemudian dapat diterapkan dalam berbagai bentuk. Generic Class seperti Repository<T> memungkinkan sebuah class bekerja dengan berbagai entitas, Generic Method seperti Max<T>() memungkinkan sebuah method digunakan untuk berbagai tipe, Generic Interface seperti IRepository<T> memberikan abstraction yang tetap mempertahankan informasi tipe, sedangkan Generic Struct seperti Pair<TFirst, TSecond> dapat menyimpan beberapa tipe data dalam satu struktur. Ketika sebuah abstraction membutuhkan lebih dari satu tipe data, Multiple Generic Type seperti Dictionary<TKey, TValue> menjadi pilihan yang sesuai. Constraints kemudian memberikan batasan pada type parameter ketika implementasi membutuhkan kemampuan tertentu, misalnya class untuk reference type, struct untuk value type, notnull untuk tipe yang tidak boleh bernilai null, new() untuk constructor tanpa parameter, atau constraint berupa class maupun interface tertentu.

Generic juga menjadi bagian penting dari banyak API bawaan .NET. Delegate seperti Func<>, Action<>, Predicate<>, dan Comparison<> menggunakan Generic agar dapat bekerja dengan berbagai tipe data. Konsep yang sama digunakan pada event melalui EventHandler<TEventArgs>, serta pada berbagai collection seperti List<T>, Dictionary<TKey, TValue>, HashSet<T>, Queue<T>, dan Stack<T>. Di luar collection, Generic juga ditemukan pada Task<T> untuk asynchronous programming, Lazy<T> untuk lazy initialization, dan Nullable<T> untuk memungkinkan value type memiliki nilai null. Hal ini menunjukkan bahwa Generic bukan fitur yang berdiri sendiri, tetapi menjadi bagian dari banyak abstraction yang digunakan sehari-hari dalam .NET.

Generic juga dapat digunakan dalam pola desain tertentu. Generic Factory, misalnya, dapat membantu membuat object berdasarkan tipe dengan mengurangi ketergantungan pada percabangan berbasis string. Generic Repository dapat mengurangi pengulangan operasi CRUD pada berbagai entitas. Namun, keduanya bukan pola yang harus selalu digunakan. Jika abstraction yang dibuat justru menambah kompleksitas atau tidak memberikan manfaat yang jelas, implementasi yang lebih sederhana dapat menjadi pilihan yang lebih tepat.

Poin penting dari keseluruhan pembahasan adalah bahwa Generic bukan sekadar sintaks berupa <T>. Generic merupakan salah satu fondasi penting dalam desain .NET karena memungkinkan kode digunakan kembali untuk berbagai tipe dengan tetap mempertahankan informasi tipe. List<T>, Dictionary<TKey, TValue>, Func<T, TResult>, hingga Task<T> mungkin terlihat seperti fitur yang berbeda, tetapi semuanya dibangun di atas ide yang sama: memisahkan logika dari tipe data yang digunakan, tanpa kehilangan type safety.

Memahami Generic juga bukan hanya tentang menghafal sintaks atau mengetahui berbagai tipe bawaan .NET. Yang lebih penting adalah memahami kapan Generic benar-benar memberikan manfaat. Generic dapat membantu mengurangi duplikasi, meningkatkan type safety, dan membuat abstraction lebih reusable. Namun, Generic yang terlalu kompleks, constraint yang tidak diperlukan, atau abstraction yang dibuat tanpa kebutuhan nyata justru dapat membuat kode lebih sulit dipahami. Karena itu, penggunaan Generic tetap perlu mempertimbangkan keseimbangan antara fleksibilitas, kesederhanaan, dan kebutuhan aplikasi.

Pada akhirnya, inti dari Generic dapat dirangkum dalam satu gagasan sederhana: menulis logika sekali agar dapat digunakan untuk berbagai tipe data, tanpa mengorbankan type safety, performa, dan kejelasan kode.

Daftar isi

Bagian 1: Pendahuluan
Bagian 2: Masalah Sebelum Generic

Baca juga

Artikel sebelumnya

Artikel berikutnya

Dibuat pelan-pelan dengan rasa hangat dari Kuningan.

© 2026 Rifky Haekal Al-Fadillah