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.
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:
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.
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.
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:
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:
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:
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
:
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
idispose
, 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
iRelease
– 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 prostuMark
i zapamiętać ile pamięci było wolne w pewnym momencie, a następnie po prostuRelease
ją, resetując wskaźnik do poprzedniej wartości, odrzucając wszystko, co zaalokowano od czasu kiedy wywołaliśmyMark
.
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ć.
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
- 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 ↩︎ - https://github.com/TomHarte/CP-M-for-OS-X ↩︎
- https://rc2014.co.uk/ – RC2014 homebrew Z80 computer ↩︎
vi
and therefore also its childvim
seem to be heavily inspired byed
,
see https://lunduke.locals.com/post/4400197/the-true-history-of-vi-and-vim and https://francopasut.netlify.app/post/golden_line/ ↩︎