Teori Pemrograman: Varian

Halo, nama saya Dmitry Karlovsky dan saya ... Saya ingin memberi tahu Anda tentang fitur mendasar dari sistem tipe, yang sering atau tidak mengerti atau tidak mengerti dengan benar melalui prisma implementasi bahasa tertentu, yang, karena perkembangan evolusi, memiliki banyak atavisme. Karena itu, bahkan jika Anda pikir Anda tahu apa "variasi" itu, cobalah untuk melihat masalah dengan tampilan yang segar. Kami akan mulai dari yang paling dasar, sehingga seorang pemula pun akan mengerti segalanya. Dan kami terus tanpa air, sehingga bahkan pro akan berguna untuk menyusun pengetahuan mereka. Contoh kode akan menggunakan bahasa pseudo yang mirip dengan TypeScript. Kemudian pendekatan dari beberapa bahasa nyata akan diperiksa. Dan jika Anda mengembangkan bahasa Anda sendiri, maka artikel ini akan membantu Anda untuk tidak menginjak penggaruk orang lain.


bagaimana jika ada rubah?


Argumen dan Parameter


Parameter adalah apa yang kami terima. Menjelaskan tipe parameter, kami menetapkan batasan pada set tipe yang dapat diteruskan kepada kami. Beberapa contoh:


//   function log( id : string | number ) {} //   class Logger { constructor( readonly id : Natural ) {} } //   class Node< Id extends Number > { id : Id } 

Argumen adalah apa yang kami sampaikan. Pada saat transfer, argumen selalu memiliki beberapa tipe tertentu. Namun, dalam analisis statis, tipe tertentu mungkin tidak diketahui, itulah sebabnya kompiler kembali beroperasi dengan batasan tipe. Beberapa contoh:


 log( 123 ) //   new Logger( promptStringOrNumber( 'Enter id' ) ) //       new Node( 'root' ) //   ,   

Subtipe


Jenis dapat membentuk hierarki. Subtipe adalah kasus khusus dari supertipe . Subtipe dapat dibentuk dengan mempersempit set nilai yang mungkin dari suatu supertipe. Misalnya, tipe Natural adalah subtipe Integer dan Positive. Dan ketiganya adalah subtipe dari Real pada saat yang sama. Dan tipe Perdana adalah subtipe dari semua yang di atas. Pada saat yang sama, tipe Positif dan Integer tumpang tindih, tetapi tidak ada yang menyempit yang lain.


gambar


Cara lain untuk membentuk subtipe adalah memperluasnya dengan menggabungkannya dengan tipe lain ortogonal. Misalnya, ada "figur warna" dengan properti "warna", dan ada "kotak" dengan properti "tinggi". Dengan menggabungkan tipe-tipe ini kita mendapatkan "kotak warna". Dan menambahkan "lingkaran" dengan "jari-jari" kita bisa mendapatkan "silinder warna".


gambar


Hierarki


Untuk narasi lebih lanjut, kita membutuhkan hierarki kecil hewan dan hierarki sel yang serupa.


 abstract class Animal {} abstract class Pet extends Animal {} class Cat extends Pet {} class Dog extends Pet {} class Fox extends Animal {} class AnimalCage { content : Animal } class PetCage extends AnimalCage { content : Pet } class CatCage extends PetCage { content : Cat } class DogCage extends PetCage { content : Dog } class FoxCage extends AnimalCage { content : Fox } 

Semuanya di bawah ini adalah penyempitan dari tipe di atas. Kandang dengan hewan peliharaan hanya bisa berisi hewan piaraan, tetapi bukan hewan liar. Sangkar dengan anjing hanya bisa berisi anjing.


gambar


Kovarian


Yang paling sederhana dan paling dapat dipahami adalah pembatasan supertipe atau kovarians. Dalam contoh berikut, parameter fungsi adalah kovarian dengan tipe yang ditentukan untuknya. Artinya, fungsinya dapat menerima tipe ini sendiri dan subtipe apa pun darinya, tetapi tidak dapat menerima tipe super atau tipe lainnya.


 function touchPet( cage : PetCage ) : void { log( `touch ${cage.content}` ) } touchPet( new AnimalCage ) // forbid touchPet( new PetCage ) // allow touchPet( new CatCage ) // allow touchPet( new DogCage ) // allow touchPet( new FoxCage ) // forbid 

gambar


Karena kita tidak mengubah apa pun di dalam kandang, kita dapat dengan aman mentransfer fungsi ke kandang bersama kucing, karena itu tidak lebih dari kandang kandang khusus dengan hewan peliharaan.


Contravariance


Agak sulit untuk memahami batasan subtipe atau contravariance. Dalam contoh berikut, parameter fungsi bertentangan dengan tipe yang ditentukan untuknya. Yaitu, fungsi dapat menerima tipe ini sendiri dan salah satu dari supertipenya, tetapi tidak dapat menerima subtipe atau tipe lainnya.


 function pushPet( cage : PetCage ) : void { const Pet = random() > .5 ? Cat : Dog cage.content = new Pet } pushPet( new AnimalCage ) // allow pushPet( new PetCage ) // allow pushPet( new CatCage ) // forbid pushPet( new DogCage ) // forbid pushPet( new FoxCage ) // forbid 

gambar


Kami tidak dapat melewati kandang bersama kucing, karena fungsinya dapat menempatkan anjing di sana, yang tidak diizinkan. Tetapi kandang dengan hewan apa pun dapat dipindahkan dengan aman, karena kucing dan anjing dapat ditempatkan di sana.


Invarian


Subtipe dan supertipe pembatas dapat dilakukan secara bersamaan. Kasus seperti ini disebut invarian. Dalam contoh berikut, parameter fungsi tidak sama dengan tipe yang ditentukan untuknya. Artinya, fungsi hanya dapat menerima jenis yang ditentukan dan tidak lebih.


 function replacePet( cage : PetCage ) : void { touchPet( cage ) pushPet( cage ) } replacePet( new AnimalCage ) // forbid replacePet( new PetCage ) // allow replacePet( new CatCage ) // forbid replacePet( new DogCage ) // forbid replacePet( new FoxCage ) // forbid 

gambar


Fungsi replacePet mewarisi keterbatasan fungsi-fungsi yang digunakan secara internal: butuh pembatasan pada jenis dari pushPet , dan pembatasan subtipe dengan pushPet . Jika kita memberinya kandang dengan hewan apa pun, dia tidak akan bisa memindahkannya ke fungsi touchPet, yang tidak tahu cara bekerja dengan rubah (hewan liar hanya akan menggigit jari). Dan jika kita mentransfer kandang dengan kucing, maka itu tidak akan berfungsi untuk memanggil pushPet .


Bivarians


Kita tidak bisa tidak menyebutkan tidak adanya batasan eksotis - bivarians. Dalam contoh berikut, suatu fungsi dapat menerima jenis apa pun yang merupakan subtipe atau subtipe.


 function enshurePet( cage : PetCage ) : void { if( cage.content instanceof Pet ) return pushPet( cage ) } replacePet( new AnimalCage ) // allow replacePet( new PetCage ) // allow replacePet( new CatCage ) // allow replacePet( new DogCage ) // allow replacePet( new FoxCage ) // forbid 

gambar


Di dalamnya Anda dapat mentransfer kandang dengan hewan. Kemudian dia akan memeriksa apakah ada hewan peliharaan di kandang, kalau tidak dia akan memasukkannya ke dalam hewan peliharaan acak. Dan Anda dapat mentransfer, misalnya, kandang dengan kucing, maka dia tidak akan melakukan apa-apa.


Generalisasi


Beberapa percaya bahwa varians entah bagaimana terkait dengan generalisasi. Seringkali karena varians sering dijelaskan menggunakan wadah generik sebagai contoh. Namun, dalam keseluruhan cerita kami masih belum memiliki generalisasi tunggal - itu sepenuhnya kelas konkret:


 class AnimalCage { content : Animal } class PetCage extends AnimalCage { content : Pet } class CatCage extends PetCage { content : Cat } class DogCage extends PetCage { content : Dog } class FoxCage extends AnimalCage { content : Fox } 

Ini dilakukan untuk menunjukkan bahwa masalah varians tidak terhubung dengan generalisasi. Generalisasi diperlukan hanya untuk mengurangi copy-paste. Misalnya, kode di atas dapat ditulis ulang melalui generalisasi sederhana:


 class Cage<Animal> { content : Animal } 

Dan sekarang Anda dapat membuat contoh sel apa pun:


 const animalCage = new Cage<Animal>() const petCage = new Cage<Pet>() const catCage = new Cage<Cat>() const dogCage = new Cage<Dog>() const foxCage = new Cage<Fox>() 

Deklarasi keterbatasan


Harap perhatikan bahwa tanda tangan dari keempat fungsi yang terdaftar sebelumnya persis sama:


 ( cage : PetCage )=> void 

Yaitu, uraian tentang parameter yang diterima dari fungsi tidak memiliki kelengkapan - tidak dapat dikatakan darinya sehingga dapat ditransfer ke fungsi. Nah, kecuali jika jelas terlihat bahwa itu pasti tidak layak melewati sangkar dengan rubah ke dalamnya.


Oleh karena itu, dalam bahasa modern ada cara untuk secara eksplisit menunjukkan batasan jenis apa yang dimiliki parameter. Misalnya, pengubah masuk dan keluar di C #:


 interface ICageIn<in T> { T content { set; } } // contravariant generic parameter interface ICageOut<out T> { T content { get; } } // covariant generic parameter interface ICageInOut<T> { T content { get; set; } } // invariant generic parameter 

Sayangnya, dalam C # untuk setiap varian pengubah perlu dimulai pada antarmuka yang terpisah. Selain itu, seperti yang saya pahami, bivarians dalam C # umumnya tidak dapat diekspresikan.


Parameter keluaran


Fungsi tidak hanya dapat menerima, tetapi juga mengembalikan nilai. Secara umum, nilai pengembalian mungkin bukan satu. Sebagai contoh, ambil fungsi membawa kandang dengan hewan peliharaan dan mengembalikan dua hewan peliharaan.


 function getPets( input : PetCage ) : [ Pet , Pet ] { return [ input.content , new Cat ] } 

Fungsi seperti itu setara dengan fungsi yang membutuhkan, selain satu parameter input, dua output lagi.


 function getPets( input : PetCage , output1 : PetCage , output2 : PetCage ) : void { output1.content = input.content output2.content = new Cat } 

Kode eksternal mengalokasikan memori tambahan pada stack sehingga fungsi menempatkan semua yang diinginkan untuk dikembalikan ke dalamnya. Dan setelah selesai, kode panggilan sudah dapat menggunakan wadah ini untuk tujuan mereka sendiri.


gambar


Dari ekivalensi dari dua fungsi ini mengikuti bahwa nilai yang dikembalikan oleh fungsi, berbeda dengan parameter, selalu bertentangan dengan tipe output yang ditentukan. Untuk suatu fungsi dapat menulis kepada mereka, tetapi tidak dapat membaca dari mereka.


Metode Objek


Metode objek adalah fungsi yang mengambil pointer tambahan ke objek sebagai parameter implisit. Artinya, dua fungsi berikut ini setara.


 class PetCage { pushPet() : void { const Pet = random() > .5 ? Cat : Dog this.content = new Pet } } 

 function pushPet( this : PetCage ) : void { const Pet = random() > .5 ? Cat : Dog this.content = new Pet } 

Namun, penting untuk dicatat bahwa metode, tidak seperti fungsi biasa, juga merupakan anggota kelas, yang merupakan perluasan dari tipe tersebut. Ini mengarah pada fakta bahwa pembatasan supertipe tambahan muncul untuk fungsi yang memanggil metode ini:


 function fillPetCage( cage : PetCage ) { cage.pushPet() } 

gambar


Kami tidak dapat meneruskan pushPet ke sana di mana metode pushPet belum didefinisikan. Ini mirip dengan kasus dengan invarian di mana ada batasan baik dari bawah maupun dari atas. Namun, lokasi metode pushPet mungkin lebih tinggi dalam hierarki. Dan di situlah batasan overtype akan terjadi.


Prinsip Pengganti Barbara Lisk (LSP)


Banyak orang berpikir bahwa rasio subtipe terhadap subtipe ditentukan bukan atas dasar metode penyempitan dan perluasan jenis yang disebutkan sebelumnya, melainkan oleh kemungkinan mengganti subtipe di sembarang tempat menggunakan supertipe. Rupanya, alasan kesalahan ini tepatnya di LSP. Namun, mari kita baca definisi prinsip ini dengan hati-hati, perhatikan apa yang primer dan apa yang sekunder:


Fungsi yang menggunakan tipe dasar harus dapat menggunakan subtipe dari tipe dasar tanpa menyadarinya dan tanpa melanggar kebenaran program.

Untuk objek yang tidak berubah (termasuk yang tidak mereferensikan), prinsip ini dilakukan secara otomatis, karena tidak ada tempat untuk mengambil batasan subtipe.


Dengan bisa berubah-ubah, ini semakin sulit, karena dua situasi berikut ini saling eksklusif untuk prinsip LSP:


  1. Kelas A memiliki subkelas B , di mana bidang B::foo adalah subtipe dari A::foo .
  2. Kelas A memiliki metode yang dapat mengubah bidang A::foo .

Dengan demikian, hanya ada tiga cara yang tersisa:


  1. Mencegah objek dari mewarisi mempersempit jenis bidangnya. Tapi kemudian Anda bisa mendorong seekor gajah ke dalam kandang untuk kucing.
  2. Dipandu bukan oleh LSP, tetapi oleh variabilitas setiap parameter dari masing-masing fungsi secara terpisah. Tetapi kemudian Anda harus banyak berpikir dan menjelaskan kepada kompiler di mana batasan jenisnya.
  3. Meludahi semuanya dan pergi ke biara pemrograman fungsional, di mana semua objek tidak dapat diubah, yang berarti bahwa parameter mereka menerima kovarian dengan tipe yang dideklarasikan.

TypeScript


Dalam skrip waktu, logikanya sederhana: semua parameter fungsi dianggap kovarian (yang tidak benar), dan nilai kembalinya dianggap contravariant (yang benar). Sebelumnya ditunjukkan bahwa parameter fungsi dapat memiliki variasi apa pun, tergantung pada apa fungsi ini dengan parameter ini. Oleh karena itu, ini adalah insiden berikut:


 abstract class Animal { is! : 'cat' | 'dog' | 'fox' } abstract class Pet extends Animal { is! : 'cat' | 'dog' } class Cat extends Pet { is! : 'cat' } class Dog extends Pet { is! : 'dog' } class Fox extends Animal { is! : 'fox' } class Cage<Animal> { content! : Animal } function pushPet( cage : Cage<Pet> ) : void { const Pet = Math.random() > .5 ? Cat : Dog cage.content = new Pet } pushPet( new Cage<Animal>() ) // forbid to push Pet to Animal Cage :-( pushPet( new Cage<Cat>() ) // allow to push Dog to Cat Cage :-( 

Untuk mengatasi masalah ini, Anda harus membantu kompiler dengan kode yang agak non-pribadi:


 function pushPet< PetCage extends Cage<Animal> >( cage: Cage<Pet> extends PetCage ? PetCage : never ): void { const Pet = Math.random() > .5 ? Cat : Dog cage.content = new Pet } pushPet( new Cage<Animal>() ) // allow :-) pushPet( new Cage<Pet>() ) // allow :-) pushPet( new Cage<Cat>() ) // forbid :-) pushPet( new Cage<Dog>() ) // forbid :-) pushPet( new Cage<Fox>() ) // forbid :-) 

Coba online


Flowjs


FlowJS memiliki sistem tipe yang lebih maju. Secara khusus, dalam deskripsi tipe dimungkinkan untuk menunjukkan variabilitasnya untuk parameter umum dan untuk bidang objek. Dalam contoh sel kami, tampilannya seperti ini:


 class Animal {} class Pet extends Animal {} class Cat extends Pet {} class Dog extends Pet {} class Fox extends Animal {} class Cage< Animal > { content : Animal } function touchPet( cage : { +content : Pet } ) : void { console.log( `touch ${typeof cage.content}` ) } function pushPet( cage: { -content: Pet } ): void { const Pet = Number((0: any)) > .5 ? Cat : Dog cage.content = new Pet } function replacePet( cage : { content : Pet } ) : void { touchPet( cage ) pushPet( cage ) } touchPet( new Cage<Animal> ) // forbid :-) touchPet( new Cage<Pet> ) // allow :-) touchPet( new Cage<Cat> ) // allow :-) touchPet( new Cage<Dog> ) // allow :-) touchPet( new Cage<Fox> ) // forbid :-) pushPet( new Cage<Animal> ) // allow :-) pushPet( new Cage<Pet> ) // allow :-) pushPet( new Cage<Cat> ) // forbid :-) pushPet( new Cage<Dog> ) // forbid :-) pushPet( new Cage<Fox> ) // forbid :-) replacePet( new Cage<Animal> ) // forbid :-) replacePet( new Cage<Pet> ) // allow :-) replacePet( new Cage<Cat> ) // forbid :-) replacePet( new Cage<Dog> ) // forbid :-) replacePet( new Cage<Fox>) // forbid :-) 

Coba online


Bivarians di sini tidak bisa diungkapkan. Sayangnya, saya tidak dapat menemukan cara untuk lebih mudah mengatur varians tanpa secara eksplisit menjelaskan jenis semua bidang. Misalnya, sesuatu seperti ini:


 function pushPet( cage: Contra< Cage<Pet> , 'content' > ): void { const Pet = Number((0: any)) > .5 ? Cat : Dog cage.content = new Pet } 

C tajam


C # pada awalnya dirancang tanpa pemahaman variasi. Namun, kemudian keluar dan pengubah parameter ditambahkan, yang memungkinkan kompiler untuk memeriksa dengan benar jenis argumen yang diteruskan. Sayangnya, menggunakan pengubah ini lagi sangat tidak nyaman.


 using System; abstract class Animal {} abstract class Pet : Animal {} class Cat : Pet {} class Dog : Pet {} class Fox : Animal {} interface ICageIn<in T> { T content { set; } } interface ICageOut<out T> { T content { get; } } interface ICageInOut<T> { T content { get; set; } } class Cage<T> : ICageIn<T>, ICageOut<T>, ICageInOut<T> { public T content { get; set; } } public class Program { static void touchPet( ICageOut<Pet> cage ) { Console.WriteLine( cage.content ); } static void pushPet( ICageIn<Pet> cage ) { cage.content = new Dog(); } static void replacePet( ICageInOut<Pet> cage ) { touchPet( cage as ICageOut<Pet> ); pushPet( cage as ICageIn<Pet> ); } void enshurePet( Cage<Pet> cage ) { if( cage.content is Pet ) return; pushPet( cage as ICageIn<Pet> ); } public static void Main() { var animalCage = new Cage<Animal>(); var petCage = new Cage<Pet>(); var catCage = new Cage<Cat>(); var dogCage = new Cage<Dog>(); var foxCage = new Cage<Fox>(); touchPet( animalCage ); // forbid :-) touchPet( petCage ); // allow :-) touchPet( catCage ); // allow :-) touchPet( dogCage ); // allow :-) touchPet( foxCage ); // forbid :-) pushPet( animalCage ); // allow :-) pushPet( petCage ); // allow :-) pushPet( catCage ); // forbid :-) pushPet( dogCage ); // forbid :-) pushPet( foxCage ); // forbid :-) replacePet( animalCage ); // forbid :-) replacePet( petCage ); // allow :-) replacePet( catCage ); // forbid :-) replacePet( dogCage ); // forbid :-) replacePet( foxCage ); // forbid :-) } } 

Coba online


Jawa


Untuk Java, kemampuan untuk mengganti variasi ditambahkan cukup terlambat dan hanya untuk parameter umum, yang muncul sendiri relatif baru. Jika parameter tidak digeneralisasi, maka masalah.


 abstract class Animal {} abstract class Pet extends Animal {} class Cat extends Pet {} class Dog extends Pet {} class Fox extends Animal {} class Cage<T> { public T content; } public class Main { static void touchPet( Cage<? extends Pet> cage ) { System.out.println( cage.content ); } static void pushPet( Cage<? super Pet> cage ) { cage.content = new Dog(); } static void replacePet(Cage<Pet> cage ) { touchPet( cage ); pushPet( cage ); } void enshurePet( Cage<Pet> cage ) { if( cage.content instanceof Pet ) return; pushPet( cage ); } public static void main(String[] args) { Cage<Animal> animalCage = new Cage<Animal>(); Cage<Pet> petCage = new Cage<Pet>(); Cage<Cat> catCage = new Cage<Cat>(); Cage<Dog> dogCage = new Cage<Dog>(); Cage<Fox> foxCage = new Cage<Fox>(); touchPet( animalCage ); // forbid :-) touchPet( petCage ); // allow :-) touchPet( catCage ); // allow :-) touchPet( dogCage ); // allow :-) touchPet( foxCage ); // forbid :-) pushPet( animalCage ); // allow :-) pushPet( petCage ); // allow :-) pushPet( catCage ); // forbid :-) pushPet( dogCage ); // forbid :-) pushPet( foxCage ); // forbid :-) replacePet( animalCage ); // forbid :-) replacePet( petCage ); // allow :-) replacePet( catCage ); // forbid :-) replacePet( dogCage ); // forbid :-) replacePet( foxCage ); // forbid :-) } } 

Coba online


C ++


C ++, berkat sistem template yang kuat, dapat mengekspresikan berbagai variasi, tetapi tentu saja ada banyak kode.


 #include <iostream> #include <typeinfo> #include <type_traits> class Animal {}; class Pet: public Animal {}; class Cat: public Pet {}; class Dog: public Pet {}; class Fox: public Animal {}; template<class T> class Cage { public: T *content; }; template<class T, class = std::enable_if_t<std::is_base_of<Pet, T>::value>> void touchPet(const Cage<T> &cage) { std::cout << typeid(T).name(); } template<class T, class = std::enable_if_t<std::is_base_of<T, Pet>::value>> void pushPet(Cage<T> &cage) { cage.content = new Dog(); } void replacePet(Cage<Pet> &cage) { touchPet(cage); pushPet(cage); } int main(void) { Cage<Animal> animalCage {new Fox()}; Cage<Pet> petCage {new Cat()}; Cage<Cat> catCage {new Cat()}; Cage<Dog> dogCage {new Dog()}; Cage<Fox> foxCage {new Fox()}; touchPet( animalCage ); // forbid :-) touchPet( petCage ); // allow :-) touchPet( catCage ); // allow :-) touchPet( dogCage ); // allow :-) touchPet( foxCage ); // forbid :-) pushPet( animalCage ); // allow :-) pushPet( petCage ); // allow :-) pushPet( catCage ); // forbid :-) pushPet( dogCage ); // forbid :-) pushPet( foxCage ); // forbid :-) replacePet( animalCage ); // forbid :-) replacePet( petCage ); // allow :-) replacePet( catCage ); // forbid :-) replacePet( dogCage ); // forbid :-) replacePet( foxCage ); // forbid :-) return 0; } 

Coba online


D


D tidak memiliki cara waras untuk secara eksplisit menunjukkan varians, tetapi ia tahu cara menyimpulkan tipe berdasarkan penggunaannya.


 import std.stdio, std.random; abstract class Animal {} abstract class Pet : Animal { string name; } class Cat : Pet {} class Dog : Pet {} class Fox : Animal {} class Cage(T) { T content; } void touchPet( PetCage )( PetCage cage ) { writeln( cage.content.name ); } void pushPet( PetCage )( PetCage cage ) { cage.content = ( uniform(0,2) > 0 ) ? new Dog() : new Cat(); } void replacePet( PetCage )( PetCage cage ) { touchPet( cage ); pushPet( cage); } void main() { Cage!Animal animalCage; Cage!Pet petCage; Cage!Cat catCage; Cage!Dog dogCage; Cage!Fox foxCage; animalCage.touchPet(); // forbid :-) petCage.touchPet(); // allow :-) catCage.touchPet(); // allow :-) dogCage.touchPet(); // allow :-) foxCage.touchPet(); // forbid :-) animalCage.pushPet(); // allow :-) petCage.pushPet(); // allow :-) catCage.pushPet(); // forbid :-) dogCage.pushPet(); // forbid :-) foxCage.pushPet(); // forbid :-) animalCage.replacePet(); // forbid :-) petCage.replacePet(); // allow :-) catCage.replacePet(); // forbid :-) dogCage.replacePet(); // forbid :-) foxCage.replacePet(); // forbid :-) } 

Coba online


Epilog


Itu saja untuk saat ini. Saya berharap materi yang disajikan telah membantu Anda lebih memahami pembatasan jenis, dan bagaimana mereka diterapkan dalam berbagai bahasa. Di suatu tempat yang lebih baik, di suatu tempat yang lebih buruk, di suatu tempat tidak mungkin, tetapi secara keseluruhan - begitu-begitu. Mungkin Andalah yang akan mengembangkan bahasa di mana semua ini akan dilaksanakan dengan mudah dan aman. Sementara itu, bergabunglah dengan obrolan telegram kami , tempat kami kadang-kadang mendiskusikan konsep teoritis bahasa pemrograman .

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


All Articles