Finch/docs/pl/dla-inzynierow.md
Claude 8495bc718d
docs: the Polish cheat sheet and engineers' guide rewritten in natural Polish; the engineers' guide matches the Finch compiler
- names from the C++ compiler (declareFns, HelperScope, Fn::borrowParam, mutatingMethods) replaced by the real ones
- section 8's subsections numbered 8.x; the map's LLVM type has 7 fields; @W in the index records

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Psc42moPLwP43Cmb9oZqSR
2026-11-01 14:01:56 +00:00

36 KiB
Raw Blame History

Finch dla inżynierów

Jak od środka działa Finch 2.6, którego kompilator jest napisany w Finchu. Jak kod źródłowy zamienia się w tokeny, drzewo AST i LLVM IR w postaci tekstu, który clang kompiluje i linkuje z małym runtime'em w C; jak nagłówki C są czytane przez libclang z poziomu samego Fincha i jak ręcznie odtworzono konwencje wywołań C; reguły typów i własności wartości; jak kompilator buduje sam siebie z pliku startowego; i jak działa serwer języka.

Zakładam, że umiesz czytać kod w Finchu (zob. przewodnik dla techników) i że widziałeś już LLVM IR.

🇬🇧 English version: for-engineers.md


Spis treści

  1. Architektura
  2. Pliki źródłowe
  3. Część frontowa: lekser, parser, moduły
  4. Generowanie kodu
  5. Import z C i ABI języka C
  6. Sterownik, linkowanie i systemy docelowe
  7. System typów
  8. Własność i pamięć
  9. Typy generyczne, enumy i match
  10. Bootstrap
  11. Serwer języka
  12. Semantyka w porównaniu z C
  13. Znane ograniczenia

1. Architektura

W kompilatorze nie ma biblioteki LLVM. Zapisuje on LLVM IR jako tekst, a optymalizację, generowanie kodu maszynowego i linkowanie zostawia clangowi. Dzięki temu jest mały (ok. 12 tys. linii) i dało się go napisać w samym Finchu.

 main.fch ─► loader ── lex ── parse ──► Program   (+ każdy importowany moduł, rekurencyjnie)
                                          │
        `import "x.h"` ─► cimport.fch (libclang wołany z Fincha) ─► funkcje, struktury i stałe C
                                          │
                                          ▼
                       gen.fch ─► generate(): deklaracje struktur i funkcji, potem ciała funkcji;
                                  sprawdzanie typów i emisja IR w jednym przejściu; klasyfikacja ABI dla wywołań C
                                          │   (tekst IR)
                                          ▼
        linker.fch ─► clang -c prog.ll  ─►  clang prog.o rt-<hash>.o [twoje .c/.o/.a] -lm [pkg-config / -l]
                                          │
                                          ▼
                        plik wykonywalny  (finch run: uruchamiany z pliku tymczasowego, potem usuwany)

Decyzje, od których zależy cała reszta:

  • Pierwszy błąd kończy pracę. Kompilator wypisuje plik:linia:kolumna: error: … z linią źródła i strzałką, po czym się zatrzymuje. Nie próbuje kontynuować po błędzie, więc dalsze etapy nigdy nie widzą częściowo poprawnego wejścia.
  • Jedno przejście. gen.fch sprawdza typy i emituje IR w jednym przejściu po AST. Każde wyrażenie daje operand IR, swój typ Fincha (rodzaj zapisany tekstem: int, f32, str, array, map, struct, enum, vec, named…) i flagi.
  • Resztę robi clang. Zmienne lokalne to alloca, które mem2reg zamienia na rejestry. Optymalizuje -O2 clanga (albo -O0 przy -g).
  • Wartości, nie referencje. Tablice, teksty i struktury zachowują się jak wartości; o własności decyduje kompilator (§8).
  • Mały runtime w C (runtime/finch_rt.c, ok. 1150 linii) robi to, co w IR byłoby żmudne: teksty, powiększanie tablic, mapy, wczytywanie, pliki, błędy w trakcie działania. Kompiluje się go raz i trzyma w ~/.cache/finch.

2. Pliki źródłowe

Plik Linie Za co odpowiada
src/lexer.fch 470 Tokeny, pozycje, tekst """, zamiana "\(x)" na tokeny ("…" + str(x) + "…")
src/ast.fch 170 Jedna struktura Node z rodzajem (kind) jako tekstem, dziećmi, nazwami typów i pozycją (line, endLine); rozpoznawanie typów wektorowych
src/parser.fch 910 Zejście rekurencyjne: deklaracje, instrukcje, wyrażenia, typy generyczne ([T]), enum, match, export, link, const, test "…" { }
src/gen.fch 7030 Sprawdzanie typów i IR: struktury, funkcje, metody, zasięgi i zwalnianie, instrukcje, wyrażenia, funkcje wbudowane, mapy, błędy jako wartości, typy generyczne (monomorfizacja), enumy i match, wektory (SIMD), metadane debuggera (-g), tryb index
src/cimport.fch 790 libclang: funkcje, struktury i unie (pola, przesunięcia, wyrównanie), enumy, stałe, zmienne globalne, makra (także o wartości struktury)
src/platform.fch 120 Target: triple, układ danych, kompilator C, znak typu char na danym systemie
src/linker.fch 480 Pamięć podręczna (runtime, obiekt programu, pliki C/C++), biblioteki (link, pkg-config), podpowiedzi przy błędach linkera
src/main.fch 530 Sterownik: wiersz poleceń, wczytywanie modułów i nakładek, build/run/test/fmt/ir/index/lsp
src/manifest.fch 240 finch.toml: mały czytnik TOML, listy dla poszczególnych systemów
src/fmt.fch 225 finch fmt i formatowanie w serwerze języka
src/lsp.fch 950 Serwer języka (§11)
src/docs.fch 140 Jeden opis funkcji wbudowanych, wspólny dla podpowiedzi i dymków
std/*.fch 1040 json, path, time (+_windows), proc (+_windows), fs, thread
runtime/finch_rt.c 1150 Runtime w C: teksty, tablice, mapy, pliki, wypisywanie liczb, fs, wątki, uruchamianie testów, ścieżka wywołań
bootstrap/ build.sh, install.sh, pliki startowe (§10)

3. Część frontowa: lekser, parser, moduły

Lekser. Koniec linii kończy instrukcję; wcięcia nie mają znaczenia. Każdy token zna plik, linię i kolumnę, z których korzystają wszystkie komunikaty o błędach.

Parser. Zejście rekurencyjne, jedna funkcja na każdą konstrukcję. Warto wiedzieć, że: typy są parsowane do nazwy ([]str, map[str]int, ptr[char], Stack[int], f32x8); fn Typ.metoda deklaruje metodę; fn nazwa[T] i struct Nazwa[T] deklarują szablony; enum i match mają własne węzły; export fn oznacza funkcję widoczną z C; blok zapamiętuje swoją endLine (korzysta z niej serwer języka). Stara kolejność fn nazwa(...) typ daje błąd z podpowiedzią.

Moduły. import nazwa szuka po kolei: nazwa_<system>.fch obok importującego pliku (windows, linux, macos), nazwa.fch w tym samym miejscu, katalogów z FINCH_PATH, a na końcu biblioteki standardowej std/. Każdy moduł jest parsowany raz; jego nazwy są dostępne jako modul.nazwa. import "x.h" trafia zamiast tego do importu z C.

Moduł rozpoznaje się po pliku: kluczem jest jego ścieżka względem katalogu głównego pliku (albo katalogu z FINCH_PATH, albo std/), bez .fch i bez końcówki _<system>, np. ui/button. Program.imports przechowuje nazwę, której używa dany plik (button albo btn przy import ui/button as btn), a Program.modules klucz; modFor() przechodzi od jednego do drugiego. Symbole używają klucza, w którym / zamieniono na $ (@finch.ui$button.make).

Stałe. const NAZWA = wyrazenie to węzeł najwyższego poziomu; constValue() oblicza wyrażenie przy każdym użyciu, w module, w którym je zdefiniowano, bez zmiennych lokalnych, więc wynik zawsze składa się do literału (operatory na literałach całkowitych są składane w arith). checkConst() dopuszcza tylko literały, nazwy, operatory, pola i konwersje liczbowe; constBusy wykrywa cykle.

Drobiazgi leksera. 1e9 i 2.5e-3 to tokeny zmiennoprzecinkowe (tekst jest uzupełniany o ., którego wymaga LLVM); z tekstu """ funkcja longString() usuwa wcięcie linii zamykającej.


4. Generowanie kodu

gen.fch to jedna duża struktura Gen i funkcje, które na niej pracują. generate() wykonuje kolejno:

  1. declare(): każda struktura Fincha i C dostaje typ LLVM (struktury C z prawdziwym wyrównaniem; struktury, które przez wartość zawierają same siebie, są odrzucane), a potem każda funkcja dostaje sygnaturę z analizą pożyczania parametrów (§8.2).
  2. define() dla każdej funkcji: ciało, ze sprawdzeniem typów i emisją IR. Zasięgi pamiętają, co trzeba zwolnić przy wyjściu. Tworzenie instancji typów generycznych dokłada w tym czasie nowe funkcje, więc pętla trwa, dopóki pojawiają się nowe.
  3. Punkt wejścia C, main(argc, argv) (a przy --shared: finch_module_on_fatal i opakowania funkcji export fn).
  4. assemble(): deklaracje, typy struktur, stałe tekstowe, funkcje pomocnicze i ciała funkcji jako jeden tekst .ll.

Przy -g metadane DWARF są dopisywane na bieżąco, w trakcie zapisywania IR (najlepiej razem z -O0).

Typy generyczne są monomorfizowane: wywołanie max(1, 2) wyznacza T, a potem AST szablonu jest generowane jeszcze raz z podstawionym T, pod nazwą max[int] (symbol max.int). match na enumie musi być wyczerpujący (brak wariantu to błąd, else obejmuje resztę); na zwykłej wartości to ciąg porównań. Wartości enumów to i32; Kolor(n) sprawdza zakres; do wypisywania służy wygenerowana tabela nazw.

Błędy jako wartości: -> T! zwraca parę (wartość, tekst błędu); try przy błędzie wychodzi z funkcji, or go obsługuje, error("…") go tworzy.


5. Import z C i ABI języka C

cimport.fch woła libclang bezpośrednio z Fincha (sam jest napisany z import "clang-c/Index.h"), parsuje nagłówek z flagami systemu docelowego i przechodzi po kursorach. Typy tłumaczy tak: parametr const char * → str, inne char * → ptr[char], wynik char * → str; liczby całkowite według rozmiaru i znaku (char jest bez znaku na Linuksie ARM64); struktury i enumy według ich układu w pamięci.

LLVM IR nie realizuje sam konwencji wywołań C, więc gen.fch odtwarza ją ręcznie:

  • classify: System V x86-64; struktura do 16 bajtów idzie w rejestrach według klasy każdych 8 bajtów (INTEGER/SSE), większa przez pamięć;
  • classifyArm64: AAPCS64, w tym jednorodne agregaty liczb zmiennoprzecinkowych (HFA) i wariant Apple;
  • reguły Microsoft x64 (struktury 1, 2, 4 i 8-bajtowe przez wartość w rejestrze, pozostałe przez wskaźnik);
  • makePlan zamienia klasyfikację w plan wywołania; cThunk sprawia, że addr(fn) da się wywołać z C (funkcje zwrotne).

Ten sam kod pozwala kompilatorowi wołać libclang, którego API przekazuje CXCursor, CXType i CXString przez wartość.

Makra. #define z jedną liczbą albo jednym literałem tekstowym jest odczytywany z tokenów. Taki, którego tokeny mogą tworzyć wyrażenie stałe (bez klamer, średników i słów kluczowych instrukcji), oblicza clang: drugie parsowanie nagłówka dopisuje dla każdego static const __auto_type __finch_m_NAZWA = (NAZWA);, a clang_Cursor_Evaluate zwraca liczbę całkowitą, liczbę typu double albo tekst. Makra, które nie są stałymi, dają w tym drugim parsowaniu tylko błędy i są pomijane.

Unie to CStruct z isUnion: typ LLVM to { [n x iA] } (rozmiar i wyrównanie podaje clang), każde pole ma przesunięcie 0, a pole czyta się przez pamięć. Nie da się ich przekazać do C przez wartość (pole unsupported mówi dlaczego).


6. Sterownik, linkowanie i systemy docelowe

finch build|run|ir|index plik.fch [-o wynik] [-O0] [-g] [-l bib] [-I katalog] [--target t] [--cpu c] [--shared] [--overlay sciezka=plik]. ir wypisuje IR; index wypisuje rekordy dla serwera języka (§11); --shared tworzy bibliotekę współdzieloną z funkcji export fn.

--cpu <nazwa> (albo cpu w finch.toml) dodaje -march=<nazwa> na x86-64 albo -mcpu=<nazwa> na ARM64 do wywołania clanga, które kompiluje IR (nie runtime); wartość wchodzi do klucza pamięci podręcznej.

--target windows|linux|arm64|macos|android|<triple> zmienia triple i układ danych, znak typu char w C, reguły ABI opisane wyżej, wariant modułów dla systemu (nazwa_windows.fch) i kompilator C: FINCH_CC albo clang --target=…. Przy link "bib" linkowanie pyta pkg-config o flagi; gdy linker zawiedzie, jego wyjście jest analizowane i kompilator podpowiada brakujące link "…". Obiekt runtime'u ma klucz z hasza swojego źródła i systemu docelowego.

Zmienne środowiskowe: FINCH_PATH (katalogi modułów), FINCH_RT (źródło runtime'u), FINCH_CC (kompilator C i linker; IR zawsze kompiluje clang, nigdy CC), FINCH_LIBCLANG (dla bootstrap/build.sh).

Kompilator szuka runtime/ i std/ obok siebie (../, ./) albo w ../share/finch/ (bootstrap/install.sh).

Pamięć podręczna kompilacji (host.cacheDir(): $XDG_CACHE_HOME/finch, ~/.cache/finch, %LOCALAPPDATA%\finch):

  • ir/<hash głównego pliku + systemu + flag>.o z plikiem .key, w którym jest hash clanga, flag i całego IR. Jedno miejsce na program, więc pamięć nie rośnie przy każdej zmianie kodu; to samo IR daje ten sam obiekt bez uruchamiania clanga.
  • c/<hash źródła + kompilatora + flag + systemu>.o dla link "x.c" / .cpp, budowany z -MMD; .key to hash wszystkich plików wymienionych w .d (źródła i jego nagłówków). Zmiana któregokolwiek z nich wymusza ponowną kompilację.
  • Obiekty są zapisywane do pliku .part<pid> i dopiero potem przemianowywane, więc dwie równoległe kompilacje nie zobaczą połowy pliku. Hasze to dwa niezależne 32-bitowe (FNV-1a i djb2), razem 64 bity.

finch.toml (src/manifest.fch) jest szukany od katalogu głównego pliku (albo katalogu roboczego) w górę. Trafia do values["sekcja.klucz"] i lists["sekcja.klucz"]; list(klucz, systemy) łączy listę z najwyższego poziomu z listami z [target.<system>] i [target.<system>-<procesor>]. Ścieżki są liczone względem katalogu manifestu. Wpisy trafiają tam, gdzie opcje z wiersza poleceń: paths → wczytywanie modułów, include → -I (libclang i pliki C), links → link, libdirs i ldflags → polecenie linkowania, cflags → pliki C/C++.

Pliki C++: plik .cpp/.cc/.cxx z links kompiluje cxxFor(cc) (FINCH_CXX albo clang → clang++, …gcc → …g++, cc → c++), a potem ten sam sterownik linkuje, co dołącza bibliotekę standardową C++.

Android: --target android to aarch64-linux-android24; kompilator C to clang z NDK, toolchains/llvm/prebuilt/<host>/bin/aarch64-linux-android24-clang (z ANDROID_NDK_HOME/ANDROID_NDK_ROOT); linkowanie dodaje -llog, a nie dodaje -lpthread (bionic ma wątki w libc). Wariantem modułów jest linux.

finch test kompiluje tak jak run, z Options.test: wszystkie funkcje są definiowane jak zwykle, ale @main woła finch_test_run(nazwa, fn) dla każdego bloku test z modułów użytkownika (__test_<n>, funkcja void utworzona przez parser), a potem finch_test_summary(). Runtime uruchamia każdy test pod setjmp; fatal() (każdy błąd w trakcie działania i nieudany assert) wraca przez longjmp z pomocą fatal_hook, więc jeden nieudany test nie przerywa reszty. Pamięć, którą test miał w chwili błędu, nie jest zwalniana.

finch fmt działa na liniach: śledzi { }, ( )/[ ], teksty (razem z \( )), '…', """ i /* */. Linia, która zaczyna się na głębokości nawiasów 0 i nie kontynuuje linii zakończonej operatorem albo ,, dostaje wcięcie 4 × głębokość klamer; pozostałe (kontynuacje, wnętrza """) przesuwają się o tyle samo co linia, od której zaczęła się instrukcja, więc zachowują kształt (i usuwanie wcięcia z """).


7. System typów

Reprezentacja w LLVM

Finch LLVM
bool i1
char i8 u8 / i16 u16 / i32 u32 / int u64 i8 / i16 / i32 / i64
f32 / float float / double
str, []T { ptr, i64, i64 } (wskaźnik, długość, pojemność)
map[K]V { ptr, i64, i64, i64, ptr, i64, i64 } (wpisy, len, used, cap, indeks, icap, adds; zob. src/gen.fch)
ptr, ptr[T], null ptr (nieprzezroczysty)
[N]T [N x T]
f32x8, i8x32, … <8 x float>, <32 x i8> (§7, Wektory)
struktura Fincha nazwany typ struktury, naturalny układ
self w metodzie ptr (pierwszy parametr)
wynik -> T! { i1 ok, T wartosc, str blad } (i8 zamiast T dla -> !)
struktura C nazwana struktura packed z jawnymi bajtami wyrównania (§5)

Znak liczby istnieje tylko w typie Fincha i wybiera sdiv/udiv, ashr/lshr, icmp s*/u*, sext/zext, sitofp/uitofp, fptosi/fptoui.

Wartości

struct Val {
    str v           // operand IR: %t12, 42, @.str.3, zeroinitializer
    ast.Type ty
    bool fresh
    bool lit
}
  • lit: liczba zapisana w kodzie albo stała zaimportowana z C. Może stać się dowolnym typem całkowitym, w którym się mieści (fits()), albo f32. Przetrwa jednoargumentowy minus i działania na samych literałach, które składają się do stałej; jawne konwersje ją usuwają (u8(250) to stała z typem, więc u8(250) + 10 daje 4).
  • fresh: wartość posiadająca pamięć (str, tablica, struktura z takimi polami), której jeszcze nikt nie trzyma: wynik wywołania, literał, połączenie tekstów, konstruktor. Trzeba ją zapisać (przenieść) albo zwolnić (§8).

Konwersje niejawne: coerce(v, want)

Identyczne typy; null → dowolny wskaźnik; wskaźnik z typem ↔ ptr bez typu; liczba całkowita → całkowita, jeśli to pasujący literał albo typ się „poszerza” (widens(): ten sam znak i więcej bitów, albo bez znaku → większy ze znakiem); liczba całkowita → zmiennoprzecinkowa; f32 → f64; literał f64 → f32. Nic poza tym: komunikat błędu podpowiada jawną konwersję. Typy posiadające pamięć przechodzą tylko w siebie. Literał tablicy bierze typ elementów od miejsca, do którego trafia (exprWant()), więc []f32 xs = [1, 2.5] działa.

Argumenty operatorów: unify(l, r)

Strona, która jest literałem, dopasowuje się do drugiej (także literał zmiennoprzecinkowy obok f32: x * 2.0 zostaje f32); w przeciwnym razie rośnie ta strona, którą da się poszerzyć; i32 + u32 to błąd; całkowita + zmiennoprzecinkowa → zmiennoprzecinkowa; f32 + f64 → f64. Nie ma promocji liczb całkowitych jak w C: u8 + u8 zostaje u8. Dwa literały całkowite składają się w literał, więc FLAG_A | FLAG_B nadal mieści się w u64 albo u32.

Konwersje jawne: convert()

Z → Na IR
int/char ↔ int/char trunc/sext/zext według znaku źródła
bool → int zext
zmiennoprzecinkowa ↔ całkowita fptosi/fptoui, sitofp/uitofp
zmiennoprzecinkowa ↔ zmiennoprzecinkowa fpext/fptrunc
int → bool icmp ne 0
liczba/bool/char → str finch_str_from_int/uint/float/char/bool (fresh)
str → int/float finch_str_to_int/float (przy złym tekście błąd w trakcie działania)
[]u8/[]char → str finch_str_from_bytes
ptr → str finch_str_from_c (pożyczony, cap = -1)
ptr ↔ ptr nic
int → ptr / ptr → int inttoptr / ptrtoint

Porównania

Wskaźniki i null: tylko icmp eq/ne ptr. str: finch_str_eq (długość + memcmp) dla ==/!=, finch_str_cmp dla kolejności. Liczby zmiennoprzecinkowe: oeq olt ole ogt oge oraz une dla !=. Liczby całkowite i char według znaku (char bez znaku). Bool: tylko równość. Tablice i struktury: błąd.

Wektory

ast.vecType(nazwa) rozpoznaje <element>x<N> (elementy i8…u64, f32, f64; N = 2…64; najwyżej 512 bitów) i zwraca Type(kind: "vec", elem: [element], count: N), czyli wektor LLVM <N x T>. Parser przyjmuje taką nazwę wszędzie tam, gdzie może stać typ; wywołanie f32x8(…) trafia do vecMake().

Finch IR
f32x8(x) insertelement do elementu 0 + shufflevector … zeroinitializer (rozpowielenie)
f32x8(a, …, h) ciąg insertelement
f32x8(lista, i) sprawdzenie zakresu i … i+7, potem load <8 x float>, ptr …, align 4 (wyrównanie elementu)
f32x8(i32x8 v) sitofp, fptosi, sext, trunc… na całym wektorze
v + w, v * 2.0 fadd/add… na wektorach; argument skalarny jest dopasowywany do typu elementu i rozpowielany
v[i] miejsce w pamięci: getelementptr <element>, ptr %v, i64 i po sprawdzeniu zakresu; albo extractelement na wartości tymczasowej
v == w fcmp oeq/icmp eq → <N x i1> → llvm.vector.reduce.and
min, max, abs llvm.minnum/maxnum, llvm.smin/umin…, llvm.fabs/llvm.abs
v.sum(), .min(), .max() llvm.vector.reduce.fadd (z reassoc), .add, .fmin, .smin…
v.sqrt(), .floor(), .ceil(), .round() funkcja wewnętrzna llvm.* o tej nazwie
v.fma(b, c) llvm.fmuladd (jedna instrukcja tam, gdzie procesor ma FMA; llvm.fma bez FMA wołałoby funkcję z libm dla każdego elementu)
v.store(lista, i) sprawdzenie zakresu, store <8 x float> …, align 4

Dzielenie całkowite / i % jest odrzucane: ani x86, ani ARM nie mają instrukcji dzielącej liczby całkowite element po elemencie, więc LLVM i tak rozbiłby je na pojedyncze dzielenia. Porównania kolejności są odrzucane, bo ich wynikiem byłaby maska, dla której Finch nie ma typu.

Wyrównanie. LLVM daje <8 x float> wyrównanie ABI równe 32 (jego rozmiarowi), a malloc i finchowe alloca … align 16 gwarantują tylko 16. llty() zapisuje każdy wektor szerszy niż 16 bajtów w g.wide, a resolveStructBody zapisuje tam strukturę, która taki wektor zawiera; load() i store() dodają dla tych typów , align 16. Bez tego LLVM mógłby użyć vmovaps na adresie wyrównanym do 16 i program by się wysypał.

Jakie instrukcje wybiera clang: --cpu <nazwa> dodaje -march=<nazwa> (x86-64) albo -mcpu=<nazwa> (ARM64) do kompilacji IR, więc to samo IR staje się kodem SSE2, AVX2 + FMA, AVX-512 albo NEON. Wektory szersze niż rejestry procesora są dzielone na części.


8. Własność i pamięć

8.1 Model

Każda wartość typu posiadającego pamięć (str, []T albo struktura Fincha z takim polem) ma dokładnie jednego właściciela: zmienną, element tablicy, pole struktury albo komórkę na stercie z new(). Właściciel zwalnia wartość, gdy sam znika. Nie ma liczenia referencji ani garbage collectora; wszystkie decyzje zapadają przy kompilacji.

Sytuacja Co się dzieje
x := wyr / T x = wyr / x = wyr / pole, element, push own(v): wartość świeża zostaje przeniesiona, pożyczona jest głęboko kopiowana
x = … (typ posiadający) najpierw liczona jest nowa wartość, a stara jest zwalniana po zapisie
koniec bloku, return, break, continue zmienne we wszystkich opuszczanych zasięgach są zwalniane (od najnowszej), wykonują się defer
return zmienna_lokalna zmienna jest przenoszona (oznaczona jako moved na czas sprzątania przy tym return)
przekazanie argumentu pożyczka: funkcja dostaje płytką kopię i nigdy jej nie zwalnia; świeże argumenty zwalnia wywołujący po wywołaniu
parametr, który funkcja zmienia kopiowany na wejściu i należący do funkcji (decyduje mutates(), niżej)
wartość tymczasowa, której nikt nie zatrzymuje (print(a + b), f(g()), wyrażenie jako instrukcja) release(v): zwalniana zaraz po użyciu
a.pop(), a.remove(i) element jest przenoszony na zewnątrz (świeży)
for x in lista x pożycza kolejne elementy; jeśli ciało pętli może zmienić lista, elementy są kopiowane
wywołanie metody v.m() self to jest v (przekazywany jest adres): bez kopii, zmiany widzi wywołujący
mapy są właścicielami kluczy i wartości tak jak tablice elementów; m[k] = v przenosi albo kopiuje v i zwalnia starą wartość

Literał tekstowy to świeża wartość z cap = 0: przeniesienie go nic nie kosztuje, zwalnianie jest pomijane (release() ignoruje stałe), a zapis do niego (s[i] = c) najpierw robi kopię na stercie (finch_str_own). Teksty z C są pożyczone z cap = -1 i zawsze kopiowane, zanim zostaną zatrzymane.

8.2 Pożyczanie parametrów: mutates()

Przy deklarowaniu funkcji każdy parametr typu posiadającego jest sprawdzany przez mutates(nazwa, cialo): przypisanie, którego zmienną bazową (rootVar(): przez .pole i [i]) jest ten parametr, zmieniająca metoda na nim (push pop insert remove clear resize sort reverse) albo addr(…) na nim. Niezmieniane parametry są pożyczane (FnSig.borrow); zmieniane są kopiowane raz, na wejściu. Analiza jest składniowa i ostrożna: wszystko, co mogłoby zmienić parametr, powoduje kopię, a to zawsze jest poprawne. Ta sama analiza decyduje, czy for x in lista musi kopiować elementy.

Metody też w tym uczestniczą: declare() najpierw ustala, które metody zmieniają self (mutates("self", cialo)), powtarzając to, dopóki coś się zmienia, bo wywołanie zmieniającej metody na self też zmienia self. Ich nazwy trafiają do g.mm, które mutates() traktuje jak push: p.przesun(1) zmienia p. Dopiero potem dla każdej funkcji i metody liczone jest borrow.

8.3 Kopiowanie i zwalnianie

dropAt(adres, T) / copyAt(cel, zrodlo, T) działają na adresach:

  • str: finch_str_drop (zwalnia, gdy cap > 0) / finch_str_copy (literały współdzieli, resztę duplikuje).
  • []T i struktury posiadające pamięć: wygenerowane funkcje pomocnicze finch.drop.<klucz> i finch.copy.<klucz>, tworzone raz na typ (dropFn, copyFn, klucz z typeKey(), np. arr_S_main_Player). Tablica zwalnia każdy element posiadający pamięć, a potem woła finch_arr_free; kopia woła finch_arr_clone_raw, a potem głęboko kopiuje takie elementy w miejsce surowych bitów. Struktury zwalniają i kopiują tylko pola posiadające pamięć.

Funkcje pomocnicze powstają jako osobne funkcje (beginHelper() / endHelper() odkładają na bok funkcję, która jest w trakcie pisania, i wyłączają lokalizacje debuggera), mogą wołać się nawzajem rekurencyjnie (struct Node { []Node kids }), a O2 je wkleja w miejsce wywołań.

8.4 Jak to wygląda

fn przywitaj(str imie) -> str {
    wiad := "Cześć, " + imie
    return wiad
}

fn main() {
    a := ["x"]
    b := a
    b.push(przywitaj("Ola"))
    print(b[1])
}

IR funkcji main przy -O0 (w skrócie):

  %3 = call ptr @finch_alloc(i64 24)                         ; ["x"]: jeden element na stercie
  store { ptr, i64, i64 } { ptr @str.1, i64 1, i64 0 }, ptr %4  ; literał, cap 0
  ...
  store { ptr, i64, i64 } %7, ptr %a                          ; przeniesione do a (świeże)
  %a1 = load { ptr, i64, i64 }, ptr %a
  store { ptr, i64, i64 } %a1, ptr %1
  call void @finch.copy.arr_str(ptr %2, ptr %1)               ; b := a  kopiuje
  %8 = load { ptr, i64, i64 }, ptr %2
  store { ptr, i64, i64 } %8, ptr %b
  %9 = call { ptr, i64, i64 } @finch.przywitaj({ ptr, i64, i64 } { ptr @str.2, i64 3, i64 0 })
  ...
  call void @finch_arr_reserve(ptr %b, i64 %12, i64 24)       ; push: powiększenie, jeśli trzeba
  store { ptr, i64, i64 } %9, ptr %15                         ; wynik przywitaj przeniesiony, bez kopii
  ...
  %20 = icmp uge i64 1, %19                                   ; b[1]: sprawdzenie zakresu
  br i1 %20, label %out.of.range, label %in.range
  ...
  call void @finch.drop.arr_str(ptr %b)                       ; koniec main: od najnowszej
  call void @finch.drop.arr_str(ptr %a)
  ret void

a przywitaj zwraca wiad bez kopiowania (ret … %5 po wczytaniu zmiennej: została przeniesiona).

8.5 defer

defer instrukcja zapisuje instrukcję w bieżącym zasięgu (Scope.defers, razem z orders: ile zmiennych wtedy istniało) oraz Cleanup{isDefer}; w tym miejscu nic nie emituje. Ilekroć emitowane jest sprzątanie zasięgu (koniec bloku, return, wyjście przez break/continue), ciało jest generowane przy tym wyjściu, z deferLimit ustawionym tak, by lookup() ukrywało zmienne zadeklarowane po defer: w tym momencie są już zwolnione (sprzątanie idzie od końca). return/break/continue wewnątrz odroczonej instrukcji to błąd (inDefer). Ciała defer są więc powielane na każdą drogę wyjścia i dlatego na zwykłej ścieżce nic nie kosztują.

8.6 Pamięć zarządzana ręcznie

new(v) przydziela sizeof(T) przez finch_alloc, zapisuje own(v) i zwraca ptr[T]. free(p) zwalnia to, na co wskazuje wskaźnik, jeśli T posiada pamięć (pomija null), i woła free z libc. Wskaźniki nigdy niczego nie posiadają: addr(x) może wskazywać w próżnię po końcu bloku x; tego się nie sprawdza.

8.7 Sprawdzanie

MEMCHECK=1 tests/run.sh buduje każdy program testowy i uruchamia go pod valgrindem z --leak-check=full --errors-for-leak-kinds=all: zero wycieków i zero błędnych dostępów we wszystkich, w tym przy return z wnętrza pętli, continue/break ze zmiennymi posiadającymi pamięć, defer, zmianach w trakcie iteracji, zagnieżdżonych tablicach struktur i przekazywaniu struktur C.


9. Typy generyczne, enumy i match

Jak są generowane, opisuje §4. Reguły widoczne dla użytkownika są w rozdziałach 23 i 24 przewodnika dla techników. Testy to tests/new/generics.fch, tests/new/enums.fch i oczekiwane błędy w tests/new/fail/.


10. Bootstrap

Kompilator powstaje z pliku startowego (ang. seed): jego własnego IR, zapisanego w repozytorium jako bootstrap/<system>.ll (zob. bootstrap/README.md). bootstrap/build.sh robi:

plik .ll ──clang──► finch0 ──buduje src/──► finch1 ──buduje src/──► build/finch

i przerywa, jeśli finch1 i finch nie dają identycznego bajt w bajt IR (punkt stały: kompilator kompiluje sam siebie tak samo, niezależnie od tego, co go zbudowało). bootstrap/build.sh seed zapisuje plik startowy na nowo z bieżącego kompilatora; trzeba to zrobić, gdy kod kompilatora zaczyna używać czegoś, czego zapisany plik startowy nie zna.

Plik startowy jest osobny dla każdego systemu, bo zawiera konwencję wywołań C dla funkcji libclang (§5). Są pliki dla Linuksa x86-64 i ARM64, macOS ARM64 i x86-64 (zrobione z nagłówkami SDK Apple) oraz Windowsa x86-64 (z nagłówkami llvm-mingw). CI na każdym z tych systemów buduje z nich kompilator i uruchamia tam testy.


11. Serwer języka

finch lsp rozmawia przez JSON-RPC na stdin/stdout (ramki Content-Length, std/json). Sam nie sprawdza typów: przy każdej zmianie uruchamia finch index plik.fch --overlay sciezka=kopia …, gdzie nakładki to niezapisany tekst z edytora. W trybie index kompilator zamiast IR wypisuje rekordy rozdzielone tabulatorami:

Rekord Znaczenie
@R plik linia kol dlug plikDef liniaDef kolDef dymek odwołanie: co pokazać w dymku i gdzie jest definicja
@V nazwa typ plik linia kol plikFn liniaFn koniecFn zmienna i funkcja, w której żyje
@F modul nazwa odbiorca plik linia kol koniec sygnatura parametry funkcja albo metoda
@S / @SF, @E, @I struktury (i pola), enumy, importy
@W ostrzeżenie

Pierwszy błąd jest wypisywany jako sciezka:linia:kol: error: komunikat i staje się diagnostyką. Gdy kod się nie kompiluje, serwer nadal podpowiada uzupełnienia i parametry na podstawie ostatniej wersji, która się kompilowała. Testem akceptacyjnym jest tests/lsp_test.py.


12. Semantyka w porównaniu z C

Sytuacja C Finch
Przepełnienie liczby całkowitej ze znakiem UB zdefiniowane zawinięcie (bez nsw)
Dzielenie całkowite przez zero, INT_MIN / -1 UB błąd kompilacji, jeśli to stała; w przeciwnym razie błąd w trakcie działania
Indeks tablicy poza zakresem UB błąd w trakcie działania (także dla pól [N]T struktur C)
Dereferencja null UB błąd w trakcie działania dla .value, .pole, [i]
Użycie po zwolnieniu / podwójne zwolnienie tablic, tekstów, struktur częste błędy niemożliwe: tych nie zwalnia się ręcznie
Wyciek pamięci tablic, tekstów, struktur częsty błąd niemożliwy bez new
x & 1 == 0 x & (1 == 0) (x & 1) == 0
Niejawne zwężanie, mieszanie liczb ze znakiem i bez po cichu błędy kompilacji
Niezainicjowane zmienne UB niemożliwe (zero albo wartości domyślne)
Brak return UB, jeśli wynik jest użyty błąd kompilacji
Kolejność obliczania w dużej mierze nieokreślona od lewej do prawej; w przypisaniu najpierw prawa strona
Przesunięcie o ≥ szerokość, liczba zmiennoprzecinkowa poza zakresem przy zamianie na całkowitą UB poison, jak w LLVM (niesprawdzane)
Wiszący wskaźnik z addr() / new + free UB nadal UB

Ścieżka wywołań przy błędach w trakcie działania

Z Options.trace (domyślnie dla run, test, -g, -O0; --trace/--no-trace; nigdy z --shared) każda funkcja Fincha ma na stosie ramkę:

%frame = alloca { ptr, ptr, ptr, i64 }          ; ramka wywołującego, nazwa, plik, linia
store ptr (load @finch_trace), ...pole 0
store ptr @"nazwa", ...pole 1 ; ptr @"plik", pole 2 ; i64 <linia>, pole 3
store ptr %frame, ptr @finch_trace

@finch_trace to globalna zmienna thread_local w module programu (model initial-exec, chyba że to biblioteka albo Windows); każda instrukcja zapisuje swoją linię do pola 3. Po każdym wywołaniu funkcji Fincha wywołujący zapisuje z powrotem swoją ramkę do @finch_trace, co przy okazji usuwa ramki pozostawione przez funkcję, która wyszła wcześniej, więc na licznych ścieżkach powrotu nie trzeba nic sprzątać. Opakowania wywoływane z C (addr(fn), export fn) zapamiętują i odtwarzają @finch_trace wokół wywołania. main woła finch_trace_init(top, set) z dwiema małymi funkcjami, które ją czytają i zapisują; tak runtime (print_trace() w fatal() i uruchamianie testów) dostaje się do zmiennej thread_local, której nie potrafi nazwać. Koszt: ok. 3 razy wolniej na fib(40), które składa się wyłącznie z wywołań; dlatego zwykłe finch build tego nie dodaje.

Wątki i operacje atomowe

std/thread.fch to cienka warstwa nad runtime'em (finch_thread_spawn/join, finch_mutex_*: pthreads albo, na Windowsie, CreateThread i CRITICAL_SECTION). Funkcja wątku to dowolna fn f(ptr), przekazywana przez addr(f), co tworzy opakowanie wywoływalne z C. atomic_add/sub/swap stają się atomicrmw add/sub/xchg … seq_cst, atomic_load/store to load/store atomic … seq_cst, align <rozmiar>, a atomic_cas to cmpxchg … seq_cst seq_cst zwracające i1. Przyjmują ptr[T] do dowolnego typu całkowitego. Tablice, teksty i mapy Fincha nie są synchronizowane; dzielenie ich między wątkami wymaga muteksu.

Ostrzeżenia

warn() wypisuje plik:linia:kol: warning: … na stderr (żeby finch ir pozostało czystym IR) albo, w trybie index, rekord @W, który serwer języka publikuje z ważnością 2. Jedno ostrzeżenie na miejsce w kodzie. Są dwa rodzaje: parametr będący strukturą oraz element w for x in lista, jeśli ich zwykłe dane (byteSize(): liczby, zagnieżdżone struktury, [N]T z C; str, tablice i mapy liczą się jako 0, bo kopiuje się tylko ich uchwyt) mają co najmniej 256 bajtów.

Struktury przekazywane przez adres

argWant() (tylko argumenty wywołań, nie przypisania): gdy parametr ma typ ptr[S], a argument jest zwykłą zmienną typu S, argumentem staje się adres tej zmiennej, tak jakby napisano addr(x). Dotyczy to tylko zmiennych, więc nic nie jest obliczane dwa razy, a wskaźnik nigdy nie wskazuje na wartość tymczasową.


13. Znane ograniczenia

  • Jeden błąd na uruchomienie, bez kontynuowania po nim.
  • Informacje dla debuggera (-g) są najlepsze z -O0.
  • Typy generyczne są monomorfizowane (kod rośnie z każdą instancją); nie ma ograniczeń na parametry typu, więc złe użycie daje błąd wewnątrz ciała instancji.
  • Pamięć, którą miał nieudany test, nie jest zwalniana (uruchamianie testów wychodzi z niego przez longjmp).
  • Wektory nie mają masek (v < w), przetasowań ani dzielenia całkowitego.