Hauptthema unseres letzten Kursteils ist die Abstraktion und Datenkapselung. Mit ihr verhindern wir den wilden Zugriff aller Module auf die sonst globalen Daten« Zusammen mit letzten Grundlagen der Programmiersprache Modula schließen wir unser Projekt des Übersetzungsprogramms ab.
Bislang hatten wir bereits eine Top-Down-Zerlegung, die Modularisierung und die Herstellung der Definitionsmodule durchgeführt. Doch stießen wir nach Abschluß dieser Etappen auf eine Unzulänglichkeit: Unsere Wörterbuchdaten waren im gleichnamigen Modul als globale Variable abgelegt, auf welche jede Prozedur anderer Module nach Belieben zugreifen durfte.
Bei komplexeren Programmen kommt es oft zu kaum überschaubaren Situationen. Welche Prozedur wann auf welche Datenstruktur zugreift, läßt sich nur noch schwer beurteilen. Dies ist mit schwerwiegenden Nachteilen verbunden: Sowohl die Testbarkeit als auch die Änderbarkeit der Datenorganisation leiden sehr unter dieser direkten Zugriffstechnik.
Nehmen wir an, wir schreiben ein Programm, das eine Adresskartei in Form einer verketteten Liste (siehe Bild 1) verwaltet. Während des Testens stellen wir nun fest, daß nach einiger Zeit einige Zeiger nicht mehr richtig gesetzt sind, also nicht korrekt auf den nächsten Eintrag der Liste zeigen. Die Suche nach der fehlerhaften Stelle entwickelt sich schnell zur Nervensache, da wir jede Prozedur untersuchen müssen, die Änderungen an der Liste durchführt: Adresse einfügen, Adresse löschen, Adresse ändern. Die Suche kann uns durch sämtliche Module des Projektes führen.
Ebenso problematisch ist eine Änderung der Datenorganisation. Nehmen wir an, wir hätten unsere Adressverwaltung zunächst nur für private Anwendungen angefertigt und deshalb alle Anschriften einfach in einem Feld abgelegt. Nun wollen wir unser Programm erweitern und deshalb eine dynamische Speicherverwaltung in Form einer verketteten Liste einführen. Auch hier ist unsere Geduld schnell am Ende: Wir müssen alle Module nach Zugriffen auf die Adressdaten durchsuchen und diese der neuen Datenstruktur anpassen.
Doch dieser Kurs soll nicht zum Sprachrohr frustrierter Softwareentwickler werden, sondern Lösungen anbieten: Die sogenannte Datenkapsel ist die Rettung des Programmierers. Bei der Datenkapselung übertragen wir das Konzept der Modularisierung auf unsere Daten: Wir fassen die Datenstruktur, hier unsere verkettete Liste, zusammen mit den zugehörigen Deklarationen (Datentypen, Konstanten) und Prozeduren in ein eigenes Modul. Arbeitet ein Programm mit mehreren Datenstrukturen, so wandert jede Struktur »mit Anhang« in ein eigenes Modul. Und schon haben wir eine Menge Ordnung geschaffen.
Die Datenkapsel in Modula-2 besteht natürlich aus einem Definitions- und einem Implementationsmodul, weshalb sorgfältig zu überlegen ist, welche Elemente in weichen Modulteil gelangen. Die zu kapselnde Variable gehört ausschließlich in das Implementationsmodul, so daß kein direkter Zugriff per IMPORT möglich ist. Der Zugriff auf die Variable erfolgt ausschließlich über spezielle Zugriffsprozeduren, deren Deklarationsköpfe deshalb in das Definitionsmodul zu übernehmen sind. Bei unserer Adressverwaltung würden sich beispielsweise folgende Zugriffsprozeduren anbieten: Initialisierung der Adressliste, Einträgen einer Adresse, Auffinden einer Adresse, Löschen einer/aller Adressen, Laden/Speichern der Adressen.
Neben den nach außen hin sichtbaren Zugriffsprozeduren kann eine Datenkapsel selbstverständlich auch lokale Prozeduren enthalten, die nur im Implementationsteil enthalten ist. Beispiele hierfür sind etwa Hilfsfunktionen zum Einsortieren einer neuen Adresse. Auch bei den Typ- und Variablendeklarationen gilt das Gesagte: Es sind nur die Deklarationen im Definitionsmodul publik, die für die Arbeit mit der Datenkapsel bekannt sein müssen. Bei der Adressverwaltung sind auf alle Fälle die maximale Wortlänge (Konstante) und die Struktur eines Adresssatzes zugänglich zu machen. Als kleines Beispiel legen wir einen LIFO-Stapel (Last-In-First-Out, das zuletzt abgelegte Element kommt zuerst vom Stapel) in Form einer verketteten Liste an, der nur die vier grundlegende Funktionen kennt: Initialisierung (»InitLifo«), Ablegen eines Elementes auf den Stapel (»Push«), Holen eines Elementes vom Stapel »Pop« und einem Test (»EmptyLifo«), ob der Stapel leer ist.
Doch zuvor sollten wir den Aufbau einer verketteten Liste kennenlernen, wie Sie ihn in Bild 1 sehen. Das grundlegende Prinzip hierbei ist die dynamische Speicherverwaltung: Wollen wir ein Element abgelegen, so reservieren wir über das Betriebssystem einen ausreichend großen Speicherblock, legen den Wert in diesem ab und fügen den Datenblock in unsere Liste ein.
Die Verkettung in der Liste erfolgt über die sogenannten Zeiger oder Pointer, die selber Variablen sind und auf eine Speicherzelle deuten, in der sich die referenzierten Daten befinden. Wollen wir den Zeiger auf gar kein Element setzen, so weisen wir ihm die Nullkonstante »NIL« zu. Beim Lesen des Zeigers können wir so prüfen, ob dieser auf ein Element zeigt:
IF Zeiger=NIL THEN WriteString(“Kein Element“); END;
Damit wir das erste Element unserer verketteten Liste überhaupt auffinden können, benötigen wir eine Zeigervariable auf das erste Listenelement. Damit auch die nachfolgenden Elemente erreichbar sind, umfaßt jedes Listenelement einen Zeiger auf das nächste Listenelement, das beim letzten Element den Inhalt NIL hat.
Einen LIFO-Stapel realisieren wir, indem wir jedes abzulegende Element an den Anfang unserer Liste setzen, das bislang erste Element wird zu dessen Nachfolger. Auch die Entnahme erfolgt vom Anfang der Liste in umgekehrter Form. Mit diesen Kenntnissen realisieren wir nun einen Stapel, auf dem wir Zeichenketten ablegen. In unser Definitionsmodul »LifoKapsel« packen wir - neben der Typdefinition »Name« -für den abzulegenden Text die vier Zugriffsprozeduren »InitLifo, EmptyLifo, Push, Pop«.
Im Implementationsmodul (siehe Quelltext auf der TOS-Diskette) müssen wir die Prozeduren »Allocate« zur Speicherreservierung, »Deallocate« zur Speicherfreigabe und »TSIZE« zur Größenermittlung eines Typs importieren. Wichtig zur Verwaltung der Liste ist der Zeiger auf das nächste Listenelement, den wir als »Lifo«-TYPE-Zeiger auf »Satz« deklarieren und diesen als RECORD-Datenstruktur definieren. Als gekapselte Variable deklarieren wir lediglich den Zeiger auf den Listenanfang »L«.
Die Prozedur »InitLifo« setzt lediglich die Variable »L« auf NIL. Analog gibt die »EmptyLifo«-Prozedur dann TRUE zurück, wenn »L« den Wert NIL annimmt und somit die Liste leer ist. Komplizierter wird es schon bei »Push«: Wir merken uns den Zeiger auf das erste Listenelement, Reservieren einen Speicherblock, dessen Zeiger wir in »L« ablegen, übertragen den zu merkenden Wert und stellen den »Next«-Zeiger auf das bislang erste Element. Umgekehrt verfährt die »Pop«-Prozedur, in der wir zuerst den Inhalt des Datensatzes auslesen, den Zeiger auf das zweite Element setzen, den Speicherblock freigeben und »L« auf das bislang zweite Element umstellen.
Auf Probleme stoßen wir mit unserer Datenkapsel, wenn wir mehrere Stapel simultan verwalten wollen. Arbeiten wir mit einer flexiblen Anzahl von gekapselten Datenstrukturen, bietet sich der abstrakte Datentyp an (Quelltexte »UFO.*« auf Diskette). Im Unterschied zur Datenkapsel wird hier der Typ der gekapselten Datenstruktur nach außen bekannt gemacht, wobei jedoch nur der Name, aber nicht der Aufbau des Datentyps erkennbar ist. Für jeden einzusetzenden Stapel deklarieren wir nun eine solche abstrakte Variable: VAR a,b,c:Lifo;
Den Zugriffsprozeduren übergeben wir neben den bisherigen Parametern die abstrakte Variable. Dieses Verfahren bietet die Vorzüge der dynamischen Variablenverwendung bei gleichzeitigem Schutz der Variablen vor direkten Zugriffen. Hierzu deklarieren wir in unserem Definitionsmodul »Lifo« einen Typ ohne Angabe seines Aufbaus: TYPE Lifo;
Die Zugriffsprozeduren eerweitern wir jeweils um einen Parameter für die abstrakte Variable vom Typ »Lifo«. Erst im Implementationsmodul legen wir die Gestalt von »Lifo« fest, die jedoch immer die Form eines Zeigers haben muß, in unserem Fall auf eine »Satz«-Struktur. Abgesehen von den zusätzlichen Prozedurparametern erfährt das Implementationsmodul keinerlei Änderungen.
Interessant ist nun die Betrachtung unseres Testmoduls »TestLifo«: Hier definieren wir die zwei Stapel »Stapel 1« und »Stapel2«, die wir darauf initialisieren und mit drei beziehungsweise zwei Werten füllen und in einer WHILE-Schleife auslesen. Diesen Vorgang könnten wir natürlich auch für zehn, hundert oder tausend Stapel erledigen, die auch Teil von komplexeren Variablenkonstrukten wie Feldern oder Records sein dürfen.
Durch den Einsatz einer Datenkapsel und des abstrakten Datentyps perfektionieren wir nun die Struktur unseres Übersetzungsprogrammes. In Bild 2 sehen Sie die modifizierte Hierarchie des Projektes, Bild 3 zeigt die Datenflüsse. Alle Zugriffe auf Wörterbuchdaten erfolgen nun über das Modul »Datenkapsel«, welche nach außen hin nur den Zeichenketten-Datentyp »WortTyp« zur Verfügung stellt.
Zur Verwaltung des Wörterbuches stehen die fünf aus dem Top-Down-Entwurf bekannten Zugriffsprozeduren zur Verfügung. »LiesWoerterbuch« und »Schreib-Woerterbuch« erledigen das Einlesen und Sichern des Wortschatzes, was unter einem im Implementierungsmodul festgelegten, konstanten Dateinamen (»ENGLISCH. DIC«) erfolgt, der sich natürlich durch Modifikation der Konstanten »Woerterbuchname« ändern läßt.
»LoeschWoerterbuch« gibt den vom Wörterbuch belegten Speicher wieder frei und sollte auf jeden Fall vor dem Programmende aufgerufen werden. »InWoerter-buch« trägt das übergebene deutsche Wort »wortdeutsch« der Länge »laengedeutsch« mit der Übersetzung »wortfremd« in das Wörterbuch ein. Auch »HoleFremdwort« kennen wir aus dem Top-Down-Entwurf: Für das Wort »wortdeutsch« der Länge »laengedeutsch« erhalten wir die Übersetzung »wortfremd«, wobei eine erfolgreiche Suche der Wert TRUE in der Variable »ok« signalisiert.
Werfen wir einen Blick auf die Implementierung der Datenkapsel, die keine eigene Liste verwaltet, sondern ein Feld von 256 abstrakten Variablen des aus dem Modul »AbstrakteVokabelliste« importierten Typs »VokabellistenTyp« verwaltet:
VAR Wortschatz:ARRAY[0..255] OF VokabellistenTyp;
Für jeden Anfangsbuchstaben von CHR(O) bis CHR(255) legen wir also eine eigene verkettete Liste an, was die Wortsuche erheblich beschleunigt: Zum Auffinden der Übersetzung von »sonnig« wird lediglich die Liste »Wortschatz[ORD('s')]« nach einer Übersetzung durchsucht. Die Initialisierung der abstrakten Variablen erfolgt »automatisch« im Hauptteil der Datenkapsel mit Hilfe von 256 »lnitListe«-Aufrufen.
Die Datenkapsel importiert die Wortstruktur »Voka-belTyp«, die eine Vokabel mit Länge und Übersetzung aufnimmt. Die Prozedur »InWoerterbuch« eine Vokabel ein, indem sie die Vokabeldaten in eine solche Struktur kopiert und »VokabelIn Liste« aus dem Modul »AbstrakteVokabelliste« aufruft. Die »I iesWoerter-buch«-Prozedur trägt die per »LiesDateiwort« eingela-senen Vokabeln über »lnWoerterbuch«-Aufrufe ein. Zum Zugriff auf die abstrakten Vokabellisten wurde die Prozedur »LiesVokabel« implementiert, welcher wir neben der abstrakten Variable eine BOOLEAN-Variable übergeben, die festlegt, ob wir das allererste (TRUE) oder das jeweils folgende (FALSE) Listenelement auslesen. Als Ergebnis liefert die Prozedur TRUE zurück, falls der angeforderte Eintrag gefunden wurde, sonst FALSE.
Das Speichern der gesamten Vokabelliste (»Schreib-Woerterbuch«) erfolgt wortweise über »LiesVokabel«-und »SchreibDateiwort«. Auch die Proedur »Hole-Fremdwort« zur Suche nach einer Übersetzung, durchsucht die Vokabeleinträge über »LiesVokabel«. Stimmt eine so eingelesene Vokabel mit der gesuchten überein, liefern wir diese an die aufrufende Prozedur zurück.
Das Modul »AbstrakteVokballiste« exportiert die Typen »WortTyp« und »VokabelTyp« sowie den abstrakten Datentyp »VokabellistenTyp«. Als Zugriffsprozeduren stehen »InitListe«, »Loesch Liste«, »Vokabel In Liste« und »LiesVokabel« zur Verfügung, die wir bereits kennengelernt haben.
Im Implementierungsmodul deklarieren wir den abstrakten Typ »VokabellistenTyp« als Zeiger auf »VokabelsatzTyp«, der neben dem Vokabeleintrag einen Zeiger auf den nächsten Listeneintrag umfaßt. Die Implementierung der Zugriffsprozeduren weist deutliche Ähnlichkeiten zu unserem »Lifo«-Modul auf, wobei »LoeschListe« in einer WHILE-Schleife alle Elemente der Liste löscht und »LiesVokabel« mit Hilfe eines Suchzeigers das erste beziehungsweise nächste Listenelement ausliest.
Die Implementierung der weiteren drei Module orientiert sich eng an unserem Top-Down-Entwurf, die zum Compiler mitgelieferten Bibliotheken (Terminalfenster, Dateiselektor) finden rege Anwendung.
Bevor ich Sie in die Freiheit des Programmiereralltags entlasse, sollten wir jedoch das Resultat unserer Arbeit betrachten, das Sie auch in compilierter Form auf der TOS-Diskette unter dem Namen »PROCRAMM.PRG« vorfinden. Nach dem Start erscheint ein GEM-Fenster und eine Dateiauswahlbox, in der sich die zu übersetzende Textdatei auswählen. Nach dem Druck auf »OK« beginnt der Übersetzungsvorgang, wobei das Programm eine neue Textdatei mit gleichem Namen, aber der Extension ».NEU« erzeugt.
Stößt das Programm auf ein unbekanntes Wort, so fragt es Sie nach einer Übersetzung. Diese vorzugsweise in Kleinschrift getätigte Eingabe landet identisch im Text und im Wörterbuch. RETURN alleine übernimmt das Wort unverändert in den Zieltext. Nach der Übersetzung speichert das Programm das eventuell erweiterte Wörterbuch.
Noch ein betrüblicher Hinweis: In der PRG-Version des Übersetzungsprogrammes entstehen aufgrund eines Betriebssystemfehlers Probleme mit der Speicherverwaltung, weshalb das Programm bei sehr großen Wörterbüchern nicht mehr lauffähig ist. Dieses Problem können Sie umgehen, indem Sie im Modular-Compiler (Maxon-PD-Disk 225) die .DEF- und .MOD-Dateien compilieren und das Hauptmodul »PROGRAMM.OBM« direkt starten. Da nun die Compiler-interne Speicherverwaltung zum Einsatz kommt, tritt das Problem nicht mehr auf.
Das Konzept unseres Übersetzungsprogrammes ist sehr einfach, da wir einfach wortweise übersetzten, weshalb Satzbau und Groß-/Kleinschreibung in der Regel per Hand zu modifizieren sind. Ähnlich verhält es sich mit Synonymen und Homonymen: Viele Worte wie zum Beispiel »sie« besitzen mehrere Übersetzungen (»she«, »they«...) und können deshalb nicht eindeutig übersetzt werden. Geben Sie deshalb mehrere Bedeutungen an. Bei der Überarbeitung des Textes entfernen Sie dann die nicht zutreffenden Übersetzungen. Auf der TOS-Disk befindet sich der Demotext »TOSDISK.DOC« und das bei der Übersetzung generierte Mikro-Wörterbuch »ENGLISCH.DIC«. (ah)