Hai Habr!
Suatu hari, saya sekali lagi mendapat kode jenis
if(someParameter.Volatilities.IsEmpty()) { // We have to report about the broken channels, however we could not differ it from just not started cold system. // Therefore write this case into the logs and then in case of emergency IT Ops will able to gather the target line Log.Info("Channel {0} is broken or was not started yet", someParameter.Key) }
Ada satu fitur yang agak penting dalam kode: penerima sangat ingin tahu apa yang sebenarnya terjadi. Memang, dalam satu kasus kami memiliki masalah dengan sistem, dan dalam kasus lain, kami hanya melakukan pemanasan. Namun, model tidak memberi kami ini (untuk menyenangkan pengirim, yang sering menjadi penulis model).
Selain itu, bahkan fakta "mungkin ada sesuatu yang salah" berasal dari kenyataan bahwa koleksi Volatilities
kosong. Yang dalam beberapa kasus mungkin benar.
Saya yakin bahwa sebagian besar pengembang yang berpengalaman dalam kode melihat garis yang berisi pengetahuan rahasia dalam gaya "jika kombinasi flag ini diset, maka kami diminta untuk membuat A, B dan C" (walaupun ini tidak terlihat oleh model itu sendiri).
Dari sudut pandang saya, penghematan semacam itu pada struktur kelas memiliki dampak yang sangat negatif pada proyek di masa depan, mengubahnya menjadi serangkaian peretasan dan penopang, secara bertahap mengubah kode yang lebih mudah atau kurang nyaman menjadi warisan.
Penting: dalam artikel ini saya memberikan contoh yang berguna untuk proyek di mana beberapa pengembang (dan bukan satu), plus yang akan diperbarui dan diperluas untuk setidaknya 5-10 tahun. Semua ini tidak masuk akal jika proyek memiliki satu pengembang selama lima tahun, atau jika tidak ada perubahan yang direncanakan setelah rilis. Dan itu masuk akal, jika proyek hanya diperlukan untuk beberapa bulan, tidak ada gunanya berinvestasi dalam model data yang jelas.
Namun, jika Anda melakukan permainan lama - selamat datang di kucing.
Gunakan pola pengunjung
Seringkali bidang yang sama berisi objek yang dapat memiliki makna semantik yang berbeda (seperti dalam contoh). Namun, untuk menyelamatkan kelas, pengembang hanya menyisakan satu jenis, menyediakannya dengan bendera (atau komentar dengan gaya "jika tidak ada apa pun di sini, maka tidak ada yang dihitung"). Pendekatan serupa mungkin menutupi kesalahan (yang buruk untuk proyek, tetapi nyaman bagi tim yang memasok layanan, karena bug tidak terlihat dari luar). Opsi yang lebih benar, yang memungkinkan bahkan di ujung kabel untuk mengetahui apa yang sebenarnya terjadi, adalah menggunakan antarmuka + pengunjung.
Dalam kasus ini, contoh dari header berubah menjadi kode formulir:
class Response { public IVolatilityResponse Data { get; } } interface IVolatilityResponse { TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) } class VolatilityValues : IVolatilityResponse { public Surface Data; TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) => visitor.Visit(this, input); } class CalculationIsBroken : IVolatilityResponse { TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) => visitor.Visit(this, input); } interface IVolatilityResponseVisitor<TInput, TOutput> { TOutput Visit(VolatilityValues instance, TInput input); TOutput Visit(CalculationIsBroken instance, TInput input); }
Dengan pemrosesan semacam ini:
- Kami membutuhkan lebih banyak kode. Sayangnya, jika kita ingin mengekspresikan lebih banyak informasi dalam model, itu harus lebih.
- Karena pewarisan semacam ini, kami tidak dapat lagi membuat cerita
Response
ke json
/ protobuf
, karena informasi jenis hilang di sana. Kami harus membuat wadah khusus yang akan melakukan ini (misalnya, Anda bisa membuat kelas yang berisi bidang terpisah untuk setiap implementasi, tetapi hanya satu yang akan diisi). - Memperluas model (yaitu, menambahkan kelas-kelas baru) membutuhkan perluasan
IVolatilityResponseVisitor<TInput, TOutput>
, yang berarti kompiler akan memaksanya untuk didukung dalam kode. Programmer tidak akan lupa untuk memproses tipe baru, jika tidak proyek tidak akan dikompilasi. - Karena pengetikan statis, kami tidak perlu menyimpan dokumentasi di suatu tempat dengan kemungkinan kombinasi bidang, dll. Kami menjelaskan semua opsi yang mungkin dalam kode yang dapat dimengerti oleh kompiler dan orang tersebut. Kami tidak akan memiliki desync antara dokumentasi dan kode, karena kami dapat melakukannya tanpa yang pertama.
Tentang pembatasan warisan dalam bahasa lain
Sejumlah bahasa lain (misalnya, Scala
atau Kotlin
) memiliki kata kunci yang memungkinkan Anda untuk melarang pewarisan dari jenis tertentu, dalam kondisi tertentu. Jadi, pada tahap kompilasi, kita tahu semua kemungkinan keturunan tipe kita.
Secara khusus, contoh di atas dapat ditulis ulang di Kotlin
seperti ini:
class Response ( val data: IVolatilityResponse ) sealed class VolatilityResponse class VolatilityValues : VolatilityResponse() { val data: Surface } class CalculationIsBroken : VolatilityResponse()
Ternyata sedikit kurang dari kode, tetapi sekarang dalam proses kompilasi kita tahu bahwa semua kemungkinan VolatilityResponse
berada di file yang sama dengan itu, yang berarti bahwa kode berikut tidak akan dikompilasi, karena kami tidak melalui semua nilai yang mungkin dari kelas.
fun getResponseString(response: VolatilityResponse) = when(response) { is VolatilityValues -> data.toString() }
Namun, perlu diingat bahwa pemeriksaan semacam itu hanya berfungsi untuk panggilan fungsional. Kode di bawah ini akan dikompilasi tanpa kesalahan:
fun getResponseString(response: VolatilityResponse) { when(response) { is VolatilityValues -> println(data.toString()) } }
Tidak semua tipe primitif memiliki arti yang sama
Pertimbangkan pengembangan yang relatif tipikal untuk basis data. Kemungkinan besar, di suatu tempat dalam kode Anda akan memiliki pengidentifikasi objek. Sebagai contoh:
class Group { public int Id { get; } public string Name { get; } } class User { public int Id { get; } public int GroupId { get; } public string Name { get; } }
Sepertinya kode standar. Jenisnya bahkan cocok dengan yang ada di database. Namun, pertanyaannya adalah: apakah kode di bawah ini benar?
public bool IsInGroup(User user, Group group) { return user.Id == group.Id; } public User CreateUser(string name, Group group) { return new User { Id = group.Id, GroupId = group.Id, name = name } }
Jawabannya kemungkinan besar tidak, karena kami membandingkan Id
pengguna dan Id
grup dalam contoh pertama. Dan di yang kedua, kita secara keliru menetapkan id
dari Group
sebagai id
dari User
.
Anehnya, ini cukup mudah untuk diperbaiki: dapatkan saja tipe GroupId
, UserId
, dan sebagainya. Dengan demikian, pembuatan User
tidak akan berfungsi lagi, karena tipe Anda tidak akan bertemu. Yang sangat keren, karena Anda bisa memberi tahu kompiler tentang model.
Selain itu, metode dengan parameter yang sama akan bekerja dengan baik untuk Anda, karena sekarang mereka tidak akan diulang:
public void SetUserGroup(UserId userId, GroupId groupId) { /* some sql code */ }
Namun, mari kita kembali ke contoh perbandingan pengidentifikasi. Ini sedikit lebih rumit, karena Anda harus mencegah kompiler dari membandingkan yang tak tertandingi selama proses pembangunan.
Dan Anda dapat melakukan ini sebagai berikut:
class GroupId { public int Id { get; } public bool Equals(GroupId groupId) => Id == groupId?.Id; [Obsolete("GroupId can be equal only with GroupId", error: true)] public override bool Equals(object obj) => Equals(obj as GroupId) public static bool operator==(GroupId id1, GroupId id2) { if(ReferenceEquals(id1, id2)) return true; if(ReferenceEquals(id1, null) || ReferenceEquals(id2, null)) return false; return id1.Id == id2.Id; } [Obsolete("GroupId can be equal only with GroupId", error: true)] public static bool operator==(object _, GroupId __) => throw new NotSupportedException("GroupId can be equal only with GroupId") [Obsolete("GroupId can be equal only with GroupId", error: true)] public static bool operator==(GroupId _, object __) => throw new NotSupportedException("GroupId can be equal only with GroupId") }
Sebagai hasilnya:
- Kami lagi membutuhkan lebih banyak kode. Sayangnya, jika Anda ingin memberikan lebih banyak informasi kepada kompiler, Anda sering perlu menulis lebih banyak baris.
- Kami telah membuat tipe baru (kami akan berbicara tentang optimasi di bawah), yang kadang-kadang dapat sedikit menurunkan kinerja.
- Dalam kode kami:
- Kami telah melarang untuk membingungkan pengidentifikasi. Baik kompiler dan pengembang sekarang jelas melihat bahwa tidak mungkin untuk
GroupId
bidang GroupId
ke bidang GroupId
- Kita dilarang membandingkan yang tak tertandingi. Saya
IEquitable
bahwa kode perbandingan tidak sepenuhnya selesai (juga diinginkan untuk mengimplementasikan antarmuka IEquitable
, Anda juga harus menerapkan metode GetHashCode
), jadi contohnya tidak hanya perlu disalin ke proyek. Namun, idenya sendiri jelas: kami secara eksplisit melarang kompiler untuk mengungkapkan ketika jenis yang salah dibandingkan. Yaitu alih-alih mengatakan "apakah buah ini sama?" kompiler sekarang melihat "apakah pir sama dengan apel?".
Sedikit lebih banyak tentang sql dan batasan
Seringkali dalam aplikasi kami untuk jenis, aturan tambahan diperkenalkan yang mudah diverifikasi. Dalam kasus terburuk, sejumlah fungsi terlihat seperti ini:
void SetName(string name) { if(name == null || name.IsEmpty() || !name[0].IsLetter || !name[0].IsCapital || name.Length > MAX_NAME_COLUMN_LENGTH) { throw .... } /**/ }
Artinya, fungsi mengambil jenis input yang cukup luas, dan kemudian menjalankan pemeriksaan. Ini umumnya tidak terjadi karena:
- Kami tidak menjelaskan kepada programmer dan kompiler apa yang kami inginkan di sini.
- Dalam fungsi lain yang serupa, Anda harus menyalin cek.
- Ketika kami menerima
string
yang akan menunjukkan name
, kami tidak langsung jatuh, tetapi untuk beberapa alasan eksekusi lanjutan jatuh pada beberapa instruksi prosesor nanti.
Perilaku yang benar:
- Buat jenis yang terpisah (dalam kasus kami, tampaknya,
Name
). - Di dalamnya, lakukan semua validasi dan pemeriksaan yang diperlukan.
- Bungkus
string
di Name
secepat mungkin untuk mendapatkan kesalahan secepat mungkin.
Sebagai hasilnya, kita mendapatkan:
- Lebih sedikit kode, karena kami memeriksa cek untuk
name
di konstruktor. - Strategi Gagal Cepat - sekarang, setelah menerima nama yang bermasalah, kita akan langsung jatuh, alih-alih memanggil beberapa metode lagi, tetapi masih jatuh. Selain itu, alih-alih kesalahan dari database dari tipe tipe tipe terlalu besar, kami segera menemukan bahwa tidak masuk akal untuk mulai memproses nama tersebut.
- Sudah lebih sulit bagi kita untuk mencampur argumen jika tanda tangan fungsinya adalah:
void UpdateData(Name name, Email email, PhoneNumber number)
. Lagi pula, sekarang kita melewati bukan tiga string
identik, tetapi tiga entitas yang berbeda.
Sedikit tentang casting
Memperkenalkan pengetikan yang cukup ketat, kita juga tidak boleh lupa bahwa saat mentransfer data ke Sql, kita masih perlu mendapatkan pengenal nyata. Dan dalam hal ini, masuk akal untuk sedikit memperbarui jenis yang membungkus satu string
:
- Tambahkan implementasi antarmuka antarmuka bentuk
interface IValueGet<TValue>{ TValue Wrapped { get; } }
interface IValueGet<TValue>{ TValue Wrapped { get; } }
. Dalam hal ini, di lapisan terjemahan di Sql, kita bisa mendapatkan nilainya secara langsung - Alih-alih membuat banyak jenis yang kurang lebih identik dalam kode, Anda dapat membuat leluhur abstrak, dan mewarisi sisanya. Hasilnya adalah kode formulir:
interface IValueGet<TValue> { TValue Wrapped { get; } } abstract class BaseWrapper : IValueGet<TValue> { protected BaseWrapper(TValue initialValue) { Wrapped = initialValue; } public TValue Wrapped { get; private set; } } sealed class Name : BaseWrapper<string> { public Name(string value) :base(value) { /*no necessary validations*/ } } sealed class UserId : BaseWrapper<int> { public UserId(int id) :base(id) { /*no necessary validations*/ } }
Performa
Berbicara tentang membuat sejumlah besar jenis, Anda seringkali dapat memenuhi dua argumen dialektik:
- Semakin banyak tipe, kode sarang dan il, semakin lambat perangkat lunaknya, karena semakin sulit bagi jit untuk mengoptimalkan program. Oleh karena itu, pengetikan yang ketat semacam ini akan menyebabkan rem serius dalam proyek.
- Semakin banyak pembungkus, semakin banyak aplikasi memakan memori. Oleh karena itu, menambahkan pembungkus akan secara serius meningkatkan persyaratan RAM.
Sebenarnya, kedua argumen sering diberikan tanpa fakta, namun:
- Bahkan, di sebagian besar aplikasi di java yang sama, string (dan byte array) mengambil memori utama. Artinya, membuat pembungkus umumnya tidak akan terlihat oleh pengguna akhir. Namun, karena jenis pengetikan ini, kami mendapatkan nilai tambah yang penting: ketika menganalisis dump memori, Anda dapat mengevaluasi kontribusi apa yang dibuat oleh masing-masing tipe Anda terhadap memori. Bagaimanapun, Anda melihat bukan hanya daftar garis anonim yang tersebar di proyek. Sebaliknya, kita bisa memahami jenis objek apa yang lebih besar. Plus, karena fakta bahwa hanya Wrappers yang memiliki string dan objek besar lainnya, lebih mudah bagi Anda untuk memahami kontribusi masing-masing jenis pembungkus tertentu terhadap memori bersama.
- Argumen tentang optimasi jit sebagian benar, tetapi tidak sepenuhnya lengkap. Memang, karena pengetikan yang ketat, perangkat lunak Anda mulai menyingkirkan banyak pemeriksaan di pintu masuk ke fungsi. Semua model Anda diperiksa kecukupannya dalam desainnya. Dengan demikian, dalam kasus umum, Anda akan memiliki lebih sedikit pemeriksaan (cukup dengan hanya memerlukan jenis yang benar). Selain itu, karena fakta bahwa cek ditransfer ke konstruktor, dan tidak diolesi oleh kode, menjadi lebih mudah untuk menentukan yang mana dari mereka yang benar-benar membutuhkan waktu.
- Sayangnya, dalam artikel ini saya tidak dapat memberikan tes kinerja lengkap, yang membandingkan proyek dengan sejumlah besar mikrotipe dan dengan pengembangan klasik, hanya menggunakan
int
, string
, dan tipe primitif lainnya. Alasan utama adalah bahwa untuk ini, Anda harus terlebih dahulu membuat proyek tebal yang khas untuk pengujian, dan kemudian membenarkan bahwa proyek khusus ini adalah yang khas. Dan dengan poin kedua, semuanya rumit, karena dalam kehidupan nyata proyeknya sangat berbeda. Namun, akan agak aneh untuk melakukan tes sintetis, karena, seperti yang sudah saya katakan, pembuatan objek mikrotipe di aplikasi Enterprise, menurut pengukuran saya, selalu mengambil sumber daya yang dapat diabaikan (pada tingkat kesalahan pengukuran).
Bagaimana Anda dapat mengoptimalkan kode yang terdiri dari sejumlah besar mikrotipe tersebut.
Penting: Anda harus berurusan dengan optimasi seperti itu hanya ketika Anda menerima fakta yang dijamin bahwa itu adalah mikrotipe yang memperlambat aplikasi. Dalam pengalaman saya, situasi seperti itu agak tidak mungkin. Dengan probabilitas yang lebih tinggi, logger yang sama akan memperlambat Anda, karena setiap operasi sedang menunggu flush ke disk (semuanya dapat diterima pada komputer pengembang dengan M.2 SSD, tetapi pengguna dengan HDD lama melihat hasil yang sama sekali berbeda).
Namun, triknya sendiri:
- Gunakan tipe yang bermakna alih-alih yang referensi. Ini dapat berguna jika Wrapper juga bekerja dengan tipe yang signifikan, yang berarti bahwa secara teori Anda dapat meneruskan semua informasi yang diperlukan melalui tumpukan. Meskipun harus diingat bahwa akselerasi hanya akan terjadi jika kode Anda benar-benar sering menderita GC justru karena mikrotipe.
struct
di .Net dapat menyebabkan tinju / unboxing yang sering. Dan pada saat yang sama, struktur seperti itu mungkin memerlukan lebih banyak memori dalam koleksi Dictionary
/ Map
(karena array dialokasikan dengan margin di dalamnya).- tipe
inline
dari Kotlin / Scala memiliki penerapan terbatas. Misalnya, Anda tidak bisa menyimpan beberapa bidang di dalamnya (yang terkadang dapat berguna untuk menyimpan nilai ToString
/ GetHashCode
). - Sejumlah pengoptimal dapat mengalokasikan memori pada stack. Secara khusus, .Net melakukan ini untuk objek sementara kecil , dan GraalVM di Jawa dapat mengalokasikan objek pada stack, tetapi kemudian menyalinnya ke heap jika harus dikembalikan (cocok untuk kode yang kaya dalam kondisi).
- Gunakan objek interniran (yaitu, coba ambil objek yang sudah jadi, yang dibuat sebelumnya).
- Jika konstruktor memiliki satu argumen, maka Anda bisa membuat cache di mana kuncinya adalah argumen ini, dan nilainya adalah objek yang dibuat sebelumnya. Dengan demikian, jika variasi objek cukup kecil, Anda bisa menggunakan kembali yang sudah jadi.
- Jika suatu objek memiliki beberapa argumen, maka Anda cukup membuat objek baru, dan kemudian memeriksa untuk melihat apakah ada dalam cache. Jika ada yang serupa, maka lebih baik mengembalikan yang sudah dibuat.
- Skema seperti itu memperlambat kerja para desainer, karena
Equals
/ GetHashCode
harus dilakukan untuk semua argumen. Namun, ini juga mempercepat perbandingan objek di masa mendatang, jika Anda menyimpan nilai hash, karena dalam kasus ini, jika mereka berbeda, maka objeknya berbeda. Dan benda-benda identik seringkali memiliki satu tautan. - Namun, optimasi ini akan mempercepat program, karena
GetHashCode
/ Equals
lebih cepat (lihat paragraf di atas). Plus, masa hidup objek baru (yang, bagaimanapun, dalam cache) akan turun secara dramatis, sehingga mereka hanya akan masuk ke Generasi 0.
- Saat membuat objek baru, periksa parameter input, dan jangan sesuaikan. Terlepas dari kenyataan bahwa saran ini sering masuk dalam paragraf tentang gaya pengkodean, pada kenyataannya, ini memungkinkan Anda untuk meningkatkan efektivitas program. Misalnya, jika objek Anda memerlukan string dengan hanya SURAT BESAR, maka dua pendekatan yang sering digunakan untuk memeriksa: membuat
ToUpperInvariant
dari argumen, atau periksa dalam satu lingkaran bahwa semua huruf besar. Dalam kasus pertama, baris baru dijamin akan dibuat, dalam kasus kedua, iterator maksimum dibuat. Akibatnya, Anda menghemat memori (namun, dalam kedua kasus, setiap karakter masih akan diperiksa, sehingga kinerja hanya akan meningkat dalam konteks pengumpulan sampah yang lebih jarang).
Kesimpulan
Sekali lagi, saya akan mengulangi poin penting dari judul: semua hal yang dijelaskan dalam artikel masuk akal dalam proyek-proyek besar yang telah dikembangkan dan digunakan selama bertahun-tahun. Di tempat-tempat yang berarti mengurangi biaya dukungan dan mengurangi biaya penambahan fungsi baru. Dalam kasus lain, sering kali paling masuk akal untuk membuat produk secepat mungkin tanpa repot dengan tes, model, dan "kode yang baik".
Namun, untuk proyek jangka panjang, masuk akal untuk menggunakan pengetikan yang paling ketat, di mana dalam model ini kita dapat secara ketat menggambarkan nilai apa yang mungkin pada prinsipnya.
Jika layanan Anda terkadang dapat mengembalikan hasil yang tidak berfungsi, maka ungkapkan dalam model dan perlihatkan secara eksplisit kepada pengembang. Jangan menambahkan ribuan bendera dengan deskripsi dalam dokumentasi.
Jika tipe Anda bisa sama dalam program, tetapi intinya berbeda dalam bisnis, maka definisikan sama persis. Jangan mencampurnya, bahkan jika jenis bidangnya sama.
Jika Anda memiliki pertanyaan tentang produktivitas, terapkan metode ilmiah dan ikuti tes (atau lebih baik, minta orang independen untuk memeriksa semua ini). Dalam skenario ini, Anda benar-benar akan mempercepat program, dan tidak hanya membuang waktu tim. Namun, yang sebaliknya juga benar: jika ada kecurigaan bahwa program atau pustaka Anda lambat, maka lakukan tes. Tidak perlu mengatakan bahwa semuanya baik-baik saja, cukup tunjukkan dalam jumlah.