Selama seluruh periode keberadaan mereka, orang-orang telah berupaya keras untuk mempelajari hampir seluruh area langit berbintang. Hingga saat ini, kami telah memeriksa ratusan ribu asteroid, komet, nebula, dan bintang, galaksi, dan planet. Untuk melihat semua keindahan ini sendiri, tidak perlu meninggalkan rumah dan membeli sendiri teleskop. Anda dapat menginstal Stellarium - planetarium virtual di komputer Anda, dan melihat langit malam, berbaring dengan nyaman di sofa ... Tetapi apakah itu nyaman? Untuk mengetahui jawaban atas pertanyaan ini, kami akan memeriksa Stellarium untuk kesalahan dalam kode komputer.
Sedikit tentang proyek ...
Menurut
deskripsi di situs web Wikipedia,
Stellarium adalah planetarium virtual open-source yang tersedia untuk Linux, Mac OS X, Microsoft Windows, Symbian, Android dan iOS, serta MeeGo. Program ini menggunakan teknologi OpenGL dan Qt untuk menciptakan langit yang realistis secara real time. Dengan Stellarium Anda dapat melihat apa yang dapat Anda lihat dengan teleskop berukuran sedang dan bahkan besar. Program ini juga menyediakan pengamatan gerhana matahari dan pergerakan komet.
Stellarium dibuat oleh programmer Prancis Fabian Chereau, yang meluncurkan proyek pada musim panas 2001. Pengembang terkemuka lainnya termasuk Robert Spearman, Johannes Gadzhozik, Matthew Gates, Timothy Reeves, Bogdan Marinov dan Johan Meeris, yang bertanggung jawab atas karya seni tersebut.
... dan tentang penganalisa
Analisis proyek dilakukan menggunakan penganalisis kode statis PVS-Studio. Ini adalah alat untuk mendeteksi kesalahan dan kerentanan potensial dalam kode sumber program yang ditulis dalam C, C ++ dan C # (segera di Jawa!). Ini berjalan pada Windows, Linux, dan macOS. Ini dirancang untuk mereka yang perlu meningkatkan kualitas kode mereka.
Analisisnya cukup sederhana. Pertama, saya
mengunduh proyek Stellarium dari GitHub, dan kemudian menginstal semua paket yang diperlukan untuk perakitan. Karena proyek dibangun menggunakan Qt Creator, saya menggunakan sistem pelacakan peluncuran kompiler, yang dibangun ke dalam versi
mandiri dari penganalisa. Di sana Anda dapat melihat laporan analisis yang sudah selesai.
Pembaca baru dan pengguna
Stellarium mungkin bertanya-tanya: mengapa unicorn muncul dalam judul artikel dan bagaimana kaitannya dengan analisis kode? Saya menjawab: Saya adalah salah satu pengembang PVS-Studio, dan unicorn adalah maskot nakal favorit kami. Jadi naik!
Saya berharap bahwa berkat artikel ini, pembaca akan belajar sesuatu yang baru untuk diri mereka sendiri, dan pengembang Stellarium akan dapat menghilangkan beberapa kesalahan dan meningkatkan kualitas kode.
Bawalah kopi dengan croissant udara dan buat diri Anda nyaman, karena kita akan ke bagian yang paling menarik - ikhtisar hasil analisis dan analisis kesalahan!
Kondisi mencurigakan
Untuk kesenangan membaca yang lebih besar, saya sarankan untuk tidak langsung melihat peringatan penganalisa, tetapi coba di sini dan lebih jauh untuk menemukan kesalahan sendiri.
void QZipReaderPrivate::scanFiles() { ....
Peringatan PVS-Studio: V654 Kondisi 'start_of_directory == - 1' dari loop selalu benar. qzip.cpp 617
Bisakah Anda menemukan kesalahan? Jika demikian, maka pujilah.
Kesalahannya terletak pada kondisi
loop sementara . Itu selalu benar, karena variabel
start_of_directory tidak berubah dalam tubuh loop. Kemungkinan besar, siklus tidak akan abadi, karena mengandung
kembali dan
rusak , tetapi kode tersebut terlihat aneh.
Sepertinya saya bahwa dalam kode mereka lupa membuat tugas
start_of_directory = pos di tempat di mana tanda tangan sedang diverifikasi. Maka pernyataan
break mungkin berlebihan. Dalam hal ini, kode dapat ditulis ulang seperti ini:
int i = 0; int start_of_directory = -1; EndOfDirectory eod; while (start_of_directory == -1) { const int pos = device->size() - int(sizeof(EndOfDirectory)) - i; if (pos < 0 || i > 65535) { qWarning() << "QZip: EndOfDirectory not found"; return; } device->seek(pos); device->read((char *)&eod, sizeof(EndOfDirectory)); if (readUInt(eod.signature) == 0x06054b50) start_of_directory = pos; ++i; }
Namun, saya tidak yakin kode harus seperti apa. Yang terbaik adalah pengembang proyek itu sendiri menganalisis bagian dari program ini dan membuat perubahan yang diperlukan.
Kondisi aneh lainnya:
class StelProjectorCylinder : public StelProjector { public: .... protected: .... virtual bool intersectViewportDiscontinuityInternal(const Vec3d& capN, double capD) const { static const SphericalCap cap1(1,0,0); static const SphericalCap cap2(-1,0,0); static const SphericalCap cap3(0,0,-1); SphericalCap cap(capN, capD); return cap.intersects(cap1) && cap.intersects(cap2) && cap.intersects(cap2); } };
Peringatan PVS-Studio: V501 Ada sub-ekspresi identik 'cap.intersects (cap2)' di kiri dan di kanan operator '&&'. StelProjectorClasses.hpp 175
Seperti yang mungkin sudah Anda tebak, kesalahan terletak pada baris terakhir fungsi: programmer membuat kesalahan ketik, dan pada akhirnya ternyata fungsi mengembalikan hasil terlepas dari nilai
cap3 .
Jenis kesalahan ini sangat umum: di hampir setiap proyek yang diuji, kami telah menemukan kesalahan ketik yang terkait dengan nama-nama bentuk
name1 dan
name2 dan sejenisnya. Biasanya, kesalahan seperti itu terkait dengan salin-tempel.
Contoh kode ini adalah contoh utama dari pola kesalahan umum lainnya, yang bahkan kami lakukan studi-mini terpisah. Rekan saya Andrei Karpov menyebutnya "
efek garis terakhir ." Jika Anda tidak terbiasa dengan materi ini, saya sarankan membuka tab di browser untuk dibaca nanti, tetapi untuk sekarang, mari kita lanjutkan.
void BottomStelBar::updateText(bool updatePos) { .... updatePos = true; .... if (location->text() != newLocation || updatePos) { updatePos = true; .... } .... if (fov->text() != str) { updatePos = true; .... } .... if (fps->text() != str) { updatePos = true; .... } if (updatePos) { .... } }
Peringatan PVS-Studio:- V560 Bagian dari ekspresi kondisional selalu benar: updatePos. StelGuiItems.cpp 732
- Ekspresi V547 'updatePos' selalu benar. StelGuiItems.cpp 831
- V763 Parameter 'updatePos' selalu ditulis ulang di badan fungsi sebelum digunakan. StelGuiItems.cpp 690
Nilai parameter
updatePos selalu ditimpa sebelum digunakan, mis. fungsi akan bekerja sama, terlepas dari nilai yang diteruskan ke sana.
Terlihat aneh, bukan? Di semua tempat di mana parameter
updatePos terlibat , itu
benar . Ini berarti bahwa kondisi
if (location-> text ()! = NewLocation || updatePos) dan
if (updatePos) akan selalu benar.
Cuplikan lain:
void LandscapeMgr::onTargetLocationChanged(StelLocation loc) { .... if (pl && flagEnvironmentAutoEnabling) { QSettings* conf = StelApp::getInstance().getSettings(); setFlagAtmosphere(pl->hasAtmosphere() & conf->value("landscape/flag_atmosphere", true).toBool()); setFlagFog(pl->hasAtmosphere() & conf->value("landscape/flag_fog", true).toBool()); setFlagLandscape(true); } .... }
Peringatan PVS-Studio:- V792 Fungsi 'toBool' yang terletak di sebelah kanan operator '&' akan dipanggil terlepas dari nilai operan kiri. Mungkin, lebih baik menggunakan '&&'. LandscapeMgr.cpp 782
- V792 Fungsi 'toBool' yang terletak di sebelah kanan operator '&' akan dipanggil terlepas dari nilai operan kiri. Mungkin, lebih baik menggunakan '&&'. LandscapeMgr.cpp 783
Penganalisis mendeteksi ekspresi mencurigakan dalam argumen ke fungsi
setFlagAtmosphere dan
setFlagFog . Memang: di kedua sisi operator bit
& ada nilai tipe
bool . Alih-alih operator
& , Anda harus menggunakan operator
&& , dan sekarang saya akan menjelaskan alasannya.
Ya, hasil dari ungkapan ini akan selalu benar. Sebelum menggunakan bitwise "dan", kedua operan akan dipromosikan ke
int . Di C ++, konversi seperti itu
tidak ambigu : false dikonversi ke 0, dan true dikonversi ke 1. Oleh karena itu, hasil dari ekspresi ini akan sama seperti jika operator
&& digunakan.
Namun ada nuansa. Saat menghitung hasil operasi
&& , apa yang disebut "perhitungan malas" digunakan. Jika nilai operan kiri
salah , maka nilai kanan bahkan tidak dihitung, karena logika "dan" dalam hal apa pun akan mengembalikan
false . Ini dilakukan untuk menghemat sumber daya komputasi dan memungkinkan Anda untuk menulis desain yang lebih kompleks. Misalnya, Anda dapat memverifikasi bahwa pointer bukan nol, dan jika demikian, lakukan referensi untuk melakukan pemeriksaan tambahan. Contoh:
if (ptr && ptr-> foo ()) .
"Perhitungan malas" seperti itu tidak dilakukan dengan menggunakan operator bitwise
& : ekspresi
conf-> nilai ("...", true). ToBool () akan dievaluasi setiap waktu, terlepas dari nilai
pl-> hasAtmosphere () .
Dalam kasus yang jarang terjadi, ini dilakukan dengan sengaja. Misalnya, jika menghitung operan yang tepat memiliki "efek samping," hasilnya akan digunakan kemudian. Tidak terlalu baik untuk melakukannya, karena mempersulit pemahaman kode dan mempersulit pemeliharaan. Selain itu, urutan penghitungan operan
& tidak ditentukan, sehingga dalam beberapa kasus menggunakan "trik" seperti itu Anda bisa mendapatkan perilaku yang tidak terdefinisi.
Jika Anda perlu menyimpan efek samping - lakukan dalam baris terpisah dan simpan hasilnya dalam variabel terpisah. Orang-orang yang akan bekerja dengan kode ini di masa depan akan berterima kasih kepada Anda :)
Kami melanjutkan ke topik berikutnya.
Penanganan memori salah
Mari kita mulai topik memori dinamis dengan fragmen ini:
GLUEShalfEdge* __gl_meshMakeEdge(GLUESmesh* mesh) { GLUESvertex* newVertex1 = allocVertex(); GLUESvertex* newVertex2 = allocVertex(); GLUESface* newFace = allocFace(); GLUEShalfEdge* e; if ( newVertex1 == NULL || newVertex2 == NULL || newFace == NULL) { if (newVertex1 != NULL) { memFree(newVertex1); } if (newVertex2 != NULL) { memFree(newVertex2); } if (newFace != NULL) { memFree(newFace); } return NULL; } e = MakeEdge(&mesh->eHead); if (e == NULL) { return NULL; } MakeVertex(newVertex1, e, &mesh->vHead); MakeVertex(newVertex2, e->Sym, &mesh->vHead); MakeFace(newFace, e, &mesh->fHead); return e; }
Peringatan PVS-Studio:- V773 Fungsi itu keluar tanpa melepaskan pointer 'newVertex1'. Kebocoran memori dimungkinkan. mesh.c 312
- V773 Fungsi itu keluar tanpa melepaskan pointer 'newVertex2'. Kebocoran memori dimungkinkan. mesh.c 312
- V773 Fungsi itu keluar tanpa melepaskan pointer 'newFace'. Kebocoran memori dimungkinkan. mesh.c 312
Fungsi ini mengalokasikan memori untuk beberapa struktur dan meneruskannya ke pointer
newVertex1 ,
newVertex2 (nama yang menarik, bukan?) Dan
newFace . Jika salah satunya berubah menjadi nol, maka semua memori yang disimpan di dalam fungsi dibebaskan, setelah itu aliran kontrol meninggalkan fungsi.
Apa yang terjadi jika memori untuk ketiga struktur dialokasikan dengan benar, dan fungsi
MakeEdge (& mesh-> eHead) mengembalikan
NULL ? Aliran kontrol akan mencapai pengembalian kedua.
Karena pointer
newVertex1 ,
newVertex2 dan
newFace adalah variabel lokal, mereka tidak akan ada lagi setelah keluar dari fungsi. Tetapi pelepasan memori milik mereka tidak akan terjadi. Itu akan tetap dicadangkan, tetapi kami tidak akan lagi memiliki akses ke sana.
Situasi seperti ini disebut kebocoran memori. Skenario khas dengan kesalahan seperti itu: dengan penggunaan program yang berkepanjangan, ia mulai mengkonsumsi lebih banyak dan lebih banyak RAM, hingga habisnya.
Perlu dicatat bahwa dalam contoh ini, pengembalian ketiga tidak salah. Fungsi
MakeVertex dan
MakeFace mentransfer alamat memori yang dialokasikan ke struktur data lain, sehingga mendelegasikan tanggung jawab untuk rilisnya.
Kelemahan berikutnya adalah dalam metode, yang membutuhkan 90 baris. Untuk kenyamanan, saya menguranginya, hanya menyisakan area masalah.
void AstroCalcDialog::drawAngularDistanceGraph() { .... QVector<double> xs, ys; .... }
Hanya satu baris yang tersisa. Biarkan saya memberi Anda petunjuk: ini adalah satu-satunya penyebutan objek
xs dan
ys .
Peringatan PVS-Studio:- Objek 'xs' V808 dari tipe 'QVector' telah dibuat tetapi tidak digunakan. AstroCalcDialog.cpp 5329
- Objek 'ys' V808 dari tipe 'QVector' telah dibuat tetapi tidak digunakan. AstroCalcDialog.cpp 5329
Vektor
xs dan
ys dibuat, tetapi tidak digunakan di mana pun. Ternyata setiap kali Anda menggunakan metode
drawAngularDistanceGraph , kreasi dan penghapusan ekstra dari wadah kosong terjadi. Saya pikir iklan ini tetap dalam kode setelah refactoring. Ini, tentu saja, bukan kesalahan, tetapi Anda harus menghapus kode tambahan.
Gips tipe aneh
Contoh lain setelah sedikit pemformatan terlihat seperti ini:
void SatellitesDialog::updateSatelliteData() { ....
Untuk memahami apa kesalahannya, Anda harus melihat prototipe
konstruktor kelas Qcolor :
Peringatan PVS-Studio:- V674 '0,4' tipe 'ganda' literal secara implisit dilemparkan ke tipe 'int' sambil memanggil fungsi 'QColor'. Periksa argumen pertama. SatellitesDialog.cpp 413
- V674 '0,4' tipe 'ganda' literal secara implisit dilemparkan ke tipe 'int' sambil memanggil fungsi 'QColor'. Periksa argumen kedua. SatellitesDialog.cpp 413
- V674 '0,4' tipe 'ganda' literal secara implisit dilemparkan ke tipe 'int' sambil memanggil fungsi 'QColor'. Periksa argumen ketiga. SatellitesDialog.cpp 413
Kelas
Qcolor tidak memiliki konstruktor yang menerima tipe
ganda , jadi argumen dalam contoh ini akan secara implisit dikonversi ke
int . Ini menyebabkan bidang
r ,
g ,
b dari objek
buttonColor memiliki nilai
0 .
Jika programmer berniat untuk membuat objek dari nilai tipe
double , ia harus menggunakan konstruktor yang berbeda.
Misalnya, Anda bisa menggunakan konstruktor yang menerima
Qrgb dengan menulis:
buttonColor = QColor(QColor::fromRgbF(0.4, 0.4, 0.4));
Itu bisa dilakukan secara berbeda. Qt menggunakan nilai nyata dalam kisaran [0,0, 1.0] atau nilai integer dalam kisaran [0, 255] untuk menunjukkan warna RGB.
Oleh karena itu, programmer dapat menerjemahkan nilai dari real ke integer dengan menulis seperti ini:
buttonColor = QColor((int)(255 * 0.4), (int)(255 * 0.4), (int)(255 * 0.4));
atau adil
buttonColor = QColor(102, 102, 102);
Apakah kamu bosan? Jangan khawatir: ada kesalahan lebih menarik di depan kita.
"Unicorn di luar angkasa." Pemandangan dari Stellarium.Kesalahan lainnya
Pada akhirnya, saya meninggalkan Anda lebih lezat :) Mari kita turun ke salah satu dari mereka.
HipsTile* HipsSurvey::getTile(int order, int pix) { .... if (order == orderMin && !allsky.isNull()) { int nbw = sqrt(12 * 1 << (2 * order)); int x = (pix % nbw) * allsky.width() / nbw; int y = (pix / nbw) * allsky.width() / nbw; int s = allsky.width() / nbw; QImage image = allsky.copy(x, y, s, s); .... } .... }
Peringatan PVS-Studio: V634 Prioritas operasi '*' lebih tinggi daripada operasi '<<'. Mungkin tanda kurung harus digunakan dalam ekspresi. StelHips.cpp 271
Nah, apakah Anda bisa mendeteksi kesalahan? Pertimbangkan ungkapan
(12 * 1 << (2 * urutan)) lebih terinci. Penganalisa mengingat bahwa operasi '
* ' memiliki prioritas lebih tinggi daripada operasi pergeseran bit '
<< '. Sangat mudah untuk melihat bahwa mengalikan
12 dengan
1 tidak ada gunanya, dan tanda kurung sekitar
2 * tidak diperlukan.
, : int nbw = sqrt(12 * (1 << 2 * order)); <i>12 </i> .
Catatan Selain itu, saya ingin mencatat bahwa jika nilai operan kanan '
<< ' lebih besar atau sama dengan jumlah bit dari operan kiri, maka hasilnya tidak ditentukan. Karena literal numerik adalah
int secara default, yang membutuhkan
32 bit, nilai parameter
urutan tidak boleh lebih dari
15 . Jika tidak, evaluasi ekspresi dapat mengakibatkan perilaku yang tidak terdefinisi.
Kami melanjutkan. Metode di bawah ini sangat membingungkan, tapi saya yakin pembaca yang canggih akan menangani deteksi kesalahan :)
QCPRange QCPStatisticalBox:: getKeyRange(bool& foundRange, SignDomain inSignDomain) const { foundRange = true; if (inSignDomain == sdBoth) { return QCPRange(mKey - mWidth * 0.5, mKey + mWidth * 0.5); } else if (inSignDomain == sdNegative) { if (mKey + mWidth * 0.5 < 0) return QCPRange(mKey - mWidth * 0.5, mKey + mWidth * 0.5); else if (mKey < 0) return QCPRange(mKey - mWidth * 0.5, mKey); else { foundRange = false; return QCPRange(); } } else if (inSignDomain == sdPositive) { if (mKey - mWidth * 0.5 > 0) return QCPRange(mKey - mWidth * 0.5, mKey + mWidth * 0.5); else if (mKey > 0) return QCPRange(mKey, mKey + mWidth * 0.5); else { foundRange = false; return QCPRange(); } } foundRange = false; return QCPRange(); }
Peringatan PVS-Studio: V779 Kode yang tidak dapat dideteksi terdeteksi. Mungkin saja ada kesalahan. qcustomplot.cpp 19512.
Faktanya adalah bahwa semua
jika ... cabang
lain memiliki
pengembalian . Oleh karena itu, aliran kontrol tidak pernah mencapai dua baris terakhir.
Pada umumnya, contoh ini akan berjalan secara normal dan bekerja dengan benar. Tetapi kehadiran kode yang tidak dapat dijangkau sendiri merupakan sinyal. Dalam hal ini, ini menunjukkan struktur metode yang salah, yang sangat menyulitkan keterbacaan dan kelengkapan kode.
Fragmen kode ini harus di refactored, mendapatkan fungsi yang lebih rapi pada output. Misalnya, seperti ini:
QCPRange QCPStatisticalBox:: getKeyRange(bool& foundRange, SignDomain inSignDomain) const { foundRange = true; switch (inSignDomain) { case sdBoth: { return QCPRange(mKey - mWidth * 0.5, mKey + mWidth * 0.5); break; } case sdNegative: { if (mKey + mWidth * 0.5 < 0) return QCPRange(mKey - mWidth * 0.5, mKey + mWidth * 0.5); else if (mKey < 0) return QCPRange(mKey - mWidth * 0.5, mKey); break; } case sdPositive: { if (mKey - mWidth * 0.5 > 0) return QCPRange(mKey - mWidth * 0.5, mKey + mWidth * 0.5); else if (mKey > 0) return QCPRange(mKey, mKey + mWidth * 0.5); break; } } foundRange = false; return QCPRange(); }
Yang terakhir dalam ulasan kami adalah kesalahan yang paling saya sukai. Kode tempat masalah pendek dan sederhana:
Plane::Plane(Vec3f &v1, Vec3f &v2, Vec3f &v3) : distance(0.0f), sDistance(0.0f) { Plane(v1, v2, v3, SPolygon::CCW); }
Pernahkah Anda memperhatikan sesuatu yang mencurigakan? Tidak semua orang bisa :)
Peringatan PVS-Studio: V603 Objek telah dibuat tetapi tidak digunakan. Jika Anda ingin memanggil konstruktor, 'this-> Plane :: Plane (....)' harus digunakan. Plane.cpp 29
Pemrogram berharap bahwa beberapa bidang objek akan diinisialisasi di dalam konstruktor bersarang, tetapi ternyata seperti ini: ketika konstruktor
Plane (Vec3f & v1, Vec3f & v2, Vec3f & v3) dipanggil, objek sementara yang tidak disebutkan namanya dibuat di dalamnya, yang segera dihapus. Akibatnya, bagian dari objek tetap tidak diinisialisasi.
Agar kode berfungsi dengan benar, Anda harus menggunakan fitur C ++ 11 yang nyaman dan aman - konstruktor yang mendelegasikan:
Plane::Plane(Vec3f& v1, Vec3f& v2, Vec3f& v3) : Plane(v1, v2, v3, SPolygon::CCW) { distance = 0.0f; sDistance = 0.0f; }
Tetapi jika Anda menggunakan kompiler untuk versi bahasa yang lebih lama, maka Anda dapat menulis seperti ini:
Plane::Plane(Vec3f &v1, Vec3f &v2, Vec3f &v3) : distance(0.0f), sDistance(0.0f) { this->Plane::Plane(v1, v2, v3, SPolygon::CCW); }
Atau lebih:
Plane::Plane(Vec3f &v1, Vec3f &v2, Vec3f &v3) : distance(0.0f), sDistance(0.0f) { new (this) Plane(v1, v2, v3, SPolygon::CCW); }
Saya perhatikan bahwa dua metode terakhir
sangat berbahaya . Karena itu, Anda harus sangat berhati-hati dan memahami dengan baik bagaimana metode tersebut bekerja.
Kesimpulan
Kesimpulan apa yang dapat dibuat tentang kualitas kode Stellarium? Jujur saja, tidak banyak kesalahan. Juga, dalam keseluruhan proyek, saya tidak menemukan kesalahan tunggal di mana kode terkait dengan perilaku yang tidak ditentukan. Untuk proyek opensource, kualitas kode ternyata berada pada tingkat tinggi, yang saya angkat topi untuk para pengembang. Kalian hebat! Saya senang dan tertarik untuk meninjau proyek Anda.
Bagaimana dengan planetarium itu sendiri - saya sering menggunakannya. Sayangnya, tinggal di kota, saya jarang dapat menikmati langit malam yang cerah, dan Stellarium memungkinkan saya untuk berada di mana saja di dunia tanpa bangun dari sofa. Sangat nyaman!
Saya terutama menyukai mode "Constellation art". Melihat sosok-sosok besar yang menutupi seluruh langit dalam tarian aneh sungguh menakjubkan.
"Tarian yang aneh." Pemandangan dari Stellarium.Earthlings cenderung membuat kesalahan, dan tidak ada yang memalukan dalam kenyataan bahwa kesalahan ini bocor ke dalam kode. Untuk ini, alat analisis kode, seperti PVS-Studio, dikembangkan. Jika Anda salah satu penduduk dunia -
menyukainya, saya sarankan Anda
unduh dan coba sendiri .
Saya harap Anda tertarik membaca artikel saya, dan Anda mempelajari sesuatu yang baru dan berguna untuk diri Anda sendiri. Dan saya berharap para pengembang koreksi awal dari kesalahan yang ditemukan.
Berlangganan saluran kami dan tetap ikuti berita dari dunia pemrograman!

Jika Anda ingin berbagi artikel ini dengan audiens yang berbahasa Inggris, silakan gunakan tautan ke terjemahan: George Gribkov.
Into Space Again: bagaimana Unicorn Mengunjungi Stellarium