Fragment of the historic first ad for Borland Turbo Pascal
Fragment of the historic first ad for Borland Turbo Pascal via https://blogs.embarcadero.com/50-years-of-pascal-and-delphi-is-in-power/

Ból pracy z Turbo Pascalem 3.0, studium przypadku

Tło: dlaczego

W poprzednim poście przyznałem, że Pascal pojawił się w latach 70., a Turbo Pascal istniał już w latach 80., co w teorii powinno poprawić jakość życia programistów w porównaniu do BASICa (to istotne założenie).
Warto też pamiętać, że każdy język się rozwija, i nawet jeśli chwalimy Pascala za jego wkład w programowanie ogólnie, i jeśli uznajemy, że jest to język programowania obiektowego nowoczesny, ściśle typowany, to naturalnie nie zawsze tak było.
W tym poście możesz spodziewać się kilku uwag na temat: co wydaje się dziwne podczas przełączania się na ówcznesnego Pascala dzisiaj, oraz jak przebiegło przeportowanie małego programu na rzeczywisty OS 8-bitowy.

Używam Advent Of Code 2023, wyzwania programistycznego, które nie jest tak hardcorowe jak niektóre mogą być (dość mały input, czas obliczeń nie ma znaczenia), jako dobry teren do sprawdzenia konkretnych wersji języków programowania, a także przypomnienia sobie, dlaczego cieszę się, że komputery mają dziś więcej pamięci.

Zacząłem od prostej implementacji, którą mogłem napisać na komputerze z systemem Linux i skompilować przy użyciu Free Pascal, trzymając się tego, co uważałem za fundamentalne cechy Pascala, które powinny również dobrze się kompilować z starszą wersją – więc żadnych class, żadnych bibliotek, prosta konstrukcja programu.

Pierwsze 3 zadania zrealizowałem w podzbiorze BBC BASIC (choć testowałem na Macu), więc na Dzień 4 czekało wyzwanie w postaci zadania w Pascalu (zdecydowanie po czasie, nie był to już kalendarzowy dzień 4 wyzwania ;-)). Udało mi się uzyskać proste, ale działające (bez sprytnych sztuczek) rozwiązanie, które kompiluje się, działa i daje poprawny wynik dla obu części zadania na Dzień 4: wersję we Free Pascalu, 04.PAS.

Uwagi o pisaniu w Pascalu w 2024r.

Trzymam się tu ogólnie pojęcia “Pascal”, więc nie można zakładać korzystania z żadnych funkcji dostarczanych przez dzisiejszy FreePascal.

Gdy już przejdziesz na niego jako swój język docelowy z jednego z najpopularniejszych (takich jak Golang, Python, TypeScript, C, C++, Java), zauważysz kilka bolesnych ograniczeń.

Twoje ulubione edytory nie mają pełnego wsparcia. Brak sensownych wtyczek dla środowisk JetBrains IDEs (stan na styczeń 2024 roku), a dla VS Code w najlepszym wypadku masz kilka popularnych, ale bardzo nieoficjalnych wtyczek1.

Jest wprawdzie Lazarus, ale nie jestem fanem interfejsu wielookienkowego, który został skopiowany z Delphi 7. To coś, co działało o wiele lepiej, gdy miało się jeden monitor o rozdzielczości 1600×1200 i pojedynczy wirtualny pulpit.

Istnieje również fp, który jest edytorem wiersza poleceń (działającym w trybie tekstowym) z Free Pascal Compiler, ale nie jest częścią instalacji fpc na wszystkich platformach – brakuje go na przykład po instalacji Free Pascal Compilera na Mac OS. Na niektórych platformach witają cię z logo ascii-art :-), a edycja plików bardzo przypomina to, co przyniósł Turbo Pascal 7.0.

FP (Free Pascal) editor intro ascii-cart

Kolejną trudnością jest odkrycie, że podstawowe biblioteki w Turbo są tak naprawdę również ograniczone – kompilator oczywiście nie miał rozdmuchiwać naszego wynikowego pliku binarnego megabajtami kodu, ponieważ żaden komputer docelowy nie miał megabajtów pamięci. Więc jeśli potrzebujesz “dużych liczb całkowitych”, dzielenia łańcuchów znaków lub list, prawdopodobnie będziesz musiał wszystko napisać sobie sam.

Nie dający się zignorować minus to to, że komunikaty o błędach były notorycznie złe w porównaniu do współczesnych kompilatorów. Większość z nich brzmi jak coś w stylu unexpected (, i musisz samodzielnie dowiedzieć się, dlaczego dany znak jest w danej linii nieoczekiwany (niekoniecznie wiesz, w którym jej miejscu).

Ale napisałem rozwiązanie używając fp, skompilowałem i uruchomiłem wersję na Mac/PC za pomocą fpc i byłem w miarę z siebie dumny. Nawet dodałem testy jednostkowe, które wykonują się przed główną logiką, aby sprawdzić, czy podstawowe operacje split lub operacje na liście działają poprawnie!

Przenoszenie rozwiązania na Turbo Pascal 3.0 na system CP/M

Oto gdzie zaczyna się trudna jazda. Wierzyłem, że jeśli coś się kompiluje i działa na dość małym zbiorze danych (plik wejściowy do zadania ma 22kB) w trybie zgodności z Turbo Pascal, to będzie działać także w Turbo Pascalu, prawda? Oczywiście, że nie.

Sprawmy, żeby jednak znowu się kompilowało

Tak dowiaduję się, że wiele z tego, co pozwalał mi zrobić Free Pascal, było niedozwolone przez Turbo Pascala 3.0.

Nie ma typu “po prostu string”.

W wersji FPC używałem wiele razy typu string. Byłoby logiczne, że funkcja trim przyjmowałaby argument typu string i zwracała również typ string:

function trim(input: string): string;

Okazało się, że to jest nieprawidłowe z dwóch powodów. Pierwszy z nich to fakt, że string nie jest prawidłowym typem definicji w Turbo Pascal. Sprawdźmy w podręczniku:

String Type Definition

The definition of a string type must specify the maximum number of
characters it can contain, i.e. the maximum length of strings of that
type. The definition consists of the reserved word string followed by the maximum length enclosed in square brackets. The length is specified by an integer constant in the range 1 through 255. Notice that strings do not have a default length; the length must always be specified.

Example:

type
FileName = string[14];
ScreenLine = string[80];

String variables occupy the defined maximum length in memory plus one byte which contains the current length of the variable. The individual
characters within a string are indexed from 1 through the length of the string.

Więc zawsze musimy określić maksymalną długość ciągu znaków z góry (aby kompilator mógł zarezerwować dokładnie tyle bajtów), i nie może ona być większa niż 255. Po zaktualizowaniu deklaracji ciągów znaków wszędzie, otrzymujemy:

function trim(input: string[200]): string[200];

I teraz działa, prawda? Nieprawda. Właśnie przeszliśmy do kolejnego pół-przyjaznego błędu kompilacji.

Compiling
   117 lines

Error 36: Type identifier expected. Press <ESC>

Co, po naciśnięciu ESC, przenosi nas do linii zawierającej błąd. Ale przecież określiłem typ zwracany, prawda?! No cóż, znowu nie.

Notice that the type of the parameters in the parameter part must be
specified as a previously defined type identifier. Thus, the construct:

procedure Select(Model: array[l .. 500] of Integer);

is not allowed. Instead, the desired type should be defined in the type
definition of the block, and the type identifier should then be used in the
parameter declaration:

type
Range ~ array[l .. 500] of Integer;

procedure Select(Model: Range);

Okazuje się, że typ argumentów i typ zwracany musi być pojedynczym, prostym i nazwanym typem, musimy więc najpierw go zdefiniować. Zdecydujmy, że 200 znaków to więcej niż potrzebujemy do tego programu i że LongString jest odpowiednią nazwą dla naszego typu. Kończy się to funkcją trim(input: LongString): LongString;.

Wincyj nowych typów!

Ten sam problem dotknął naszą funkcję simpleSplit odpowiedzialną za dzielenie dłuższego ciągu znaków na dwa podciągi tam, gdzie znajduje się separator (abyśmy mogli podzielić a|b na a i b). Nie możemy zadeklarować:

function simpleSplit(input: String; delimeter: char): array[0..1] of LongString;

Najpierw musimy zdefiniować nasz własny typ TwoStrings dla tablicy dokładnie dwóch stringów. Okej, niech będzie. A potem kolejna niespodzianka:

Compiling
  96 lines

Error 48: Invalid result type. Press <ESC>

Oczywiście nie powie ci, co jest w tym typie zwracanym niepoprawne, ponieważ taki komunikat błędu byłby marnotrawstwem pamięci i zwiększyłby rozmiar kompilatora. Sprawdźmy jeszcze raz instrukcję obsługi:

The result type of a function must be a scalar type (Le. Integer, Real, Boolean, Char, declared scalar or subrange), a string type, or a pointer type.

Ponieważ zwracanie tutaj wskaźnika byłoby zbyt skomplikowane by traktować go jako dobrze zdefiniowany typ, kończymy z nowym typem wskaźnika do typu tablicy dwóch łańcuchów znaków, type PTwoStrings = ^TwoStrings;, jako typem zwracanym przez naszą funkcję podziału.

To ograniczenie ma sens, a dodatkowo jest “bliskie sprzętowi” (co w poprzednim artykule wskazywałem raczej jako cechę języka C). Kompilator nie ma zasobów (czasu i pamięci) do ustalenia, jak i gdzie przydzielić pamięć dla tego tablicy. Nowoczesny kompilator przydzieliłby wartość zwracaną na stercie i przekazał wskaźnik do niej pod spodem – tutaj musimy to zrobić sami. W tym momencie zaczynam kwestionować moje stwierdzenie, że C wydawało się być dużo bliżej sprzętu niż Pascal.

Różnice we wbudowanych procedurach

Nasze simpleSplit wciąż się nie kompiluje. W wersji Free Pascal Compiler mieliśmy:

  simpleSplit[0] := copy(input, 0, splitAt-1);
  simpleSplit[1] := copy(input, splitAt+1);

Pierwszą rzeczą do zmiany jest to, że wynik musi być wskaźnikiem, a następnie będzie to simpleSplit^[0] po lewej stronie przypisania, ale to nie przejdzie (ponieważ oczekiwanie polega na przypisaniu do nazwy funkcji bezpośrednio, a nie do wartości zza wskaźnika), więc mamy lokalną zmienną retVal: PTwoStrings, i dopiero wtedy możemy zrobić retVal^[0] := ... ; retVal^[1] := ... ; simpleSplit := retVal;.

Ale wciąż dostajemy błąd “, oczekiwane” w drugiej linii. Okazuje się, że wbudowana procedura (lub, jak to nazywa podręcznik Turbo Pascala, “Procedura standardowa”) Copy mogłaby przyjąć dwa argumenty, jeśli chcielibyśmy skopiować od pozycji i do końca łańcucha, ale wersja w Turbo Pascalu 3.0 tego nigdy nie robi. Na szczęście, jeśli określisz zbyt daleką pozycję końcową, skopiuje tylko do końca łańcucha, co jest tym, czego chcemy, więc robimy to copy(input, splitAt+1, 255);.

Alert przed spoilerem: Nie dowiedziałbym się o tym dość długo, ale jest jeszcze jeden błąd. Ciągi znaków w Pascalu są indeksowane od 1, co oznacza, że pierwszym znakiem jest właściwie str[1], a nie str[0] jak w większości nowoczesnych języków programowania (oprócz być może MatLaba). Pozostawienie 0 jako indeksu początkowego spowodowałoby błąd czasu wykonania. Mówiła o tym dokumentacja, którą wcześniej wkleiłem, ale nie zajrzałem tam podczas pisania kodu…

Przerwać? Nie ma czasu na przerwę. Co? “Wyjść z pętli” (break out of the loop)? Breakout to gra!

Nasza implementacja trim dla Free Pascal szuka znaku nie będącego białym znakiem (konkretnie nie będącego spacją ani tabulatorem, dla uproszczenia) na początku ciągu znaków, a gdy taki znak zostanie znaleziony, przerywa pętlę:

  pFrom := 0;
  pTo := Length(input);For i := 1 To pTo Do
    Begin
      c := input[i];
      If (ord(c)<>32) And (ord(c) <> 9) Then
        Begin
          pFrom := i;
          break;
        End;
    End;

Turbo Pascal 3.0 krzyczy na nas w linii z instrukcją break, wspominając o nieprawidłowym identyfikatorze. Ale to nie jest identyfikator zmiennej/funkcji, to słowo kluczowe, instrukcja! Nie ma jednak mowy o instrukcji break w instrukcji obsługi, cóż.

Wyciągamy narzędzia, które “wypada nienawidzić”. Etykiety i goto. Ponieważ Pascal jest językiem kompilowanym w jednym przebiegu, musi wiedzieć, że etykieta, do której możemy skakać, istnieje w kodzie, zanim się do niej odwołamy. Dlatego dodajemy instrukcję label fromFound, toFound; tuż przed begin w funkcji, aby wiedziała, że te identyfikatory mogą pojawić się później jako etykiety.
Zamieniamy break na goto fromFound, a zagnieżdżone break na goto toFound.

Doceńmy szczodrość, jaką nas obdarowano:

Label Declaration Part
Any statement in a program may be prefixed with a label, enabling direct branching to that statement by a 90to statement. A label consists of a label name followed by a colon. Before use, the label must be declared in a label declaration part. The reserved word label heads this
part, and it is followed by a list of label identifiers separated by commas and terminated by a semi-colon.

Example:
label 10, error, 999, Quit;

Whereas standard Pascal limits labels to numbers of no more than 4 digits, TURBO Pascal allows both numbers and identifiers to be used as labels.

Instrukcja mówi tu o tym, że możemy sobie pozwolić na frywolne nazywanie etykiet czymś innym niż tylko pojedynczą liczbą! Ostatnie zdanie przypomina nam, że standardowy Pascal definiuje etykiety jako proste liczby, jak pierwsze numery linii w BASICu lub numery instrukcji w Fortranie. W Turbo przynajmniej możemy używać słów.

Słowo kluczowe “new”

Chociaż Turbo Pascal posiadał wskaźniki, naturalny sposób alokacji pamięci dla nowej instancji zmiennej, do której wskaźnik wskazywał, value := new(Type); który działał w Free Pascalu i jest znajomy dla innych języków programowania z tworzeniem obiektów, nie działa w ten sposób w Turbo Pascalu 3.00, ani właściwie w standardowym Pascalu. To składnia zapożyczona z innych języków, która przypadkiem jest też obsługiwana przez FPC.

Pascalowe wywołanie new jest inne. Powinno być new(pointerVar); – nie ma potrzeby przypisywania.

Sprawianie, że kod się uruchomi

W końcu się skompilowało! Pierwszą rzeczą, którą zobaczyłem po uruchomieniu, był oczywiście FAIL – niepowodzenie testu i zakończenie działania.

W tym momencie byłem sobie naprawdę wdzięczny za napisanie testów jednostkowych w pierwszej kolejności. W przeciwnym razie debugowanie całej aplikacji byłoby o wiele trudniejsze. Co i tak ostatecznie zrobiłem, niestety z podobnego powodu – nie wszystkie funkcje były objęte testami. Udało mi się jednak przebrnąć przez podstawowe podziały i operacje na listach.

Marnowanie pamięci

Podczas testowania pierwszego rozwiązania na współczesnym komputerze, ilość pamięci była oczywiście nigdy nie była problemem. To, w połączeniu z złymi nawykami, które nabyłem podczas ostatnich 11 lat programowania w językach, które automatycznie zarządzają sprzątaniem śmieci, doprowadziło mnie do sytuacji, gdy po przetworzeniu Karty 35 (spośród ponad 130 do przetworzenia) otrzymałem duży, ładny błąd czasu wykonania, o kodzie FF:

Kolizja sterty/stosu FF.
Zostało wykonane wywołanie standardowej procedury New lub rekurencyjnej podprocedury, a między wskaźnikiem sterty (HeapPtr) a wskaźnikiem stosu rekurencji (RecurPtr) nie ma wystarczającej ilości wolnej pamięci.

W tym momencie miało miejsce dodawanie wielu instrukcji Dispose do zwalniania przydzielonych wskaźników, w tym rekurencyjne wywołanie disposeList (ciekawostką jest tu to, że w instrukcji obsługi zapisano, że w przypadku procesora Intel 8080, rekurencja wymaga specjalnej flagi kompilacji, więc pamiętaj o tym, jeśli twój cel to CP/M-80):

Procedure disposeList(list: PNumberList);
Begin
  If list^.next <> nil Then Begin
    disposeList(list^.next);
  End;
  Dispose(list);
End;

Wrócimy do tej funkcji później, ponieważ została wyłączona zaraz po dodaniu, bo spowodowała zawieszenie programu.

Wyciek pamięci + błędy w kompilatorze?

Nawet wygodnie ignorując wyżej wspomniany problem, nadal miałem wycieki pamięci. Dodałem wypisywanie wartości MemAvail po każdym przetwarzanym kuponie i wyraźnie wartość ta malała o 140 bajtów przy każdej iteracji. Dokładnie tyle pamięci potrzebowalibyśmy, aby przechować 35 liczb (10 zwycięskich i 25 na kuponie) w naszym typie NumberList – dwa bajty na każdą liczbę i dwa dla wskaźnika do następnej liczby.

W rozwiązaniu tym zamierzałem zwalniać pamięć po przetworzeniu każdej karty:

DisposeList(c^.winning); { first, remove both list tails }      DisposeList(c^.have);      
Dispose(c^.winning);     { then pointers to both lists }      Dispose(c^.have);      
Dispose(c);              { then card itself }

z DisposeList bez zmian w stosunku do wersji pokazanej wyżej.

Jakimś sposobem, po naprawieniu błędu, w którym oryginalna lista disposeList nie zwalniała pamięci, utknąłem z programem kompletnie zawieszonym na instrukcji new(retVal), oraz przy każdej próbie wywołania MemAvail po tym jak zostało wywołane Dispose (po lewej kod, po prawej efekt).

Ponieważ wszystko nadal działało dobrze po skompilowaniu z FPC na macOS, ale zawieszało się po skompilowaniu z Turbo Pascalem 3.00 lub 3.01 na prawdziwym CP/M oraz na (moim nowym najlepszym przyjacielu narzędziowym, gdy chciałem szybko testować) CP/M dla Mac OS2, to na razie wystarczyło, aby wywnioskować, że to błąd w wygenerowanym przez kompilator kodzie. W przeciwnym razie, gdybym używał tych wywołań źle, pojawiłby się błąd czasu kompilacji lub podczas uruchomienia przy wywołaniach Dispose, MemAvail lub New.

Stąd nasza droga rozchodzi się na dwie ścieżki:

  • Zagłębienie się w to, w jaki sposób dokładnie zarządzana jest pamięć za pomocą new i dispose, oraz dalsze debugowanie problemu. Ponieważ przeciętny programista musiał częściej niż dzisiaj zmagać się z ryzykiem braku pamięci, ten mechanizm jest faktycznie dość dobrze opisany w instrukcji obsługi, dzięki czemu przynajmniej możemy wiedzieć, co powinno było się dziać.
  • Przejście na Mark i Release – Turbo Pascal oferował jeszcze jedną, bardzo uproszczoną metodę zarządzania pamięcią. Zamiast śledzić każdy alokowany wskaźnik indywidualnie, ta para pozwala nam po prostu Mark i zapamiętać ile pamięci było wolne w pewnym momencie, a następnie po prostu Release ją, resetując wskaźnik do poprzedniej wartości, odrzucając wszystko, co zaalokowano od czasu kiedy wywołaliśmy Mark.

W momencie, gdy wbudowane zarządzanie pamięcią mnie zawiodło (wieszając cały program), publicznie przyznałem, że nie radzę sobie najlepiej i poprosiłem dodatkowe oczy o pomoc. Co ciekawe, dla użytkownika @psychotimmy, nawet moja wersja działała poprawnie po skompilowaniu dla RC20143!

Rozwiązanie wszystkich powyższych problemów doprowadziło mnie do momentu, kiedy cały program działa!

Przejście na Mark/Release zadziałało, ale wydaje się trochę prowizoryczne. Sprawdzenie dlaczego zwykłe zarządzanie pamięcią na poziomie pojedynczego wskaźnika zawodzi może być tematem kolejnego artykułu. Na szczęście, algorytm alokacji pamięci i zliczania wolnych bloków jest bardzo dobrze opisany także w podręczniku Pascala – ponieważ programista musiał być świadomy każdego bajtu pamięci, jako ograniczonego zasobu.

Rozmiar liczb

Mała, ale istotna wzmianka o czymś co prawie stanęło na drodze do rozwiązania zadania: 8-bitowy Turbo Pascal (a także, co do zasady, każdy TP 3.0) zna tylko dwa rozmiary liczb całkowitych: byte (8-bit) i integer (16-bit). Z jednej strony ma to sens, ponieważ procesory takie jak Z80 nie sięgają swoją matematyką nigdzie dalej, ale jednocześnie ogranicza wszystkie wbudowane obliczenia na liczbach całkowitych do 65535 albo 32767 jako największej znanej liczby.

To wszystko. Nie ma “Big int”, nie ma “Long”, a jedynym pozostałym typem liczbowym jest 6-bajtowy Real (popularnie znany jako float w innych językach), czyli liczby rzeczywiste.

W kontekście Advent of Code okazało się, że obliczanie matematyczne na typie Real jest wystarczające, mimo że wynik musi być liczbą całkowitą rzędu 10 milionów. Jednak warto pamiętać, że dokładne obliczenia matematyczne, takie jak wynagrodzenia, na typach zmiennoprzecinkowych nigdy nie są dobrym pomysłem. Błędy gromadzą się bardziej niż byś się spodziewał, nawet w dzisiejszych komputerach.

Edycja kodu

Być może nie do końca zaskakujące, ale łatwo zapomnieć: gdy chcesz korzystać z rzeczywistego edytora z narzędzia pracującego na docelowej platformie, nawigacja nawet po stosunkowo prostych kilkuset liniach kodu może być wyzwaniem.

W tamtych czasach złotym standardem edycji tekstu był WordStar. Gdyby to było ed, dzisiejsi upośledzeni strzałkowo użytkownicy ucieszyliby się4. Ale to był WordStar. Wiesz, wtedy rzeczy wciąż były bardzo dynamiczne, jeśli chodzi o edycję tekstu, istniał szereg standardów dotyczących obsługi klawiatury, najwygodniejszych skrótów klawiszowych, a nawet układu klawiatury, którego możesz oczekiwać.

MIT “space-cadet”, klawiatura sprzed standardu ISO/IEC 9995.

Wordstar używał dość sensownego zestawu skrótów klawiaturowych do poruszania się po pliku, na przykład większość klawiszy z lewej strony klawiatury kontrolowała ruch kursora:

  • Do góry, w dół, w lewo, w prawo o 1 znak/linię są odpowiednio: (^ oznacza Ctrl+) ^E, ^X, ^S, ^D
  • Przewijanie o jedną linię w dół i w górę: ^W, ^Z
  • Przewijanie ekranu w górę i w dół: ^R ^C
  • Lewo/prawo o jedno słowo: ^A, ^F.

Jednak jeśli wytrenowałeś swoją pamięć mięśniową do ctrl/alt+lewo/prawo, page up, page down (to nie jest para klawiszy, które zawsze tam były), może to wymagać trochę więcej przyzwyczajania. Ponieważ program był krótki, nie zdążyłem przejść dalej niż te kilka, plus kombinacja dla wyszukiwania, którą jest… Ctrl+Q, F, które następnie prosiło o opcje. Nigdy nie sprawdzałem, ale zgadłem poprawnie, że “b” będzie szukaniem wstecz (backwards), i to było wszystko, czego potrzebowałem.

Krzyżowa kompilacja zdecydowanie teraz brzmi bardziej kusząco.

Podsumowując

Pascal miał istotny wkład w programowanie i był jednym z najbardziej rozbudowanych języków swojego czasu. Jednak korzystanie z niego dzisiaj, kiedy jesteśmy przyzwyczajeni do bardziej nowoczesnych języków, oraz przenoszenie do starszych wersji języka/kompilatora stwarza wyzwania. Ograniczenia w bibliotekach, brak pełnego wsparcia w popularnych edytorach oraz konieczność dostosowywania kodu do kompatybilności ze starszymi wersjami Pascala podkreślają złożoności pracy z tym językiem obecnie. Mimo tych trudności, doświadczenie w pokonywaniu przeszkód może dostarczyć cennych spostrzeżeń na temat ewolucji języków programowania oraz postępów, które ukształtowały dziedzinę tworzenia oprogramowania.

Przypisy

  1. the most popular one is: vscode-language-pascal – VS Code Pascal plugin
    while this seems to be a little more feature-rich and with fewer dependencies: OmniPascal ↩︎
  2. https://github.com/TomHarte/CP-M-for-OS-X ↩︎
  3. https://rc2014.co.uk/ – RC2014 homebrew Z80 computer ↩︎
  4. vi and therefore also its child vim seem to be heavily inspired by ed,
    see https://lunduke.locals.com/post/4400197/the-true-history-of-vi-and-vim and https://francopasut.netlify.app/post/golden_line/ ↩︎
Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments