Bret Cameron
Bret CameronDigital Consultant @ Concurrent Design Group

Co programiści JavaScript mogą czerpać z C++

Poznaj typy, pamięć i sposób, w jaki nauka języka niższego poziomu może sprawić, że staniesz się lepszym programistą.
25.06.20229 min
Co programiści JavaScript mogą czerpać z C++

Jak w przypadku wielu nowych programistów, tak i u mnie, JavaScript był pierwszym językiem, którego się nauczyłem. Jest to czołowy frontendowy język programowania stron internetowych oraz -  dzięki Node.js  - popularne narzędzie backendowe.

Uważam również, że jako język "wyższego poziomu" JavaScript jest fantastycznym wyborem dla początkujących. Można go uruchomić na dowolnej przeglądarce internetowej, a funkcje takie jak dziedziczenie prototypowe i typy dynamiczne, dają uczącym się mniej przeszkód do pokonania, zanim napiszą i wykonają swój pierwszy kawałek kodu.

Ale to, co sprawia, że JavaScript jest łatwiejszy dla początkujących, może również utrudnić pełne opanowanie go. Może zachowywać się w pozornie nieintuicyjny sposób. Wielu programistów polega na podejściu prób i błędów, jeśli chodzi o bardziej niejasne funkcje, takie jak niejawna konwersja typów lub słowo kluczowe this. Łatwiej jest znać takie cechy niż je zrozumieć.

Każdy głupiec może wiedzieć. Chodzi o to, by zrozumieć. - Albert Einstein.

Tak więc, aby stać się bardziej zaawansowanym programistą JavaScript, warto spróbować lepiej albo w ogóle zrozumieć, co dzieje się pod jego maską. Ostatecznie, najlepszym miejscem do obejrzenia jest silnik JavaScript V8: jest to najczęściej używany kompilator JavaScript ((leżący u podstaw Google Chrome, Node.js i innych) i jest open-source, więc można zobaczyć dokładnie, jak funkcje JavaScript są wykonywane w języku bazowym C++ .

Ale ten artykuł nie jest przewodnikiem po V8. Jest to raczej spojrzenie na to, jak języki niższego poziomu, takie jak C++, mogą pomóc nam w lepszym zrozumieniu języków wyższego poziomu, takich jak JavaScript. C++ nie tylko może pomóc nam zrozumieć podstawowy kod kompilatora, ale  -  przyglądając się rzeczom, które muszą zrobić programiści C++, których programiści JavaScript mogą uniknąć  -  możemy lepiej zrozumieć, gdzie avaScript oszczędza nam czas i dlaczego czasami może to powodować problemy.

W szczególności przyjrzymy się typom danych i zarządzaniu pamięcią w C++ oraz jak ich znajomość może pomóc nam uniknąć błędów typu i zapobiec wyciekom pamięci w JavaScript. Sprawdzimy również, co zarządzanie pamięcią ma wspólnego z końcem czasu.

Koercja typów w języku JavaScript

Przed skokiem do C++, przyjrzyjmy się, jak JavaScript radzi sobie z typami danych i niektórymi pułapkami swojego systemu "type coercion".

JavaScript używa koercji do automatycznego przekonwertowania jednego typu danych do innego: łańcuchów znaków do liczb, liczb do łańcuchów znaków, liczb lub łańcuchów znaków do wartości logicznych i tak dalej. Innymi słowy, jeśli nie określisz wyraźnie, jakiego typu chcesz, JavaScript będzie zgadywał w oparciu o zestaw reguł. Czasami jest to przydatne i może pomóc nam szybko i zwięźle napisać kod. Innym razem może to być przyczyną zamieszania.

Rzeczywiście, nawet jeśli to zachowanie jest  -  ostatecznie  -  przewidywalne, niektóre automatyczne decyzje są mniej niż intuicyjne i w dużym code base’ie, łatwo jest dostrzec, w jaki sposób wymuszanie typu może prowadzić do nieoczekiwanych błędów. Oto kilka pokazów wyników, uzyskanych za pomocą równań łączących ciągi i liczby:

"10" - 4
// 6

"10" + 4
// "104"

"20" - "5"
// 15

"20" + "5"
// 205

"20" + + "5"
// 205

"foo" + "bar"
// "foobar"

"foo" + + "bar"
// "fooNaN"

"6" - 3 + 3
// 6

"6" + 3 - 3
// 60


W tych przykładach, wiele potencjalnych nieporozumień obraca się wokół operatora +, który może być użyty zarówno do wymuszenia konwersji łańcucha znaków do numeru, jak i jako operator konkatenacji  - łączenia dwóch lub więcej łańcuchów znaków.

Chociaż wymuszanie typu może pomóc programistom pisać kod szybciej i zwięźlej - i oszczędza początkującym jednej rzeczy do pamiętania - to jasne jest, dlaczego taki system może prowadzić do błędów, szczególnie w większej, bardziej złożonym projekcie. Powyższe wyniki mogą mieć sens dla doświadczonych programistów JavaScript, ale nie wszystko jest intuicyjne!

Mając na uwadze zalety i wady systemu wymuszania typu JavaScript, zobaczmy teraz, jak C++ radzi sobie z typami danych.

Typy i zarządzanie pamięcią w C++

Języki niższego poziomu, takie jak C++, nie mają tych samych potencjalnych pułapek, ponieważ typy danych muszą być podane w punkcie definicji. Podczas gdy JavaScript ma trzy słowa kluczowe var, let i const  - w deklaracji nowych zmiennych w C++ każdy typ danych ma swoje własne słowo kluczowe.

Np. 7 podstawowych typów danych w C++ to liczby całkowite, zmiennoprzecinkowe, podwójnie zmiennoprzecinkowe, znak, szeroki znak, boolean i typ bez wartości. Słowa kluczowe użyte do ich zdefiniowania to odpowiednio int, float, double, bool, char, wchar_t i void.

Poniższy fragment zawiera przykładową deklarację każdego z tych typów, z dodatkowymi uwagami w komentarzach:

#include <iostream>
#include <string>
using namespace std;

int main()
{
 
  // BOOLEANS
  bool isChecked = true;
  
  // INTEGERS
  int age = 24;

  // FLOATS
  // Ogólnie rzecz biorąc, float ma 7 cyfr dziesiętnych precyzji, podczas gdy podwójny ma 15
  float pi7 = 3.1415926;
  double pi15 = 3.141592653589793;
  
  // ZNAKI
  // Regularne znaki mogą zawierać tylko wartości przechowywane w łacińskich tabelach ISO.
  // Szerokie znaki mogą jednak zawierać wartości unicode
  char englishGreeting[6] = {'H', 'e', 'l', 'l', 'o', '\0'};
  wchar_t mandarinGreeting[7] = { 'n', 'ǐ', ' ', 'h', 'ǎ', 'o', '\0' };
  
  // ŁAŃCUCH
  // W C++, łańcuch nie jest typem danych (jak to ma miejsce w JavaScript i wielu innych językach). 
  // Jest to klasa, a więc musimy napisać #include <string> na górze dokumentu
  string greeting = "Hello";
  
  // VOID
  // Powszechnym zastosowaniem void jest zdefiniowanie funkcji, które niczego nie zwracają.
  void printMessage() {
    cout << "Hello, world!";
  };
  
  return 0;
}


W odróżnieniu od JavaScript, C++ doddaje dużą kontrolę nad zarządzaniem pamięcią w ręce programistów.. W C++ za każdym razem, gdy deklarujemy zmienną, podejmujemy również decyzję o tym, ile pamięci należy zarezerwować. Np. zwykły char zawiera zazwyczaj tylko 8 bitów (1 bajt), ograniczając jego użycie do 255 znaków w łacińskich tabelach ISO. W przeciwieństwie do tego, wchar_t zawiera 16 lub 32 bity, zabierając więcej pamięci, ale pozwalając nam na dostęp do znacznie większej liczby znaków Unicode.

Największa różnorodność opcji znajduje się w typie liczb całkowitych, gdzie podstawowe słowo kluczowe int można łączyć ze słowami kluczowymi wielkości short , long i long long oraz słowami kluczowymi "znaku" signed i unsigned.

Podstawowy typ int zawiera naturalny rozmiar sugerowany przez architekturę systemu. W 64-bitowym systemie operacyjnym oznacza to zazwyczaj rozmiar 32 bitów. W praktyce oznacza to, że zmienna ze słowem kluczowym signed może zawierać wartości wahające się od -2,147,483,648 do 2,147,483,647, natomiast zmienna unsigned może zawierać wartości od 0 do 4,294,967,295.

Jeśli wiesz, że zakres możliwych liczb całkowitych jest mniejszy niż ten, możesz użyć short int, aby zaoszczędzić pamięci. Albo, jeśli masz do czynienia z bardzo dużymi liczbami całkowitymi, możesz użyć unsigned long long int, aby zapisać 64-bitowe liczby tak duże jak 2^64-1 (9 kwintylionów).

Dlaczego pamięć ma znaczenie: przypadek użycia dotyczący końca czasu

Użycie 64-bitowej deklaracji zmiennej, takiej jak long long int, pozwala komputerom mierzyć daty około 292 milionów lat w przyszłości. Może się to wydawać niepotrzebnie dużą ilością czasu, ale w rzeczywistości rozwiązuje bardzo praktyczny problem.

Zgodnie z konwencją, większość dat w obliczeniach jest mierzona przy użyciu czasu Unix, który jest datowany od północy 1 stycznia 1970 r. UTC i który jest celny z dokładnością do najbliższej sekundy. W systemach, w których czas Uniksa jest zapisywany jako podpisany 32-bitowy numer, największa wartość, jaką można zapisać to 2 147 483 647. Może wydawać się, że to dużo, ale biorąc pod uwagę, że nagrywamy co sekundę, dwa miliardy nie prowadzą nas zbyt daleko.

W rzeczywistości daty rejestrowane na systemach 32-bitowych osiągną maksymalną wartość 19 stycznia 2038 UTC (dokładnie o 03:14:07). Kiedy tak się stanie, data owinie się do wartości ujemnej 2 147 483 647, pojawiającej się 13 grudnia 1901 r. Znany jest on jako problem z 2038 r. i doprowadził do powstania wielu hiperbolicznych nagłówków, takich jak "Wszystkie komputery znikną w 2038 r."  - nagłówek z Metro, tabloidu w Wielkiej Brytanii.

Ten sensacyjny nagłówek może być daleki od prawdy, ale - kiedy nadejdzie rok 2038 - może to spowodować problemy dla 32-bitowych systemów operacyjnych i nawet starszych wersji całych języków programowania. Po raz pierwszy zetknąłem się z problemem używając PHP, które - przed wersją 5.2 - nie miało wbudowanego sposobu nagrywania dat po 2038 roku. (Dla przypomnienia, JavaScript używa 64-bitowego systemu do pomiaru daty, więc my, deweloperzy JavaScript, nie musimy się tym martwić)!

Problem z 2038 r. pokazuje potencjalną przydatność samodzielnego zarządzania pamięcią. Tam, gdzie wymagany jest mniejszy zakres wartości, możemy zaoszczędzić pamięć. A tam, gdzie wymagamy większego asortymentu, możemy mieć pewność, że nasz system przechowuje odpowiednią ilość własnej.

Zarządzanie pamięcią w języku JavaScript

JavaScript automatycznie przydziela pamięć, gdy obiekty są tworzone i zwalnia ją, gdy nie są już używane (odśmiecanie). Ta automatyczność jest potencjalnym źródłem nieporozumień: może dawać deweloperom fałszywe wrażenie, że nie muszą się martwić o zarządzanie pamięcią. - MDN

JavaScript jest znany jako język "który używa garbage collectora". Wykorzystuje algorytm Mark and Sweep, aby sprawdzić, które kawałki pamięci są aktywne, a które są "śmieciami". Kolektor może wtedy uwolnić "śmieci", zwracając niewykorzystaną pamięć do systemu operacyjnego.

"Zbieranie śmieci" jest cechą charakterystyczną dla języków wyższego poziomu i pomaga uwolnić pamięć, która - na tyle, na ile jest to możliwe bez wyraźnych instrukcji od dewelopera - nie jest już potrzebna. Aby dowiedzieć się więcej o odśmiecaniu w JavaScript, sprawdź ten artykuł i stronę MDN na temat zarządzania pamięcią

Zbieranie śmieci jest potężnym systemem automatycznego zarządzania pamięcią, ale nie jest niezawodne. W szczególności tzw. "niechciane referencje" mogą prowadzić do wycieków pamięci, co oznacza, że program zajmuje więcej pamięci, niż jest to konieczne, czyniąc go mniej wydajnym. Jeśli jednak zdajemy sobie sprawę z ryzyka wycieku pamięci, możemy podjąć kroki w celu ich usunięcia.

Jedną z częstych przyczyn przecieków pamięci jest przypadkowe użycie zmiennych globalnych. Za każdym razem, gdy definiujemy zmienną w JavaScript bez słowa kluczowego var, let lub const, to jest ona automatycznie uważana za zmienną globalną. O ile foo nie zostało już zdefiniowane, wyrażenie foo = "bar" jest odpowiednikiem window.foo = "bar".

Narzędzie lintingowe takie jak ESLint pomoże Ci znaleźć takie błędy, ale wbudowany tryb JavaScript zapobiega również przypadkowemu użyciu zmiennych globalnych, oznaczając je jako błędy. Aby włączyć tryb ścisły, wystarczy wpisać "use strict"; na początku każdego skryptu lub funkcji, w której chcesz go użyć. Więcej sposobów usuwania wycieków pamięci z kodu można znaleźć w tym artykule.

Typy w JavaScript

Istnieją również sposoby określania typów zmiennych i tworzenia własnych typów w JavaScript, w sposób przypominający języki niższego poziomu. Najbardziej popularnym i kompleksowym rozwiązaniem jest TypeScript, składniowy superset JavaScript, który dodaje opcję statycznego typowania do języka.

Do TypeScriptu znajdziemy wiele świetnych zasobów. Można powiedzieć, że TypeScript to świetny sposób na zapewnienie, że Twój kod jest skalowalny i wolny od błędów, a to pomoże nam uniknąć tego rodzaju nieintuicyjnych wyników, które widzieliśmy powyżej, w sekcji "koercja typu". Rozszerzenie pliku TypeScript to .ts i istnieje również odpowiednik dla .jsx : .tsx . Jednym z najlepszych punktów wyjścia dla początkujących jest TypeScript w ciągu 5 minut.

Warto również zauważyć, że istnieją rozwiązania adnotacyjne specyficzne dla różnych technologii JavaScript. Np. możesz dodać oficjalny moduł Node’a PropTypes do swoich projektów React. Umożliwia to dokumentowanie zamierzonych typów danych dla propów przekazywanych do komponentu oraz ustawianie wartości domyślnych. Szczególnie w połączeniu z linterem takim jak ESLint, PropTypes jest potężnym dodatkiem do projektów opartych na React.

Podsumowanie

Mam nadzieję, że ten artykuł wyjaśnił niektóre różnice pomiędzy językami niższego poziomu, takimi jak C++ i językami wyższego poziomu, jak JavaScript.

Mam również nadzieję, że wyposażył Cię w narzędzia pozwalające na wprowadzenie niektórych korzyści płynących z C++ do JavaScript, w postaci TypeScript lub PropTypes, oraz zademonstrowanie, że można wpływać na zarządzanie pamięcią w JavaScript i usprawniać ją.

Jeśli jesteś zainteresowany kontynuowaniem tematu, poniżej zamieściłem kilka linków do artykułów i zasobów, które okazały się najbardziej przydatne podczas pisania tego artykułu. A jeśli masz dobre zrozumienie języka C++ i chcesz dowiedzieć się więcej o sposobie implementacji JavaScript, najlepszym miejscem w jakie możesz się udać jest prawdopodobnie oficjalna strona V8, albo oficjalne repozytorium na GitHubie. Wesołego kodowania!


Oryginał tekstu w języku angielskim przeczytasz tutaj.

<p>Loading...</p>