Banyak pasukan TypeScript menganggap abstract class dan interface sebagai pengisian yang boleh ditukar ganti dalam sandwic yang sama. Sebenarnya tidak. Menggunakan yang salah akan mengakibatkan kesan buruk yang nyata: logik perniagaan yang berulang, pokok kelas yang kaku yang menentang setiap refaktor, dan ralat masa larian (runtime) yang halus yang berpunca daripada kelas asas yang dianggap selamat oleh seseorang. Penyelesaiannya bukanlah dengan menghafal buku peraturan. Ia adalah dengan belajar melihat keperluan sebenar anda dan memilih alat yang sepadan dengannya.
Interface Adalah Kontrak Tulen
Satu interface adalah sempadan masa kompilasi. Ia menerangkan rupa bentuk sesuatu objek dan apa yang ia mesti mampu lakukan, tetapi ia tidak membawa sebarang implementasi. Selepas TypeScript dikompilasi kepada JavaScript, interface tersebut akan hilang sepenuhnya. Ia tidak meninggalkan sebarang constructor, rantaian prototaip, mahupun byte tambahan dalam bundle anda.
Bayangkan anda sedang membina sistem pemberitahuan. Anda mentakrifkan satu interface:
interface Notifier {
send(message: string): void;
readonly channel: string;
}
Mana-mana kelas, atau pun literal objek biasa, yang memenuhi rupa bentuk tersebut adalah Notifier yang sah. Sebuah EmailNotifier boleh melaksanakannya. Begitu juga dengan SmsNotifier, SlackNotifier, atau objek mock yang anda berikan kepada ujian unit. Oleh kerana interface tidak membawa sebarang kod, setiap implementasi menulis kaedah send mereka sendiri dari awal. Itulah sebenarnya yang anda mahukan apabila implementasi tersebut tidak berkongsi apa-apa melainkan antaramuka awamnya.
Abstract Class Membawa Kod Sebenar
Satu abstract class adalah kelas yang lengkap yang kebetulan melarang penginstansian secara langsung. Ia boleh mentakrifkan medan (fields), kaedah konkrit dengan logik, dan logik constructor. Ia memaksa subkelas untuk mengisi bahagian yang kosong melalui kaedah abstrak, tetapi ia juga memberikan tingkah laku warisan yang tidak perlu ditulis sendiri oleh subkelas tersebut.
Bayangkan satu lapisan capaian data. Setiap repositori dalam aplikasi anda perlu menganalisis satu pengecam, mengesahkannya terhadap skema, dan hanya selepas itu menjalankan pertanyaan khusus storan. Satu interface tidak dapat menangkap urutan perkongsian tersebut kerana interface tidak boleh mengandungi kod yang boleh dilaksanakan. Abstract class boleh:
abstract class BaseRepository<T> {
protected validateId(id: string): boolean {
return /^[a-z0-9\-]+$/.test(id);
}
abstract fetchById(id: string): Promise<T | null>;
async findById(id: string): Promise<T | null> {
if (!this.validateId(id)) throw new Error("Invalid ID format");
return this.fetchById(id);
}
}
BaseRepository menguatkuasakan struktur. Ia menuntut subkelas untuk melaksanakan fetchById. Tetapi ia juga membekalkan logik yang berfungsi. Subkelas mendapat panduan keselamatan dan kod boilerplate secara percuma. Jika anda cuba menggantikan ini dengan interface, anda akhirnya akan menyalin validateId dan findById ke dalam setiap satu repositori. Itu bukan abstraksi. Itu adalah cukai penyelenggaraan.
Satu Soalan Yang Menentukan
Pilihan anda haruslah sentiasa berbalik kepada satu soalan tunggal: Adakah kontrak ini perlu membawa kod kongsi?
Jika jawapannya tidak, gunakan interface. Jika jawapannya ya, gunakan abstract class.
Kesilapan dalam hal ini mempunyai kesan serta-merta. Jika anda menggunakan abstract class di mana rupa bentuk (shape) sudah mencukupi, anda memaksa setiap implementasi ke dalam rantaian pewarisan. Ujian unit tiba-tiba memerlukan mocking yang janggal atau stub separa daripada kelas sebenar. Sambungan pihak ketiga mesti menjadi subkelas kepada kelas asas anda dan bukannya sekadar memadankan rupa bentuk. Anda telah menukarkan kontrak yang ringkas menjadi sebuah gunung yang besar.
Sebaliknya, jika anda menggunakan interface di mana tingkah laku kongsi wujud, anda akan menyebarkan salinan logik yang sama ke seluruh kod anda. Apabila logik tersebut mengandungi pepijat, anda tidak dapat membaikinya di satu tempat sahaja. Anda perlu mencari melalui sepuluh implementasi dan berharap anda tidak terlepas yang kesebelas.
Bagaimana Ia Sebenarnya Berbeza Dalam Praktis
Selain daripada perbezaan falsafah, terdapat tiga jurang praktikal yang membezakan kedua-duanya.
Prestasi dan saiz bundle. Interface dipadam semasa kompilasi. Ia tidak menambah sebarang berat kepada output JavaScript anda dan tidak menggunakan memori masa larian. Abstract class dikompilasi menjadi fungsi constructor sebenar dan rantaian prototaip. Setiap satu yang anda takrifkan akan menambah saiz bundle dan duduk di dalam memori apabila diinstansiasi. Pada pelayan yang mengendalikan beribu-ribu instans, atau bundle frontend yang diperhatikan dengan teliti, perbezaan itu bukan sekadar teori.
Fleksibiliti komposisi. Satu kelas boleh melaksanakan banyak interface sekaligus. Anda mungkin membina sebuah FileCache yang melaksanakan Cache, Disposable, dan EventEmitter dalam satu masa. TypeScript berpuas hati kerana interface tidak mengenakan hierarki. Walau bagaimanapun, satu kelas hanya boleh memanjangkan (extend) satu abstract class sahaja. Rantaian prototaip JavaScript tidak menyokong pewarisan berganda. Jika anda terlalu bergantung kepada abstract class, anda akhirnya akan menghadapi dilema klasik iaitu cuba menggabungkan dua kelas asas yang kedua-duanya mengandungi
