Kami menulis bahasa pemrograman kami, bagian 1: kami menulis bahasa VM

Pendahuluan


Hari baik untuk semua habrachitateli!

Jadi, mungkin layak untuk dikatakan bahwa tujuan dari pekerjaan saya, yang menjadi dasar penulisan sejumlah patung, adalah untuk membuat YP sepenuhnya berfungsi mulai dari 0 dan kemudian membagikan pengetahuan, praktik terbaik, dan pengalaman saya kepada mereka yang tertarik.

Saya akan menjelaskan penciptaan bahasa yang saya jelaskan sebelumnya di sini .

Dia tertarik banyak orang dan memicu diskusi panas di komentar. Oleh karena itu - topik ini menarik bagi banyak orang.

Saya pikir ada baiknya segera memposting informasi tentang proyek:

Situs (akan diisi dengan dokumentasi sedikit kemudian).
Repositori

Untuk menyentuh proyek sendiri dan melihat segala sesuatu dalam tindakan, lebih baik untuk mengunduh repositori dan menjalankan semuanya dari folder bin. Dalam rilisnya, saya tidak terburu-buru untuk mengunggah versi terbaru bahasa dan runtime, karena terkadang terlalu malas bagi saya untuk melakukannya.

Saya dapat kode dalam C / C ++ dan Object Pascal. Saya menulis proyek di FPC sejak itu menurut saya bahasa ini jauh lebih sederhana dan lebih cocok untuk menulis seperti itu. Faktor penentu kedua adalah bahwa FPC mendukung sejumlah besar platform target, dan Anda dapat membangun kembali proyek untuk platform yang diinginkan dengan minimum perubahan. Jika karena alasan tertentu saya tidak suka Obyek Pascal, maka jangan terburu-buru untuk menutup pos dan lari untuk melempar batu pada komentar. Bahasa ini sangat indah dan intuitif, tetapi saya tidak akan memberikan banyak kode. Apa yang Anda butuhkan.

Jadi, mungkin aku akan memulai ceritaku.

Kami menetapkan tujuan


Pertama-tama, setiap proyek membutuhkan tujuan dan TK-nya, yang harus diimplementasikan di masa depan. Penting untuk memutuskan terlebih dahulu jenis bahasa apa yang akan dibuat untuk menulis VM utama untuknya.

Poin-poin penting yang menentukan pengembangan lebih lanjut dari VM saya adalah sebagai berikut:

  • Pengetikan dinamis dan tipe casting. Saya memutuskan untuk mengatur dukungannya pada tahap pengembangan VM.
  • Dukungan multithreading. Saya memasukkan item ini dalam daftar ini sebelumnya untuk mendesain arsitektur VM dengan benar dan mengatur dukungan untuk multithreading pada level inti VM, dan tidak lebih baru dengan kruk.
  • Ekspor metode eksternal. Tanpa ini, bahasa tidak akan berguna. Kecuali untuk menanamkannya dalam proyek apa pun.
  • Kompilasi bahasa (menjadi satu file abstrak yang dapat dieksekusi). Dikompilasi atau ditafsirkan sebagian? Sangat tergantung pada ini.
  • Arsitektur VM umum. Apakah tumpukan atau daftar menjadi VM kami? Saya mencoba menerapkan ini dan itu. Saya memilih VM yang ditumpuk untuk dukungan.
  • Bagaimana Anda melihat bekerja dengan variabel, array, struktur? Secara pribadi, pada saat itu saya ingin mengimplementasikan bahasa di mana hampir semuanya terkait dengan pointer implisit, karena pendekatan seperti itu akan sangat menghemat memori dan menyederhanakan kehidupan pengembang. Jika kita membiarkan sesuatu yang besar diteruskan ke metode, maka hanya sebuah pointer ke yang besar yang akan ditransfer secara otomatis.

Jadi, saya telah memilih prioritas di atas dan saya mulai menerapkan mesin virtual bahasa. Ini aneh tentu saja, biasanya parser / penerjemah ditulis terlebih dahulu, dan kemudian VM. Yah, saya mulai mengembangkan proyek dalam urutan ini dan saya akan menjelaskannya lebih lanjut dalam urutan di mana saya mengembangkannya.

Saya harus segera mengatakan bahwa saya memanggil VM dengan fasih mungkin - SVM (Stack-based Virtual Machine).

Mari kita mulai dengan implementasi kelas variabel


Awalnya, saya hanya menggunakan tipe varian, karena lebih sederhana dan lebih cepat. Itu adalah penopang, tetapi menopang proyek dan memungkinkan saya untuk dengan cepat mengimplementasikan versi pertama VM dan bahasa. Kemudian saya duduk di kode dan menulis implementasi "varian" saya. Intinya, Anda perlu menulis kelas yang menyimpan pointer ke nilai dalam memori, dalam implementasi saya ini adalah null/cardinal/int64/double/string/array . Orang bisa menggunakan pengetikan kasus, tetapi saya pikir akan lebih baik untuk menerapkan cara saya menerapkan.

Sebelum mulai menulis kode kelas, saya memutuskan untuk segera meletakkan direktif {$ H +} di header modul untuk dukungan yang lebih fleksibel untuk string dalam bahasa yang akan datang.
P.S. bagi mereka yang mungkin tidak menyadari perbedaan antara mode H- dan H + FPC.

Saat menyusun kode dalam mode-H, string akan disajikan sebagai array karakter. Ketika H + - sebagai penunjuk ke sepotong memori. Dalam kasus pertama, panjang garis awalnya akan tetap dan dibatasi secara default hingga 256 karakter. Dalam kasus kedua, garis akan diperluas secara dinamis dan lebih banyak karakter dapat dijejalkan ke dalamnya. Mereka akan bekerja sedikit lebih lambat, tetapi lebih fungsional. Dengan H +, Anda juga dapat mendeklarasikan string sebagai array karakter, misalnya dengan cara ini:

 var s:string[256]; 
Jadi, sebagai permulaan, kami akan mendeklarasikan tipe Enum, yang akan kami gunakan sebagai flag tertentu untuk menentukan tipe data dengan pointer:

 type TSVMType = (svmtNull, svmtWord, svmtInt, svmtReal, svmtStr, svmtArr); 

Selanjutnya, kami menggambarkan struktur dasar dari tipe variabel kami dan beberapa metode:

  TSVMMem = class m_val: pointer; m_type: TSVMType; constructor Create; destructor Destroy; procedure Clear; end; ... constructor TSVMMem.Create; begin m_val := nil; m_type := svmtNull; end; destructor TSVMMem.Destroy; begin Clear; end; procedure TSVMMem.Clear; inline; begin case m_type of svmtNull: { nop }; svmtWord: Dispose(PCardinal(m_val)); svmtInt: Dispose(PInt64(m_val)); svmtReal: Dispose(PDouble(m_val)); svmtStr: Dispose(PString(m_val)); svmtArr: begin SetLength(PMemArray(m_val)^, 0); Dispose(PMemArray(m_val)); end; else Error(reVarInvalidOp); end; end; 

Kelas tidak mewarisi dari apa pun, sehingga panggilan yang diwariskan di konstruktor dan destruktor dapat dihilangkan. Saya akan memperhatikan arahan inline. Lebih baik menambahkan {$ inline on} ke header file, pasti. Penggunaan aktifnya di VMs secara signifikan meningkatkan produktivitas (Mb di suatu tempat sebanyak 15-20%!). Dia mengatakan kepada kompiler bahwa tubuh metode paling baik ditanamkan di tempat permohonannya. Kode output akan sedikit lebih besar pada akhirnya, tetapi akan bekerja lebih cepat. Dalam hal ini, disarankan menggunakan inline.

Ok, pada tahap ini kita telah menghancurkan fondasi kelas kita. Sekarang kita perlu menjelaskan sejumlah setter dan getter (setter & getter) di kelas kita.

Tugasnya adalah menulis beberapa metode yang akan memungkinkan Anda menjejalkan dan kemudian mendapatkan kembali nilai-nilai dari kelas kami.

Pertama, mari kita cari tahu penugasan nilai untuk kelas kita. Pertama, Anda dapat menulis setter umum, dan kemudian, untuk tipe data individual:

 procedure TSVMMem.SetV(const value; t:TSVMType); inline; begin if (m_val <> nil) and (m_type = t) then begin case t of svmtWord: PCardinal(m_val)^ := Cardinal(value); svmtInt: PInt64(m_val)^ := Int64(value); svmtReal: PDouble(m_val)^ := Double(value); svmtStr: PString(m_val)^ := String(value); end; end else begin if m_val <> nil then FreeMem(m_val); m_type := t; case t of svmtWord: begin New(PCardinal(m_val)); PCardinal(m_val)^ := Cardinal(value); end; svmtInt: begin New(PInt64(m_val)); PInt64(m_val)^ := Int64(value); end; svmtReal: begin New(PDouble(m_val)); PDouble(m_val)^ := Double(value); end; svmtStr: begin New(PString(m_val)); PString(m_val)^ := String(value); end; else Error(reVarTypeCast); end; end; end; ... procedure TSVMMem.SetW(value:cardinal); inline; begin if (m_val <> nil) and (m_type = svmtWord) then PCardinal(m_val)^ := value else begin if m_val <> nil then FreeMem(m_val); m_type := svmtWord; New(PCardinal(m_val)); PCardinal(m_val)^ := value; end; end; 

Sekarang Anda dapat menulis kode untuk beberapa pengambil:

 function TSVMMem.GetW:cardinal; inline; begin Result := 0; case m_type of svmtWord: Result := PCardinal(m_val)^; svmtInt: Result := PInt64(m_val)^; svmtReal: Result := Trunc(PDouble(m_val)^); svmtStr: Result := StrToQWord(PString(m_val)^); else Error(reVarTypeCast); end; end; 

Oke, bagus, sekarang setelah Anda menghabiskan waktu menatap IDE dan dengan antusias mengetik kode untuk setter dan getter, kami dihadapkan pada tugas untuk mengimplementasikan dukungan untuk jenis operasi matematika dan logis kami. Sebagai contoh, saya akan memberikan implementasi operasi tambahan:

 procedure TSVMMem.OpAdd(m:TSVMMem); inline; begin case m_type of svmtWord: case m.m_type of svmtWord: SetW(GetW + m.GetW); svmtInt: SetI(GetW + m.GetI); svmtReal: SetD(GetW + m.GetD); svmtStr: SetD(GetW + StrToFloat(m.GetS)); else Error(reVarInvalidOp); end; svmtInt: case m.m_type of svmtWord: SetI(GetI + m.GetW); svmtInt: SetI(GetI + m.GetI); svmtReal: SetD(GetI + m.GetD); svmtStr: SetD(GetI + StrToFloat(m.GetS)); else Error(reVarInvalidOp); end; svmtReal: case m.m_type of svmtWord: SetD(GetD + m.GetW); svmtInt: SetD(GetD + m.GetI); svmtReal: SetD(GetD + m.GetD); svmtStr: SetD(GetD + StrToFloat(m.GetS)); else Error(reVarInvalidOp); end; svmtStr: case m.m_type of svmtWord: SetS(GetS + IntToStr(m.GetW)); svmtInt: SetS(GetS + IntToStr(m.GetI)); svmtReal: SetS(GetS + FloatToStr(m.GetD)); svmtStr: SetS(GetS + m.GetS); else Error(reVarInvalidOp); end; else Error(reVarInvalidOp); end; end; 

Semuanya sederhana. Operasi lebih lanjut dapat dijelaskan dengan cara yang sama, dan sekarang kelas kami siap.
Untuk array, tentu saja, Anda masih memerlukan beberapa metode, contoh untuk mendapatkan elemen berdasarkan indeks:

 function TSVMMem.ArrGet(index: cardinal; grabber:PGrabber): pointer; inline; begin Result := nil; case m_type of svmtArr: Result := PMemArray(m_val)^[index]; svmtStr: begin Result := TSVMMem.CreateFW(Ord(PString(m_val)^[index])); grabber^.AddTask(Result); end; else Error(reInvalidOp); end; end; 

Bagus Sekarang kita bisa melanjutkan.

Kami menyadari setumpuk


Setelah beberapa saat, saya berpikir seperti itu. Tumpukan harus statis (untuk kecepatan) dan dinamis (untuk fleksibilitas) secara bersamaan.

Oleh karena itu, tumpukan diimplementasikan dalam blok. Yaitu bagaimana seharusnya bekerja - awalnya susunan tumpukan memiliki ukuran tertentu (saya memutuskan untuk mengatur ukuran blok ke 256 elemen sehingga itu indah dan tidak kecil). Dengan demikian, penghitung disertakan dengan array, yang menunjukkan bagian atas tumpukan saat ini. Realokasi memori adalah operasi ekstra panjang, yang dapat dilakukan lebih jarang. Jika lebih banyak nilai didorong ke tumpukan, maka ukurannya selalu dapat diperluas ke ukuran blok lain.

Saya membawa seluruh implementasi stack:

 type TStack = object public items: array of pointer; size, i_pos: cardinal; parent_vm: pointer; procedure init(vm: pointer); procedure push(p: pointer); function peek: pointer; procedure pop; function popv: pointer; procedure swp; procedure drop; end; PStack = ^TStack; procedure TStack.init(vm: pointer); begin SetLength(items, StackBlockSize); i_pos := 0; size := StackBlockSize; parent_vm := vm; end; procedure TStack.push(p: pointer); inline; begin items[i_pos] := p; inc(i_pos); if i_pos >= size then begin size := size + StackBlockSize; SetLength(items, size) end; end; function TStack.peek: pointer; inline; begin Result := items[i_pos - 1]; end; procedure TStack.pop; inline; begin dec(i_pos); if size - i_pos > StackBlockSize then begin size := size - StackBlockSize; SetLength(items, size); end; end; function TStack.popv: pointer; inline; begin dec(i_pos); Result := items[i_pos]; if size - i_pos > StackBlockSize then begin size := size - StackBlockSize; SetLength(items, size); end; end; procedure TStack.swp; inline; var p: pointer; begin p := items[i_pos - 2]; items[i_pos - 2] := items[i_pos - 1]; items[i_pos - 1] := p; end; procedure TStack.drop; inline; begin SetLength(items, StackBlockSize); size := StackBlockSize; i_pos := 0; end; 

Dalam metode eksternal, VM akan melewatkan sebuah pointer ke stack sehingga mereka dapat mengambil argumen yang diperlukan dari sana. Pointer ke aliran VM ditambahkan kemudian, sehingga panggilan panggilan balik dari metode eksternal dapat diimplementasikan dan, secara umum, untuk mentransfer lebih banyak kekuatan atas metode VM.

Jadi, bagaimana Anda berkenalan dengan bagaimana tumpukan disusun. Tumpukan panggilan balik diatur dengan cara yang sama, untuk kesederhanaan dan kenyamanan operasi panggilan & pengembalian dan tumpukan pengumpul sampah. Satu-satunya hal adalah ukuran lain dari balok.

Bicara tentang sampah


Biasanya banyak, banyak. Dan Anda perlu melakukan sesuatu dengannya.

Pertama-tama, saya ingin berbicara tentang bagaimana pengumpul sampah diatur dalam bahasa lain, misalnya, dalam Lua, Ruby, Java, Perl, PHP, dll. Mereka bekerja pada prinsip penghitungan pointer ke objek dalam memori.

Yaitu jadi kami mengalokasikan memori untuk sesuatu, itu logis - penunjuk langsung ditempatkan di variabel / array / di tempat lain. Kolektor sampah runtime segera menambahkan pointer ini ke dirinya sendiri dengan daftar objek sampah yang mungkin. Di latar belakang, pemulung terus-menerus memonitor semua variabel, array, dll. Jika tidak ada pointer ke sesuatu dari daftar kemungkinan sampah, maka itu berarti sampah dan memori dari bawahnya harus dihilangkan.

Saya memutuskan untuk menjual sepeda saya. Saya lebih terbiasa bekerja dengan ingatan pada prinsip Taras Bulba. Saya melahirkan Anda - saya akan membunuh Anda, maksud saya, ketika saya memanggil Gratis berikutnya di kelas berikutnya. Oleh karena itu, pengumpul sampah VM saya semi-otomatis. Yaitu perlu dipanggil dalam mode manual dan bekerja dengannya sesuai itu. Pada gilirannya, pointer ke objek sementara dinyatakan ditambahkan (peran ini terutama terletak pada penerjemah dan sedikit dengan pengembang). Untuk membebaskan memori dari benda-benda lain, Anda dapat menggunakan opcode terpisah.

Yaitu pemulung pada saat panggilan memiliki daftar petunjuk yang sudah jadi yang perlu Anda datangi dan membebaskan memori.

Jadi, sekarang kita akan berurusan dengan kompilasi menjadi file executable abstrak


Idenya awalnya bahwa aplikasi yang ditulis dalam bahasa saya dapat berjalan tanpa sumber, seperti halnya dengan banyak bahasa serupa. Yaitu dapat digunakan untuk tujuan komersial.

Untuk melakukan ini, Anda perlu menentukan format file yang dapat dieksekusi. Saya mendapat yang berikut:

  1. Header, misalnya "SVMEXE_CNS".
  2. Bagian yang berisi daftar pustaka dari mana metode akan diimpor.
  3. Bagian impor dari metode yang diperlukan, perpustakaan tempat metode diimpor ditunjukkan oleh nomor mereka di bagian di atas.
  4. Bagian dari konstanta.
  5. Bagian Kode

Saya pikir tidak ada gunanya memberikan langkah-langkah terperinci untuk mengimplementasikan parser untuk bagian ini, karena Anda dapat melihat semuanya sendiri di repositori saya.

Eksekusi kode


Setelah menguraikan bagian di atas dan menginisialisasi VM, kami memiliki satu bagian dengan kode. Dalam VM saya, bytecode yang tidak selaras dieksekusi, mis. instruksi bisa panjang sewenang-wenang.

Serangkaian opcode - instruksi untuk mesin virtual dengan komentar kecil yang saya tunjukkan sebelumnya di bawah ini:

 type TComand = ( {** for stack **} bcPH, // [top] = [var] bcPK, // [var] = [top] bcPP, // pop bcSDP, // stkdrop bcSWP, // [top] <-> [top-1] {** jump's **} bcJP, // jump [top] bcJZ, // [top] == 0 ? jp [top-1] bcJN, // [top] <> 0 ? jp [top-1] bcJC, // jp [top] & push callback point as ip+1 bcJR, // jp to last callback point & rem last callback point {** for untyped's **} bcEQ, // [top] == [top-1] ? [top] = 1 : [top] = 0 bcBG, // [top] > [top-1] ? [top] = 1 : [top] = 0 bcBE, // [top] >= [top-1] ? [top] = 1 : [top] = 0 bcNOT, // [top] = ![top] bcAND, // [top] = [top] and [top-1] bcOR, // [top] = [top] or [top-1] bcXOR, // [top] = [top] xor [top-1] bcSHR, // [top] = [top] shr [top-1] bcSHL, // [top] = [top] shl [top-1] bcNEG, // [top] = -[top] bcINC, // [top]++ bcDEC, // [top]-- bcADD, // [top] = [top] + [top-1] bcSUB, // [top] = [top] - [top-1] bcMUL, // [top] = [top] * [top-1] bcDIV, // [top] = [top] / [top-1] bcMOD, // [top] = [top] % [top-1] bcIDIV, // [top] = [top] \ [top-1] bcMV, // [top]^ = [top-1]^ bcMVBP, // [top]^^ = [top-1]^ bcGVBP, // [top]^ = [top-1]^^ bcMVP, // [top]^ = [top-1] {** memory operation's **} bcMS, // memory map size = [top] bcNW, // [top] = @new bcMC, // copy [top] bcMD, // double [top] bcRM, // rem @[top] bcNA, // [top] = @new array[ [top] ] of pointer bcTF, // [top] = typeof( [top] ) bcSF, // [top] = sizeof( [top] ) {** array's **} bcAL, // length( [top] as array ) bcSL, // setlength( [top] as array, {stack} ) bcPA, // push ([top] as array)[top-1] bcSA, // peek [top-2] -> ([top] as array)[top-1] {** memory grabber **} bcGPM, // add pointer to TMem to grabber task-list bcGC, // run grabber {** constant's **} bcPHC, // push copy of const bcPHCP, // push pointer to original const {** external call's **} bcPHEXMP, // push pointer to external method bcINV, // call external method bcINVBP, // call external method by pointer [top] {** for thread's **} bcPHN, // push null bcCTHR, // [top] = thread(method = [top], arg = [top+1]):id bcSTHR, // suspendthread(id = [top]) bcRTHR, // resumethread(id = [top]) bcTTHR, // terminatethread(id = [top]) {** for try..catch..finally block's **} bcTR, // try @block_catch = [top], @block_end = [top+1] bcTRS, // success exit from try/catch block bcTRR, // raise exception, message = [top] {** for string's **} bcSTRD, // strdel bcCHORD, bcORDCH, {** [!] directly memory operations **} bcALLC, //alloc memory bcRALLC, //realloc memory bcDISP, //dispose memory bcGTB, //get byte bcSTB, //set byte bcCBP, //mem copy bcRWBP, //read word bcWWBP, //write word bcRIBP, //read int bcWIBP, //write int bcRFBP, //read float bcWFBP, //write float bcRSBP, //read string bcWSBP, //write string bcTHREXT,//stop code execution bcDBP //debug method call ); 

Jadi, Anda terbiasa dengan operasi apa yang dapat dilakukan oleh VM yang ditulis oleh saya. Sekarang saya ingin berbicara tentang cara kerjanya.

VM diimplementasikan sebagai objek, sehingga Anda dapat dengan mudah mengimplementasikan dukungan multithreading.

Ini memiliki pointer ke array dengan opcodes, IP (Instruction Pointer) - offset dari instruksi yang dieksekusi dan pointer ke struktur VM lainnya.

Eksekusi kode adalah switch-case yang besar.

Berikan deskripsi VM:

 type TSVM = object public ip, end_ip: TInstructionPointer; mainclasspath: string; mem: PMemory; stack: TStack; cbstack: TCallBackStack; bytes: PByteArr; grabber: TGrabber; consts: PConstSection; extern_methods: PImportSection; try_blocks: TTRBlocks; procedure Run; procedure RunThread; procedure LoadByteCodeFromFile(fn: string); procedure LoadByteCodeFromArray(b: TByteArr); end; 

Sedikit tentang penanganan pengecualian


Untuk melakukan ini, VM memiliki setumpuk penangan pengecualian dan blok coba / tangkap besar di mana eksekusi kode dibungkus. Dari tumpukan, Anda dapat menempatkan struktur yang memiliki titik masuk offset pada tangkapan dan akhirnya / akhir blok penanganan pengecualian. Saya juga menyediakan trs opcode, yang ditempatkan sebelum menangkap dan melemparkan kode ke akhirnya / akhirnya jika berhasil, secara bersamaan menghapus blok dengan informasi tentang penangan pengecualian dari atas tumpukan yang sesuai. Apakah ini sederhana? Sederhana Apakah itu nyaman? Dengan nyaman.

Mari kita bicara tentang metode dan perpustakaan eksternal


Saya sudah menyebutkannya sebelumnya. Impor, perpustakaan ... Tanpa mereka, bahasa tidak akan memiliki fleksibilitas dan fungsionalitas yang diinginkan.

Pertama-tama, dalam implementasi VM, kami akan mendeklarasikan jenis metode eksternal dan protokol untuk memanggilnya.

 type TExternalFunction = procedure(PStack: pointer); cdecl; PExternalFunction = ^TExternalFunction; 

Saat mengimpor VM, parser bagian impor mengisi array pointer ke metode eksternal. Oleh karena itu, setiap metode memiliki alamat statis, yang dihitung pada tahap perakitan aplikasi di bawah VM dan metode yang diinginkan dapat dipanggil.

Panggilan nanti terjadi dengan cara ini selama eksekusi kode:

 TExternalFunction(self.extern_methods^.GetFunc(TSVMMem(self.stack.popv).GetW))(@self.stack); 

Mari menulis perpustakaan sederhana untuk VM kami


Dan biarkan dia pertama menerapkan metode Tidur:

 library bf; {$mode objfpc}{$H+} uses SysUtils, svm_api in '..\svm_api.pas'; procedure DSleep(Stack:PStack); cdecl; begin sleep(TSVMMem(Stack^.popv).GetW); end; exports DSleep name 'SLEEP'; end. 

Ringkasan


Mengenai hal ini saya mungkin akan mengakhiri artikel pertama saya dari siklus yang dikandung.

Hari ini saya menjelaskan pembuatan runtime bahasa secara rinci. Saya percaya bahwa artikel ini akan sangat berguna bagi orang-orang yang memutuskan untuk mencoba menulis bahasa mereka sendiri atau untuk memahami cara kerja bahasa pemrograman yang serupa.

Kode VM lengkap tersedia di repositori, di cabang / runtime / svm.

Jika Anda menyukai artikel ini, maka jangan malas melemparkan nilai tambah dalam karma dan menaikkannya di atas, saya mencoba dan akan mencoba untuk Anda.

Jika ada sesuatu yang tidak jelas bagi Anda, selamat datang di komentar atau forum .

Mungkin pertanyaan dan jawaban Anda akan menarik bukan hanya untuk Anda.

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


All Articles