next_inactive up previous


Universität Duisburg-Essen,
Abteilung Duisburg
Fakultät 5

Autor des Skriptes:
Prof. Dr. Wolfram Luther.
 
Zuletzt editiert von:
André Schaefer
mailto:andre.schaefer@uni-duisburg.de

Informatik A:
Rechnerstrukturen und Programmierparadigmen

Prof. Dr. Norbert Fuhr


Date: SS 2003


Contents

1 Einführung in die Grundlagen der Informatik

1 Ziele der Informatik

Informatik

Damit sind informatische Grundlagen für Schüler und Studenten fast aller Fachrichtungen unerlässlich. Informatikkompetenz erzeugt Eignung für neue kreative Arbeitsplätze in einem Hochtechnologieland. Im Mittelpunkt des Informatikunterrichts steht eine Vermittlung von Sach-, Handlungs- und Beurteilungskompetenz im Umgang mit Informatiksystemen auf wissenschaftlicher Grundlage, Modellierung von Problemen, Prozessen und Abläufen, ihre angemessene sprachliche Beschreibung, Abstraktion und Strukturierung, ihre algorithmische Durchdringung und eine Lösung und Steuerung mit adäquatem Werkzeug.

Eine Anwendung von Informations- und Kommunikationssystemen allein ist jedoch nicht hinreichend, um Basis für den Bildungswert der Informatik zu sein.

Nun wollen wir zunächst einige der verwendeten Begriffe erklären: Bei der Kommunikation von Rechnern werden Daten oder Nachrichten ausgetauscht. Diese erhalten nach einer Interpretation den Charakter einer Information. Daten haben eine Form und einen Träger. Als Träger kommen magnetisierte Schichten, Bildschirme, elektromagnetische Felder, Schallfelder, Drähte etc. in Frage; die Form der Daten wird in der Regel in einer Sprache definiert. Sprachen bestehen aus Sätzen, diese aus Wörtern und letztere aus Zeichen aus einem Alphabet. Typische Beispiele sind die Umgangssprache, Ausdrücke aus Operatoren und ganzen Dezimalzahlen oder Programmiersprachen, wie man sie aus der Schule her kennt. Wir werden uns im Laufe der Vorlesung mit verschiedenen Sprachklassen auseinandersetzen. Sprachen genügen einer genau festgelegten textitSyntax. Nach deren Regeln kann entschieden werden, ob ein Satz zur Sprache gehört oder nicht. Die Interpretation der Wörter und Sätze einer Sprache ist im Allgemeinen durch Regeln ausdrückbar, die man Semantik der Sprache nennt. Den Übergang von einer Sprache zu einer anderen bei gleichbleibender Interpretation nennt man Übersetzung. Ein Code ist die Zuordnung zwischen Zeichen und Zeichengruppen von zwei verschiedenen oder auch gleichen Alphabeten. Geschieht diese Zuordnung zeichenweise, so spricht man auch von Chiffrierung.

Zum Problemlösen werden oft Algorithmen entwickelt und eingesetzt. Das Wort Algorithmus kommt vom Namen des persischen Mathematikers Mohammed Ibn Musa Abu Djafar Al Khowarizmi (ca. 783 - 850), der ein weit verbreitetes Lehrbuch für die ,,Berechnung durch Vergleich und Reduktion'', das bereits Lösungen von Gleichungen mit mehreren Unbekannten behandelt, geschrieben hat. In der lateinischen Übersetzung dieses Buchs, das durch die Kreuzfahrer nach Europa kam, begannen die Abschnitte jeweils mit ''Dixit algorismi'', woraus sich die Bezeichnung Algorismus für eine Rechenvorschrift ableitete.

Ein Algorithmus ist eine endliche Anweisungsfolge in einer Befehlssprache, die bei Interpretation eine Klasse von Verarbeitungsprozessen genau und vollständig beschreibt. Er enthält Operationen auf Variablen aus wohldefinierten Wertebereichen (Datentypen). Gewisse Operationen sind standardisiert und haben eine Standardinterpretation, wie zum Beispiel die Addition von Zahlen. Man kann für die Beschreibung von Algorithmen verschiedene Abstraktionsebenen wählen, eine umgangssprachliche, eine eher formale im Wortschatz eingeschränkte Sprache (Pseudocode), die typische Befehlselemente wie Zuweisungen, wiederholte oder bedingte Anweisungen enthält oder eine ''formale Programmiersprache'', deren Syntax in Regeln genau beschrieben und die von einem Interpreter am Rechner ausgeführt werden kann.

Diese Sprachen und ihre Paradigmen werden wir im Laufe der Vorlesung noch genauer betrachten. Beispiele werden jedoch schon jetzt aufgeführt. Vor der Entwicklung eines Algorithmus ist zunächst das Problem durch eine funktionale Spezifikation zu beschreiben. Dabei geht es um die Angabe der gültigen Eingabe- und möglichen Ausgabegrößen sowie den funktionalen Zusammenhang zwischen beiden. Es werden die problemspezifischen Objekte beschrieben, Konstanten, Datentypen, Funktionen, und dabei insbesondere Prädikate, das sind Funktionen, deren Ausgabewerte ''wahr'' und ''falsch'' sind. Die Eingaben genügen gewissen Vorbedingungen; bei der Ausgabe werden gewisse Leistungen des Algorithmus zugesichert. Im Allgemeinen ist der Ablauf des Algorithmus in jedem Punkt fest vorgeschrieben (Determiniertheit), jeder Schritt ist ausführbar (Effektivität) und das Verfahren endet nach endlich vielen Schritten (Terminiertheit). Es gibt allerdings Probleme, die nicht algorithmisierbar sind. Selbst wenn ein Problem algorithmisierbar ist, dann kann doch die Ausführung des Algorithmus so viele Schritte enthalten, dass die Berechnung praktisch nicht ausführbar ist (Nichteffizienz). Wichtig ist auch, dass der Algorithmus auf einer (abstrakten) Maschine ausgeführt werden kann. Diese nimmt alle oben genannten Schritte vor. Maschinen sind in verschiedenen Ausformungen in Automatenmodellen definiert worden. Die einfachste ist der endliche Automat, der bei der Abarbeitung einer Folge von Eingabezeichen eines Wortes aus einem Startzustand über Zwischenzustände in einen Endzustand übergeht. Wörter aus mächtigeren Sprachen können mit der Turingmaschine abgearbeitet werden, deren Schreiblesekopf über einem unendlichenArbeitsband frei beweglich ist. Diese Maschine ist auch Grundlage für moderne Computer, und man kann auf ihr alle bekannten zahlentheoretischen Algorithmen, wie den euklidischen, den GGT- oder einen Faktorisierungsalgorithmus ausführen.

Bis aber der Algorithmus eine Form hat, in der er auf dieser Maschine ausgeführt werden kann, sind viele Transformationen in Form von Zerlegungen und Übersetzungen erforderlich. Aus diesen Vorbetrachtungen ergeben sich die für das Grundstudium der Informatik wichtigen Inhalte:

Grundlegende Prinzipien der Programmierung und den Aufbau eines Rechners wollen wir in dieser Vorlesung vertieft behandeln.

2 Teilgebiete der Informatik

Die Informatik teilt sich in die folgenden vier Teilgebiete auf:

3 Rechnerentwicklung

Im Folgenden geben wir einen kurzen Überblick über die Entwicklung der Rechner: Zwischen 1623 und 1818 entstanden die ersten mechanischen Rechenmaschinen, z.B. die Schickardsche Rechenuhr, die Additionsmaschine von Blaise Pascal und eine Staffelwalzenmaschine von Leibniz, die das Zweiersystem benutzt. Daher kann man mit Fug und Recht behaupten, dass die Entwicklung von Rechnern zunächst dadurch motiviert war, die Ausführung von Rechnungen in den vier Grundrechenarten +, -, *, / zu unterstützen. Aber mit der Entwicklung eines automatischen Webstuhls von Jacquard um 1800, der mittels Lochkarten gesteuert wurde, kam bereits ein anderer Aspekt neben dem Rechnen hinzu, nämlich der des Automaten, bei dem eine gewisse Abfolge von Zuständen gesteuert durchlaufen wird.

Figure 1.1: Pascalsche Addiermaschine
\includegraphics[%%
width=0.75\textwidth,
keepaspectratio]{pascal.eps}

1833 plante Babbage den ersten programmgesteuerten Rechner, der eine Ein- und Ausgabeeinheit, eine arithmetisch- logische Einheit (ALU) zur Ausführung von Rechnungen und logischen Operationen, wie Vergleich von Zahlen, und eine Befehlseinheit zur Steuerung der Maschine vorsieht. Die zur Rechnung und Steuerung nötigen Daten werden von einem Datenträger in einen Speicher eingelesen und im Takt bearbeitet. Das Ergebnis wird sodann an einer Ausgabeeinheit angezeigt. Moderne Eingabegeräte sind Tastatur, Maus, Graphiktablett, Scanner, Ausgabegeräte dagegen Monitor, Drucker oder eine Datei.

Figure 1.2: Babaggerechner
\includegraphics[%%
width=0.30\textheight]{babbage.eps}

Gebaut wurden die ersten programmgesteuerten Rechner von Zuse (1934/41) als lochstreifengesteuerte Maschine mit 2000 Relais und 64 Speichern, von Aiken 1944 in Zusammenarbeit mit IBM als Großrechenanlage MARK II mit 15 m Front und 2.5 m Höhe, bestehend aus 80 km Leitungsdraht, 700 000 Einzelteilen mit 3.5 t Gewicht. Hier kommt bereits ein anderer Aspekt ins Spiel, nämlich der Einsatz eines Rechners zu anderen Aufgaben außerhalb des Rechnens, beispielsweise zur Übernahme von Büroarbeiten und Lösung von Anwendungsproblemen, wie es schon Hollerith 1886 mit einer Lochkartenmaschine zum Einsatz bei einer Volkszählung vorgegeben hatte. In der Folgezeit setzt eine stürmische Entwicklung über die Einführung von Elektronenröhren in der ENIAC, Transistoren, Mikroschaltelementen, hoch- und höchstintegrierten Schaltkreisen ein, die in der Einführung von Mikrochips gipfelt. Während der Zuserechner die Multiplikation zweier 10stelligen Zahlen in ca. 3 sec ausführt, ist die ENIAC schon tausendmal schneller. Ein moderner Prozessor ist mit über 100 MHz getaktet, also mit über 100 Millionen Zyklen pro Sekunde. Viele Befehle werden in einem Zyklus ausgeführt. Eine Addition benötigt 3 Takte, eine Multiplikation zweier 32 Bit Zahlen 5, eine Division 18 bis 38 Takte und das Wurzelziehen 29 bis 69 Takte.

Figure 1.3: Prozessor P6
\includegraphics[%%
width=0.75\textwidth]{P6.eps}

Beim INTEL P6 sind auf einer Fläche von wenigen Hundert Quadratmillimetern 20 Millionen und mehr Transistoren auf vier Ebenen platziert, die zwischen 5 und 20 Watt Leistung aufnehmen, in fünf Pipelines Befehle parallel abarbeiten können und in der Herstellung unter 500 DM kosten. Diese Chips kommen neben Spezialchips zur Organisation des Datentransfers oder von Ein- und Ausgabe insbesondere auf graphischen Terminals in modernen Personalcomputern zum Einsatz.

Parallel zur technologischen Entwicklung verläuft eine Entwicklung der Software, deren Bedeutung immer mehr zunimmt. Die nachfolgend genannten Generationen verlaufen nicht aufbauend aufeinander, sondern überlappen sich.

1. Generation:
Programmierung in Maschinencode
2. Generation:
Assemblersprachen und Programmiersprachen wie Fortran, Algol, Cobol
3. Generation:
Strukturierte Programmierung mit Pascal, C, Ada, Modula
4. Generation:
Verteilte Systeme, Programmierumgebungen, Objektorientierung
5. Generation:
Logiksprachen, funktionale Programmiersprachen, Expertensysteme, Datenbanksysteme, Graphiksprachen
6. Generation:
Netzsprachen, Generatoren, graphische Entwicklungssysteme

Diese summarische Darstellung wollen wir etwas weiter vertiefen: Jeder Prozessor besitzt eine durch seine Bauart festgelegte Programmiersprache, die er lesen und unmittelbar in Steuersignale umsetzen kann. Man bezeichnet sie als Maschinensprache. Programme in Maschinensprache werden meist in einer leichter lesbaren mnemotechnischen Notation beschrieben, der Assemblersprache. Befehle, wie das Sprachkürzel LD heißen laden, ADD addieren, ST speichern. Leider ist, obwohl schnell ausführbare Programme entstehen, die Programmierung in Assembler aufwändig, unübersichtlich und extrem maschinenabhängig. Assemblerprogramme werden daher heute nur noch für spezielle systemspezifische Programme eingesetzt, oder wenn es auf höchste Effizienz ankommt. Für alle anderen Anwendungen wird das Programmieren durch den Einsatz problemorientierter Sprachen wesentlich erleichtert.

1 Problemorientierte Programmiersprachen

Gegenüber den Assemblersprachen sind die problemorientierten Programmiersprachen nicht an einen bestimmten Rechnertyp gebunden. Problemorientierte Sprachen verwenden in weitem Maße gebräuchliche mathematische oder sonstige anwendungsorientierte Schreibweisen und erlauben eine leicht verständliche, dem Problem angepasste Formulierung von Algorithmen. Sie lassen sich deshalb besonders leicht erlernen. Programme in einer problemorientierten Sprache können ohne Rücksicht auf einen speziellen Rechnertyp formuliert werden, sie lassen sich (zumindest im Prinzip) leicht auf andere Rechner übertragen. Zur Programmierung mathematischer, naturwissenschaftlicher und technischer Probleme haben u.ä. folgende problemorientierte Sprachen zumindest zeitweise eine gewisse Verbreitung gefunden:

Ada
Ada wurde im Rahmen eines Wettbewerbs des amerikanischen Verteidigungsministeriums in den Jahren 1977 - 1980 entwickelt. Es soll durch seine universelle Einsetzbarkeit sowohl im kommerziellen als auch im technisch wissenschaftlichen Bereich dazu dienen, Wartungskosten für Software möglichst gering zu halten. Dadurch wurde Ada extrem umfangreich: neben den Konzepten von Pascal umfasst es u.Ä. die Definition von Modulen, Paketen (zusammenhängende Daten und Operationen), generischen Unterprogrammen (mit unterschiedlichen Typen) und Prozessen (für die Parallelverarbeitung).

ALGOL 60 (ALGOrithmic Language)
Die Sprache wurde in Europa gemeinsam von vielen wissenschaftlichen Institutionen aus sechs Ländern entwickelt und 1960 publiziert. Sie hat heute keine praktische Bedeutung mehr. Viele Konzepte der strukturierten Programmierung wurden aber von Algol in moderne Programmiersprachen übernommen.

BASIC (Beginners All-purpose Symbolic Instruction Code)
Ein vereinfachtes, FORTRAN-ähnliches Programmiersystem, besonders auf Kleinrechnern verbreitet. Die Entwicklung ging 1960 von J. Kemmeny und Th. Kurtz mit einem interpretierenden System aus, bei dem Linie für Linie des Programmes ausgeführt wurde, und hat heute zu Pascal-ähnlichen Programmiersystemen geführt, bei dem ein ausführbares Maschinenprogramm mittels eines Compilers erstellt wird.

COBOL (COmmon Business Orientated Language)
Die Sprache wurde Ende der 50er Jahre entwickelt und ist in der kaufmännischen Datenverarbeitung bis heute weit verbreitet.

C, C++
C wurde Ende der 70er Jahre gemeinsam mit dem Betriebssystem UNIX für Minicomputer entwickelt und hat sich seither auf Workstations, Personal-Computer und Großrechner ausgebreitet. Es stellt in mancher Hinsicht einen Kompromiss zwischen strukturierter Hochsprache und effizienter Maschinensprache dar. C-Programme neigen daher oft dazu, schwer lesbar zu sein. Da C inzwischen sehr weit verbreitet und standardisiert ist, eignet es sich gut zur Übertragung von Programmen auf unterschiedliche Rechner. Als objektorientierte Erweiterung von C setzt sich C++ seit 1986 immer weiter durch.

FORTRAN (FORmula TRANslation)
Fortran wurde 1954 als erste problemorientierte Programmiersprache von J. W. Backus entworfen, bei IBM implementiert und von internationalen Gremien in mehreren Versionen weiterentwickelt. Der aktuelle Sprachstandard Fortran 90 hat viele strukturierte Konzepte von Pascal übernommen.

Java
Java ist der Sprache C++ ähnlich und objektorientiert. Es wurde Anfang der neunziger Jahre von Sun entwickelt und beschreitet einen Mittelweg zwischen interpretierenden und compilierenden Sprachen. Ein Java-Compiler übersetzt ein in Java geschriebenes Programm in Code für eine virtuelle Java-Maschine, die wiederum auf allen Rechnerplattformen emuliert werden kann. Zusätzlich unterstützt es Sicherheitskonzepte für das Internet, kann in Internet-Seiten einbezogen werden und ist somit Netzwerk-geeignet.

Pascal
Pascal wurde von Kathleen Jensen und Niklaus Wirth (ETH Zürich) Anfang der 70er Jahre auf der Basis von Algol und ähnlichen Sprachen (z.B. Simula) entwickelt. Es umfasst ein erweitertes Typ-, Ausdrucks- und Anweisungskonzept, wurde aber ansonsten bewusst einfach gehalten. Pascal ist international standardisiert. Später werden die Dialekte Pascal-XSC (portabel, Erweiterungen für das wissenschaftliche Rechnen) und Turbo Pascal (Erweiterungen u.ä. für Grafik, systemnahe Programmierung, objektorientierte Konzepte) besprochen. Die Sprache ist nach dem französischen Mathematiker, Philosoph und Theologen Blaise Pascal benannt (1623 - 1662), der u.ä.\,eine der ersten mechanischen Rechenmaschinen konstruierte. Er leistete Beiträge zu zahlreichen mathematischen Gebieten (Geometrie der Kegelschnitte, Wahrscheinlichkeitsrechnung, Kombinatorik, Binomialkoeffizienten /Pascal'sches Dreieck, Prinzip der vollständigen Induktion, Ansätze zur Infinitesimalrechnung).

Modula-2
Modula-2 wurde 1978 als Weiterentwicklung der Sprache Pascal von Niklaus Wirth entworfen. Einige Sprachelemente von Pascal wurden überarbeitet und dabei zum Teil systematischer, zum Teil komplizierter. Ein Modulkonzept erlaubt die Entwicklung großer Programmpakete. Standardmodule ermöglichen z.B. systemnahe Programme und die Programmierung von Prozessen.

Oberon
Oberon wurde von N. Wirth und M. Reiser seit 1985 als Weiterentwicklung von Pascal und Modula 2 entworfen. Es stellt ein kleines Betriebssystem für PC und Workstation dar mit integrierter pascalartiger objektorientierter Programmiersprache und ist sehr sparsam im Umgang von Rechnerressourcen. Wie man an den Jahreszahlen erkennen kann, dauert es oft Jahre oder Jahrzehnte, bis sich eine neue Programmiersprache etabliert hat. Bestehende Sprachen werden immer wieder überarbeitet und aktualisiert.

In der Ausbildung ist Pascal nach wie vor hervorragend geeignet, da es die Entwicklung leistungsfähiger Programme erlaubt, also genügend universell ist, zum strukturierten Programmentwurf erzieht (im Gegensatz zu BASIC, C und Fortran) und trotzdem nicht allzu komplex ist (im Gegensatz etwa zu Fortran 90 oder Ada).

Neben den genannten gibt es eine Vielzahl weiterer Programmiersprachen, z.B. die bei Expertensystemen weitverbreitete Sprachen PROLOG und LISP, zur Prozesssteuerung die Sprache PERL. In einer prädikativen Programmiersprache wird Programmieren als Beweisen in einem System von Tatsachen und Schlussfolgerungen aufgefasst. Der Anwender gibt eine Menge von gültigen Prädikaten und Regeln zum Gewinnen neuer Fakten vor, und die Aufgabe des Rechners ist es, eine gestellte Frage mit ''Richtig'' oder ''Falsch'' zu beantworten. Funktionale Programmiersprachen verstehen Programme als Funktionen, die Mengen von Eingabewerten in Mengen von Ausgabewerten abbilden. Dabei ist eine Grundmenge von wichtigen einfachen Funktionen vorgegeben.

Im zweiten Teil dieser Veranstaltung werden wir uns mit der imperativen Programmiersprache Pascal, der prädikativen Sprache PROLOG und der funktionalen Sprache Miranda beschäftigen.

Eine Sprache muss durchaus nicht immer zum Erstellen von Programmen dienen, sondern kann wie dBASE auch zur Verwaltung einer Datenbank, TEX zur Gestaltung eines Textes, VHDL zum Beschreiben und Produzieren von elektronischen Schaltungen auf Chips oder HTML zur Gestaltung von WWW-Dokumenten dienen.

4 Grundlagen der Logik

1 Geschichtlicher Überblick

Der Wunsch, logisches Schließen zu automatisieren oder Apparate zu konstruieren, die so ähnlich wie der Mensch denken können, geht auf R. Descartes und G.W. Leibniz im siebzehnten Jahrhundert zurück. Descartes Entdeckung, dass die klassische Euklidische Geometrie allein mit algebraischen Methoden entwickelt werden kann, war für die Entwicklung von Deduktionssystemen bedeutsam. Leibniz Idee war die Entwicklung einer universellen formalen Sprache, die lingua characteristica, in der jegliche Wahrheit formuliert werden könne, und dazu eines Kalküls, dem sogenannten calculus rationator, für diese Sprache. Damit wollte er natürlichsprachige Beschreibungen auch über Sachverhalte, die nicht aus der Zahlentheorie kommen, in eine formale Sprache und einen dazugehörigen Kalkül übersetzbar machen. Seiner Vorstellung nach müsse ein solcher Kalkül mechanisierbar sein und auf diese Weise dem menschlichen Denken alle langweilige Arbeit ersparbar sein.

Moderne Logik entstand 1879 unter Gottlob Frege, insbesondere mit der Schaffung seiner Begriffsschrift. Diese enthält die erste vollständige Entwicklung desjenigen Anteils der mathematischen Logik, der heute als Prädikatenlogik erster Stufe bezeichnet wird. Durch den Gebrauch Boolescher Operatoren und die Verwendung von Quantoren, Relationen und Funktionen wurde der ganze Aufbau der Logik das erste Mal beschrieben. Als Herleitungsregel verwendete er den Modus Ponens. Zudem wurden Syntax und Semantik einer formalen Sprache das erste Mal entwickelt. Skolem beschrieb in Arbeiten von 1920 und 1928 eine systematische Vorgehensweise, wie man die Erfüllbarkeit einer beliebigen Formelmenge nachweisen kann. Ebenfalls im Jahre 1928 erschien das Buch Grundzüge der theoretischen Logik von D. Hilbert und W. Ackermann. Hier wird auch das Entscheidbarkeitsproblem eingeführt, bei dem es um die Frage geht, ob es einen Algorithmus gibt, mit dem entschieden werden kann, ob eine beliebige vorgegebene prädikatenlogische Aussage wahr oder falsch ist. Herbrand bewies in seiner Doktorarbeit 1930 den Satz, dass für einen korrekten mathematischen Satz dies mit endlich vielen Schritten nachgewiesen werden kann. Gelingt dies nicht, so gibt es zwei Möglichkeiten, entweder kann die Inkorrektheit nach endlich vielen Schritten bewiesen werden, oder das Programm terminiert nicht, und man weiß es nicht. Später zeigten Alan Turing und Alonzo Church, dass es keine allgemeine Entscheidungsprozedur dafür gibt, ob eine Aussage der Prädikatenlogik wahr ist oder nicht. Turing gelang 1936 der Beweis durch Rückführung auf das Halteproblem. Um 1950 wurde die Entwicklung des Computers vorangetrieben, und es entstand das erste Programm zur Überprüfung von Aussagen. Um 1954 veröffentlichte Robinson Ergebnisse zur Prüfung von Theoremen in Klauselform mit dem Resolutionsprinzip. In den folgenden Jahren entwickelte er diese Ideen weiter und veröffentlichte 1965 die Arbeit A machine oriented logic based on the resolution principle. Kowalski stellte dann schließlich mit seinem SLD-System einen Weg dar, der mittels Hornklauseln Wissen speichert und deklarativ wie prozedural Verwendung findet. Schließlich entwickelte Alan Colmerauer 1972 ein Logik-Programmiersystem PROLOG, das Grundlage für die Programmierung in Logik ist.

2 Aussagenlogik

Logik ist die Lehre von der Folgerichtigkeit des Denkens und Schließens. Mathematische Logik ist ein formales System, in dem gewissen Aussagen und Theoremen Wahrheitswerte zugeordnet werden können. Die einfachste Form der mathematischen Logik ist die Aussagenlogik.

Unter einer Aussage oder atomaren Formel, abgekürzt mit einem Großbuchstaben ( $ A,  B,\ldots$) versteht man einen Satz, der entweder wahr oder falsch ist, z.B. ''Heute ist Dienstag''. Man wird ihm mittels einer Interpretation einen Wahrheitswert zuordnen, wahr ($ w$) oder falsch ($ f$). Dieser Wert kann in einem Bit mit 1 bzw. 0 gespeichert werden. Die Menge der Wahrheitswerte wird mit $ \mathcal{B}$ abgekürzt. Aussagen können miteinander verknüpft werden, und das Ergebnis dieser ''Operation'' ist wieder eine Aussage mit einem wohldefinierten Wahrheitswert. Als Negation erklärt man eine einstellige Operation $ \lnot$ $ \left(\overline{\phantom{x}}\right)$, die jeder Aussage das Negat mit dem entgegengesetzten Wahrheitswert zuordnet. Atomare Formeln (Aussagenvariablen) oder deren Negate heißen Literale $ L\in\left\{ A,\lnot A\right\} $.

Nunmehr erklären wir wichtige zweistellige Operationen. Die Konjunktion mit dem Konjungator $ \wedge$ (und, and) ordnet zwei Aussagen ihr Konjugat zu. Dabei hängt die Bedeutung der Junktoren nicht von den beteiligten Aussagen, sondern nur von ihren Wahrheitswerten ab. Es ergibt sich die folgende Tabelle:

\begin{displaymath}
\begin{array}{c\vert\vert c\vert c}
\wedge & w & f\\
\hline\hline w & w & f\\
\hline f & f & f\end{array}\end{displaymath}

z.B. erhält man $ w\wedge w=w$, was man am mittleren Kästchen ablesen kann.

Die Disjunktion mit dem Disjungator $ \vee$ (oder, or) ordnet zwei Aussagen ihr Disjungat zu. Dabei ergibt sich die erste der folgenden Tabellen:

\begin{displaymath}
\begin{array}{c\vert\vert c\vert c}
\vee & w & f\\
\hline\...
... & f\\
\hline\hline w & f & w\\
\hline f & w & f\end{array}\end{displaymath}

z.B. erhält man $ f\vee f=f$, was man am rechten unteren Kästchen der ersten Tabelle prüfen kann.

Neue Formeln $ F$ können über einen induktiven Prozess aus (atomaren) Formeln mit Hilfe der Operatoren $ \lnot$, $ \vee$, $ \wedge$ gebildet werden.

Die Subjunktion mit dem Subjungator $ \rightarrow$ (wenn - dann) ordnet zwei Aussagen ihr Subjungat zu. Auch hier haben wir die Tabelle notiert, aus der wir entnehmen, dass $ f\rightarrow w$ bzw.  $ f\rightarrow f$ eine wahre Aussage ist, da aus einer falschen Aussage der Wahrheitswert der zweiten Aussage nicht geprüft werden kann.

Die Bijunktion mit dem Bijungator $ \longleftrightarrow$ (genau dann - wenn) ordnet zwei Aussagen ihr Bijungat, die Antivalenz mit dem Junktor $ \oplus$ bzw. $    \not\!\!\!\longleftrightarrow$ (entweder - oder, xor) ordnet zwei Aussagen die Summe ihrer Wahrheitswerte modulo zwei zu.

Für die Junktoren gilt die folgenden Bindungshierarchie: $ \lnot$ bindet stärker als $ \wedge$, dieses stärker als $ \vee$, $ \vee$ stärker als $ \rightarrow$, $ \rightarrow$ stärker als $ \longleftrightarrow$.

Wir wollen nun einige im Laufe der Vorlesung vorkommende Begriffe kurz einführen:
Eine Klausel ist eine Disjunktion von Literalen. Eine Hornklausel enthält höchstens ein positives Literal, eine Hornformel besteht nur aus Hornklauseln, die durch Konjunktion miteinander verknüpft werden, wie zum Beispiel

$\displaystyle \left(A\vee\lnot B\vee\lnot C\right)\wedge\lnot C\wedge A\wedge\left(\lnot A\vee\lnot C\right).$

Eine aussagenlogische Form liegt in konjunktiver Form (KF) vor, wenn sie die Konjunktion von mehreren Klauseln ist:

$\displaystyle F=\left(\bigwedge\limits _{i=1}^{n}\left(\bigvee\limits _{j=1}^{n_{i}}L_{i,j}\right)\right).$

Sie liegt in disjunktiver Form (DF) vor, wenn sie die Disjunktion mehrerer durch Konjungatoren verknüpften Literale ist:

$\displaystyle F=\left(\bigvee\limits _{i=1}^{n}\left(\bigwedge\limits _{j=1}^{n_{i}}L_{i,j}\right)\right).$

Beispiel 4.1

$\displaystyle F_{1}$ $\displaystyle =$ $\displaystyle \left(A\vee B\vee\lnot C\right)\wedge\left(\lnot B\vee C\right)\textrm{  KF}$  
$\displaystyle F_{2}$ $\displaystyle =$ $\displaystyle \left(A\wedge B\wedge\lnot C\right)\vee\left(\lnot B\wedge C\right) \textrm{DF}.$  

Aussagenvariablen werden mit den Werten aus der Menge der Wahrheitswerte belegt. Sie sind Platzhalter für Aussagen. Aus den Wahrheitswerten, den Aussagen, Aussagenvariablen und den Verknüpfungsoperatoren kann man zulässige aussagenlogische Ausdrücke bilden. Auch auf diese können wieder Operationen angewandt werden.

Jeder Formel kann für eine Belegung ein Wahrheitswert zugeordnet werden. Eine Interpretation, unter der die Formel wahr ist, heißt Modell. Eine Formel heißt erfüllbar, wenn es mindestens ein Modell gibt, ansonsten unerfüllbar. Eine Formel $ F$ heißt Tautologie, wenn sie für alle Belegungen wahr ist. Wir schreiben dann $ \vDash F$. (Es ist allgemein wahr, wie z.B. $ A\vee\lnot A$). Die Allgemeingültigkeit kann mit Hilfe einer Wahrheitstafel, durch Anwendung von syntaktischen Umformungen bis zum Wahrheitswert $ w$ oder durch Zurückführung auf eine konjunktive Form bewiesen werden, in der in jeder Klausel mit mindestens einer Aussagenvariablen auch deren Negat vorkommt.

Beispiel 4.2
Zu zeigen ist $ \vDash\left(\left(P\rightarrow Q\right)\wedge P\right)\rightarrow Q$.

a)
 
\begin{displaymath}\begin{array}{l\vert l\vert c\vert c\vert c}
P & Q & P\righta...
... & f & w\\
w & f & f & f & w\\
w & w & w & w & w\end{array}\end{displaymath}
b)
Äquivalent sind die folgenden Formeln

\begin{displaymath}
\begin{array}{l}
\left(\left(P\rightarrow Q\right)\wedge P\r...
...ot P\vee\lnot Q\right)\vee Q\\
\lnot P\vee w\\
w\end{array}\end{displaymath}

c)
Überführung in konjunktive Form

\begin{displaymath}
\begin{array}{l}
\left(\left(P\rightarrow Q\right)\wedge P\r...
...t)\wedge\left(\lnot Q\vee Q\vee\lnot P\right)\right)\end{array}\end{displaymath}

Bei der Herleitung benutzen wir folgende Definitionen und Ergebnisse:

Zwei Formeln $ F$ und $ G$ heißen äquivalent $ \equiv$, wenn sie für alle passenden Belegungen den gleichen Wahrheitswert haben: $ \vDash G\longleftrightarrow F$. Für jede Formel $ F$ gibt es eine äquivalente Formel in KF oder DF. Es gelten folgende Äquivalenzen:

Tabelle 4.3

\begin{displaymath}
\begin{array}{rclr}
\left(F\wedge F\right) & \equiv & F,\hsp...
...e G\equiv F, & \text{falls }F \textrm{{Tautologie}}\end{array}\end{displaymath}


Ist $ S$ eine Menge von Formeln oder Prämissen $ \left\{ F_{1},\ldots,F_{k}\right\} $ und $ F$ eine Formel, so ist $ F$ eine logische Konsequenz von $ S$, in Zeichen $ S\vDash F$, wenn jede Belegung von $ S$, die ein Modell von $ S$ ist, auch ein Modell von $ F$ ist. Dies kann auch in der Form

$\displaystyle \vDash\left(\bigwedge\limits _{j=1}^{k}F_{j}\rightarrow F\right)$

geschrieben werden. Eine Belegung mit Wahrheitswerten von $ S$ heißt konsistent, wenn alle Formeln unter dieser Belegung wahr sind.

Es gelten die folgenden Regeln für logische Konsequenz:

Tabelle 4.4

\begin{displaymath}
\begin{array}{rclr}
F & \vDash & F \vee G & \\
F & \vDash ...
...tarrow F) & \vDash & \lnot G & \text{Modus Tollens}
\end{array}\end{displaymath}

Eine Theorie definiert zunächst eine Menge von wahren logischen Aussagen und Fakten, die Axiome. Dabei spielen die Begriffe Korrektheit, Vollständigkeit und Entscheidbarkeit eine Rolle. Sie besteht aus einer Sprache, mit deren Hilfe gewisse Sätze ausgedrückt werden können. Axiome stellen dabei die Grundsätze der Theorie dar. Aus der Menge der Axiome können neue, kompliziertere Sätze als logische Konsequenz abgeleitet werden. Dies geschieht über die oben angegebenen Interferenzregeln, wie den Modus Ponens und den Modus Tollens oder auch die Transitivitätsregel.

Beispiel 4.5 (Transitivität)

$ A$ r =( $ a$ ist eine gerade Zahl und $ b$ ist eine gerade Zahl)
$ B$

$ A$ $ a$ und $ b$ sind gerade Zahlen
$ A \rightarrow B_{1}$

Für eine Theorie sollte ein korrektes (semantisch widerspruchsfreies) und vollständiges Axiomensystem zur Verfügung stehen. Ein Axiomensystem $ AS$ ist korrekt, wenn jede Formel $ F$, die aus einer Theorie $ T$ mittels der Ableitungsregeln abgeleitet wird $ \left(T\vdash_{AS}F\right)$, eine logische Konsequenz aus $ T$ ist $ \left(T\vDash F\right)$. Ein Axiomensystem $ AS$ ist vollständig, wenn für jede Theorie $ T$ jede Formel, die aus der Theorie logisch folgt, auch ableitbar ist (das ist die Umkehrung).
Ist ein derartiges Axiomensystem vorhanden, so heißt es Basis der Theorie. Ein Axiomensystem darf auch nicht inkonsistent sein, d.h. es darf nicht gleichzeitig $ F$ und $ \lnot F$ herleitbar sein. Ferner sollten die Axiome voneinander unabhängig sein, also nicht eines aus den anderen folgen. Eine Theorie heißt unentscheidbar, wenn es eine Formel $ F$ gibt, für die gilt, dass weder die Formel selbst noch ihre Negation aus der Theorie ableitbar ist. Der Begriff der Entscheidbarkeit kann auf Axiomensysteme als Basis einer Theorie übertragen werden. Ein Axiomensystem ist entscheidbar, wenn sich immer prüfen lässt, ob eine Theorie in AS konsistent ist oder nicht, wenn sich also von jedem Satz herleiten lässt, ob er allgemeingültig ist oder nicht. Aussagenlogik ist entscheidbar und besitzt widerspruchsfreie, vollständige und unabhängige Axiomensysteme. Ein Axiomensystem AS ist halbentscheidbar, wenn es immer möglich ist, die Inkonsistenz zu prüfen.

Aus den logischen Axiomen der Aussagenlogik (Hilbert)
AS1: $ A\vee A\rightarrow A$,
AS2: $ A\rightarrow\left(A\vee B\right)$,
AS3: $ \left(A\vee B\right)\rightarrow\left(B\vee A\right)$,
AS4: $ \left(A\rightarrow B\right)\rightarrow\left(\left(C\vee A\right)\rightarrow\left(C\vee B\right)\right)$,
der Definition $ A\rightarrow B\equiv\lnot A\vee B$, der Schlussregel (Modus Ponens) und der Ersetzungsregel

Ergibt sich ein Ausdruck B aus einem abgeleiteten Ausdruck oder Axiom A, indem man in A eine Variable an jeder Stelle ihres Auftretens durch einen Ausdruck ersetzt, so kann man von A zu B übergehen
können die Äquivalenzregeln aus Tabelle 1.4.3 hergeleitet werden.

Beispiel 4.6

Wir leiten $ \left(F\vee F\right)\equiv F$ bzw. $ \vDash F\vee F\longleftrightarrow F$ mit Hilfe der oben angegebenen Mittel her. Dies ist äquivalent zu

$\displaystyle \vDash\left(\left(F\vee F\rightarrow F\right)\wedge\left(F\rightarrow F\vee F\right)\right)$ mit $\displaystyle A\wedge B:=\lnot\left(\lnot A\vee\lnot B\right).$

Wir zeigen somit die Allgemeingültigkeit von $ \left(F\vee F\rightarrow F\right)$ und von $ \left(F\rightarrow F\vee F\right)$:

$ \left(F\vee F\right)$ $ \rightarrow$ $ F$ AS1 .
$ F$

Bemerkung:
Im vorigen Beispiel haben wir die Junktoren $ \wedge$ und $ \longleftrightarrow$ mit Hilfe der Junktoren $ \lnot$ und $ \vee$ definiert.

Die Grundresolution ist eine eingeschränkte Form der Methode von Robinson, mit deren Hilfe eine vorgegebene endliche Menge von Klauseln auf ihre Unerfüllbarkeit hin getestet werden kann.

Durch geeignete Operationen zum Vereinfachen von veroderten Formeln lassen sich die Darstellungen verkürzen: Kommt in einer Klausel eine Variable $ P$ und in einer anderen die Negation $ \neg P$ vor, so lassen sich diese Klauseln unter Weglassung dieser Variablen zu einer Klausel zusammenfassen:

\begin{displaymath}
\begin{array}{l}
P\vee A_{1}\vee A_{2}\vee\ldots\vee A_{n}\\...
..._{1}\vee B_{2}\vee\ldots\vee B_{m}\text{ Resolution}\end{array}\end{displaymath}

Dann lässt sich das Verfahren folgendermaßen beschreiben:
Es sei die Aussage $ A$ zu beweisen.
Setze das negierte Theorem $ \lnot A$ zu den Formeln der Theorie, wandele sie in Klauselform um (KF) und versuche, durch Resolution die leere Klausel herzuleiten. In diesem Fall ist das Theorem $ A$ zur Theorie gehörig.

Beispiel 4.7
$ T = \left\{ A \vee B, \; A \rightarrow \lnot B, \; \lnot A \right\}$. Leite $ B$ her. Wir formen in KF um:

$\displaystyle T = \left\{ A \vee B, \; \lnot A \vee \lnot B, \; \lnot A \right\},
$

oder in der Schreibweise als Klauselmenge

$\displaystyle T = \left\{ \left( A, \; B \right), \; \left( \lnot A, \; \lnot B \right), \; \lnot A \right\},
$

und untersuchen die Klauselmenge

$\displaystyle \left\{ \left( A, \; B \right), \; \left( \lnot A, \; \lnot B \right), \; \lnot A, \; \lnot B \right\}
$

auf Unerfüllbarkeit:
$ B$ ist ein Resolvent von $ \left( A, B \right)$ und $ \lnot A$. Der Resolvent von $ B$ und $ \lnot B$ ist aber die leere Menge, und damit enthält die Klauselmenge die leere Menge und ist unerfüllbar. Somit ist $ B$ bewiesen.

3 Prädikatenlogik

Die gebräuchlichste Form der mathematischen Logik ist die Prädikatenlogik als Erweiterung der Aussagenlogik. Sie dient zur Formulierung von Axiomen, zum Beweis von Eigenschaften von Sätzen und Programmen. Sie bedient sich einer Menge von Objekten, Relationen und Funktionen. Wie in der Aussagenlogik werden Formeln (Symbolisierung einer sprachlichen Aussage, syntaktisches Gebilde aus Symbolen) mit Hilfe von logischen Verbindungen aus Atomen (elementaren logischen Aussagen) und anderen Formeln gebildet. Sie sind nach präzisen Regeln einer Grammatik aufgebaut. Als Atome (logisch unzerlegbarer Ausdruck, Zeichenfolgen aus Prädikaten und Operatoren, gebildet aus dem Alphabet als Menge der zugelassenen Symbole, Konstanten $ a$, $ b$, $ c$, Variablen $ x$, $ y$, $ z$, Prädikate $ P$, $ Q$, $ R$, Funktions- und Hilfszeichen $ f$, $ g$, $ h$, $ \rightarrow$, $ \ldots$) sind jedoch nicht nur Literale (Aussagenvariablen und deren Negation) sondern auch Prädikate (Eigenschaften von Objekten, $ P(a_{1},\ldots,a_{n})$) mit Argumenttermen erlaubt. Terme sind Konstanten, Variablen oder Funktionssymbole. Prädikatenlogik erlaubt damit viel reichere Aussagen als die Aussagenlogik.

Im Allgemeinen erklären Prädikate Teilmengen von bekannten Obermengen, wie zum Beispiel die Prädikate Primzahl oder gerade Zahl in der Menge der natürlichen Zahlen. Die Mengenoperationen $ \cup$, $ \cap$, $ \subset$, Komplement korrespondieren mit den Verknüpfungen des Aussagenkalküls $ \vee$, $ \wedge$, $ \rightarrow$, $ \lnot$.

Die Aussage

''Wenn $ x$ die Eigenschaft $ P$ besitzt, so besitzt $ y$ die Eigenschaft $ Q$.''

wird formalisiert zu $ Px\rightarrow Qy$. Wichtig sind auch die Quantoren: der Existenzquantor $ \left(\vee,\exists\right)$, der Allquantor $ \left(\wedge,\forall\right)$. Der Existenzquantor erlaubt, die Existenz eines Objektes mit gewissen Eigenschaften anzunehmen, ohne es selbst zu definieren. Der Allquantor erstreckt eine Eigenschaft auf alle Objekte. Variablen werden im gemeinsamen Auftreten von Quantoren gebunden, können aber auch frei in Formeln auftreten. Die Semantik der Prädikatenlogik gibt den nach den syntaktischen Regeln geschaffenen Formeln einen Wahrheitsgehalt in einer Domäne als geschaffener Welt. Durch Interpretation wird eine Beziehung zwischen dem Alphabet der Sprache und der Domäne hergestellt. Konstanten entsprechen Objekten, Prädikaten Relationen und Funktionssymbolen Funktionen. Variablen müssen Werte zugeordnet werden.

Beispiel 4.8
Gegeben sei die Formel

$\displaystyle F: \forall x P (f(x, a), x).
$

Die betrachtete Domäne sei die der natürlichen Zahlen $ \mathbb{N}:= \left\{ 1, 2, 3, \ldots \right\}$. Der Konstanten $ a$ ordnen wir die Zahl $ 1$ zu. Unter der Funktion $ f(x, a)$ verstehen wir die Multiplikation $ x \cdot a$. Das zweistellige Prädikat $ P$ sei als die Eigenschaft Gleichheit definiert. Damit lautet die Interpretation der Formel:
Für alle natürlichen Zahlen $ x \in \mathbb{N}$ gilt $ x \cdot 1 = x$.

Ist eine Formel in einer Interpretation wahr, so ist diese Interpretation ein Modell der Formel. Eine Formel ist erfüllbar, wenn es für sie ein Modell gibt, allgemeingültig, wenn alle Interpretationen wahr sind. Auch hier wenden wir die Definition der logischen Konsequenz einer Formel $ F$ aus einer Formelmenge $ S$ an: Wenn jede Interpretation und Belegung, die ein Modell von $ S$ ist, auch ein solches von $ F$ ist, so gilt $ S\vDash F$.

Zu unseren bekannten Schlussregeln der Aussagenlogik gesellen sich weitere Schlussregeln:

Tabelle 4.9

$\displaystyle \forall x F$ $\displaystyle \vDash$ $\displaystyle \exists x F$    für eine nichtleere Domäne  
$\displaystyle \forall x F \vee \forall x G$ $\displaystyle \vDash$ $\displaystyle \forall x (F \vee G)$  
$\displaystyle \exists x (F \wedge G)$ $\displaystyle \vDash$ $\displaystyle \exists x F \wedge \exists x G$  

Wir erhalten auch weitere logische Äquivalenzen:

Tabelle 4.10

$\displaystyle \lnot \forall x F$ $\displaystyle \equiv$ $\displaystyle \exists x \lnot F$  
$\displaystyle \lnot \exists x F$ $\displaystyle \equiv$ $\displaystyle \forall x \lnot F$  
$\displaystyle \forall x \forall y F$ $\displaystyle \equiv$ $\displaystyle \forall y \forall x F$  
$\displaystyle \exists x \exists y F$ $\displaystyle \equiv$ $\displaystyle \exists y \exists x F$  
$\displaystyle \forall x (F \wedge G)$ $\displaystyle \equiv$ $\displaystyle \forall x F \wedge \forall x G$  
$\displaystyle \exists x (F \vee G)$ $\displaystyle \equiv$ $\displaystyle \exists x F \vee \exists x G$  

Auch in der Prädikatenlogik können der Zeichenvorrat und syntaktische Regeln zur Ableitung von prädikatenlogischen Ausdrücken definiert werden. Auf Hilbert geht ein Axiomensystem mit sechs Axiomen zurück. Sodann sind die Ableitungsregeln zu definieren. Leider stellt es sich heraus, dass das Erfüllbarkeits- und das Allgemeingültigkeitsproblem für prädikatenlogische Formeln unentscheidbar ist. Die Wahrheitstafelmethode kann nicht übertragen werden. Immerhin können die Formeln in eine Normalform überführt werden, die sogenannte Skolemform, und ein Algorithmus angegeben werden, der bei unerfüllbaren Formeln nach endlicher Zeit mit der Ausgabe ''unerfüllbar'' stoppt.

Wir wollen nun die Peano-Axiome zur Begründung der natürlichen Zahlen und ihrer Arithmetik kennenlernen.

Axiome 4.11 (Peano-Axiome)
1.
$ 1$ ist eine natürliche Zahl: $ P1$
Hier benötigen wir das einstellige Prädikat $ Px$: $ x$ ist eine natürliche Zahl.
2.
Zu jeder natürlichen Zahl gibt es eine natürliche Zahl als deren Nachfolger:

$\displaystyle \forall x \left( Px \rightarrow \exists y \left( Py \wedge Qxy \right) \right).
$

Dazu definieren wir das zweistellige Prädikat $ Qxy$: Die natürliche Zahl $ x$ hat die natürliche Zahl $ y$ zum Nachfolger.
3.
Die Zahl 1 ist Nachfolger keiner natürlichen Zahl: $ \lnot \exists x \left( Px \wedge Qx1 \right)$.
4.
Verschiedene natürliche Zahlen haben auch verschiedene Nachfolger:

$\displaystyle \forall x_{1} \forall x_{2} \forall y_{1} \forall y_{2} \left( Px...
...y_{2} \wedge \lnot (x_{1} = x_{2}) \rightarrow \lnot (y_{1} = y_{2}) \right).
$

5.
Jede Teilmenge $ M$ aus den natürlichen Zahlen, die die $ 1$ und mit jedem Element auch deren Nachfolger enthält, ist mit der Menge $ \mathbb{N}$ der natürlichen Zahlen identisch (vollständige Induktion):

$\displaystyle \forall M \left( M1 \wedge \forall x \forall y \left( Px \wedge P...
...ge Mx \wedge Qxy \rightarrow My \right) \rightarrow (M = \mathbb{N}) \right).
$

Dazu führt man die Prädikatenvariable $ M$ ein sowie das Prädikat $ Mx$: $ x$ ist Element von $ M$. Ferner findet die Konstante $ \mathbb{N}$ Verwendung.

Hier ist zu bemerken, dass über die Prädikatenvariable quantisiert wurde. Damit sprechen wir von der Prädikatenlogik höherer Stufe. In der ersten Stufe sind nur Quantifizierungen über Objektvariablen erlaubt.

Die Prädikatenlogik ist auch Basis einer Programmiersprache, nämlich Prolog. Dabei spielen die Hornklauseln eine wesentliche Rolle. Sie werden in der Form

$\displaystyle P:-Q_{1},Q_{2},\ldots,Q_{n}$ (Prozedurklausel)

notiert. Dies steht für

$\displaystyle Q_{1}\wedge Q_{2}\wedge\ldots\wedge Q_{n}\rightarrow P$ bzw. äquivalent $\displaystyle \neg Q_{1}\vee\neg Q_{2}\vee\ldots\vee\neg Q_{n}\vee P.$

Dabei steht das Komma für die Konjunktion. Hornklauseln enthalten nur eine Atomformel als Konklusion im Kopf auf der linken Seite und mehrere durch $ \wedge$ verknüpfte Atomformeln als Prämissen im Rumpf auf der rechten Seite. Sie stellen in Prolog die Regeln dar und können als Prozeduren abgearbeitet werden. Hornklauseln ohne Rumpf (Prämissen) stellen Fakten dar. Diese beiden Typen von Klauseln machen ein Prolog-Programm aus und heißen daher Programmklauseln. Hornklauseln ohne Kopf (Konklusion) sind die Anfragen, die zu beweisenden Aussagen; leere Hornklauseln sind immer falsch und entsprechen in Prolog dem Fail. Es gelingt, Formeln der Prädikatenlogik dahingehend zu vereinfachen, dass die Quantoren verschwinden und nur noch Klauseln in konjunktiver Form auftreten. Wir führen ein Beispiel für ein Prolog-Programm auf:

Beispiel 4.12
Fakten:
mag(heinrich, tiere).
mag(heinrich, sport).
mag(heinrich, biologie).
mag(fritz, sport).
mag(fritz, biologie).
Regel:
interessiert(X, biologie):- mag (X, biologie), mag(X, tiere).
Anfrage:
?- interessiert(X, biologie).
Antwort:
X=heinrich.

Das Resolutionsverfahren erlaubt ähnlich wie in der Aussagenlogik das mechanische Beweisen von Aussagen der Prädikatenlogik, die in Klauselform vorliegen. Ein Programm besteht aus einer endlichen Menge von Klauseln. Das angewandte Schema gliedert sich in die folgenden Schritte:

Ordne Klauseln so an, dass die nicht negierten Literale links, die negierten rechts stehen. Taucht in zwei Klauseln dasselbe Literal auf entgegengesetzten Seiten auf, kann daraus eine neue Klausel gebildet werden, indem man die beiden Klauseln unter Fortlassung der entgegengesetzten Literale zu einer neuen addiert. Treffen zwei je einelementige Klauseln so aufeinander, so entsteht die leere Klausel, die Widerspruchsklausel, und das Verfahren terminiert. Somit lässt sich zwar keine Klausel ableiten, jedoch ein Widerspruchsbeweis führen, indem man das Gegenteil der Annahme als Klausel hinzufügt.

SLD-Resolution ist nur für Hornklauseln definiert und spielt eine entscheidende Rolle bei der Logikprogrammierung und insbesondere bei Prolog-Programmen. Sei

$\displaystyle F=\{ K_{1},K_{2},\ldots,K_{n},N_{1},\ldots,N_{m}\},$

wobei $ K_{i}$ die Programmklauseln und $ N_{i}$ die negativen Klauseln sind. Eine SLD-Resolution ist eine Herleitung der leeren Klausel: Ausgehend von $ j\in\left\{ 1,2,\ldots,m\right\} $ sowie der Zielklausel $ N_{j}$ wird dann mittels einer Folge $ \left\{ i_{1},\ldots,i_{l}\right\} $ mit $ K_{i_{1}}$ bis $ K_{i_{l}}$ zunächst $ N_{j}$ mit $ K_{i_{1}}$ resolviert. Dabei entsteht ein Zwischenresultat, das nur negative Klauseln enthält, und dann in jedem Schritt $ \nu$ mit $ K_{i_{v}}$, $ v=1,\ldots,l$, weiter resolviert wird, wiederum nur mit negativen Klauseln als Zwischenergebnis, bis der letzte Schritt dann auf die leere Klausel, die Halteklausel führt.

Diese Form der Resolution ist vollständig für die Klasse der Hornformeln, d.h. für jede unerfüllbare Klauselmenge $ F$ gibt es eine Klausel $ K\in F$, so dass die leere Klausel durch diese lineare SLD-Resolution aus $ F$ basierend auf $ K$ hergeleitet werden kann.

4 Literatur

Gert Böhme: Einstieg in die Mathematische Logik. Hanser, München 1981.

V. Claus und A. Schwill: Schülerduden Informatik. Dudenverlag, Mannheim, 1997.

Herbert Klaeren: Vom Problem zum Programm. Teubner, Stuttgart 1991.

Uwe Schöning: Logik für Informatiker. 4. Auflage. Spektrum-Verlag, Mannheim 2000.

Ramin Yasdi: Logik und Programmieren in Logik. Prentice Hall, München 1995.

2 Schaltfunktionen

1 Zahldarstellung

Bei den in der Theoretischen Informatik behandelten Maschinenmodellen, wie z.B. endlicher Automat oder Turingmaschine, wird stets von zu verarbeitenden Worten über einem Alphabet $ E$ ausgegangen, die eine variable Länge haben, die auch nicht durch eine Maximallänge beschränkt sind.

Bei realen Rechnern ist dies nicht sinnvoll, da hier mit einer festen Wortlänge gearbeitet wird. Wir haben somit im Folgenden stets die generelle Voraussetzung, dass zum Alphabet $ E$ noch eine feste Konstante $ n$ hinzukommt, die die Wortlänge angibt.

Wichtige Zahlensysteme und Alphabete sind die $ b$-adischen Zahlensysteme, die für eine natürliche Zahl $ b$ auf dem Alphabet $ E_{b}=\{0,1,\dots,b-1\}$ basieren.

Beispiel 1.1

Wichtige $ b$-adische Zahlensysteme sind die folgenden:

a)

Dezimalsystem mit $ E_{10} = \{0, 1, 2, \ldots, 9 \}$, in dem wir im Allgemeinen rechnen. Eine feste Wortlänge erreichen wir hierbei, indem wir ggf. durch führende Nullen auffüllen. So ist z.B. $ 123$ bei einer Wortlänge $ n = 4$ als $ 0123$ darzustellen.

b)
Dual- oder Binärsystem mit $ E_2 = \{0, 1\}$, welches im Computer zum Einsatz kommt.
Oktalsystem mit $ E_8 = \{0, 1, \ldots, 7\}$.
Hexadezimalsystem mit $ E_{16} = \{0, 1, \ldots, 9, A, B, C, D, E, F\}$. Hierbei ist $ E_{16}$ eigentlich das Alphabet $ \{0, 1, \ldots, 9, 10, \ldots, 15 \}$, anstelle von Ziffern $ 10, 11, \ldots$ werden aber generell bei Alphabeten mit $ b > 9$ neue, einstellige Symbole eingeführt. Gerade diese Zweierpotenzbasen haben in der Informatik eine besondere Bedeutung.

Satz 1.2 (b-adische Darstellung von Fixpunktzahlen)

Sei $ b \in \mathbb{N}$ mit $ b > 1$. Dann ist jede Fixpunktzahl $ z$ (mit $ n$ Vorkomma- und $ k$ Nachkommastellen) mit $ 0 \leq z \cdot b^k \leq b^{n+k} - 1$ (und $ n\in \mathbb{N}$, $ k \in \mathbb{N}_0$) eindeutig als Wort der Länge $ n+k$ über $ E_b$ darstellbar durch

$\displaystyle z = \sum_{i=-k}^{n-1} z_i b^i, \hspace{0.5em}z_i \in \{ 0, 1, \ldots b-1 \}.$

Abkürzend wird $ z$ geschrieben als $ z = (z_{n-1}z_{n-2} \ldots z_0.z_{-1}z_{-2} \ldots z_{-k})_b$. Eine Einheit der letzten Stelle, also des Faktors von $ b^{-k}$, wird als 1 ulp (unit last place) bezeichnet, und gibt eine Genauigkeitsschranke des Fixpunktsystems an. Für $ k=0$ gilt obige Darstellung insbesondere für die natürlichen Zahlen $ \{0, 1, \ldots, b^{n} - 1 \}$.

Aufgrund von physikalischen Gegebenheiten (Spannung = 0V, oder Spannung $ >$ 0V bzw. Schwellwert) ist im Rechner das System für $ b=2$ sinnvoll. Häufig werden jedoch auch hier zur Darstellung Systeme mit $ b=8$ oder $ b=16$ verwendet, da hiermit kürzere und lesbarere Darstellungen erzielt werden. Auch arbeiten moderne Prozessoren in der Regel nicht auf Bitebene, sondern auf Wortebene, wobei hier Wortlängen von $ b=16$, $ 32$ oder $ 64$ anzutreffen sind.

Für natürliche Zahlen ist auch eine we9itere Zahldarstellung möglich, die polyadische Darstellung.

Satz 1.3 (Polyadische Darstellung natürlicher Zahlen)

Es sei

$\displaystyle (b_{n})_{n\in\mathbb{N}}$

eine Folge natürlicher Zahlen mit

$\displaystyle b_{n}>1$

für alle

$\displaystyle n\in\mathbb{N}$

. Dann gibt es für jede natürliche Zahl $ z$ genau eine Darstellung der Form
$ z=(z_{N}\ldots z_{0})_{PD}=\sum_{i=0}^{N}z_{i}\prod_{j=0}^{i-1}b_{j}=z_{0}+z_{1}b_{0}+z_{2}b_{1}b_{0}+\ldots+z_{N}b_{N-1}\ldots b_{0}$

mit $ 0\leq z_{i}<b_{i}$ für $ i=0,\ldots,N$ .

1 Komplementdarstellungen

Eine weitere wichtige Zahldarstellung beim Rechnereinsatz ist die Zweierkomplementdarstellung von Fixpunktzahlen, über die eine Darstellung negativer Zahlen möglich ist. Das Zweierkomplement und andere Komplementdarstellungen sind wie folgt definiert:

Definition 1.4 (Komplementdarstellungen für n+k Fixpunktzahlen)

Sei $ x=(x_{n-1}\ldots x_{0}.x_{-1}\ldots x_{-k})_{2}$ eine$ n+k$ Dualzahl in Fixpunktdarstellung.

a)
Das Einerkomplement $ K_{1}(x)$ ist definiert als

$\displaystyle K_{1}(x)=(\overline{x}_{n-1}\overline{x}_{n-2}\ldots\overline{x}_{0}.\overline{x}_{-1}\ldots\overline{x}_{-k})_{2}$

wobei $ x_{i}=0\leftrightarrow\overline{x}_{i}=1$, und $ \overline{\overline{x}} _{i}=x_{i}$.
b)
Das Zweierkomplement $ K_{2}(x)$ ist definiert als

$\displaystyle K_{2}(x)=(\overline{x}_{n-1}\overline{x}_{n-2}\ldots\overline{x}_{0}.\overline{x}_{-1}\ldots\overline{x}_{-k})_{2}+1\; ulp=K_{1}(x)+1\; ulp$ (modulo $\displaystyle 2^{n})$

.

Das Einerkomplement erhält man also durch Invertieren aller Bits, das Zweierkomplement durch anschließendes Addieren von einer Einheit zur letzten Stelle. Da hier ggf. ein Überlauf auftreten kann (Bitmuster nur mit Einsen), ist modulo $ 2^{n}$ zu rechnen. Analog zum Einer- und Zweikomplement lässt sich zu jeder beliebigen Basis $ b$ das $ (b-1)$- und $ b$-Komplement definieren als:

Definition 1.5 (b-Komplement für n+k Fixpunktzahlen)
Sei $ x=(x_{n-1}\ldots x_{0}.x_{-1}\ldots x_{-k})_{b}\in\{0,1,\ldots,b-1\}^{n+k}$ eine $ n+k$ Fixpunktdarstellung zur Basis $ b$ .

a)
Das $ (b-1)$-Komplement $ K_{b-1}(x)$ ist definiert als
$\displaystyle K_{b-1}(x)$ $\displaystyle =$ $\displaystyle (\tilde{x}_{n-1}\tilde{x}_{n-2}\ldots\tilde{x}_{0}.\tilde{x}_{-1}\ldots\tilde{x}_{-k})_{b},$  
$\displaystyle \tilde{x}_{i}$ $\displaystyle :=$ $\displaystyle (b-1)-x_{i},\hspace{0.5em}i=-k,\ldots,n-1.$  

b)
Das $ b$-Komplement $ K_{b}(x)$ ist definiert als

$\displaystyle K_{b}(x)=(\tilde{x}_{n-1}\tilde{x}_{n-2}\ldots\tilde{x}_{0}.\tilde{x}_{-1}\ldots\tilde{x}_{-k})_{b}+1\; ulp$

.

Für die Darstellung von ganzen Zahlen werden im Rechner im Allgemeinen Zweierkomplementdarstellungen (mit $ k=0$) verwendet. Bei Komplementdarstellungen ist aber zu beachten, dass diese generell bzgl. einer festen Wortlänge gebildet werden. Mit der Komplementdarstellung kann man nun negative Zahlen nach folgender Idee darstellen. Gehen wir von einer Registerlänge von $ n$ Bit aus, so gibt es $ N=2^{n}$ verschiedene Bitmuster.

Um Mehrdeutigkeiten auszuschließen, muss nun noch festgelegt werden, dass alle Darstellungen, deren höchstwertiges Bit gesetzt ist, als negative Zahlen interpretiert werden.

Beispiel 1.6

Bei einer Wortlänge von $ n = 8$ Bits lauten die Darstellungen von +92 und -92 im Einer- bzw. Zweierkomplement:

Komplement +92 -92
Einer-

Zur Ausführung einer Subtraktion der Form $ x - y$ ist im Zweierkomplement lediglich $ K_{2}(y)$ zu bestimmen und zu $ x$ zu addieren. Ein eventuell auftretender Übertrag wird ignoriert. Ist das höchstwertige Bit des Ergebnisses wieder gesetzt, so handelt es sich um einen negativen Wert, der ebenfalls im Zweierkomplement dargestellt ist.

Beispiel 1.7
Bei einer Wortlänge von $ n = 8$ Bits ist $ x - y$ mit $ x = 45$ und $ y = 92$ mit Hilfe der Zweierkomplement-Addition zu bestimmen:

$ 45 = 32 + 8 + 4 + 1$: $ (00101101)_2$
+ $ K_2(92)$:
mit $ z = x - y$. Rückumwandeln nach dem gleichen Algorithmus ergibt: $ \vert z\vert = (00101110)_2 + 1 = (00101111)_2 = 32 + 8 + 4 + 2 + 1 = 47$, somit ergibt sich das korrekte Ergebnis $ z = -47$.

2 Darstellung von Gleitpunktzahlen nach IEEE 754/854

Zum Abschluss des ersten Abschnittes wollen wir noch kurz auf die Darstellung ,,reeller`` Zahlen in Form einer Gleitkommadarstellung zur Basis $ b=2$ eingehen, wie sie im Standard IEEE 754/854 festgelegt ist. Eine Gleitkommazahl besteht dabei aus einem Vorzeichen $ S$, $ l$ Bit zur Darstellung des Exponenten und $ m$ Bit zur Darstellung der Mantisse. Die Mantisse ist dabei bei normalisierten Zahlen, die im Allgemeinen verwendet werden, so dargestellt, dass diese eine führende 1 vor dem Dezimalpunkt besitzt, die dann in der Regel nicht kodiert wird. Wir erhalten somit die folgende Darstellung für eine Gleitkommazahl:

Vorzeichen-Bit

\begin{displaymath}\begin{array}{rl}
\textrm{Exponentencharakteristik:} & E=(e_{...
...tisse:}} & M=(f_{m-1}f_{m-2}\ldots\ldots f_{1}f_{0})\end{array}\end{displaymath}

\begin{displaymath}\begin{array}{lll}
{\textrm{Dargestellte Zahl:}}\\
(-1)^{S}...
...} & E=2\cdot E_{Bias}+1, M\neq0 & \hbox{NotaNumber}\end{array}\end{displaymath}

Die Konstanten $ l$ und $ m$ bestimmen die Genauigkeit, der Summand $ E_{Bias}$ dient zur Verschiebung des Exponentenwertes, um auch negative Exponenten zu erhalten. Dadurch ist es nicht erforderlich, den Exponenten in Zweierkomplementdarstellung zu schreiben, was einen Vergleich von Exponenten erleichtert.

Die im IEEE Standard festgelegten Genauigkeiten ,,single`` und ,,double`` haben folgende Parameter:

Genauigkeit Exponent Mantisse $ E_{Bias}$ Bereich Stellen
single: 32 Bit

Beispiel 1.8
Die Darstellung der Zahl $ 1.0$ ergibt sich also in normalisierter Darstellung wie folgt:
single
$\displaystyle 1.0 = 1.0 * 2^0 = 1.0 * 2^{127-127}$ $\displaystyle \hat=$ $\displaystyle 0  \overbrace{01111111}^{\textrm{Exponent}}  \overbrace{00000000000000000000000}^{\textrm{Mantisse}}$  
  $\displaystyle =$ $\displaystyle \texttt{\$3F800000}$  

double
$\displaystyle 1.0 = 1.0 * 2^0 = 1.0 * 2^{1023-1023}$ $\displaystyle \hat=$ $\displaystyle 0  \overbrace{01111111111}^{\textrm{Exponent}}  \underbrace{\overbrace{00000...............00000}^{\textrm{Mantisse}}}_{\textrm{52 Nullen}}$  
  $\displaystyle =$ $\displaystyle \texttt{\$3FF0000000000000}$  

Bei dieser Zahl sieht man, dass die Mantisse identisch Null sein kann, ohne dass die Zahl selbst Null ist. Will man eine beliebige Zahl normalisiert darstellen, so transformiert man sie erst in den Bereich $ [1, 2)$ durch Division durch eine geeignete Zweierpotenz und verfährt wie unten gezeigt:
$\displaystyle 6.5$ $\displaystyle =$ $\displaystyle 2^2 \cdot 1.625$  
  $\displaystyle =$ $\displaystyle 2^2 \cdot \left( 1 + \frac{1}{2} + \frac{0}{2^2} + \frac{1}{2^3} \right)$  
  $\displaystyle =$ $\displaystyle 2^{129 - 127} \cdot \left( 1 + \frac{1}{2} + \frac{0}{2^2} + \frac{1}{2^3} \right)$  
  $\displaystyle =$ $\displaystyle 2^{129 - 127} \cdot \left( 1.101 \right)_2$  
  $\displaystyle \hat=$ $\displaystyle 0  10000001  10100000000000000000000$  
  $\displaystyle =$ $\displaystyle \texttt{\$40D00000}.$  

2 Boolesche Algebra

Von den angegebenen Beispielen $ b$-adischer Zahlsysteme ist der Fall $ b=2$ aus der Sicht der Schaltungstechnik ausgezeichnet. Diesen Fall kann man leicht als ,,wahr - falsch`` bzw. ,,Strom - kein Strom`` interpretieren. Wir wollen daher das Alphabet $ E_{2}=\{0,1\}$ mit $ B$ bezeichnen, in Erinnerung an den Mathematiker George Boole, der sich mit den folgenden Strukturen aus mathematischer Sicht beschäftigt hat.

Seien $ x,y\in B$. Erklärt man auf $ B$ die folgenden drei Verknüpfungen

$\displaystyle x\cup y$ $\displaystyle :=$ Max$\displaystyle (x,y),$  
$\displaystyle x\cap y$ $\displaystyle :=$ Min$\displaystyle (x,y),$  
$\displaystyle \overline{x}$ $\displaystyle :=$ $\displaystyle 1-x,$  

so ist $ (B,   \cup,   \cap,   \overline{\phantom{x}})$ eine Boolesche Algebra, d.h. ein distributiver, komplementärer Verband, in welchem es ein kleinstes (0) und ein größtes (1) Element gibt.

In einer Booleschen Algebra gelten die folgenden Gesetze:

  1. Kommutativgesetze: $ x\cup y=y\cup x,\hspace{0.5em}x\cap y=y\cap x$
  2. Assoziativgesetze: $ (x\cup y)\cup z=x\cup(y\cup z),\hspace{0.5em}(x\cap y)\cap z=x\cap(y\cap z)$
  3. Verschmelzungsgesetz: $ (x\cup y)\cap x=x,\hspace{0.5em}(x\cap y)\cup x=x$
  4. Distributivgesetze: $ x\cap(y\cup z)=(x\cap y)\cup(x\cap z),\hspace{0.5em}x\cup(y\cap z)=(x\cup y)\cap(x\cup z)$
  5. Komplementgesetz: $ x\cup(y\cap\overline{y})=x,\hspace{0.5em}x\cap(y\cup\overline{y})=x$
  6. $ x\cup0=x,\hspace{0.5em}x\cap0=0,\hspace{0.5em}x\cap1=x,\hspace{0.5em}x\cup1=1$
  7. de Morgansche Regeln: $ \overline{x\cup y}=\overline{x}\cap\overline{y},\hspace{0.5em}\overline{x\cap y}=\overline{x}\cup\overline{y}$
  8. $ x=x\cup x=x\cap x=\overline{\overline{x}}$

Satz 2.1
Für $ B = \{0, 1\}$ ist $ (B,   \cup,   \cap,   \overline{\phantom{x}})$ eine Boolesche Algebra.

BEWEISIDEE: Da $ B$ hier nur zwei Elemente besitzt, kann der Beweis über Wertetafeln erfolgen. Für die Regel c) erhält man dabei exemplarisch:

Argumente   linke Seite rechte Seite
x y $ x\cup y$ $ \left(x\cup y\right)\cap x$ x
0 0 0 0 0
0 1 1 0 0
1 0 1 1 1
1 1 1 1 1

Im Folgenden wollen wir die Verknüpfungen der Booleschen Algebra wie folgt identifizieren: Maximum $ \cup$ mit der Addition $ +$ (logisch OR) und Minimum $ \cap$ mit der Multiplikation $ \cdot$ (logisch AND).

Bemerkung:
Die Potenzmenge $ {\mathcal{P}}(A)$ einer Menge $ A$, d.h. die Menge aller Teilmengen von $ A$, ist mit den Mengenoperationen $ \cup$, $ \cap$ und $ \overline{\phantom{x}}={\mathcal{C}}=$Komplement, $ 0=\emptyset$, $ 1=A$, eine boolesche Algebra.

3 Schaltfunktionen

Definition 3.1
Eine Funktion $ F:B^{n}\to B^{m}$ heißt Schaltfunktion.

Eine totale Schaltfunktion $ F$ liefert zu den $ n$ Inputs (I) $ m$ eindeutige Outputs (O).

Beispiel 3.2
Die Addition von zwei 16 Bit Zahlen kann als Schaltfunktion mit einem Inputvektor der Länge 32 und einem Output der Länge 17 realisiert werden. Hierbei seien der Input $ b_1 b_2 \ldots b_{16} b_{17} \ldots b_{32}$, wobei die ersten 16 Bit den ersten Summanden, und die zweiten 16 Bit den zweiten Summanden bilden. Wir erhalten somit eine Schaltfunktion $ A: B^{32} \to B^{17}$. Analog kann man die Multiplikation als Schaltfunktion darstellen. Hier erhält man $ M: B^{32} \to B^{32}$ mit $ b_1 \ldots b_{16} b_{17} \ldots b_{32} \to c_1 \ldots c_{32}$.

Beispiel 3.3
Ein weiteres Beispiel ist das Sortieren von z.B. 30 Zahlen der Länge 16 Bit und Ausgabe der Zahlen in sortierter Reihenfolge. Hier erhält man eine Schaltfunktion $ S: B^{480} \to B^{480}$.

Einen wichtigen Spezialfall einer Schaltfunktion erhalten wir für $ m=1$:

Definition 3.4
Eine Schaltfunktion $ f: B^n \to B$ heißt $ n$-stellige Boolesche Funktion.

Mit dieser Definition können wir nun eine beliebige Schaltfunktion $ F:B^{n}\rightarrow B^{m}$ mit $ F(x_{1},\ldots,x_{n})=(y_{1},\ldots,y_{m})$ als Vektor von Booleschen Funktionen auffassen. Setzen wir für $ 1\leq i\leq m$ die Boolesche Funktion $ f_{i}:B^{n}\rightarrow B$ definiert durch

$ f_{i}(x_{1},\ldots,x_{n})=y_{i}$,

so ist $ F$ für alle $ x_{1},\ldots,x_{n}\in B$ darstellbar als

$ F(x_{1},\ldots,x_{n})=(f_{1}(x_{1},\ldots,x_{n}),f_{2}(x_{1},\ldots,x_{n}),\ldots,f_{m}(x_{1},\ldots,x_{n}))$.

Somit sind alle folgenden Betrachtungen für Boolesche Funktionen auch auf die einzelnen Komponenten einer beliebigen Schaltfunktion anwendbar.

Für $ n=1,2$ wollen wir in den Tabellen 2.2, 2.3 und 2.4 alle Booleschen Funktionen notieren.


 

$ x$ $ f_0(x)$ $ f_1(x)$ $ f_2(x)$ $ f_3(x)$
0




 

  (1) $ x \cdot \overline{x}$ $ x \cdot y$ $ x \cdot \overline{y}$ $ x$ $ \overline{x} \cdot y$ $ y$ $ \oplus$ $ x + y$



 

  (1) $ \overline{x+y}$ $ \overline{x \oplus y}$ $ \overline{y}$ $ x + \overline{y}$ $ \overline{x}$ $ \overline{x} + y$ $ \overline{x \cdot y}$ $ x + \overline{x}$

Einige der obigen Funktionen werden auch mit Namen versehen:
$ f_1$ Konjunktion (AND)
$ f_6$

Die in obigen Tabellen in der Zeile (1) verwendete Notation wird in Zusammenhang mit der Booleschen Algebra verwendet, die Notation (2) stammt aus der Arithmetik und (3) schließlich aus der Logik.

Hiermit haben wir gesehen, dass es für $ n=1$ vier verschiedene und für $ n=2$ sechzehn verschiedene Boolesche Funktionen gibt. Allgemein gilt:

Satz 3.5
Für jedes $ n\in \mathbb{N}$ gibt es $ 2^{2^n}$ verschiedene Boolesche Funktionen $ f: B^n \to B$.

BEWEISIDEE: Für $ n$ Argumente existieren insgesamt $ 2^{n}$ verschiedene Belegungen. Für jede Belegung wiederum kommen die Funktionswerte 0 und $ 1$ vor, als insgesamt $ 2^{2^{n}}$ verschiedene Funktionen.

Für Schaltfunktionen gilt allgemeiner:

Satz 3.6

Für $ m,n\in\mathbb{N}$ gibt es $ 2^{m2^{n}}$ verschiedene Schaltfunktionen $ f:B^{n}\to B^{m}$.

Um eine systematische Darstellung Boolescher Funktionen zu erhalten, wollen wir im Folgenden einige Normalformen diskutieren. Hierzu benötigen wird die folgenden Begriffe:

Definition 3.7
Sei $ i=(i_{1}i_{2}\ldots i_{n})_{2}$ von $ i$. $ i$ heißt genau dann einschlägiger Index zu $ f:B^{n}\to B$, wenn $ f(i_{1},i_{2},\ldots,i_{n})=1$ gilt.

Definition 3.8

Sei $ i=(i_{1}i_{2}\ldots i_{n})_{2}$ eines Index $ i$, $ f:B^{n}\to B$ Boolesche Funktion. Dann heißt die Funktion $ m_{i}:B^{n}\to B$ definiert durch $ m_{i}(x_{1},x_{2},\ldots,x_{n}):=x_{1}^{i_{1}}x_{2}^{i_{2}}\ldots x_{n}^{i_{n}}$ $ i$-ter Minterm von $ f$. Hierbei ist $ x_{j}^{i_{j}}:=\left\{ \begin{array}{cc}
x_{j} & \textrm{falls}  i_{j}=1\\
\overline{{x_{j}}} & \textrm{falls}  i_{j}=0\end{array}\right.$. d.h. es gilt $ m_{i}=1$ genau dann, wenn das Argument die duale Codierung von $ i$ ist.

Beispiel 3.9

Gegeben sei die folgende Boolesche Funktion $ f:B^{3}\to B$ durch die Wertetabelle

$ i$

$ x_1$

$ x_2$

$ x_3$

$ f(x_1,x_2,x_3)$

0

Die einschlägigen Indizes sind somit 3, 5, 7. Die zugehörigen Minterme sind $ m_{3}(x_{1},x_{2},x_{3})=\overline{x}_{1}x_{2}x_{3}$, $ m_{5}(x_{1},x_{2},x_{3})=x_{1}\overline{x}_{2}x_{3}$ und
$ m_{7}(x_{1},x_{2},x_{3})=x_{1}x_{2}x_{3}$.

Satz 3.10 (Darstellungssatz für Boolesche Funktionen)
Jede Boolesche Funktion $ f:B^{n}\to B$ ist eindeutig darstellbar als Summe der Minterme ihrer einschlägigen Indizes, d.h ist $ I\subseteq\{0,1,\ldots,2^{n}-1\}$ die Menge der einschlägigen Indizes zu $ f$, so gilt:

$\displaystyle f=\sum_{i\in I}m_{i}.$

Keine andere Mintermsumme stellt $ f$ dar.

BEWEIS:

Zu zeigen ist: (1) Existenz einer solchen Darstellung und (2) die Eindeutigkeit.

1)
Existenz
Wir zeigen, dass die Funktionen $ f$ und $ \sum_{i\in I}m_{i}$ für alle Argumente den gleichen Funktionswert besitzen. Sei dazu $ j\in\{0,1,\ldots,2^{n}-1\}$ und $ (j_{1}j_{2}\ldots j_{n})_{2}$ dessen . Betrachte die Fälle

a)
$ f(j_{1},\ldots,j_{n})=1$. Hieraus folgt, $ j$ ist einschlägiger Index von $ f$ und somit $ j\in I$. Daher ist $ \sum_{i\in I}m_{i}=1$.

b)
$ f(j_{1},\ldots,j_{n})=0$. Hieraus folgt, $ j$ ist kein einschlägier Index von $ f$ und somit $ j\not\in I$. Dann kommt $ j$ aber nicht in der Indexmenge bei der Summation von $ \sum_{i\in I}m_{i}$ vor, aber nur der Minterm $ m_{j}$ liefert für das Argument $ j$ den Wert 1, womit also in diesem Fall $ \sum_{i\in I}m_{i}=0$ gilt.
Damit stellen sowohl $ f$ als auch $ \sum_{i\in I}m_{i}$ dieselbe Funktion dar und die Existenz ist gezeigt.

2)
Eindeutigkeit
Angenommen, es existieren zwei verschiedene Darstellungen als Summe von Mintermen für eine Funktion $ f$, d.h. es existieren zwei Indexmengen $ I,J\subseteq\{0,1,\ldots,2^{n}-1\}$, mit $ I\neq J$ und

$\displaystyle f=\sum_{i\in I}m_{i}=\sum_{j\in J}m_{j}$

. Wegen $ I\neq J$ existiert dann mindestens ein Index $ k$, der o.B.d.A. in $ I$ enthalten ist aber nicht in $ J$. Sei $ (k_{1}\ldots k_{n})_{2}$ dessen . Dann gilt:
$\displaystyle \sum_{i\in I}m_{i}(k_{1}\ldots k_{n})$ $\displaystyle =$ $\displaystyle 1,$   da$\displaystyle \hspace{0.5em}k\in I,$  
$\displaystyle \sum_{j\in J}m_{j}(k_{1}\ldots k_{n})$ $\displaystyle =$ $\displaystyle 0,$   da$\displaystyle \hspace{0.5em}k\not\in J.$  

Somit stellen beide Darstellungen verschiedene Funktionen dar, im Widerspruch zur Annahme.

Also ist gezeigt, dass die Darstellung als Summe von Mintermen eindeutig ist, womit die nächste Definition gerechtfertigt ist.

$ \Box$

Definition 3.11

Die eindeutige Darstellung einer Booleschen Funktion als Mintermsumme laut Satz subsec:IEEE heißt disjunktive Normalform (DNF) einer Booleschen Funktion $ f$.

Bemerkung:
Wir halten an dieser Stelle bereits fest, dass in einer DNF Darstellung einer Funktion höchstens ein Summand gleich 1 ist.

Beispiel 3.12

Im Fall unseres Beispiels 2.3.9 ergibt sich die DNF:

$\displaystyle f(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle m_{3}+m_{5}+m_{7}=\overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}+x_{1}x_{2}x_{3}$  

.

Aus der Darstellung einer Booleschen Funktion in DNF lässt sich unmittelbar folgern, dass jede Boolesche Funktion durch die zweistelligen Grundfunktionen $ +$ und $ \cdot$ sowie mit der einstelligen Funktion $ \overline{\phantom{x}}$ dargestellt werden kann. Eine solcher Satz ausgezeichneter Funktionen hat eine besondere Bezeichnung:

Definition 3.13
Ein System $ {\mathcal{B}} = \{f_1, \ldots, f_n\}$ Boolescher Funktionen heißt (funktional) vollständig, wenn jede Boolesche Funktion allein durch Einsetzungen bzw. Kompositionen von Funktionen aus $ {\mathcal{B}}$ dargestellt werden kann.

Es gilt:

Satz 3.14
a)
$ \{+, \cdot, \overline{\phantom{x}}\}$ ist funktional vollständig.
b)
$ \{+, \overline{\phantom{x}}\}$ ist funktional vollständig.
c)
$ \{\cdot, \overline{\phantom{x}}\}$ ist funktional vollständig.
d)
$ \{$NAND$ \}$ ist funktional vollständig.
e)
$ \{$NOR$ \}$ ist funktional vollständig.

Der Beweis zu a) ergibt sich aus der Darstellung als DNF, b) - e) verbleiben als Übung.

Eine zweite Normalform, die sozusagen ,,dual`` zur DNF ist, ergibt sich wie folgt.

Definition 3.15

Sei $ i$ ein Index von $ f:B^{n}\to B$, und sei $ m_{i}$ der $ i$-te Minterm von $ f$. Dann heißt die Funktion

$\displaystyle M_{i}:B^{n}\to B$

, definiert durch

$\displaystyle M_{i}(x_{1},\ldots,x_{n}):=\overline{m_{i}(x_{1},\ldots,x_{n})}=x_{1}^{\overline{i_{1}}}+\ldots+x_{n}^{\overline{i_{n}}}$

$ i$-ter Maxterm von $ f$.

Beispiel 3.16

Im Fall des Minterms $ m_{3}$ aus obigem Beispiel ergibt sich also

$\displaystyle M_{3}(x_{1},x_{2},x_{3})=\overline{m_{3}(x_{1},x_{2},x_{3})}=\overline{\overline{x}_{1}x_{2}x_{3}}=x_{1}+\overline{x_{2}}+\overline{x_{3}}$

.

In Analogie zu Mintermen gilt dann: Ein Maxterm $ M_{i}$ nimmt genau dann den Wert 0 an, wenn das Argument $ (x_{1}\ldots x_{n})$ die von $ i$ ist.

Dies liefert den folgenden Satz:

Satz 3.17
a)
Jede Boolesche Funktion $ f: B^n \to B$ ist eindeutig darstellbar als Produkt der Maxterme ihrer nicht einschlägigen Indizes. Diese Darstellung heißt konjunktive Normalform (KNF) von $ f$.
b)
Es gilt: KNF $ (f) = \overline{\text{DNF}(\overline{f})}$.

Beispiel 3.18
Die konjunktive Normalform für unser Beispiel 2.3.9 ergibt sich somit als:
$\displaystyle f(x_1, x_2, x_3)$ $\displaystyle =$ $\displaystyle M_0 \cdot M_1 \cdot M_2 \cdot M_4 \cdot M_6$  
  $\displaystyle =$ $\displaystyle (x_1 + x_2 + x_3) \cdot (x_1 + x_2 + \overline{x}_3) \cdot (x_1 + \overline{x}_2 + x_3)$  
    $\displaystyle \cdot (\overline{x}_1 + x_2 + x_3) \cdot (\overline{x}_1 + \overline{x}_2 + x_3).$  

Beim Vergleich dieser Darstellung mit der DNF sieht man, dass eine DNF dann zu bevorzugen ist, wenn die Anzahl der einschlägigen Indizes geringer ist als die der nicht einschlägigen. Im anderen Fall liefert die KNF eine Darstellung mit weniger Operationen. Wie wir später sehen werden, können die Normalformen durch verschiedene Algorithmen in kostengünstigere verkürzte Formen transformiert werden. Die Normalformen eignen sich in der Regel zu Beweisen oder als Ausgangspunkt verschiedener Algorithmen sehr gut, da man hier z.B. Aussagen über die Häufigkeit der Einssummanden oder Nullfaktoren machen kann.

Bemerkung:
In der Literatur werden die Darstellungen, die wir im ersten Kapitel ''konjunktive'' bzw. ''disjunktive Form'' genannt haben, häufig mit dem Begriff ''konjunktive'' bzw. ''disjunktive Normalform'' bezeichnet. Um Missverständnissen vorzubeugen, haben wir die verschiedenen Bezeichnungen gewählt.

4 Schaltnetze

Im letzten Abschnitt haben wir gesehen, dass Boolesche Funktionen über die DNF dargestellt werden können, und dass hieraus vollständige Funktionensysteme für Boolesche Funktionen resultierten. Führen wir für diese Grundfunktionen spezielle Schaltsymbole ein, so kann man aus den Normalformen Schaltnetze entwickeln. Wir wollen hier die folgenden, nach der neuen deutschen DIN-Norm gültigen, Schaltsymbole benutzen:

\includegraphics[]{Symbole.eps}

Hieraus lässt sich für unser Beispiel

$ f(x_{1},x_{2},x_{3})=\bar{x_{1}}x_{2}x_{3}+x_{1}\bar{x_{2}}x_{3}+x_{1}x_{2}x_{3}$

folgendes Schaltnetz entwickeln:

\includegraphics[]{Bsp1.eps}

In verschiedenen Literaturstellen werden die Negatoren sofort am Eingang eines Gatters platziert, dies vereinfacht zwar das Schaltbild, ist technisch aber nicht sinnvoll, da es keine Gatterbausteine gibt, die negierte Eingänge haben. Auch werden häufig Gatter mit mehr als zwei Inputs verwendet. In gewissem Maße ist dies auch technisch sinnvoll, da z.B. Gatter mit drei, vier oder acht Eingängen existieren. Soll ein Schaltnetz tatsächlich mit logischen Bausteinen verdrahtet werden, so sollte man bereits beim Entwurf darauf achten, dass man nur Gatter benutzt, die auch als Bausteine verfügbar sind.

Wie obige Schaltung zeigt, benötigt eine Boolesche Funktion mit drei Variablen bei der Verschaltung der DNF und Verwendung von Gattern mit lediglich zwei Inputs ein Schaltnetz mit fünf Stufen, wenn mindestens eine negierte Variable und mehr als zwei Summanden vorkommen. In diesem Fall entspricht also jeder Operator der DNF einem Gatter des Schaltnetzes und wir erhalten so im Beispiel insgesamt 10 Gatter. Fasst man das Schaltnetz nun als Black-Box mit drei Eingängen und einem Ausgang auf, so hängt die Verzögerungszeit zwischen Anlegen der Inputs und zur Verfügung stehen des Outputs von den verwendeten Gattern und insbesondere von der Anzahl der Schaltungsstufen ab.

Auf heute üblichen hochintegrierten Schaltungen befinden sich große Anzahlen von Gattern und Verbindungsleitungen auf einer sehr kleinen Fläche. Beim Entwurf sind hier eine Reihe von Beschränkungen zu berücksichtigen:

1)
Geschwindigkeit:
Jedes Gatter hat, wie bereits erwähnt, eine gewisse Verzögerungszeit, die im Bereich von wenigen Picosekunden liegt. Die Verzögerung eines komplexen Schaltnetzes hängt somit stark von der Anzahl der notwendigen Stufen der Schaltung ab. Generell sollte man versuchen, Schaltungen mit möglichst wenigen Stufen zu realisieren (vgl. Vereinfachungsmethoden für Boolesche Funktionen im nächsten Kapitel).
2)
Größe:
Die Herstellungskosten eines Chips sind proportional zur Anzahl der verwendeten Gatter. Somit ist aus diesem Grund eine möglichst geringe Anzahl von Gattern erstrebenswert. Hierdurch wird auch im Allgemeinen die Anzahl der Stufen und damit die Geschwindigkeit beeinflusst. Auch benötigt ein großer Chip ggf. längere Verbindungen zwischen einzelnen Chipteilen. Aufgrund der Lichtgeschwindigkeit kann ein Signal etwa 0.3 mm/psec zurücklegen, so dass es auch hierdurch zu Schaltverzögerungen kommen kann. Hinzu kommt, dass bei wachsender Chipfläche auch die Wahrscheinlichkeit eines Produktionsfehlers steigt.
3)
Fan-In/Out:
Die Anzahl der Inputs, mit denen ein Output (oder die Anzahl der Auffächerungen eines Eingangs) verbunden ist, wird Fan-Out genannt. Analog heißt die Anzahl der Inputs eines Gatters Fan-In. Gatter mit hohem Fan-In und/oder hohem Fan-Out sind im Allgemeinen langsamer als solche mit einer geringeren Zahl. Zu bevorzugen sind also Schaltungen mit möglichst geringem Fan-In/Out. Zudem kann es vorkommen, dass bei zu hohem Fan-Out zusätzliche Treiberschaltungen verwendet werden müssen.
Die genannten Entwurfsziele sind im Allgemeinen nicht alle gleichzeitig voll zu erfüllen, so muss man bei jeder Schaltung abwägen, welches das wichtigere Optimierungskriterium ist.

Formal kann ein Schaltnetz wie folgt über einen Graphen definiert werden:

Sei $ P$ eine endliche Punktmenge, o.B.d.A. gelte $ P\subseteq\mathbb{N}$. Ist $ K\subseteq P\times P$ eine symmetrische, nicht-reflexive Relation über $ P$ (d.h. mit $ (x,y)$ ist auch $ (y,x)$ aber nicht $ (x,x)$ aus $ K$), so heißt das Paar $ G:=(P,K)$ ein Graph mit der Punktmenge $ P$ und der Kantenmenge $ K$. Bei einem ungerichteten Graphen werden im Allgemeinen zwei zueinander inverse Kanten $ (p_{i},p_{j})$ und $ (p_{j},p_{i})$ zu einer ungerichteten Kante $ \{ p_{i},p_{j}\}$ zusammengefasst. Ein $ n$-Tupel $ w=(p_{1},p_{2},\ldots,p_{n})$ von Punkten aus $ P$ heißt dann Weg in $ G$, falls für alle $ i=1,\ldots,n-1$ die ungerichtete Kante $ \{ p_{i},p_{i+1}\}$ zu $ G$ gehört. Ist $ p_{1}=p_{n}$, so sprechen wir von einem Kreis oder Zykel. Lassen wir für $ K$ eine beliebige Relation zu (nicht notwendig symmetrisch) und geben den Kanten somit eine Richtung, so sprechen wir von einem gerichteten Graphen.

Definition 4.1
Ein Schaltnetz ist ein gerichteter zykelfreier Graph (engl. Directed Acyclic Graph, kurz DAG). Als Input (des Schaltnetzes) bezeichnet man die Punkte des DAG, in die keine Kante hineinführt, und entsprechend als Output die Punkte, aus denen keine Kante herausführt.

Erstellt man zu einem Schaltnetz den zugehörigen DAG, so erhält man ein Verbindungsnetz, fügt man an den Knoten zusätzlich die entsprechenden Bezeichnugen der Booleschen Verknüpfungen an, so erhält man ein Operator-Schaltnetz. Als Operator-Schaltnetz zu unserem Beispiel erhalten wir dann:

\includegraphics[]{DAG1.eps}

Für einen DAG, und somit auch für Schaltnetze, gilt folgender Satz:

Satz 4.2
Jeder nichtleere DAG mit endlich vielen Punkten hat mindestens einen Input und einen Output.

BEWEIS: Sei $ G = (P, K)$ ein DAG mit $ P \neq \emptyset$, $ \vert P\vert < \infty$. Angenommen, $ G$ hat keinen Input. Sei dann $ p_1$ ein beliebiger Punkt von $ G$. Dann hat $ p_1$ mindestens einen Vorgänger $ p_2$ und dieser wiederum einen Vorgänger $ p_3$ usw. Da $ G$ endlich ist und laut Annahme in jeden Punkt mindestens eine Kante hineinführt, gilt irgendwann $ p_i = p_j$ mit $ i < j$, d.h. es liegt ein Zykel vor, im Widerspruch zur Annahme der Zykelfreiheit. Analog argumentiert man mit den Nachfolgeknoten um zu zeigen, dass mindestens ein Output existiert.$ \Box$

5 Ringsummennormalform

Wir wollen nun, neben den bereits oben angegebenen vollständigen Systemen, zwei weitere vollständige Systeme, und hierauf basierende Normalformen kennenlernen. Eines dieser beiden Systeme wird dann auch ohne Negationen auskommen.

Wir gehen hierzu davon aus, dass für eine Boolesche Funktion $ f:B^{n}\rightarrow B$ die Menge der einschlägigen Indizes mit $ I$ bezeichnet sei, und dass somit $ f$ in der DNF als

$\displaystyle f=\sum_{i\in I}m_{i}$

gegeben ist. In dieser Darstellung ist also immer höchstens ein Summand gleich 1. Hieraus resultiert der folgende Satz:

Satz 5.1 (Ringsummennormalform, RNF)

Sei $ f:B^{n}\to B$ und $ I=\{\alpha_{1},\ldots,\alpha_{k}\}$ die Menge der einschlägigen Indizes zu $ f$. Dann gilt:

$\displaystyle f=m_{\alpha_{1}}\oplus m_{\alpha_{2}}\oplus\ldots\oplus m_{\alpha_{k}}.$

Der Operator $ \oplus$ bezeichnet die XOR-Verknüpfung, die auch als Antivalenz, Addition modulo 2 oder Ringsumme bezeichnet wird.

BEWEIS: $ f$ liege in DNF vor, d.h.

$\displaystyle f=\sum_{i=1}^{k}m_{\alpha_{i}}.$

Sei $ x\in B^{n}$. Dann sind zwei Fälle zu unterscheiden:

  1. [1.)] $ f(x)=0\Rightarrow$ alle Summanden in der DNF und RNF sind gleich 0.
  2. [2.)] $ f(x)=1\Rightarrow$ genau ein Summand in der DNF ist gleich 1, nämlich derjenige, dessen Index $ \alpha_{i}$ die Darstellung von $ x$ hat.
Hieraus folgt die Behauptung, da eine Ringsumme genau dann 1 ist, wenn eine ungerade Anzahl von Summanden gleich 1 ist, was im zweiten Fall zutrifft.
$ \Box$

Der folgende Satz stellt einige Eigenschaften der Ringsumme zusammen, die uns dann erlauben werden, ein komplementfreies vollständiges System anzugeben.

Satz 5.2
Für alle $ x,y,z\in B$ gilt:

  1. [a)] $ x\oplus1=\overline{x},\hspace{0.5em}x\oplus0=x$
  2. [b)] $ x\oplus x=0,\hspace{0.5em}x\oplus\overline{x}=1$
  3. [c)] $ x\oplus y=y\oplus x$ (Kommutativität)
  4. [d)] $ x\oplus(y\oplus z)=(x\oplus y)\oplus z$ (Assoziativität)
  5. [e)] $ x\cdot(y\oplus z)=x\cdot y\oplus x\cdot z$ (Distributivität bzgl. $ \cdot$)
  6. [f)] $ 0\oplus0\oplus\ldots\oplus0=0$
  7. [g)] $ \underbrace{1\oplus1\oplus\ldots\oplus1}_{n-\mbox{mal}}=\left\{ \begin{array}...
...falls }n\mbox{ ungerade}\\
0 & \mbox{falls }n\mbox{ gerade}\end{array}\right.$

Satz 5.3 (Komplementfreie Ringsummennormalform, Reed-Muller Form)
Jede Boolesche Funktion $ f:B^{n}\to B$ ist eindeutig darstellbar als Polynom (Multinom) in den Variablen $ x_{1},x_{2},\ldots,x_{n}$ mit den Koeffizienten $ a_{0},a_{1},\ldots,a_{1\ldots n}\in B$. Die Darstellung ist wie folgt:
$\displaystyle f$ $\displaystyle =$ $\displaystyle a_{0}$  
    $\displaystyle \oplus\; a_{1}x_{1}\oplus a_{2}x_{2}\oplus\ldots\oplus a_{n}x_{n}$  
    $\displaystyle \oplus\; a_{12}x_{1}x_{2}\oplus\ldots\oplus a_{n-1,n}x_{n-1}x_{n}$  
    $\displaystyle \qquad\vdots$  
    $\displaystyle \oplus\; a_{1\ldots n}x_{1}x_{2}\cdot\ldots\cdot x_{n}.$  

BEWEIS:

Für einen Beweis ist zum einen die Existenz und zum anderen die Eindeutigkeit zu zeigen. Die Existenz ergibt sich aus der Konstruktion der Normalform (vgl. folgendes Beispiel). Hierbei werden zuerst in der DNF alle Summen durch Ringsummen ersetzt. Im zweiten Schritt werden alle Literale $ \overline{x}_{i}$ durch $ x_{i}\oplus1$ ersetzt, der resultierende Ausdruck ausmultipliziert, und jeweils gleiche Terme zusammengefasst (jeweils zwei gleiche Terme ergeben in der Summe 0).

Zum Beweis der Eindeutigkeit überlegt man sich, dass ein Polynom $ 2^{n}$ verschiedene Summanden haben kann, es also $ 2^{2^{n}}$ verschiedene Polynome gibt, genauso viel, wie Boolesche Funktionen existieren.$ \Box$

Beispiel 5.4

Die Umwandlung einer DNF in eine komplementfreie Ringsummenform ergibt sich für unser Beispiel wie folgt:


$\displaystyle f(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}+x_{1}x_{2}x_{3}$  
  $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}\oplus x_{1}\overline{x}_{2}x_{3}\oplus x_{1}x_{2}x_{3}$  
  $\displaystyle =$ $\displaystyle (x_{1}\oplus1)x_{2}x_{3}\oplus x_{1}(x_{2}\oplus1)x_{3}\oplus x_{1}x_{2}x_{3}$  
  $\displaystyle =$ $\displaystyle x_{1}x_{2}x_{3}\oplus x_{2}x_{3}\oplus x_{1}x_{2}x_{3}\oplus x_{1}x_{3}\oplus x_{1}x_{2}x_{3}$  
  $\displaystyle =$ $\displaystyle x_{2}x_{3}\oplus x_{1}x_{3}\oplus x_{1}x_{2}x_{3}.$  

Also gilt:

$\displaystyle a_{0}=a_{1}=a_{2}=a_{3}=a_{12}=0;\hspace{0.5em}a_{13}=a_{23}=a_{123}=1.$

Als unmittelbare Folgerung der beiden letzten Sätze ergibt sich der folgende Satz:

Satz 5.5

[a)] $ \{\oplus,\cdot,\overline{\phantom{x}}\}$ ist funktional vollständig.

[b)] $ \{\oplus,\cdot,1\}$ ist funktional vollständig.

3 Schaltnetze und ihre Optimierung

1 Beispiele für Schaltnetze

In diesem ersten Abschnitt wollen wir aus den bisher bekannten Grundbausteinen neue, größere Einheiten spezieller Schaltnetze entwickeln, die wir in Form einer Black-Box im weiteren verwenden wollen.

Wie wir bereits im Satz 2.3.14 d) und e) gesehen haben, sind die Systeme $ \{$NAND$ \}$ und $ \{$NOR$ \}$ funktional vollständig. Es ist daher möglich, jede Boolesche Funktion mit Hilfe dieser Grundfunktionen, die auch als eigenständige Gatter existieren, zu realisieren. Eine weitere Grundfunktion war das XOR. Diese Funktion wollen wir nun als Schaltnetz mittels des Systems $ \{+, \cdot, \overline{\phantom{x}}\}$ darstellen. Als Wertetabelle der XOR Funktion ergab sich:

\begin{displaymath}
\begin{array}{cc\vert c}
x & y & x\oplus y\\
\hline 0 & 0 & 0\\
0 & 1 & 1\\
1 & 0 & 1\\
1 & 1 & 0\end{array}\end{displaymath}

woraus sich als DNF unmittelbar die Darstellung $ x\oplus y=\overline{x}y+x\overline{y}$ ergibt.

\includegraphics[%%
width=0.75\textwidth,
keepaspectratio]{exor.eps}

Mittels NAND-Verknüpfungen stellt sich die XOR-Funktion wie folgt dar:

$\displaystyle x\oplus y=x\overline{y}+y\overline{x}=\overline{\overline{x\overl...
...line{x(\overline{x}+\overline{y})}\cdot\overline{y(\overline{x}+\overline{y})}}$ $\displaystyle =$ $\displaystyle \overline{\overline{x\cdot\overline{xy}}\cdot\overline{y\cdot\overline{xy}}}$ (4 Gatter)  
  $\displaystyle =$ $\displaystyle \overline{\overline{x\overline{yy}}\cdot\overline{y\overline{xx}}}$ (5 Gatter)  

Dies ergibt das folgende Schaltnetz:

\includegraphics[%%
width=0.75\textwidth,
keepaspectratio]{exor2.eps}

Realisierung der XOR-Funktion mittels NAND-Gattern

Weitere Beispiele für grundlegende Schaltnetze ergeben sich aus der Addition von binären Zahlen. Ist die Aufgabe der Addition von zwei $ n$-stelligen Dualzahlen zu lösen, so kann dies als eine Schaltfunktion $ f:B^{2*n}\rightarrow B^{n+1}$ gelöst werden. Nehmen wir einmal als realistischen Wert $ n=16$ an, so hätte die resultierende Funktion $ f:B^{32}\rightarrow B^{17}$ eine Funktionstafel mit ca. $ 3.4\cdot10^{10}$ Termen. Die Reduktion einer solchen Funktion bedarf exponentieller Laufzeit (Bestimmung einer kostengünstigsten Darstellung ist NP-vollständig). Aus diesem Grund kommen üblicherweise andere Ansätze zur Anwendung. Eine Möglichkeit besteht darin, ein Schaltnetz zu designen, welches nur zwei Bit ggf. unter Berücksichtigung eines Übertrages aus der nächstniedrigeren Stelle addiert. Ein solches Schaltnetz kann mit Hilfe der XOR-Funktion (Addition modulo 2) realisiert werden. Wir wollen dies in zwei Stufen tun.

2 Vereinfachung von Schaltnetzen

Zur Vereinfachung von Schaltnetzen können wir die Resolutionsregel der Schaltalgebra anwenden. Diese besagt, das zwei Summanden einer disjunktiven Form, die sich genau in einer Variablen unterscheiden, also in einem Summand kommt $ x_{i}$, im anderen $ \bar{x_{i}}$ vor, und alle anderen Variablen sind gleich, durch ihren gemeinsamen Teil ersetzt werden können, z.B. $ x_{1}x_{2}\overline{x}_{3}+x_{1}\overline{x}_{2}\overline{x}_{3}=x_{1}\overline{x}_{3}$. Die Resolutionsregel darf auf einen Summanden einer disjunktiven Form mehrfach angewendet werden, da $ x+x=x$ gilt und somit eine Doppelung von Summanden möglich ist. Die Regel kann auch iteriert werden, d.h.ãuf die Ergebnisse der ersten Resolution kann wieder die Resolution angewendet werden, falls ein entsprechender Partnerterm existiert.

Beispiel 2.1

Für die Boolesche Funktion $ f:B^{4}\to B$ mit

$\displaystyle f(x_{1},x_{2},x_{3},x_{4})=x_{1}\overline{x}_{2}x_{3}x_{4}+x_{1}\...
...overline{x}_{2}\overline{x}_{3}x_{4}+\overline{x}_{1}\overline{x}_{2}x_{3}x_{4}$

ergibt sich die folgende Vereinfachung:

% latex2html id marker 19171
\includegraphics[%%
scale=0.9]{bsp221.eps}

1 Das Verfahren von Karnaugh

Die Resolution werden wir im Folgenden verwenden, um zwei systematische Verfahren zur Vereinfachung Boolescher Schaltfunktionen in DNF kennenzulernen. Das erste Verfahren ist hauptsächlich für den Fall einer Funktion $ f:B^{n}\rightarrow B$ mit $ n\in\{3,4\}$ gedacht:

Definition 2.2 (Karnaugh-Diagramm)
Ein Karnaugh Diagramm zu $ f:B^{n}\to B$ mit $ n\in\{3,4\}$ ist eine graphische Darstellung der Funktionstabelle von $ f$ durch eine $ 0-1$-Matrix der Größe $ 2\times4$ für $ n=3$ bzw. $ 4\times4$ für $ n = 4$, deren Zeilen mit den möglichen Belegungen von $ x_{1}$ $ (n=3)$ bzw. $ x_{1}x_{2}$ $ (n=4)$ und deren Spalten mit den Belegungen von $ x_{2}x_{3}$ $ (n=3)$ bzw. $ x_{3}x_{4}$ $ (n=4)$ beschriftet sind. Die Reihenfolge der Beschriftung erfolgt dabei so, dass sich zwei zyklisch benachbarte Zeilen bzw. Spalten in genau einer Komponente unterscheiden:

\includegraphics[]{Karna3_4.eps}

Das Karnaughverfahren arbeitet dann wie folgt:

Beispiel 2.3

Auf unser obiges Beispiel angewendet ergibt sich also folgendes Karnaugh-Diagramm:

\includegraphics[]{Bsp222.eps}

Wir erhalten also

$\displaystyle f(x_{1},x_{2},x_{3},x_{4})=\overline{x}_{2}x_{4}+x_{1}x_{3}x_{4}.$

Beispiel 2.4

Für die Boolesche Funktion $ f:B^{4}\to B$ mit

$\displaystyle f(x_{1},x_{2},x_{3},x_{4})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}\overline{x}_{2}\overline{x}_{3}\overline{x}_{4}+...
..._{4}+\overline{x}_{1}x_{2}\overline{x}_{3}x_{4}+x_{1}x_{2}\overline{x}_{3}x_{4}$ (1)
    $\displaystyle +\overline{x}_{1}x_{2}x_{3}x_{4}+x_{1}x_{2}x_{3}x_{4}+\overline{x...
...overline{x}_{2}x_{3}\overline{x}_{4}+x_{1}\overline{x}_{2}x_{3}\overline{x}_{4}$ (2)

lassen sich alle Einsen durch zwei Viererblöcke überdecken, so dass sich folgende Vereinfachung der Funktion ergibt:

% latex2html id marker 19247
\includegraphics[]{bsp223.eps}

$\displaystyle f(x_{1},x_{2},x_{3},x_{4})=x_{2}x_{4}+\overline{x}_{2}\overline{x}_{4}$

Es ist jedoch nicht immer notwendig (oder sinnvoll), die größten möglichen Blöcke auszuwählen. Betrachten wir dazu das Karnaugh-Diagramm in folgendem Beispiel

% latex2html id marker 19250
\includegraphics[]{bsp224.eps}

Wählen wir den maximalen Viererblock in der Mitte des Diagramms, so bleiben die vier isolierten Einsen übrig. Überdecken wir diese durch einen Einerblock, so ergibt sich insgesamt eine Darstellung mit vier Termen von vier Variablen und einem Term mit zwei Variablen. Die isolierten Einsen können wir jedoch unter Verwendung der bereits durch den Vierblock überdeckten Einsen zu Zweierblöcken zusammenfassen, was zu vier Termen mit drei Variablen führen würde. Jetzt ist aber die Überdeckung mit dem Vierblock überflüssig, da die vier Einsen ja bereits durch die Zweierblöcke erfasst sind. Insgesamt wäre durch die zweite Alternative eine kostengünstigere Darstellung erreicht. Als Fazit halten wir hier fest, dass es nicht immer notwendigerweise die beste Lösung ist, alle maximalen Blöcke zu verwenden.

Bei der Verwendung der Karnaugh-Diagramme ist ein weiterer Umstand ggf. vorteilhaft auszunutzen. Bisher waren wir nämlich davon ausgegangen, dass die darzustellende Funktion total ist, d.h. für alle möglichen $ 2^{n}$ Eingaben (bei $ n$ Variablen) ist die Ausgabe definiert. Es gibt jedoch Fälle, bei denen gewisse Eingaben gar nicht vorkommen, d.h. die Funktion nur partiell ist. Ein Beispiel hierfür ist die Siebensegmentanzeige, wenn gewährleistet ist, dass die Ansteuerung mit Argumenten größer als 10 nicht vorkommt. In solchen Fällen werden die nicht vorkommenden Argument-Tupel 'Don't-care'-Fälle genannt und im Karnaugh-Diagramm mit dem Eintrag eines 'D' bezeichnet. Bei der Überdeckung der Einsen können diese Don't-care-Fälle wie Einsen behandelt werden, wenn hierdurch größere Blöcke entstehen. Es ist aber nicht notwendig, alle Don't cares im Karnaugh-Diagramm zu überdecken. Folgendes Beispiel nutzt dies aus:

Beispiel 2.5

Sei $ f$ für $ x\in\{0,\ldots,9\}$ definiert durch $ f(x):=\left\{ \begin{array}{ll}
1 & \mbox{falls }x\in\{1,5,8,9\}\\
0 & \mbox{sonst}\end{array}\right.$ .

Da wir für die zehn möglichen Argumente 4 Bit zur Codierung benötigen, ergibt sich für $ f$ eine Funktion von $ B^{4}\to B$, bei der 6 Argumente nicht auftreten. Im Folgenden sehen wir die zugehörigen Karnaugh-Diagramme ohne und mit Ausnutzung der Don't cares.

% latex2html id marker 19268
\includegraphics[]{bsp225.eps}

Im ersten Fall ergibt sich

$\displaystyle f(x_{1},x_{2},x_{3},x_{4})=\overline{x}_{1}\overline{x}_{3}x_{4}+x_{1}\overline{x}_{2}\overline{x}_{3}.$

Mit Ausnutzung der Don't cares erhalten wir jedoch die kürzere Darstellung

$\displaystyle f(x_{1},x_{2},x_{3},x_{4})=\overline{x}_{3}x_{4}+x_{1}.$

Das Karnaugh-Diagramm kann auch zur Erstellung von verkürzten Ringsummendarstellungen (nicht Reed-Muller Form) verwendet werden. Hierzu trägt man neben den Einsen auch alle Nullen in das Diagramm ein, und erzeugt nun Blöcke, die auch inhomogen sein dürfen, d.h. sowohl Nullen als auch Einsen enthalten. Hierbei ist jedoch darauf zu achten, dass jede 1 durch eine ungerade Anzahl und jede Null durch eine gerade Anzahl (oder keinmal) von Blöcken überdeckt wird. Da die Ringsumme eine gerade Anzahl von Eins-Summanden in der Summe zu Null macht, wird hierdurch eine korrekte Darstellung erzielt.

Beispiel 2.6

Für die Funktion

$\displaystyle f(x_{1},x_{2},x_{3},x_{4})=x_{1}\overline{x}_{2}x_{3}x_{4}+x_{1}\...
...overline{x}_{2}\overline{x}_{3}x_{4}+\overline{x}_{1}\overline{x}_{2}x_{3}x_{4}$

ergibt sich das folgende Diagramm:

% latex2html id marker 19278
\includegraphics[]{bsp226.eps}

Hieraus erhalten wir also die Ringsummenform

$\displaystyle f(x_{1},x_{2},x_{3},x_{4})=x_{4}\oplus\overline{x}_{1}x_{2}x_{4}\oplus x_{1}x_{2}\overline{x}_{3}x_{4}$

Bemerkung:
Die Beschriftung der Ränder des Karnaugh-Diagramms hat nach Konstruktionsvorschrift so zu erfolgen, dass sich benachbarte Felder in horizontaler und vertikaler Richtung an genau einer Stelle unterscheiden. Dieses Prinzip stammt vom Gray-Code, der z.B. für die Codierung der Dezimalzahlen verwendet wird und in A/D-Wandler zu Einsatz kommt. Der Code ist ebenfalls so gebaut, dass sich der Code aufeinanderfolgender Ziffern (und zwar zyklisch, d.h. der 9 folgt die 0) an einem Bit unterscheidet. Ein solcher Gray-Code ist z.B.

$ x$ Gray-Code zu $ x$ Alternativer Code
0

Wie wir sehen, gibt es natürlich unterschiedliche Möglichkeiten, einen Gray-Code zu erzeugen.

2 Das Verfahren von Quine und McCluskey

Bisher haben wir mit dem Verfahren von Karnaugh eine Möglichkeit kennengelernt, mit der wir Boolesche Funktionen von 3 und 4 Variablen vereinfachen können. Das Verfahren lässt sich auch auf $ n=5$ und $ n\geq6$ übertragen, erfordert aber z.B. für $ n=5$ schon die Verwendung von zwei Oberflächen eines Würfels, und es ist somit nicht leicht zu erkennen, wo die Blöcke maximaler Einsen sind. Für $ n\geq6$ ist dies noch schlechter möglich.

Im Folgenden wollen wir daher ein weiteres Verfahren, welches für beliebige $ n$ gilt, erarbeiten. Hierzu sind jedoch einige vorbereitende Überlegungen und Definitionen notwendig.

Definition 2.7

Eine Boolesche Funktion $ f:B^{n}\to B$ liegt in disjunktiver Form vor, wenn $ f$ als Summe von Termen

$\displaystyle f=\sum_{i=1}^{k}\widetilde{m}_{i},\hspace{0.5em}k\geq1$

dargestellt ist. Ein Term $ \widetilde{m_{i}}$ hat dabei die Form

$\displaystyle \widetilde{m}_{i}=\prod_{j=1}^{l}x_{i_{j}}^{\alpha_{j}},\hspace{0.5em}l\geq1$

.

Die DNF ist somit eine disjunktive Form, bei der alle $ \widetilde{m}_{i}$ Minterme sind, und somit aus $ n$ Faktoren bestehen. Terme einer beliebigen disjunktiven Form enthalten im Allgemeinen weniger als $ n$ Faktoren. Zu solchen disjunktiven Formen wollen wir nun ein Kostenmaß definieren:

Definition 2.8

Sei $ f:B^{n}\to B$ eine Boolesche Funktion, und sei $ d$ eine Darstellung von $ f$ in disjunktiver Form. Für $ d$ erklären wir die Kosten $ K(d)$ wie folgt:

  1. [$ (i)$]Für $ d\equiv x_{i_{1}}^{\alpha_{1}}\cdot x_{i_{2}}^{\alpha_{2}}\cdot\ldots\cdot x_{i_{t}}^{\alpha_{t}}: K(d):=t-1$
  2. [$ (ii)$]Für $ d\equiv\widetilde{m}_{1}+\widetilde{m}_{2}+\ldots+\widetilde{m}_{k}: K(d):=(k-1)+\sum_{i=1}^{k}K(\widetilde{m}_{i})$.

Obige Definition bedeutet also, dass jede der Operationen Addition und Multiplikation die Kosten Eins verursacht. Somit ist das folgende Problem zu lösen:

Vereinfachungsproblem Boolescher Funktionen

Bestimme zu einer gegebenen Booleschen Funktion $ f:B^{n}\rightarrow B$ eine die Funktion darstellende disjunktive Form $ d$, so dass deren Kosten minimal sind, d.h. es gilt:

$\displaystyle K(d)=\min_{\mathrm{{{disjunktive Formen d',\atop die f darstellen}}}}K(d').$

Definition 2.9

Sei $ f:B^{n}\to B$ eine Boolesche Funktion. Ein Term $ \mu$ heißt Implikant von $ f$, kurz $ \mu\leq f$, falls für alle $ x\in B^{n}$ gilt:

$\displaystyle \mu(x)=1\Rightarrow f(x)=1.$

Ein Implikant $ \mu$ heißt Primimplikant, falls keine echte Verkürzung von $ \mu$ ebenfalls Implikant von $ f$ ist.

Bemerkungen:

  1. Minterme zu einschlägigen Indizes einer Funktion $ f$ sind Implikanten von $ f$.
  2. Ist $ \mu$ Implikant von $ f$ und $ m$ ein Minterm von $ f$ derart, dass $ \mu$ eine Verkürzung von $ m$ ist, so gilt $ m\leq\mu$, d.h. $ m$ ist Implikant von $ \mu$.
  3. Im Karnaugh-Diagramm entsprechen rechteckige Blöcke von Einsen den Implikanten und die maximalen Blöcke den Primimplikanten.

Satz 2.10

Sei $ f:B^{n}\to B$ eine Boolesche Funktion, $ f\not\equiv0$. Ist $ d=\mu_{1}+\mu_{2}+\ldots+\mu_{k}$ eine Darstellung von $ f$ als disjunktive Form mit minimalen Kosten, so sind die $ \mu_{i}$, $ i=1,\ldots,k$ , Primimplikanten von $ f$.

BEWEIS:

Da $ f$ eine Disjunktion der $ \mu_{i}$ ist, ist jedes $ \mu_{i}$ eine Implikant von $ f$. Nehmen wir an, ein $ \mu_{i}$ ist kein Primimplikant, so existiert eine Verkürzung $ \nu$ von $ \mu_{i}$, so dass $ \nu$ Implikant von $ f$ ist. Ersetzt man in obiger disjunktiver Darstellung $ d$ $ \mu_{i}$ durch $ \nu$, so erhält man eine neue disjunktive Darstellung von $ f$, wobei $ K(\nu)<K(\mu_{i})$ gilt, d.h. die neue Darstellung hat geringere Kosten, was ein Widerspruch zur Minimalität von $ d$ ist.

$ \Box$

Unser Vereinfachungsproblem lässt sich somit auf die folgenden zwei Schritte reduzieren:

  1. [1.)]Bestimme alle Primimplikanten von $ f$.
  2. [2.)]Treffe eine kostenminimale Auswahl der Primimplikanten, so dass deren Summe $ f$ darstellt.
Das sich daraus ergebende Verfahren wurde zuerst 1952 von W. Quine und E. McCluskey angegeben und sieht wie folgt aus:

Algorithmus 2.11 (Quine & McCluskey)

Eingabe:
Funktionstabelle $ (i,f(i))$, $ i\in B^{n}$, $ f:B^{n}\to B$

Ausgabe:
$ PI(f)$, Darstellung von $ f$ durch Primimplikanten
Schritt 1:
Bestimme Primimplikanten

  1. [1.)] Berechne $ Q_{n}:=$ Menge der Minterme aller einschlägigen Indizes von $ f$ .
    Setze $ j:=n$; $ PI(f):=\emptyset$ .
  2. [2.)]Solange $ Q_{j}\neq\emptyset$ führe aus:

    1. [a)]$ j:=j-1$
    2. [b)] $ Q_{j}:=\{\mu\;\vert\;\exists l:x_{l},\overline{x}_{l}\not\in\mu;\mu x_{l},\mu\overline{x}_{l}\in Q_{j+1}\}$
    3. [c)] $ P_{j+1}:=\{\mu\;\vert\;\mu\in Q_{j+1}$; es ex. keine Verkürzung von $ \mu$ in $ Q_{j}\}$

    4. [d)] $ PI(f):=PI(f)\cup P_{j+1}$
Schritt 2:
Kostenminimale Darstellung durch Primimplikanten
Problem:
Auswahl der geeigneten Primimplikanten
Vorgehen:
Matrixverfahren (Überdeckungsmatrix Primimplikanten $ \leftrightarrow$ Minterme)

Der Schritt 1 des Algorithmus liefert die Menge der Primimplikanten und hat eine worst-case-Laufzeit von $ O(3^{n}n^{2})$.

Die Bestimmung der minimalen Überdeckung der Menge der Primimplikanten ist NP-vollständig.

Das Vorgehen des Algorithmus wollen wir nun an einem Beispiel erläutern. Da der Teil 2b) aus Schritt 1 wiederum auf der Resolution beruht, und hierfür die Terme an genau einer Variable unterschiedlich sein müssen, geht man nun wie folgt vor:

Man gruppiert die Minterme in Gruppen nach der Anzahl der vorkommenden negierten Variablen und notiert den zugehörigen Implikanten, den Index in Dualcodierung und die dezimale Nummer des Minterms. Nun können nur zwei Terme aus benachbarten Gruppen mit Hilfe der Resolution verkürzt werden. Für den verkürzten Term notieren wir wiederum den Implikanten, den Index, der nun allerdings an der verkürzten Position einen '*' enthält sowie alle Nummern der Minterme, die durch den verkürzten Implikanten repräsentiert werden. Man erhält so schrittweise neue Tabellen, die nach dem gleichen Vorgehen bearbeitet werden, bis keine Änderung mehr eintritt. Wird ein Implikant einer Gruppe in einem Schritt nicht verwendet, so ist dies bereits ein Primimplikant, da er in keinem späteren Schritt mehr verwendet werden kann (die Implikanten werden von Schritt zu Schritt kürzer). Betrachten wir das Vorgehen nochmals am Beispiel:

Beispiel 2.12

Gegeben sei eine Funktion $ f:B^{4}\to B$ als DNF in der Form

$\displaystyle f=\overline{x}_{1}\overline{x}_{2}\overline{x}_{3}\overline{x}_{4...
...overline{x}_{4}+x_{1}x_{2}\overline{x}_{3}x_{4}+x_{1}x_{2}x_{3}\overline{x}_{4}$

Die Gruppierung nach Anzahl der negativen Terme ergibt folgende Tabelle:

 

Gruppe

Minterm

einschlägiger Index

Minterm-Nummer

Verwendet man alle Primimplikanten, so ergibt sich die Darstellung

$\displaystyle f=x_{1}\overline{x}_{2}x_{3}x_{4}+x_{1}x_{2}\overline{x}_{3}+x_{2}\overline{x}_{4}+\overline{x}_{1}\overline{x}_{3}\overline{x}_{4}.$

Vergleicht man die Kosten dieser Darstellung mit der der DNF, so ergibt sich hierfür $ K(d)=11$ und $ K(DNF)=27$ . Wir haben hier allerdings noch nicht den Schritt 2 des Algorithmus durchgeführt, d.h. eine kostenminimale Überdeckung der Primimplikanten bestimmt. Im Allgemeinen ist nämlich nicht die Menge aller Primimplikanten notwendig, um die Funktion $ f$ in disjunktiver Form darzustellen. Eine solche Menge zu finden ist, wie bereits bemerkt, ein NP-vollständiges Problem. Das folgende Verfahren liefert jedoch mit einer Heuristik eine recht gute Näherung für die optimale Lösung.

Wir halten dazu den in Schritt 1 festgestellten Zusammenhang zwischen Primimplikant und Minterm (letzte Spalte der Tabelle) in einer Matrix $ A=(a_{ij})$ fest. Hierbei wird $ a_{ij}$ gleich eins gesetzt, falls der $ i$-te Primimplikant ein Implikant des $ j$-ten Minterms ist. Für unser Beispiel ergibt sich:

  Minterm 0 4 6 11 12 13 14
Primimplikant                
$ x_1 \overline{x}_2 x_3 x_4$   0 0 0 1 0 0 0
$ x_1 x_2 \overline{x}_3 $

Aus dieser Matrix ist nun eine Auswahl der Primimplikanten (Zeilen) so zu treffen, das einerseits die Kosten minimal sind und andererseits in der aus den ausgewählten Zeilen resultierenden Teilmatrix in jeder Spalte mindestens eine 1 enthalten ist. Im Beispiel erkennt man, dass alle vier Primimplikanten notwendig sind, um alle Minterme zu überdecken, womit die obige disjunktive Darstellung mit $ K(d)=11$ die kostengünstigste ist.

Allgemein kann man für die Lösung des Vereinfachungsproblems folgende Heuristik verwenden:

Bei dieser Heuristik werden alle absolut notwendigen Primimplikanten ausgewählt, und es ist bekannt, dass auch im ungünstigsten Fall gilt:

$\displaystyle \frac{\mbox{Anzahl der durch die Heuristik ausgewählten Primimplikanten}}{\mbox{Anzahl der minimal erforderlichen Primimplikanten}}\leq1+\ln m$

wobei mit $ m$ die Anzahl der einschlägigen Minterme bezeichnet sei.

3 Fehlerdiagnose von Schaltnetzen

In diesem Abschnitt wollen wir zwei Methoden zur Fehlerdiagnose von Schaltnetzen diskutieren. Man denke hierbei z.B. an eine CPU, die ein Schaltnetz mit mehr als $ 10^{6}$ Bauteilen darstellt. Hier kann man sicherlich nicht alle möglichen Eingaben anlegen und die Ausgaben überprüfen, da dies viel zu aufwändig wäre. Auch kann man wegen der geringen Fertigungsgröße (Kantenlänge ca. 4 cm) nicht nach gerissenen Verbindungen suchen. Im Allgemeinen werden solche Chips als Ganzes getestet, und es besteht nun die Aufgabe, eine möglichst kleine Testmenge zu bestimmen, um einen Chip möglichst genau zu testen, d.h. wähle eine Teilmenge aller möglichen Eingaben aus, die Aufschluss über die Qualität der Schaltung gibt.

Wir werden hierzu zwei verschiedene Verfahren kennenlernen, die Fehler bestimmter Art mit möglichst wenigen Testtupeln erkennen, allerdings nicht notwendigerweise lokalisieren. Die erste Methode ist die schaltungsabhängige Fehlerdiagnose, bei der wir von einem gegebenen Schaltnetz ausgehen und folgende Grundannahme machen:

Annahme a) kann man eventuell aufgrund der Güte des Fertigungsprozesses und der damit verbundenen Fehlerwahrscheinlichkeit treffen, Annahme b) beschreibt den wahrscheinlichsten Fehler. Andere Fehler könnten natürlich defekte Gatter oder andere defekte Halbleiter sein. Bei Annahme b) geht man im Allgemeinen von einer sog. 0-Verklemmung aus, d.h. ein gerissener Draht leitet keinen Impuls und erzeugt am Eingang eine 0. Je nach verwendeter Technologie kann aber auch eine 1-Verklemmung sinnvoll sein, d.h. bei einem gerissenen Draht liegt am Eingang eine 1 an. Unter diesen Annahmen wollen wir nun die Diagnose-Methode anhand des folgenden Beispiels (vgl. Beispiel 2.3.9) diskutieren:

Gehen wir von einem DAG zu einem Schaltnetz aus, bei dem die verschiedenen Drähte durchnumeriert sind. Wir erhalten für unser Beispiel den folgenden DAG:

\includegraphics[]{DAG2.eps}

Wir definieren nun zu den Drahtnummern $ i=1,\ldots,18$ die zugehörige Funktion $ f_{i}$, die entsteht, wenn der Draht $ i$ reißt und hierdurch eine 0-Verklemmung auftritt. Wir erhalten dann die 18 verschiedenen Funktionen.

Aus $ f(x_{1},x_{2},x_{3})=\overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}+x_{1}x_{2}x_{3}$ ergibt sich dann:

$\displaystyle f_{1}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{0}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}+x_{1}x_{2}x_{3}=x_{2}x_{3}+x_{1}x_{3}$  
$\displaystyle f_{2}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle 0x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}+x_{1}x_{2}x_{3}=x_{1}x_{3}$  
$\displaystyle f_{3}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+0\overline{x}_{2}x_{3}+x_{1}x_{2}x_{3}=x_{2}x_{3}$  
$\displaystyle f_{4}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}+0x_{2}x_{3}=\overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}$  
$\displaystyle f_{5}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}0x_{3}+x_{1}\overline{x}_{2}x_{3}+x_{1}x_{2}x_{3}=x_{1}x_{3}$  
$\displaystyle f_{6}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+x_{1}\overline{0}x_{3}+x_{1}x_{2}x_{3}=x_{2}x_{3}+x_{1}x_{3}$  
$\displaystyle f_{7}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+x_{1}0x_{3}+x_{1}x_{2}x_{3}=x_{2}x_{3}$  
$\displaystyle f_{8}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}+x_{1}0x_{3}=\overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}$  
$\displaystyle f_{9}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}0+x_{1}\overline{x}_{2}x_{3}+x_{1}x_{2}x_{3}=x_{1}x_{3}$  
$\displaystyle f_{10}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}0+x_{1}x_{2}x_{3}=x_{2}x_{3}$  
$\displaystyle f_{11}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}+x_{1}x_{2}0=\overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}$  
$\displaystyle f_{12}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle 0x_{3}+x_{1}\overline{x}_{2}x_{3}+x_{1}x_{2}x_{3}=x_{1}x_{3}$  
$\displaystyle f_{13}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+0x_{3}+x_{1}x_{2}x_{3}=x_{2}x_{3}$  
$\displaystyle f_{14}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}+0x_{3}=\overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}$  
$\displaystyle f_{15}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle 0+x_{1}\overline{x}_{2}x_{3}+x_{1}x_{2}x_{3}=x_{1}x_{3}$  
$\displaystyle f_{16}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+0+x_{1}x_{2}x_{3}=x_{2}x_{3}$  
$\displaystyle f_{17}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle 0+x_{1}x_{2}x_{3}=x_{1}x_{2}x_{3}$  
$\displaystyle f_{18}(x_{1},x_{2},x_{3})$ $\displaystyle =$ $\displaystyle \overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}+0=\overline{x}_{1}x_{2}x_{3}+x_{1}\overline{x}_{2}x_{3}$  

Zu diesen Fehlerfunktionen (hier $ f_{1}$ bis $ f_{18}$) wird die Wertetabelle, die sogenannte Ausfalltafel erstellt.

Wir erhalten in unserem Beispiel die Ausfalltafel in Tabelle 3.3.


Table 3.1: Ausfalltafel
\begin{table}\begin{displaymath}
\begin{array}{ccc\vert ccccccccc}
x_{1} & x_{2}...
...1 & 0 & 1 & 1 & 0 & 1 & 1 & 1 & 0\end{array}\end{displaymath}\par\par\end{table}


Diese Ausfalltafel lässt sich zur sogenannten Ausfallmatrix verkürzen, indem man doppelte Spalten eliminiert, d.h. gleiche Fehlerfunktionen zusammenfasst. Die Ausfallmatrix in unserem Beispiel ergibt sich aufgrund der folgenden Gleichheiten zu:

$\displaystyle f_{1}$ $\displaystyle =$ $\displaystyle f_{6}=f$  
$\displaystyle f_{2}$ $\displaystyle =$ $\displaystyle f_{5}=f_{9}=f_{12}=f_{15}$  
$\displaystyle f_{3}$ $\displaystyle =$ $\displaystyle f_{7}=f_{10}=f_{13}=f_{16}$  
$\displaystyle f_{4}$ $\displaystyle =$ $\displaystyle f_{8}=f_{11}=f_{14}=f_{18}$  


Table 3.2: Ausfallmatrix
\begin{table}\begin{displaymath}
\begin{array}{ccc\vert ccccc}
x_{1} & x_{2} & x...
...\\
1 & 1 & 1 & 1 & 1 & 1 & 0 & 1\end{array}\end{displaymath}\par\par\end{table}


Die zu den Spalten der Ausfallmatrix in Tabelle 3.3 gehörenden Fehlerfunktionen $ f_{i}$ werden nun mit der Originalfunktion $ f$ mit XOR verknüpft, wodurch die sogenannte Fehlermatrix entsteht, die an den Stellen, an denen sich die Fehlerfunktionen von der Originalfunktion unterscheiden, eine 1, an den anderen Stellen eine 0 besitzt.

Wir erhalten hier die Fehlermatrix in Tabelle 3.3.


Table 3.3: Fehlermatrix
\begin{table}\begin{displaymath}
\begin{array}{c\vert ccc\vert ccccc}
\mbox{Zeil...
...\\
7 & 1 & 1 & 1 & 0 & 0 & 0 & 1 & 0\end{array}\end{displaymath}\par\end{table}


Das Ziel ist es nun, aus der Menge aller möglichen Eingaben (hier $ 2^{3}=8$) eine minimale Teilmenge so auszuwählen, dass alle Fehler erkannt werden, d.h. dass jede Spalte mit mindestens einer 1 in der Fehlermatrix mindestens einmal überdeckt wird. Ein analoges Problem hatte sich bereits beim Algorithmus von Quine & McCluskey ergeben. Im Beispiel erkennt man leicht, dass die Eingaben der Zeilen $ \{3,5,7\}$ eine solche minimale Testmenge bilden, d.h. von den 8 möglichen reichen 3 Eingaben aus, um alle Fehler zu diagnostizieren. Man kann hierbei allerdings keinen Rückschluss führen, welcher Draht gerissen ist, da zum einen in einer Zeile einer Fehlermatrix mehrere Einsen stehen können, d.h. bei verschiedenen Fehlerfunktionen tritt derselbe Fehler auf, zum anderen führen verschiedene gerissene Drähte zu gleichen Fehlerfunktionen. Auch ist es möglich, dass Defekte nach außen hin gar nicht relevant sind, da hierdurch die gleiche Funktion wie die Originalfunktion entsteht (vgl. $ f_{1}$). Dies tritt dadurch auf, dass wir in unserem Beispiel nicht von der verkürzten Form, sondern von der DNF ausgegangen sind.

Die zweite Methode zur Fehlerdiagnose ist die schaltungsunabängige. Hierbei ist eine Testmenge gesucht, die unabhängig von der speziellen Implementierung Fehler, die der folgende Fehlerannahme genügen, erkennt:

Es tritt ein Defekt auf, welcher die tatsächliche Abhängigkeit von $ f$ von der $ i$-ten Variable zerstört.

Durch diese recht allgemeine Annahme werden jedoch nicht alle möglichen Fehler abgedeckt.

Beispiel 3.1

Sei $ f:B^{3}\to B$ definiert durch

$\displaystyle f(x_{1},x_{2},x_{3})=\overline{x}_{1}x_{3}+x_{2}$

Da $ f(0,0,1)=1$ und $ f(1,0,1)=0$ gilt, hängt $ f$ offensichtlich von $ x_{1}$ ab. Ist nun irgendeine Realisierung von $ f$ gegeben, so kann man durch Anlegen der beiden Inputs $ (0,0,1)$ und $ (1,0,1)$ testen, ob in der Schaltung die Abhängigkeit von $ x_{1}$ gegeben ist. Die beiden Inputs sind somit ein Testpaar für die Eingabe $ x_{1}$.

Allgemein gilt:

Definition 3.2

Sei $ f:B^{n}\to B$ eine Boolesche Funktion. Ein $ n$-Tupel $ a\in B^{n}$ heißt dann Test. Zwei Tests $ a$ und $ b$ bilden ein $ f$-Testpaar zu $ x_{i}$, wenn sich $ a$ und $ b$ nur genau an der Stelle $ i$ unterscheiden und $ f(a)\neq f(b)$ gilt. Eine minimale Testmenge ist dann eine Menge T von Tests, so dass zu jeder Variablen $ x_{i}$, von der $ f$ tatsächlich abhängt, ein Testpaar $ \{ a,b\}$ existiert mit $ \{ a,b\}\subseteq T$ und die minimal bzgl. Mengeninklusion unter allen möglichen Testmengen ist.

In obigem Beispiel ergeben sich also die folgenden Testpaare:

$\displaystyle x_{1}$ $\displaystyle :$ $\displaystyle (0,0,1)$ und $\displaystyle (1,0,1)$  
$\displaystyle x_{2}$ $\displaystyle :$ $\displaystyle (1,0,0)$ und $\displaystyle (1,1,0)$  
    $\displaystyle (1,0,1)$ und $\displaystyle (1,1,1)$  
    $\displaystyle (0,0,0)$ und $\displaystyle (0,1,0)$  
$\displaystyle x_{3}$ $\displaystyle :$ $\displaystyle (0,0,0)$ und $\displaystyle (0,0,1)$  

Hieraus ergibt sich als minimale Testmenge

$ \{(0,0,0),(0,0,1),(1,0,1),(1,1,1)\}$

bzw.

$ \{(0,0,0),(0,0,1),(0,1,0),(1,0,1)\}$.

Somit reichen also vier Tests aus, um unter obiger Fehlerannahme jede beliebige Realisierung von $ f$ auf Defekte zu testen.

4 Hazards in Schaltnetzen

Bisher haben wir bei unseren Betrachtungen von Schaltnetzen stets technisch physikalische Zusammenhänge ausgeklammert. Aber auch diese Zusammenhänge können die Zuverlässigkeit von Schaltnetzen beeinflussen, z.B. die Signallaufzeiten durch unterschiedlich tiefe Teilschaltungen etc. Wir wollen dies im Folgenden diskutieren und treffen folgende Annahmen:

  1. Jedes Signal, welches ein Gatter durchläuft, hat eine zwar kurze, aber nicht zu vernachlässigende Laufzeit.
  2. Änderungen von Inputsignalen an verschiedenen Eingängen, welche logisch gleichzeitig erfolgen, können im Allgemeinen physikalisch nicht gleichzeitig erfolgen.
  3. Signallaufzeiten können für unterschiedliche Gattertypen, die in einem Schaltnetz verwendet werden, unterschiedlich sein (hängt z.B. vom FanIn und FanOut ab).
Diese Annahmen haben für das tatsächliche Verhalten einer Schaltung Konsequenzen. So kann z.B. das nicht gleichzeitige Wechseln von Inputs nach Annahme 2 dazu führen, dass kurzzeitig am Output falsche Werte anliegen (Flimmern am Output), was eventuell unerwünscht oder sogar verboten sein kann. Betrachten wir hierzu das folgende Beispiel:

Beispiel 4.1

Sei $ f(x_{1},x_{2},x_{3})=x_{1}x_{3}+x_{2}\overline{x}_{3}$. Es gilt:

$\displaystyle f(1,1,0)$ $\displaystyle =$ $\displaystyle 1$  
$\displaystyle f(1,1,1)$ $\displaystyle =$ $\displaystyle 1$  
$\displaystyle f(1,0,0)$ $\displaystyle =$ 0  
$\displaystyle f(1,0,1)$ $\displaystyle =$ $\displaystyle 1$  

Falls nun bei einer Realisierung der Funktion durch ein Schaltnetz von der Eingabe (1,1,0) auf die Eingabe (1,0,1) umgeschaltet werden soll, und wir davon ausgehen, dass die beiden zu ändernden Inputbits nicht gleichzeitig umgeschaltet werden, so ergeben sich zwei Umschaltungsreihenfolgen:
$\displaystyle (1,1,0)$ $\displaystyle \to$ $\displaystyle (1,0,0)\to(1,0,1) $    oder  
$\displaystyle (1,1,0)$ $\displaystyle \to$ $\displaystyle (1,1,1)\to(1,0,1)$  

Im ersten Fall wird der Output kurzzeitig 0, im zweiten Fall bleibt er stets 1, d.h. hier ist die zweite Schaltreihenfolge zu bevorzugen.

Ein Phänomen, wie es in obigem Beispiel beschrieben ist, wird Hazard (engl. Gefahr, Risiko) genannt. Man unterscheidet hier verschiedene Typen von Hazards, einmal die Funktionshazards, die unabhängig von der konkreten Realisierung durch das Übergangsverhalten der Funktion gegeben sind, zum anderen Schaltungshazards, die durch die konkrete Realisierung erzeugt werden. Weiter wird nach statischen und dynamischen Hazards unterschieden. Der erste Typ bewirkt eine (zwischenzeitliche) unerwünschte Änderung des Outputs, der bei Inputwechsel konstant bleibt, dynamische Hazards können auftreten, wenn bei einem Wechsel des Outputs dieser erst nach einem kurzen Flimmern konstant wird. Im Folgenden werden wir uns nur mit den statischen Hazards beschäftigen, die wir hier nun präzisieren wollen:

Definition 4.2

Sei $ f:B^{n}\to B$ eine Boolesche Funktion und seien $ a_{0}\in B^{k}$ $ (1\leq k\leq n)$, $ a_{1}\in B^{n-k}$ , $ a=(\{ a_{0},a_{1}\})$, $ b=(\{ a_{0},\overline{a}_{1}\})$, wobei die Mengenklammern bedeuten, dass die Komponenten von $ a_{0}$ und $ a_{1}$ den Vektor $ a$ bilden, aber die Reihenfolge nicht festgelegt ist. $ f$ besitzt einen (statischen) Funktionshazard beim Inputwechsel von $ a$ nach $ b$, falls gilt:

$ (i)$
$ f(a) = f(b)$;
$ (ii)$
es gibt ein $ a_{1}^{\prime}\in B^{n-k}$ so, dass für $ c=(\{ a_{0},a_{1}^{\prime}\})$ gilt: $ f(a)\neq f(c)$

Funktionshazards für Funktionen $ f:B^{n}\rightarrow B$ mit $ n=3$ oder $ n = 4$ lassen sich am Karnaugh-Diagramm ablesen. Hier sind zu jedem Paar $ (a,b)$ von Eingaben, die gleiche Funktionswerte besitzen, alle kürzesten Wege im Karnaugh-Diagramm von $ a$ nach $ b$ zu bestimmen, die nur horizontale oder vertikale Schritte (in jedem Schritt kippt ein Input) benutzen (hierbei sind auch wieder Wege über die Ränder zu berücksichtigen). Unterscheidet sich der Eintrag im Diagramm in einem Feld dieses Weges vom Eintrag im Ausgangsfeld (und damit auch vom Endfeld), so liegt ein Hazard vor. Wir können hier noch zwischen vermeidbarem und unvermeidbarem Hazard unterscheiden. Ein Hazard ist hier vermeidbar, wenn es mindestens einen kürzesten Weg von $ a$ nach $ b$ gibt, bei dem nur gleiche Einträge (also gleiche Funktionswerte) im Diagramm vorliegen. Liegen auf allen kürzesten Wegen unterschiedliche Funktionswerte vor, so ist der Hazard unvermeidbar, da er bei allen denkbaren Schaltreihenfolgen auftritt.

Betrachten wir dies wiederum an einem Beispiel:

Beispiel 4.3

Gegeben sei eine Funktion $ f:B^{4}\to B$ durch folgendes Karnaugh-Diagramm

\includegraphics[]{Hazard.eps}

Betrachten wir nun die Inputwechsel, die zu Funktionswerten 1 gehören, so ergeben sich folgende Hazards:

\begin{displaymath}
\begin{array}{clll}
0000 & \leftrightarrow & 0101 & \textrm{...
...
1000 & \leftrightarrow & 1111 & \textrm{vermeidbar}\end{array}\end{displaymath}

Auf analoge Weise könnte man dynamische Hazards erkennen. Hier werden Paare untersucht, die unterschiedliche Funktionswerte besitzen. Tritt der Wechsel von 0 nach 1 (bzw. umgekehrt) auf einem kürzesten Weg mehrfach auf, so bedeutet dies ein Flimmern am Ausgang, also einen dynamischen Funktionshazard.

Betrachten wir nun zum Abschluss des Kapitels noch die Schaltungshazards, die sich als Folge von unterschiedlichen Signallaufzeiten ergeben. Wir gehen hierbei von einer konkreten Realisierung einer Booleschen Funktion aus. Wir wollen das Phänomen zuerst an einem Beispiel studieren:

Beispiel 4.4

Gegeben sie die Boolesche Funktion $ f(x_{1},x_{2},x_{3})=x_{1}x_{3}+x_{2}\overline{x}_{3}$, die durch folgendes Schaltnetz realisiert sei:

\includegraphics[%%
clip]{Hazard2.eps}

Wir wollen einen Input-Wechsel von (1,1,1) nach (1,1,0) untersuchen. Hier hat die Funktion jeweils den Funktionswert 1, und es liegt dort kein Funktionshazard vor, da es bei diesem Übergang kein ''Zwischentupel'' gibt. Beim Umschalten des Inputs $ x_{3}$ von 1 nach 0 sind in der Schaltung die Signalwege ACE und BDE zu durchlaufen, wobei wir annehmen, dass wegen des Inverters im Weg BDE dieser eine längere Schaltzeit benötigt. Hierdurch bedingt, hat der Ausgang von Gatter C aber bereits eine 0, wenn der Ausgang von D noch eine 0 hat, was kurzzeitig zu zwei Nullen am Eingang von E und damit auch am Ausgang von E bewirkt. Hierdurch entsteht ein kurzzeitiges Fehlverhalten der Schaltung.

Wir wollen nun noch die Definition des Schaltungshazards genauer notieren und einen Satz angeben, der ein Kriterium zur Vermeidung von Schaltungshazards angibt.

Definition 4.5
Sei $ f: B^n \to B$ eine Boolesche Funktion, $ S$ ein Schaltnetz, welches $ f$ realisiert, und $ a, b \in B^n$. $ S$ besitzt einen statischen Schaltungshazard (logischen Hazard) für den Input-Wechsel von $ a$ nach $ b$, falls gilt:
$ (i)$
$ f(a) = f(b)$
$ (ii)$
$ f$ besitzt keinen Funktionshazard für den Wechsel von $ a$ nach $ b$.
$ (iii)$
während des Wechsels von $ a$ nach $ b$ ist am Ausgang von $ S$ eine vorübergehende Fehlfunktion beobachtbar.

Satz 4.6 (Eichelberger 1965)
Ein zweistufiges Schaltnetz $ S$ für eine Boolesche Funktion $ f$ in disjunktiver Form ist frei von statischen Schaltungshazards, wenn die UND-Gatter von $ S$ in einer 1-1-Korrespondenz zu den Primimplikanten von $ f$ stehen, d.h. jedes UND-Gatter von $ S$ realisiert genau einen Primimplikanten von $ f$ und jedem Primimplikanten entspricht genau ein UND-Gatter in $ S$.

4 Schaltwerke

Nachdem wir im letzten Kapitel (asynchrone) Schaltnetze kennengelernt haben, wollen wir uns nun mit sogenannten Schaltwerken befassen. Hierbei werden ggf. Teilergebnisse (Ausgaben) wieder in die Schaltung eingespeist, um in zeitlicher Abfolge ein bestimmtes Verhalten einer Schaltung zu erzielen, z.B. bei Zählerschaltungen, wo die neue Ausgabe von letzten Zählerstand abhängt. Hierzu ist es notwendig, gewisse Verzögerungs- und Speicherelemente in ein Schaltung einfließen zu lassen und eine Schaltung mit Hilfe eines Taktsignals zu steuern. Wir wollen daher zuerst einige Bausteine, die als solche Verzögerungs- und Speicherelemente dienen können, kennenlernen.

1 Flip-Flops

Unter einem Flip-Flop (FF) versteht man ein Speicherglied, das zwei stabile Zustände einnehmen kann. Durch geeignete Ansteuerung lässt sich das Flip-Flop von einem Zustand in den anderen überführen.

Um die Funktionsweise des RS-Flip-Flops, das wir später betrachten wollen, näher zu erklären, wollen wir zunächst die bistabile Kippstufe, die aus zwei NAND-Gattern aufgebaut ist, untersuchen. Dabei geben wir an den beiden Gattern die Signale $ x$ und $ y$ zur Zeit $ t$ ein. Am Ausgang $ Q$ liegt dann nach Durchlauf durch das Gatter $ Q=\overline{x}+\overline{P}$ an, am Ausgang $ P$ dagegen $ P=\overline{y}+\overline{Q}$.

% latex2html id marker 20052
\includegraphics[]{Bistab.eps}

Dabei wird an jedem NAND-Gatter als zweites Signal jeweils das Ausgangssignal des anderen eingespeist. Nun kommt es entscheidend darauf an, welches der Gatter zuerst schaltet. Ist es das $ Q$-Gatter und ist die Laufzeit des Signals $ \Delta t$, so ergibt sich

$\displaystyle Q_{t+\Delta t}=\overline{x}_{t}+\overline{P}_{t},\hspace{0.5em}P_{t+\Delta t}=\overline{y}_{t}+\overline{Q}_{t+\Delta t},$

anderenfalls jedoch gerade umgekehrt

$\displaystyle Q_{t+\Delta t}=\overline{x}_{t}+\overline{P}_{t+\Delta t},\hspace{0.5em}P_{t+\Delta t}=\overline{y}_{t}+\overline{Q}_{t}.$

Wir wollen nun in einer Tabelle bei festen Eingangssignalen $ x$ und $ y$ das Verhalten im Zeitablauf an den Ausgängen in Abhängigkeit von der Schaltreihenfolge notieren.

Schaltet $ P$ zuerst, so erhalten wir:

$ \rule[-3mm]{0mm}{8mm} x_{t}$ $ y_{t}$ $ P_{t + \Delta t} = \overline{y}_{t} + \overline{Q}_{t}$ $ Q_{t + \Delta t} = \overline{x}_{t} + \overline{P}_{t + \Delta t}$ $ P_{t + 2 \Delta t} = \overline{y}_{t} + \overline{Q}_{t + \Delta t}$ $ Q_{t + 2\Delta t} = \overline{x}_{t} + \overline{P}_{t + 2 \Delta t}$
$ \rule[-1mm]{0mm}{5mm} 0$

Schaltet dagegen $ Q$ zuerst, so ergibt sich das folgende Bild:

$ \rule[-3mm]{0mm}{8mm} x_{t}$ $ y_{t}$ $ Q_{t + \Delta t} = \overline{x}_{t} + \overline{P}_{t}$ $ P_{t + \Delta t} = \overline{y}_{t} + \overline{Q}_{t + \Delta t}$ $ Q_{t + 2 \Delta t} = \overline{x}_{t} + \overline{P}_{t + \Delta t}$ $ P_{t + 2 \Delta t} = \overline{y}_{t} + \overline{Q}_{t + 2 \Delta t}$
0

Man erkennt das folgende Verhalten:

Das hat jedoch zur Folge, dass die Eingabe $ (0,0)$ ausgeschlossen werden muss, da sie die Ausgabe $ (1,1)$ zur Folge hat. Dies verletzt jedoch die Bedingung $ P=\overline{Q}$ und hat ein unbestimmtes Ergebnis in der bistabilen Kippstufe zur Folge.

Fassen wir zusammen. Bei der bistabilen NAND-Kippstufe kann durch eine geeignete Eingabe am Ausgang $ Q$ eine 1 (Set) oder 0 (Reset) erzeugt werden, die Eingabe $ (1,1)$ bewirkt ein Halten des Zustandes $ Q$. Dagegen ist die Eingabe $ (0,0)$ verboten. Da jedoch bei Schaltvorgängen von $ (0,1)\rightarrow(1,0)$ immer die Gefahr besteht, dass kurzfristig die Eingabe $ (0,0)$ erzeugt wird, geht man direkt zum RS-Flip-Flop über.

Das RS-FF hat zwei Eingänge $ R$ (Reset) und $ S$ (Set). Liegen beide Eingänge $ R$ und $ S$ auf 0, so behält das FF seinen Zustand bei. Ist $ R=1$ und $ S=0$, dann nimmt das FF den Zustand $ Q=0$ an, das FF wird zurückgesetzt. Ist $ R=0$ und $ S=1$, dann wird das FF gesetzt, d.h. $ Q=1$. Die Eingangskombination $ R=1$, $ S=1$ hingegen ergibt die mehrdeutige Ausgangssituation $ Q=0$, $ \overline{Q}=0$ und ist deshalb unzulässig. Alle Ansteuerschaltungen sind also so zu entwerfen, dass die Situation $ R=S=1$ unmöglich ist.

Zwischen $ x$ und $ S$ bzw. $ y$ und $ R$ finden wir den folgenden Zusammenhang:

$\displaystyle x=\overline{S}+\overline{C}=\overline{SC},\hspace{0.5em}y=\overline{R}+\overline{C}=\overline{RC},$

und die Nebenbedingung $ S\cdot R=0$, denn $ S\cdot R=1$ entspricht $ x=y=0$.

Für die Ausgangsfunktion finden wir

$\displaystyle Q_{t+2\Delta t}$ $\displaystyle =$ $\displaystyle \overline{x}_{t}+\overline{P}_{t+\Delta t}$  
  $\displaystyle =$ $\displaystyle \overline{x}_{t}+y_{t}Q_{t}$  
  $\displaystyle =$ $\displaystyle SC+\overline{RC}Q_{t}$  
  $\displaystyle =$ $\displaystyle SC+\left(\overline{R}+\overline{C}\right)Q_{t}$  
  $\displaystyle =$ $\displaystyle SC+\left(\overline{R}Q_{t}+\overline{C}Q_{t}\right)$  
  $\displaystyle =$ $\displaystyle C\cdot(S+\overline{R}Q_{t})+\overline{C}Q_{t}.$  

$ C$ ist ein Taktsignal, das periodisch zwischen 0 und $ 1$ hin und herschaltet. Wir erkennen, dass bei $ C=0$ der Wert von $ Q_{t}$ erhalten bleibt. Bei $ C=1$ schaltet das RS-Flip-Flop, und es gilt das folgende Verhalten beim Übergang vom Zustand $ n\simeq t$ und $ n+1\simeq t+2\Delta t$:

a)
Übergangsfunktion:

$\displaystyle Q^{n+1}=S+(\overline{R}\cdot Q^{n}),$ Nebenbedingung: $\displaystyle S\cdot R=0$

b)
Wahrheitswertetabelle:
$ R$ $ S$ $ Q^{n+1}$ $ \overline{Q}^{ n+1}$
($ ^*$ nicht zugelassen)

$ n$ deutet hierbei den Zustand vor und $ n+1$ den Zustand nach dem Schaltschritt an.

Mittels NAND Gattern sieht eine Realisierung folgendermaßen aus:

\includegraphics[]{RS_FFv7.eps}

Hierbei gibt das Symbol am Takteingang Aufschluss über das Schaltverhalten des Flip-Flops. Es bedeuten hierbei:

\includegraphics[]{FF2v7.eps}

Es existieren nun zwei Abwandlungen des RS-FF, bei denen der verbotene Zustand R=S=1 ausgeschlossen ist. Zum einen das D-Flip-Flop (Delay-FF) bzw. das JK-Flip-Flop, deren Schaltungen und Wahrheitswertetabellen wie folgt aussehen:

Wahrheitswertetabelle des JK-FF:

$ J$ $ K$ $ Q^{n+1}$ $ \overline{Q}^{ n+1}$

In der folgenden Schaltung haben wir dann:

$\displaystyle R(J,K,Q^{n})$ $\displaystyle =$ $\displaystyle KQ^{n},$  
$\displaystyle S(J,K,Q^{n})$ $\displaystyle =$ $\displaystyle J\overline{Q}^{n},$  
$\displaystyle Q^{n+1}(J,K,Q^{n})$ $\displaystyle =$ $\displaystyle J\overline{Q}^{n}+\overline{K}Q^{n}.$  

Genauer gilt die folgende Tabelle:

$ J$ $ K$ $ Q^{n}$ $ Q^{n+1}$ $ S$ $ R$ Aktion
0

\includegraphics[]{JK_FFv7.eps}

Wahrheitswertetabelle des D-FF:

$ D$ $ Q^{n+1}$ $ \overline{Q}^{ n+1}$

\includegraphics[]{D_FFv7.eps}

2 Sequentielle Schaltungen

Anwendungen von Flip-Flops finden sich z.B. bei Schieberegistern oder Zählerschaltungen. Wir wollen nun hierfür exemplarisch einige Beispiele ansehen.

Ein Schieberegister dient dazu, eine Information Bitweise mit jedem Takt zu verschieben und damit in zeitlichem Abstand am Ausgang bereitzustellen. Im einfachsten Fall kann das Register den Inhalt nur in einer Richtung verschieben, es handelt sich also um ein Links- oder Rechtsschieberegister. In anderen Fällen kann das Register durch einen oder mehrere Steuereingänge beeinflusst werden. Wir wollen hier ein Beispiel betrachten, in dem eine Information parallel oder seriell in ein Register eingelesen und auch wieder ausgegeben werden kann. In der folgenden Schaltung bewirkt der Schalter $ E$ entweder serielles oder paralleles Lesen, der Schalter $ A$ schaltet die Ausgänge durch oder belegt die Ausgabe mit dem Wert 1. Das Register verändert seinen Inhalt mit jedem Takt $ T$.

\includegraphics[%%
width=1.0\textwidth,
keepaspectratio]{Coy2.eps}

Will man das Verhalten einer mit Speicherbausteinen aufgebauten Schaltung systematisch beschreiben und analysieren, eignet sich hierzu ein Mealy-Automat, der als sequentielle Maschine betrachtet wird. Diese ist wie folgt definiert:

Definition 2.1

Eine sequentielle Maschine $ M=(E,S,Z,\delta,\gamma,s_{0})$ (Mealy-Automat) ist beschrieben durch ein Eingabealphabet $ E=\{ e_{1},\ldots,e_{r}\}$, einer Menge von Zuständen $ S=\{ s_{0},\ldots,s_{n}\}$ mit einem ausgezeichneten Zustand $ s_{0}$, einem Ausgabealphabet $ Z=\{ z_{1},\ldots,z_{n}\}$ sowie der partiell definierten Übergangsfunktion $ \delta:E\times S\to S$ und der Ausgabefunktion $ \gamma:E\times S\to Z$.

Beispiel 2.2 (Modellierung einer 1-Bit Addition durch Automat)

Als Eingabemenge $ E$ dienen die Codierungen der beiden möglichen Eingaben durch 2 Bit, also $ E=\{00,01,10,11\}$. In den Zuständen ist der Übertrag der vorherigen Stelle zu speichern, d.h. $ S=\{0,1\}$ und als Ausgabe erhalten wir das Summenbit, also somit $ Z=\{0,1\}$. Damit ergibt sich folgender Automat:

\includegraphics[]{Meal_Add.eps}

Es ergibt sich somit für die Zustände und Eingaben/Ausgaben folgende Übergangstabelle:

$ S$ $ E$ $ \delta(s, e)$ $ \gamma(s,e)$
0

Wollen wir dieses Übergangsverhalten mit Hilfe von Flip-Flops erreichen und wollen wir z.B. JK-FF verwenden, so untersuchen wir, bei welcher Ansteuerung der Eingänge J und K der Ausgang Q das gewünschte Verhalten zeigt, d.h. der Carry des vorhergehenden Bits anliegt. Es ergibt sich generell beim Vergleich des Ausgangs Q zur Zeit $ n$ und $ n+1$ folgendes Bild:

$ Q^n$ $ Q^{n+1}$ $ J$ $ K$
0

Wendet man dies hier an, so ergibt sich für die Ansteuerung:

$ S$ $ E$ $ \delta(s, e)$ $ J$ $ K$
0

\includegraphics[]{Meal_Ad2.eps}

Beispiel 2.3

Als ein zweites Beispiel wollen wir einen Modulo-6 Vorwärts-/Rückwärtszähler anschauen. Der Zähler verfügt über einen Eingang $ e\in\{0,1\}$, der darüber entscheidet, ob vorwärts oder rückwärts gezählt wird. Hierbei stehe $ e=1$ für rückwärts Zählen. Die möglichen Zählfolgen sind also (jeweils beginnend bei 0) $ 0-1-2-3-4-5-0$ bzw. $ 0-5-4-3-2-1-0$. Codieren wir die Zählerstände binär mit 3 Bit, und nehmen diese als Zustandsbeschreibung des zugehörigen Automaten an, so hat unser Mealy-Automat die Zustandsmenge $ S=\{000,001,010,011,100,101\}$. Da der Zustand zugleich den Zählerstand darstellt, gilt $ Z=S$. Als Anfangszustand wählen wir $ 000$. Als Übergangsdiagramm ergibt sich das folgende:

\includegraphics[]{Zaehler1.eps}

Wollen wir den Zähler mit Hilfe von RS-Flip-Flops aufbauen, so benötigen wir für jedes Bit der Zustandscodierung ein Flip-Flop. Der Zustand des Flip-Flops (Wert am Ausgang $ Q$) ist das entsprechende Bit des momentanen Zählerstandes, und die Ansteuerung der Eingänge ist so zu wählen, dass beim nächsten Taktsignal die Flip-Flops in den Folgezustand übergehen. Wir haben also für die sechs Flip-Flop-Eingänge Boolesche Ansteuerfunktionen zu designen, die von der Eingabe $ e$ und den Ausgängen $ q_{0},q_{1},q_{2}$ der Flip-Flops abhängen.

Bezeichnen wir den momentanen Ausgabewert eines FF mit $ Q$, und den Wert am Ausgang im nächsten Takt als $ Q'$, so erhalten wir aus dem Übergangsverhalten der FF die folgenden Ansteuerungen der Eingänge, wobei ''*'' wiederum ein Don't care bedeutet.

$ Q$ $ Q'$ $ R$ $ S$
0

Wenden wir dies nun auf den obigen Ringzähler modulo 6 an, so erhalten wir folgende Tabelle:

$ e$ $ q_0$ $ q_1$ $ q_2$ $ q^{\prime}_0$ $ q^{\prime}_1$ $ q^{\prime}_2$ $ R_0$ $ S_0$ $ R_1$ $ S_1$ $ R_2$ $ S_2$
0
Hieraus ergeben sich die folgenden Ansteuergleichungen (unter Ausnutzung der don't cares):

$\displaystyle R_{0}$ $\displaystyle =$ $\displaystyle eq_{0}\overline{q}_{2}+\overline{e}q_{0}q_{2}=q_{0}\cdot\left(e\oplus q_{2}\right)$  
$\displaystyle S_{0}$ $\displaystyle =$ $\displaystyle \overline{e} \overline{q}_{0}q_{1}q_{2}+e\overline{q}_{0}\overline{q}_{1}\overline{q}_{2}$  
$\displaystyle R_{1}$ $\displaystyle =$ $\displaystyle \overline{e}q_{1}q_{2}+eq_{1}\overline{q}_{2}=q_{1}\cdot\left(e\oplus q_{2}\right)$  
$\displaystyle S_{1}$ $\displaystyle =$ $\displaystyle \overline{e} \overline{q}_{0}\overline{q}_{1}q_{2}+eq_{0}\overline{q}_{2}$  
$\displaystyle R_{2}$ $\displaystyle =$ $\displaystyle q_{2}$  
$\displaystyle S_{2}$ $\displaystyle =$ $\displaystyle \overline{q}_{2}$  

Als Schaltung ergibt sich dann:

\includegraphics[%%
width=0.75\textwidth,
keepaspectratio]{Zaehl2n.eps}

3 Lineare Schaltkreise

Definition 3.1

Ein linearer Schaltkreis über einem Körper $ K$ ist ein Schaltwerk, welches aus den folgenden drei Grundbausteinen aufgebaut ist:

  1. [a)] Addierer, wobei am Ausgang die Summe der beiden Körperelemente, die an den Eingängen angelegt sind, anliegt.
  2. [b)] Skalar-Multiplizierer, wobei das Körperelement am Eingang mit einem festen Element $ a$ multipliziert wird.
  3. [c)] Delay, welches ein eingegebenes Körperelement für die Länge eines Taktes speichert.
\includegraphics[%%
width=0.75\textwidth,
keepaspectratio]{LSR.eps}

Wir wollen nun die Anwendung von linearen Schaltkreisen bei der Codierung/Decodierung studieren. Zuvor führen wir einige Begriffe über Codierung ein, die die Umsetzung als linearen Schaltkreis begründen.

Definition 3.2

  1. [a)] $ E$ sei endliches Alphabet, $ E^{n}$ die Menrge aller $ n$-Tupel, $ \vec{0}\in E$. Jede Teilmenge $ C\subseteq E^{n}$ mit $ (0,0,\ldots,0)\in C$ heißt Code über $ E$, $ x\in C$ Codewort.
  2. [b)] $ F$ sei ein endlicher Körper, $ E^{n}$ Vektorraum über $ F$, $ C$ sei $ k$-dimensionaler Unterraum, dann heißt $ C$ ein linearer $ (n,k)$ Code.

Definition 3.3

Sei $ E$ endlicher Körper, $ Z:E^{n}\to E^{n}$, $ Z(x_{0},x_{1},\ldots,x_{n-1})=(x_{n-1},x_{0},x_{1},\ldots,x_{n-2})$ die zyklische Verschiebung des Arguments. Ein linearer Code $ C$ heißt zyklisch, wenn er unter $ Z$ invariant ist.

Wenn wir nun Polynome $ a(x)=a_{0}+a_{1}x+\ldots+a_{m}x^{m}$ mit Koeffizienten aus einem Körper $ F$ und $ a_{m}\neq0$ betrachten, so gilt der Divisionsalgorithmus für Polynome und man kann insbesondere modulo des Polynoms $ x^{n}-1$ rechnen. Ordnen wir jedem Codewort aus $ C$ in natürlicher Weise ein Polynom wie folgt zu: $ (a_{0},\ldots,a_{m})\hat{=}a_{0}+a_{1}x+\ldots+a_{m}x^{m}$, so entspricht ein Shift (nach links) des Codewortes einer Multiplikation mit $ x$, wobei im Fall $ m=n-1$ modulo $ x^{n}-1$ gerechnet werden muss. Dann gilt der folgende Satz:

Satz 3.4
$ C \subseteq E^n$ ist ein zyklischer Code genau dann, wenn es ein Polynom $ g$ gibt, welches Teiler von $ x^n - 1$ ist und für das die Beziehung $ u \in C \leftrightarrow g \vert u$ gilt.

( $ g = g_0 + g_1 x + \ldots + g_{n-k} x^{n-k}$ erzeugt damit einen linearen zyklischen Code.)

Codierung:

$\displaystyle a_{0},\ldots,a_{k-1}$ $\displaystyle \rightarrow$ $\displaystyle a_{0}+a_{1}x+\ldots+a_{k-1}x^{k-1}$  
  $\displaystyle \stackrel{\cdot g(x)}{\rightarrow}$ $\displaystyle f_{0},\ldots,f_{n-1}$   (Codewort)  
  $\displaystyle \stackrel{\cdot1/g(x)}{\rightarrow}$ $\displaystyle a_{0}+a_{1}x+\ldots+a_{k-1}x^{k-1}$   Decodierung  

Sowohl das Codieren (Multiplizieren) als auch das Decodieren (Dividieren) bzgl. eines zyklischen Linearcodes kann mit Hilfe von linearen Schaltkreisen erfolgen. Soll ein Polynom $ a(x)=a_{0}+a_{1}x+a_{2}x^{2}+\ldots+a_{k-1}x^{k-1}$ mit einem festen Polynom $ h(x)=h_{0}+h_{1}x+h_{2}x^{2}+\ldots+h_{n-k}x^{n-k}$ multipliziert werden, so wird dies durch die folgende Schaltung realisiert:

\includegraphics[%%
width=0.75\textwidth,
keepaspectratio]{LSK_Mul.eps}

Ist das Polynom $ h(x)$ von Grad $ n-k$, so benötigen wir ein $ n-k$ stelliges Schieberegister, $ n-k$ Addierer und $ n-k+1$ Multiplizierer, welche jeweils ihren Input mit einem bestimmten Koeffizienten von $ h$ multiplizieren.

Die Delays werden zu Anfang mit Nullen vorbelegt. Dann werden die Koeffizienten von $ a(x)$ beginnend mit dem höchsten $ a_{k-1}$ der Reihe nach eingegeben. Ist der erste Koeffizient eingegeben, so erhalten wir am Ausgang den Wert $ a_{k-1}\cdot h_{n-k}$. Nach einem Takt steht im ersten Delay $ a_{k-1}$ und am Input $ a_{k-2}$. Der Ausgang liefert dann $ a_{k-2}\cdot h_{n-k}+a_{k-1}\cdot h_{n-k-1}$, also den zweithöchsten Koeffizienten des Ergebnisses. Nachdem der letzte Koeffizient $ a_{0}$ eingegeben ist, folgen wieder Nullen, der letzte Koeffizient des Produktes ergibt sich nach $ n$ Takten somit zu $ a_{0}\cdot h_{0}$. Wenn wir über dem Körper $ E=\{0,1\}$ arbeiten, so entfallen die Multiplikationsglieder und alle Addierer, die zu Koeffizienten $ h_{i}=0$ gehören. Die Addierglieder sind dann Ringsummen-Addierer.

Beispiel 3.5

Sei $ h(x)=1\oplus x^{3}\oplus x^{4}\oplus x^{5}$. Hierfür ergibt sich dann folgender linearer Schaltkreis

\includegraphics[%%
width=0.75\textwidth]{LSK_Mul2.eps}

Auf ähnliche Art und Weise ergibt sich ein Schaltkreis für die Division eines beliebigen Polynoms $ f(x)$ mit Grad $ n-1$ durch ein festes Polynom $ h(x)$ vom Grad $ n-k$. Wir wollen uns dies am folgenden Beispiel klarmachen:

Beispiel 3.6

Sei $ f(x)=f_{2}x^{2}+f_{1}x+f_{0}$ und $ h(x)=h_{1}x+h_{0}$ . Dann gilt:

$\displaystyle f(x):h(x)=\frac{f_{2}}{h_{1}}x+\frac{f_{1}-\frac{f_{2}h_{0}}{h_{1}}}{h_{1}}$

mit Rest

$\displaystyle f_{0}-h_{0}\cdot\frac{f_{1}-\frac{f_{2}h_{0}}{h_{1}}}{h_{1}}$

Hieraus ergibt sich:

$\displaystyle f(x):h(x)=f_{2}h_{1}^{-1}x+(f_{1}-f_{2}h_{0}h_{1}^{-1})h_{1}^{-1}+$Rest

Die Division geht auf, falls der Rest gleich Null ist. Allgemein erhält man als höchsten Koeffizienten des Quotienten den Faktor $ h_{n-k}^{-1}$, als Koeffizient der zweithöchsten Potenz den Faktor $ h_{n-k}^{-2}$ usw. Zudem wird in jedem Schritt mit einer Differenz multipliziert. Den Vorzeichenwechsel erhält man durch Multiplikation mit negativen Zahlen.

Im allgemeinen Fall leistet der folgende lineare Schaltkreis die Division:

\includegraphics[%%
width=0.75\textwidth,
keepaspectratio]{LSK_Div.eps}

Gehen wir wieder von einer Vorbelegung der Delays mit Null aus, so bleibt während der ersten $ n-k-1$ Takte der Output Null, dann hat $ f_{n-1}$ das Ende des Registers erreicht und es wird $ f_{n-1}\cdot h_{n-k}^{-1}$ ausgegeben. Nach dem nächsten Takt erhält man dann $ (f_{n-2}-f_{n-1}h_{n-k-1}h_{n-k}^{-1})h_{n-k}^{-1}$ usw.

Im Fall des Booleschen Körpers ergeben sich analoge Vereinfachungen wie bei der Multiplikation, wie wir an folgendem Beispiel exemplarisch erkennen.

Beispiel 3.7

Sei $ h(x)=1\oplus x\oplus x^{4}$. Wegen $ h_{i}=-h_{i}$ und $ h_{n-k}=1$ (Beachte $ K=B$) ergibt sich dann folgender linearer Schaltkreis

\includegraphics[]{LSK_Div2.eps}

5 Programmierbare Logische Arrays (PLA)

Wir wollen nun die in den letzten beiden Kapiteln besprochenen Schaltnetze und Schaltwerke mit Hilfe eines universellen, programmierbaren Bausteins realisieren.

Wir gehen hierzu von der Tatsache aus, dass man die DNF oder KNF einer Booleschen Funktion mit Hilfe von Gattern mit ausreichend hoher Zahl von Eingängen über eine zweistufige Schaltung darstellen kann. Wir wollen nun einen Einheitsbaustein betrachten, der in der Lage ist, eine beliebige zweistufige Schaltung zu realisieren.

1 Aufbau und Arbeitsweise eines PLA

Ein solcher Einheitsbaustein ist das sogenannte Programmierbare Logische Array (PLA). Dieser Baustein besteht aus einer Gitterstruktur von Drähten, an deren Kreuzungspunkte jeweils ein einheitlich formatierter Baustein platziert ist, der vier verschiedene Funktionen einnehmen kann. Die vier verschiedenen Funktionen seien im Folgenden mit 0, 1, 2 und 3 bezeichnet, und haben die im folgendem Bild gezeigte Wirkung als Identer, Addierer, Multiplizierer und Negat-Multiplizierer.

\includegraphics[]{PLA1.eps}

Alle Bausteine enthalten zwei Eingänge, von links und oben, und zwei Ausgänge, nach rechts und unten. Ihnen ist gemeinsam, dass mindestens ein Eingang unverändert an einen Ausgang übergeben wird, beim Identer sogar beide. Der Addierer gibt auf den rechten Ausgang die Summe der Eingänge und an den unteren den oberen Eingang aus. Der Multiplizierer gibt an den unteren Ausgang das Produkt der beiden Eingänge und schaltet den linken Eingang auf den rechten Ausgang durch. Analoges passiert beim Negat-Multiplizierer, hier wird allerdings der linke Eingang vor der Multiplikation negiert.

Die Realisierung der PLA Bausteintypen mittels Gattern sieht dann so aus:

\includegraphics[]{PLA2.eps}

Beispiel 1.1

Wählen wir ein PLA mit $ n=5$ Inputs auf der linken Seite und $ m=5$ Outputs auf der rechten Seite sowie $ k=4$ Spalten, so können wir die Schaltfunktion $ F:B^{3}\to B^{2}$ mit $ F(x,y,z)=(\overline{y}z+xyz,xz+xy\overline{z})=(u,v)$ wie folgt in diesem PLA verschalten:

\includegraphics[]{PLA4.eps}

Von den 5 möglichen Inputs werden nur drei benötigt. Die unteren beiden ''sperren'' wir deshalb durch anlegen einer '0' am linken Eingang des linken Bausteins. Außerdem werden die oberen Eingänge der oberen Bausteine nicht nach außen geführt und durch Eingabe '1' neutralisiert. Der links an der oberen Bausteinreihe anliegende Input wird somit unverändert (Baustein 2) oder negiert (Baustein 3) an den unteren Output der oberen Bausteinreihe weitergeleitet. In jeder Spalte des PLA wird nun in den oberen 3 Zeilen einer der vier Literale der disjunktiven Darstellung von $ u$ und $ v$ erzeugt, wir benötigen hierzu nur die Bausteine 0, 2 und 3. In den unteren beiden Zeilen werden dann die zusammengehörenden Terme addiert, was lediglich mit den Bausteinen 0 und 1 geschieht. Diese Trennung ist im Bild durch die gestrichelte Linie angedeutet.

Die im Beispiel angesprochene Trennung findet grundsätzlich in einem PLA statt. Man unterscheidet hier die 'UND-Ebene', die lediglich aus Identer und Multiplizierer (Bausteine 0, 2, 3) und der 'ODER-Ebene', die aus Identer und Addierer aufgebaut ist. Liegt eine zu verschaltende Funktion in disjunktiver Form vor, so werden in der UND-Ebene alle benötigten Produktterme aufgebaut und in der ODER-Ebene die entsprechenden Terme addiert. Die Inputs führen nur noch in die UND-Ebenen hinein und die Outputs kommen aus der ODER-Ebene heraus. Demzufolge werden die linken Eingänge der ODER-Ebene durch '0' gesperrt, die oberen Eingänge der UND-Ebene durch '1' neutralisiert. Die Ausgänge der 'UND-Ebene' und die unteren Ausgänge der letzten Zeile werden nicht herausgeführt. Somit stellt sich ein allgemeines PLA wie folgt dar:

\includegraphics[]{PLA5.eps}

Vereinfachend lässt sich nun ein PLA mit $ n$ Eingängen, $ m$ Ausgängen und $ k$ Spalten als $ (n+m)\times k$ Matrix darstellen, in der nur noch die Ziffern 0, 1, 2 und 3 eingetragen werden.

Eine der ersten Realisierungen als Baustein war der DM 7575 von National Semiconductor, der mit 14 Inputs, 8 Outputs und 96 Spalten versehen war, d.h. es waren insgesamt $ 14\times96+8\times96=2112$ Bausteine verdrahtet. Von den insgesamt $ 2^{8\cdot2^{14}}$ verschiedenen Funktionen $ F:B^{14}\rightarrow B^{8}$ konnten mit diesem Baustein insgesamt $ 2^{8\cdot96}=2^{768}=1.5\cdot10^{231}$ verschiedene solcher Funktionen geschaltet werden, falls sie in DNF vorliegen.

Generell kann man festhalten:

Satz 1.2
Durch geeignete Eintragung in die PLA-Matrix kann jede Schaltfunktion in einem hinreichend großen PLA realisiert werden. Die Anzahl der Inputs legt die Anzahl der Zeilen der UND-Ebene fest, die Anzahl der Outputs bestimmt die Zeilen der ODER-Ebene und die Anzahl der verschiedenen Produktterme legt die Anzahl der Spalten fest.

Bei der Beschaltung eines PLA ist nur dann eine Vereinfachung der darzustellenden Funktion, z.B. mit dem Algorithmus von Quine & McCluskey, notwendig, wenn die Anzahl der verfügbaren Spalten des PLA's nicht ausreicht, um die DNF zu verschalten. Ansonsten wähle man der Einfachheit halber stets die DNF.

2 Programmierung eines PLA, universelles PLA

Wir haben bei der Diskussion des Aufbaus und der Arbeitsweise eines PLA festgestellt, dass eine beliebige Funktion in das PLA ''einprogrammiert'' werden kann, indem man die Funktion der einzelnen Bausteine festlegt. Eine solche Programmierung kann durch einen Ätzprozess erfolgen, womit eine einmal realisierte Funktion in einem PLA nicht mehr geändert werden kann. Diese Vorgehensweise eignet sich z.B. bei der Herstellung von großen Anzahlen von Bausteinen mit gleicher Funktion z.B. für eine Steuerung.

Ein anderer Ansatz geht wie folgt vor:
An jeder Zelle des PLA's werden zusätzlich zwei weitere Eingänge angelegt, die eine Steuerfunktion für diese Zelle übernehmen. Mittels dieser beiden Eingänge kann nun die jeweilige Funktion der Zelle durch Auswahl der Codierungen 0, 1, 2 und 3, die ja durch zwei Bit möglich ist, festgelegt werden. Dies sähe dann wie folgt aus:

\includegraphics[]{PLA6.eps}

Nun wird über ein ROM das PLA angesteuert, d.h. das PLA wird über ein ''Programm'', welches bei $ M$ PLA Bausteinen aus einer Folge von $ 2M$ Bits besteht, programmiert. Für das oben beschriebene DM 7575 PLA wären also $ 2\cdot2112=4224$, also etwas mehr als 4 KByte notwendig. Dies ist bei den heute zur Verfügung stehenden EPROM (Electrical Programmable ROM) Bausteinen kein Problem. Diese EPROM können mit speziellen ''Brennern'' elektrisch programmiert werden und über UV-Strahlung auch wieder gelöscht werden. Andere Möglichkeiten sind EEPROM (Electrical Erasable Programmable ROM) Bausteine, die sowohl elektrisch programmiert als auch gelöscht werden können.

Andere Abwandlungen von PLA's speisen sowohl die Variablen als auch die negierten Inputs ein, so dass in der UND-Ebene ebenfalls neben dem Identer nur noch ein weiterer Baustein (der Baustein 2) notwendig ist. Abwandlungen dieses Typs sind dann die sogenannten PAL's (Programmable And Logic), die in der ODER-Ebene zu jedem Output eine feste Anzahl von Implikanten verdrahtet haben, und nur noch die UND-Ebene frei programmierbar ist. Hierbei ist es dann nicht möglich, dass ein in der UND-Ebene erzeugtes Produkt in verschiedene Summen des Outputs eingeht.

3 Anwendungen von PLA's

Eine erste Anwendung von PLA's ist der bereits erwähnte Festwertspeicher, das ROM. Wollen wir etwa $ 2^{n}$ Worte der Länge $ m$ speichern, können wir den Speicher als eine $ m\times2^{n}$ Matrix auffassen, deren Spalten die Adressen von 0 bis $ 2^{n}-1$ entsprechen. Mit Hilfe eines PLA kann man dies wie folgt realisieren: Man fasst die ODER-Ebene des PLA als Speicher auf und codiert in der UND-Ebene die Adressen von 0 bis $ 2^{n}-1$, d.h. alle Minterme der Länge $ n$. In der Spalte, die zum Minterm $ k$ gehört, wird dann in der ODER-Ebene das entsprechende Datum abgelegt, welches an dieser Adresse gespeichert werden soll. Wollen wir Daten der Länge $ m$ Bit speichern, benötigen wir also $ m$ Zeilen in der ODER-Ebene, und um $ 2^{n}$ Informationen speichern zu können, $ n$ Zeilen in der UND-Ebene. Die Anzahl der Spalten muss $ 2^{n}$ betragen.

Beispiel 3.1
Für den Fall $ n=3$ und $ m = 4$ sieht dies dann wie folgt aus, wobei die Daten im ROM-Teil willkürlich gewählt sind. Durch Anlegen der binär codierten Adresse 5 wird genau in der Spalte der UND-Ebene, die den Minterm 5 implementiert, eine '1' am unteren Ausgang der UND-Ebene erzeugt, bei allen anderen Mintermen wird eine '0' generiert. Hierdurch wird in der ODER-Ebene der Inhalt der Zelle, die zur Adresse 5 gehört, zu den ''Nullen'' aus den anderen Adressspalten addiert und somit am Ausgang ausgegeben.

\includegraphics[]{PLA7.eps}

Allgemein sieht also die Realisierung eines ROM's über ein PLA wie folgt aus:

\includegraphics[]{PLA8.eps}

Eine weitere Anwendung von PLA's ist die Verwendung in Schaltwerken. Hier werden ein Teil der Outputs über Delays an einen Teil der Inputs zurückgekoppelt. So lassen sich z.B. Zählerschaltungen mittels PLA's realisieren. Eine allgemeine Architektur hierfür könnte etwa wie folgt aussehen:

\includegraphics[]{PLA9.eps}

Bausteine, die sowohl ein PLA als auch eine Rückkoppelung mittels Delays enthalten, werden integrierte PLA's genannt.

Mit dieser Architektur kann z.B. auch ein Addierer unter Verwendung eines PLA's aufgebaut werden. Ein Akku und ein zweites Register für die Operanden sowie ein Delay für den Übertrag werden hier zurückgekoppelt. Abhängig von zwei weiteren Steuerleitungen könnte man z.B. die Addition und Subtraktion gleichzeitig realisieren.

Ein derartiger Addierer könnte also wie folgt aussehen:

\includegraphics[]{PLA10.eps}

6 Bemerkungen zum Entwurf von VLSI Schaltungen

Bisher haben wir bei unseren Betrachtungen technologische Gesichtspunkte im wesentlichen ausgeklammert. Diese spielen jedoch beim Design und bei der zu erzielenden Leistungsfähigkeit eines Rechners eine entscheidende Rolle.

Die Entwicklung ist gerade bei der Fertigungstechnologie in den letzten Jahren sehr rasant fortgeschritten, womit gleichzeitig die Leistung stieg und der Preis der Rechner fiel.

Hatte der 1946 an der University of Pennsylvania entwickelte ENIAC Rechner noch 18000 Röhren, benötigte eine Standfläche von 300 m$ ^{2}$, wog 30 t, hatte eine Leistungsaufnahme von 50000 W und kostete damals in der Herstellung 500000 Dollar, so wird seine Leistung heute von einem Taschenrechner erreicht. Die rasante Verkleinerung der Geräte wurde mit der Erfindung des Transistors im Jahre 1948 ausgelöst. Schnell danach erschienen die ersten integrierten Bausteine, in denen Gatter, Delays und Verbindungsdrähte innerhalb eines Gehäuses, dem sogenannten Chip, in einem gemeinsamen Herstellungsprozess gefertigt werden.

Ein Maß für die fortschreitende Technologie ab 1965 ist die Anzahl der Gatter pro Chip

SSI Small Scale Integration $ \leq 10$ Gatter pro Chip
MSI Medium Scale Integration $ > 10$ und $ \leq 10^2$ Gatter pro Chip
LSI Large Scale Integration $ > 10^2$ und $ \leq 10^5$ Gatter pro Chip
VLSI Very Large Scale Integration $ > 10^5$ Gatter pro Chip

Hierbei sei bemerkt, dass man verschiedentlich auch die Angabe Transistoren pro Chip vorfindet. In diesem Fall sind obige Zahlen mit 3 bis 5 zu multiplizieren. Als Fortführung der VLSI Technologie wird bereits von der ULSI (Ultra Large Scale Integration) Technologie gesprochen.

Die bei den VLSI Bausteinen benötigten Elemente, Gatter, Delays und Verbindungen sind in drei verschiedenen Ebenen untergebracht. In der heute üblichen NMOS-Technologie (Negative Channel Metal Oxid Semiconductor) werden diese Ebenen entsprechend der folgenden Graphik angeordnet.

\includegraphics[]{VLSI1.eps}

Eine Verbindungsleitung unterbricht die Isolation zwischen den Ebenen, und es lassen sich durch gezielte Unterbrechung der Isolation und Schaffung eines Kontaktes zwischen der Metall- und der Polysilizium-Ebene auch Transistoren erzeugen.

Die uns in diesem Zusammenhang vorrangig interessierende Frage ist, wie klein und wie schnell ist ein Chip, der eine bestimmte vorgegebene Aufgabe lösen soll, in einer vorgegebenen Technologie überhaupt zu designen? Hierzu wollen wir einige einführende Bemerkungen machen.

1 Komplexität von VLSI Schaltungen

Wir wollen den Zusammenhang zwischen der Größe eines Chips, der ein bestimmtes Problem löst, und dessen Ausführungszeit zur Lösung des Problems analysieren. Wir gehen dazu von folgendem VLSI-Modell aus:

Ein VLSI-Chip besteht logisch aus einer Gitterstruktur von aufeinander senkrecht stehenden Gitterlinien pro Ebene. Wir nehmen an, dass Verbindungsdrähte zwischen den einzelnen Schaltelementen nur entlang dieser Gitterlinien verlaufen können. Weiterhin dürfen sich Drähte in verschiedenen Ebenen zwar kreuzen (wegen der Isolierung), aber nicht stückweise übereinander verlaufen, um Störungen durch Induktion zu vermeiden. Ein Ebenenwechsel kann durch einen Kontakt an einem Gitterpunkt erfolgen. Aufgrund der Fertigungstechnologie müssen wir davon ausgehen, dass nicht alle Drähte exakt auf einer Gitterlinie platziert werden, sondern um maximal einer ''technologischen Abstandskonstante'' $ \lambda>0$ hiervon abweichen. Der Abstand von zwei Gitterlinien ist dann ein kleines Vielfaches, z.B. $ 5\lambda$. Bereits 1975 war ein Wert von $ \lambda=6\cdot10^{-6}$ m erreichbar, 1997 war eine Verkleinerung auf weniger als $ 0.65\mu$m$ -0.35\mu$m erreicht. Zu dieser Zeit aktuelle Prozessoren wie Pentium 200MMX oder PowerPC 604e waren in $ 0.25\mu$m Technologie gefertigt (zum Vergleich: ein menschliches Haar ist etwa $ 400\mu$m dick). Ein für den Servereinsatz konzipierter Microprozessor wie der Intel PentiumPro verfügte z.B. auf einer Fläche von $ 306\;$mm$ ^{2}$ über 20.5 Millionen Transistoren (davon 15 Mill. für den integrierten L2 Cache), war in $ 0.5\mu$m Technologie gefertigt und hatte 387 Pins.
Heute aktuelle Prozessoren werden in $ 0.13\mu$m Technologie gefertigt und nehmen bis zu 100 Millionen Transistoren auf. Die internen Taktraten liegen bei 1.4 Gigahertz.

Wegen der Gitterstruktur ist somit die Kantenlänge auch ein Vielfaches des Gitterabstandes und damit von $ \lambda$. Die Chipfläche sei mit $ A$ bezeichnet und ist dann ein Vielfaches von $ \lambda^{2}$. Die Größe des Chips hat entscheidenden Einfluss auf die Herstellungskosten, da bei kleineren Chips mehr aus einem Wafer gewonnen werden können und vor allen Dingen die Ausschussrate von nahezu 90 %, die aus Fehler auf den Wafern resultiert, kann bei kleiner Chipfläche besser minimiert werden.

Die auf der Schaltung platzierten Gatter liegen in den Kreuzungspunkten des Gitters, und nehmen je nach Anzahl der Gatterinputs und -outputs mehrere Gitterpunkte ein. Wir wollen den maximal vorkommenden Fan-In (und damit die maximal Anzahl Gitterpunkte, die durch ein Gatter überdeckt werden) mit $ \kappa$ bezeichnen.

Die in einen VLSI-Baustein hinein- und herausführenden Kontakte sind groß im Vergleich zu der inneren Strukturgröße $ \lambda$, so z.B. $ (100\lambda)^{2}$. Daher ist die gesamte Fläche eines Chips häufig wesentlich größer als durch den eigentlichen Kern notwendig wäre. Wir wollen dies momentan außer Acht lassen, und davon ausgehen,dass von den Kontakten, den sog. Pads, Verbindungsleitungen in der Breite der anderen Verbindungsdrähte in den Chipkern hineinführen.

\includegraphics[%%
width=8cm]{P6.eps}

Wir wollen uns nun der folgenden Frage nach den Grenzen der Möglichkeiten von VLSI-Schaltungen widmen. Genauer heißt dies:
Gegeben sei ein durch eine Schaltfunktion $ F$ beschreibbares Problem. Gibt es eine untere Grenze für die Fläche $ A$ bzw. die Arbeitszeit $ T$ eines VLSI-Chips, welcher dieses Problem löst, d.h. welcher zu jedem Input $ x\in B^{n}$ einen Output $ F(x)\in B^{m}$ berechnet?

Aussagen der folgenden Form scheinen sinnvoll:

Je größer die Komplexität von $ F$, desto
a) größer muss die Chipfläche $ A$ sein bzw.
b) länger muss die Rechenzeit $ T$ sein.
Häufiger werden allerdings Aussagen über das Produkt von Fläche und Zeit gemacht, da mit wachsender Komplexität nicht notwendig Fläche und Zeit anwachsen müssen. Wir werden später sehen, dass eine Aussage folgender Art gilt:

Je größer die Komplexität von $ F$, desto größer ist
a) $ A\cdot T$ bzw.
b) $ A\cdot T^{2}$.
Dabei geben wir $ A$ und $ T$ in Einheiten einer Grundfläche $ A_{0}$ und einer Grundzeit $ T_{0}$ an.

Der folgende Satz gibt eine erste, einfache untere Schranke an, die allerdings nicht besonders scharf ist.

Satz 1.1
Sei $ F: B^n \to B^m$ eine Schaltfunktion und $ k = \max\{n, m\}$. Dann gilt für einen Modellschaltkreis (MSK), der $ F$ berechnet:

$\displaystyle A \cdot T \geq k.$

BEWEIS: Sei $ C$ ein Musterschaltkreis mit Fläche $ A$, dann hat $ C$ höchstens $ A$ Ports. Er benötigt daher mindestens $ k/A$ Takteinheiten, um alle Input-Bits zu lesen (falls $ k = n$) oder alle Output-Bits zu schreiben, also gilt:

$\displaystyle T \geq \frac{k}{A},$   d. h. $\displaystyle A \cdot T \geq k.$

$ \Box$

Ein zweiter Satz gibt nun eine Schranke für $ A\cdot T^{2}$:

Satz 1.2
Sei $ C$ ein MSK der Fläche $ A$, welcher $ n$ Dualzahlen der Stellenzahl $ k$ $ (k \geq \lceil$   ld $ n + 1 \rceil)$ in $ T$ Zeittakten sortiert. Dann gibt es eine Konstante $ K > 0$ so, dass

$\displaystyle A \cdot T^2 \geq K \cdot n^2$

gilt.

Dabei ist

$\displaystyle K = \frac{1}{9 \cdot L^2 \cdot \kappa^2}$

wobei $ L$ die Anzahl der Ebenen und $ \kappa$ den maximalen Gatter-Fan-In beschreibt.

BEWEISIDEE: (vollständiger Beweis siehe Oberschelp, Vossen [7])
Wir betrachten einen Modellschaltkreis (MSK), welcher die Sortierfunktion $ S:B^{nk}\rightarrow B^{nk}$ berechnet, und schneiden diesen parallel zur kürzeren Seite etwa in der Mitte auf. Pro Takt kann dann nur ein gewisser Informationsfluss über diesen Schnitt gehen. Der Schnitt wird nun so gewählt, dass er ein Maß für die Komplexität von $ S$ darstellt. Wir zeigen, dass eine gewisse Information über den Schnitt laufen muss, und schätzen die Anzahl ab.
Genauer betrachten wir zwei Fälle:

1. Fall:
$ T$ ist groß, etwa $ T\geq\frac{n}{3}$. Wegen $ A\geq1$ folgt dann unmittelbar:

$\displaystyle A\cdot T^{2}\geq1\cdot\frac{n^{2}}{9}=K\cdot n^{2}\textrm{ mit }K=\frac{1}{9}$

2. Fall:
Sei $ T<\frac{n}{3}$, d.h. auf jeden Port von $ C$ werden weniger als $ \frac{n}{3}$ Input- bzw. Output-Bits entfallen. Für die Sortierfunktion $ S:B^{nk}\rightarrow B^{nk}$ hat jeder Input die Form

$ (x_{1},...,x_{k},x_{k+1},...,x_{2k},x_{2k+1},...,x_{nk})$

und jeder Output die Form

$ (y_{1},...,y_{k},y_{k+1},...,y_{2k},y_{2k+1},...,y_{nk})$.

Von den Output-Bits betrachten wir die $ n$ niederwertigsten $ y_{k},y_{2k},\ldots,y_{nk}$ und markieren an jedem Port von $ C$, wieviele niederwertigste Bits den Schaltkreis dort verlassen. Dann teilen wir den Schaltkreis mit Hilfe eines Vertikalschnittes so auf, dass auf jeder Seite des Schnittes möglichst die Hälfte der Output-Bits liegt. Da dies natürlich nicht immer möglich ist, fordern wir nur, dass auf jeder Seite des Schnittes mindestens ein Drittel der Bits liegen. Auch das können wir nur erreichen, wenn wir im Schnitt einmal einen ''Haken'' um die Länge eines Einheitsquadrates nach links zulassen.

Beispiel: Für $ n=30$ kann man sich z.B. folgende Verteilung der niederwertigsten Bits auf die Ports denken:

\includegraphics[]{VLSI2.eps}

Ein solcher Schnitt lässt sich immer konstruieren, falls durch einen Port weniger als $ \frac{n}{3}$ Bit die Schaltung verlassen. Man beginnt dazu mit einer senkrechten Scanlinie am linken Rand und führt diese nach rechts, bis erstmals mindestens $ \frac{n}{3}$ der niederwertigsten Bits die Schaltung links vom Schnitt verlassen. Liegen weniger als $ \frac{2n}{3}$ Bits links, so ist der Schnitt gefunden. Anderenfalls sucht man von oben beginnend eine Stelle, durch die ein horizontaler Haken um die Länge eines Einheitsquadrates diese Forderung erfüllt. Da in jedem senkrechten Schritt höchstens $ \frac{n}{3}$ Outputs hinzukommen, existiert ein solcher Haken in jedem Fall.

Wenn der Schnitt konstruiert ist, betrachten wir die Verteilung der niederwertigsten Input-Bits. Hiervon werden auf einer Seite des Schnitts wenigstens $ \frac{n}{2}$ gelesen, dies sei o.B.d.A. die linke Seite. Wir haben somit folgende Situation:

$\displaystyle x_{k},x_{2k},x_{3k},\ldots{},x_{\frac{n}{2}k}$ werden links gelesen,

$\displaystyle y_{i_{1}k},y_{i_{2}k},y_{i_{3}k},\ldots{},y_{i_{\frac{n}{3}}k}$ werden rechts geschrieben.

Der MSK liest Input -- sortiert -- schreibt Output. Jede Eingabe $ \alpha$ zerfällt dann in einen Teil $ \alpha_{L}$, der links vom Schnitt $ S$ gelesen wird, und $ \alpha_{R}$, der rechts vom Schnitt gelesen wird. Sei dann $ \beta=(\beta_{L},\beta_{R})$ eine weitere Eingabe, die mit $ \alpha$ rechts übereinstimmt. Dann sagen wir, dass $ \alpha$ und $ \beta $ die Schaltung $ C$ täuschen, falls für die zu berechnende Funktion $ F$ gilt:

$\displaystyle F(\alpha_{L},\alpha_{R})=F(\beta)$ im rechten Teil.

Eine Menge $ X\subseteq B^{nk}$ heißt dann Unterscheidungsmenge für $ C$, falls je zwei Elemente $ \alpha,\beta$ die Funktion $ S$ nicht täuschen.

Man kann nun zeigen, dass es bzgl. des konstruierten Schnittes eine Unterscheidungsmenge $ A$ gibt mit mindestens $ 2^{n/3}$ Elementen. Damit muss für die ersten $ {n/3}$ unteren Input-Bits Information über den Schnitt fließen, da sonst zwei Eingaben $ \alpha$ und $ \beta $ existieren würden, die den Schaltkreis täuschen. Hierzu sind aber die Verbindungsdrähte über den Schnitt notwendig. Wenn der MSK $ C$ die Höhe $ h$ und Breite $ w$ hat, können maximal $ h-1$ (bzw. $ h$, falls der Schnitt $ S$ einen Haken hat) Verbindungsdrähte den Schnitt kreuzen. Dem Schnitt wird dann ein Wert zugeordnet, der sich aus der binären Codierung des Informationsflusses pro Verbindungsdraht bzw. Schaltelement, welches auf dem Schnitt liegt, ergibt. Bei den Verbindungsdrähten wird die Codierung so gewählt, dass die Richtung des Informationsflusses über den Schnitt hierdurch beschrieben wird, bei den Schaltelementen werden alle Inputbits in dieses berücksichtigt. Da ein Schaltelement nach Voraussetzung maximal $ \kappa$ Inputs besitzen kann, und in einem Takt über jede Leitung nur ein Bit Information fließt, ergibt sich für einen Schaltkreis mit maximal $ h$ Verbindungen, die den Schnitt treffen und $ L$ Ebenen dann die Möglichkeit der Darstellung des gesamten Informationsflusses über den Schnitt mit maximal $ h\cdot\kappa\cdot L$ Bits. Hat der Schaltkreis einen Zeitbedarf von $ T$ Takten, so können maximal $ h\cdot\kappa\cdot L\cdot T$ Bits insgesamt über den Schnitt fließen, einen solchen gesamten Informationsfluss nennt man dann eine Schnittsequenz. Da deren Länge $ h\cdot\kappa\cdot L\cdot T$ ist, gilt für die Anzahl $ c$ von Schnittsequenzen:

$\displaystyle c\leq2^{h\cdot\kappa\cdot L\cdot T}$

Man zeigt nun weiter, dass die Schnittsequenzen zu zwei Elementen einer Unterscheidungsmenge bzgl. eines festen Schnittes unterschiedlich sind. Da wir aber bereits wissen, dass eine Unterscheidungsmenge $ X$ mit $ \vert X\vert\geq2^{n/3}$ gibt, erhalten wir für die Anzahl der Schnittsequenzen $ c\geq2^{n/3}$. Somit gilt also insgesamt:

$\displaystyle 2^{h\cdot\kappa\cdot L\cdot T}$ $\displaystyle \geq$ $\displaystyle c\geq2^{n/3}$  
$\displaystyle \Leftrightarrow h\cdot T\cdot L$ $\displaystyle \geq$ $\displaystyle \frac{n}{3\kappa}$  

Wegen $ w\geq h$ folgt weiter:

$\displaystyle w\cdot T\cdot L\geq\frac{n}{3\kappa}$

Nach Multiplikation erhalten wir dann insgesamt:

$\displaystyle h\cdot w\cdot T^{2}\cdot L^{2}$ $\displaystyle \geq$ $\displaystyle \frac{n^{2}}{9\kappa^{2}}$  
$\displaystyle \Leftrightarrow A\cdot T^{2}$ $\displaystyle \geq$ $\displaystyle \frac{n^{2}}{9\cdot L^{2}\cdot\kappa^{2}},$  

was zu zeigen war.

Beispiel 1.3
Ist z.B. $ \kappa=L=10$, so gilt

$\displaystyle A\cdot T^{2}\geq\frac{n^{2}}{9\cdot10^{4}}\simeq\frac{n^{2}}{10^{5}}$

Angenommen, der Sortierchip hat die Fläche $ AA_{0}=1$ cm$ ^{2}$ , dessen Einheitsquadrate $ A_{0}$ im Gitter die Kantenlänge $ 5\lambda$, mit $ \lambda=6\cdot10^{-6}$ m, haben, so gilt:


    $\displaystyle (5\lambda)^{2}=900\cdot10^{-12}$ m$\displaystyle ^{2}$  
  $\displaystyle \Rightarrow$ $\displaystyle A=\frac{10^{-4}\mbox{ m}^{2}}{900\cdot10^{-12}\mbox{ m}^{2}}\simeq10^{5}$  

Falls der Schaltkreis in der Lage ist, $ n$ eingegebene $ k$-stellige Dualzahlen zu sortieren, so benötigt er z.B. für $ n=10^{12}$ und $ k\simeq41$ die Zeit:


$\displaystyle T^{2}$ $\displaystyle \geq$ $\displaystyle \frac{10^{24}}{10^{5}\cdot10^{5}}=10^{14}$  
$\displaystyle \Rightarrow T$ $\displaystyle \geq$ $\displaystyle 10^{7}$  

Bei einer Taktdauer $ T_{0}$ von $ 10^{-8}$ Sekunden ergibt sich somit:

$\displaystyle TT_{0}\geq\frac{10^{7}}{10^{8}}$ sec$\displaystyle =0.1$ sec$\displaystyle .$

2 Layout von VLSI-Schaltungen -- H-Bäume

Wir wollen abschließend noch kurz eine Möglichkeit diskutieren, wie man ein Schaltnetz für eine Boolesche Funktion statt in Form eines DAG anders darstellen kann, um diese unmittelbar in ein VLSI Layout, welches unserer Annahme nach einer gitterförmig aufgebauten Struktur entspricht, umzusetzen.

Wir gehen hierbei von einem binären Minterm-Baum aus, der beginnend von der Wurzel ''1'' in jedem Level eine Variable und deren Komplement neu einspeist und so alle möglichen Minterme erzeugt.

Im Fall von drei Variablen sieht dies dann folgendermaßen aus:

\includegraphics[]{H_Baum1.eps}

Dieser binäre Mintermbaum kann wie folgt in einen rechteckigen H-Baum umorganisiert werden, die folgende Graphik zeigt den Fall des Levels 2. Bei höheren Levels werden an den Blättern entsprechende ''H'' hinzugefügt.

\includegraphics[]{H_Baum2.eps}

Im vollständigen H-Baum repräsentiert jetzt jedes Blatt genau einen Minterm. Jede Boolesche Funktion kann somit durch Weglassen der nicht benötigten Minterme in Form eines Teilbaumes des vollständigen H-Baums organisiert werden. An den Blättern befinden sich dann die entsprechenden Minterme, die in einer zweiten Stufe durch Hinzufügen von ODER-Gattern zur Funktion aufaddiert werden.

Beispiel 2.1
Betrachten wir die Funktion

$\displaystyle f(x_1, x_2, x_3) = \overline{x}_1 x_2 x_3 + x_1 \overline{x}_2 x_3 + x_1 x_2 x_3.
$

Wir erhalten dann den folgenden H-Baum:

\includegraphics[%%
width=0.90\textwidth]{H_Baum3.eps}

7 Grundlegende Additions- und Multiplikationsalgorithmen und Schaltungen

In diesem Kapitel sollen grundlegende Algorithmen und zugehörige Schaltungen oder Architekturen diskutiert werden, die zur Addition oder Multiplikation von zwei binär codierten Zahlen dienen. Hieran wollen wir auch noch einmal den Zusammenhang zwischen Anzahl (Fläche) benötigter Gatter und Geschwindigkeit der Schaltung diskutieren.

1 Addition

Im dritten Kapitel hatten wir bereits die Grundbausteine Halbaddierer und Volladdierer und deren Realisierung mittels elementarer Gatter diskutiert. Aus dem Volladdierer kann nun ein Schaltkreis zur Addition von Dualzahlen designt werden, der bitseriell arbeitet und daher mit minimalem Hardwareaufwand auskommt, allerdings sehr viele Takte benötigt, um das Ergebnis zu bestimmen. Eine solche Schaltung könnte prinzipiell so aussehen:

\includegraphics[]{Ser_add.eps}

Ein andere Möglichkeit wäre ein Pipeline-Ansatz. Dieser ist dann sinnvoll, wenn eine Kolonne mit $ k$ Paaren aus jeweils 2 Zahlen der Länge $ n$-Bit addiert werden soll.

\includegraphics[]{Pipe_add.eps}

Die Summe der ersten beiden Zahlen ergibt sich hierbei nach $ (n-1)$ Takten. Nach dem $ n$-ten Takt steht allerdings schon das Ergebnis der nächsten Addition am Ausgang an. So sind nach $ n+k-1$ Takten alle Summen bestimmt, statt in $ n\cdot k$ Takten wie bei einer reinen seriellen Addition. Allerdings ist hier ein erhöhter Hardwareaufwand notwendig. Wir benötigen für Zahlen der Länge $ n$-Bit insgesamt $ 0.5\cdot n\cdot(n+1)$ Halbaddierer und $ 0.5\cdot(n-1)\cdot n$ Delays.

1 Von-Neumann-Addierwerk

Als nächstes wollen wir ein von-Neumann-Addierwerk betrachten. Wir wollen dies für 4-Bit Zahlen tun, eine Erweiterung auf $ n$-Bit wird aber sofort ersichtlich sein. Das von-Neumann-Addierwerk besteht aus einem $ n$-Bit breiten Akku und einem $ n$-Bit breiten Puffer sowie einem Status-Delay $ S$ und einem Übertragsdelay $ U$. Die beiden Summanden stehen zu Beginn im Akku und im Puffer, das Ergebnis steht im Akku und im Übertragsdelay. Das Statusdelay gibt an, ob die aktuelle Berechnung abgeschlossen ist oder nicht.

Eine Schaltung hierfür sieht folgendermaßen aus:

\includegraphics[%%
width=0.75\textwidth,
keepaspectratio]{vn_add.eps}

Zuerst werden alle Stellen parallel addiert. Dies geschieht für jede Stelle mit einem Halbaddierer, der das Resultat $ A_{i}\oplus P_{i}$ wieder in die entsprechende Zelle des Akkus und den Übertrag $ A_{i}\cdot P_{i}$ nach $ P_{i+1}$ bzw. in das Übertragsdelay schreibt. In $ P_{0}$ wird durch die zusätzliche Logik dafür gesorgt, dass ab dem zweiten Takt dort eine Null steht und somit auch der Übertrag, der vom Halbaddierer für die niederwertigste Stelle erzeugt wird, im zweiten Takt Null ist. Im nächsten Schritt werden dann die Teilüberträge nach dem gleichen Schema wieder zum Akku hinzuaddiert. Sind nach einer gewissen Zeit nur noch Nullen im Puffer, erscheint am Status Delay eine Null, was gleichbedeutend mit abgeschlossener Rechnung ist. Die Logik vor dem Übertragsdelay sorgt dafür, dass ein einmal erzeugter Übertrag erhalten bleibt, egal in welcher Phase der Addition er entsteht.

Allgemein gilt:
Im $ (i+1)$-ten Schritt der Addition sind mindestens die $ i$ rechten Stellen des Puffers Null, d.h. nach höchstens $ n+1$ Schritten ist eine $ n$-stellige Addition abgeschlossen. Es gilt der folgende Satz, der mit wahrscheinlichkeitstheoretischen Ansätzen über die Verteilung von Null- und Eins-Bits in den Summanden argumentiert:

Satz 1.1
Das $ n$-Bit von-Neumann-Addierwerk addiert zwei Summanden durchschnittlich in ld$ \; n + 1$ Schritten.

Betrachten wir zur Arbeitsweise ein Beispiel.

Beispiel 1.2
Es sollen die Summen $ 13 + 11$, $ 10 + 12$, $ 15 + 15$, $ 9 + 10$ und $ 0 + 0$ gebildet werden. In den einzelnen Schritten hat das Addierwerk folgende Belegungen:
      Puffer- Puffer- Akku- Akku-  
      Inhalt Inhalt Inhalt Inhalt  
      dual dezimal dual dezimal  
Zeile $ S$ $ U$ $ P_3 P_2 P_1 P_0$   $ A_3 A_2 A_1 A_0$   Schritt
1 0 0 $ 0000$ 0 $ 0000$ 0  
2 $ 1$ 0 $ 1101$ 13 $ 1011$ 11 1
3 $ 1$ $ 1$ $ 0010$ 2 $ 0110$ 22 2
4 $ 1$ $ 1$ $ 0100$ 4 $ 0100$ 20 3
5 $ 1$ $ 1$ $ 1000$ 8 $ 0000$ 16 4
6 0 $ 1$ $ 0000$ 0 $ 1000$ 24 5
7 $ 1$ 0 $ 1010$ 10 $ 1100$ 12 1
8 0 $ 1$ $ 0000$ 0 $ 0110$ 22 2
9 $ 1$ 0 $ 1111$ 15 $ 1111$ 15 1
10 $ 1$ $ 1$ $ 1110$ 14 $ 0000$ 16 2
11 0 $ 1$ $ 0000$ 0 $ 1110$ 30 3
12 $ 1$ 0 $ 1001$ 9 $ 1010$ 10 1
13 0 $ 1$ $ 0000$ 0 $ 0011$ 19 2
14 $ 1$ 0 $ 0000$ 0 $ 0000$ 0 1
15 0 0 $ 0000$ 0 $ 0000$ 0 2

2 Carry-Look-ahead Addition

Ein Problem bei der Addition ist das Auftreten von Überträgen bei der Addition der einzelnen Bits modulo zwei. Da ein entstehender Übertrag auf alle weiteren Bits links dieser Position Einfluss nehmen kann, müssen selbst Schaltungen, die die Bits der Summanden parallel addieren, ggf. die entstehenden Überträge verarbeiten (siehe z.B. von-Neumann-Addierwerk). Somit müssen die verschiedenen Volladdierer in diesem Fall als hintereinandergeschaltete Stufen angesehen werden.

Eine Beschleunigungsmöglichkeit könnte nun darin bestehen, durch zusätzliche Hardware, die mit möglichst wenigen Stufen auskommt, alle Überträge vorherzusagen und diese dann schon im ersten Additionsschritt mit zu verwenden. Es ist jedoch einsichtig, dass bei größerer Länge der Summanden der Aufwand für die höherwertigen Stellen immer weiter steigt. Ein Kompromiss ist daher, die Bits zu gruppieren und innerhalb der Gruppen die Überträge der einzelnen Stellen vorherzubestimmen. Sinnvolle Gruppengrößen sind 4, 5 oder 6. Ein Verfahren, welches mit einer solchen Technik arbeitet, ist der Carry-Look-ahead Addierer (CLA), den wir kurz erläutern wollen.

Wir gehen aus von den gegebenen Bits einer Gruppe $ (y_{k}\ldots y_{0})$ und $ (x_{k}\ldots x_{0})$. Weiter seien die Überträge mit $ (r_{k}\ldots r_{0})$ bezeichnet, wobei man sich unter $ r_{0}$ den einlaufenden Übertrag aus der vorherigen Gruppe vorstellen kann. Die Überträge lassen sich dann bei gegebenem $ r_{0}$ rekursiv wie folgt bestimmen:

$\displaystyle r_{1}$ $\displaystyle :=$ $\displaystyle x_{0}y_{0}+r_{0}(x_{0}\oplus y_{0})$  
$\displaystyle r_{2}$ $\displaystyle :=$ $\displaystyle x_{1}y_{1}+r_{1}(x_{1}\oplus y_{1})=x_{1}y_{1}+x_{0}y_{0}(x_{1}\oplus y_{1})+r_{0}(x_{0}\oplus y_{0})(x_{1}\oplus y_{1})$  
$\displaystyle \vdots$   $\displaystyle \vdots$  
$\displaystyle r_{i}$ $\displaystyle :=$ $\displaystyle x_{i-1}y_{i-1}+r_{i-1}(x_{i-1}\oplus y_{i-1})$  
  $\displaystyle =$ $\displaystyle x_{i-1}y_{i-1}+x_{i-2}y_{i-2}(x_{i-1}\oplus y_{i-1})+x_{i-3}y_{i-3}(x_{i-2}\oplus y_{i-2})(x_{i-1}\oplus y_{i-1})$  
    $\displaystyle +\dots+r_{0}\prod_{j=0}^{i-1}(x_{j}\oplus y_{j})$  

Führen wir folgende Abkürzungen ein:

$\displaystyle p_{i}$ $\displaystyle :=$ $\displaystyle x_{i}\oplus y_{i}=\left\{ \begin{array}{ll}
1 & \mbox{Übertrag setzt sich fort}\\
0 & \mbox{Übertrag wird aufgezehrt}\end{array}\right.$  
$\displaystyle g_{i}$ $\displaystyle :=$ $\displaystyle x_{i}y_{i}=\left\{ \begin{array}{ll}
1 & \mbox{Übertrag generiert}\\
0 & \mbox{Übertrag absorbiert, }\end{array}\right.$  

so ist $ p_{i}=1$, falls $ x_{i}$ und $ y_{i}$ verschieden sind, und somit muss der Übertrag $ r_{i}$ weitergeleitet werden (Propagation). Sind $ x_{i}$ und $ y_{i}$ beide gleich Eins, so wird ein neuer Übertrag $ r_{i+1}$ erzeugt (Generation), der unabhängig vom einlaufenden Übertrag $ r_{i}$ entsteht. Nur im Fall von $ x_{i}=y_{i}=0$ wird ein einlaufender Übertrag absorbiert und es entsteht für die nächste Stelle kein neuer Übertrag.

Mit den Abkürzungen erhalten wir also für die einzelnen Überträge folgende Darstellung:

$\displaystyle r_{0}$   gegeben  
$\displaystyle r_{1}$ $\displaystyle :=$ $\displaystyle g_{0}+r_{0}p_{0}$  
$\displaystyle r_{2}$ $\displaystyle :=$ $\displaystyle g_{1}+g_{0}p_{1}+r_{0}p_{0}p_{1}$  
$\displaystyle \vdots$   $\displaystyle \vdots$  
$\displaystyle r_{i}$ $\displaystyle :=$ $\displaystyle g_{i-1}+g_{i-2}p_{i-1}+g_{i-3}p_{i-2}p_{i-1}+\ldots+r_{0}p_{0}p_{1}\cdot\ldots\cdot p_{i-1}$  

Pro Gruppe wird also eine Logik erzeugt, die aus den Eingaben nach obigen Gleichungen die Überträge für alle Stellen der Gruppe bestimmt. Diese werden dann mittels XOR zu der bereits für $ p_{i}$ berechneten Summe der Summandenbits addiert.

Bei steigender Gruppengröße benötigt man Gatter, die eine immer größere Anzahl von Inputs haben, um eine Zweistufigkeit zu gewährleisten. Bei Gattern mit einem hohen Fan-In steigen in der Regel aber die Gatterlaufzeiten an, so dass bei großen Gruppenlängen hierdurch der Vorteil gegenüber einer höheren Stufenanzahl bei einer reinen seriellen Addition verloren gehen kann.

Beispiel 1.3
Die Addition der folgenden Bitgruppen zeigt die Propagation und Generation der Gruppenüberträge:

% latex2html id marker 22012
\includegraphics[%%
scale=0.9]{bsp613.eps}

2 Multiplikation

Zum Abschluss des Kapitels wollen wir noch kurz auf eine Architektur zur Multiplikation von zwei $ n$-Bit Zahlen eingehen. Der Multiplizierer besteht aus drei $ n$-stelligen Registern, einem Delay und einem $ n$-Bit Addierer.

\includegraphics[%%
width=0.75\textwidth]{mult.eps}

Der Algorithmus läuft dann für die Multiplikation natürlicher Zahlen wie folgt:

Gesucht ist das Produkt $ A\cdot B$, $ A$, $ B$ $ n$-stellige Binärdarstellungen. Das Register für den Multiplikator $ M$ wird mit der Zahl $ B$ geladen und während der ganzen Rechnung nicht verändert. Der Multiplikand wird in das Register $ AC0$ geladen, $ AC1$ und $ R$ werden mit 0 initialisiert. Algorithmisch sieht die Multiplikation dann folgendermaßen aus:

  z := n;  {Anzahl der Ziffern}
  while z <> 0 do begin
    add;
    shift;
    dec(z);
  end;

Die Prozeduren add und shift haben folgende Funktion:

add    
     
$ AC0_{0}=0$ tue nichts  
     
$ AC0_{0}=1$ Addiere $ AC1+M$  
     
Schreibe Ergebnis nach $ R,AC1$,wobei der Übertrag in $ R$ steht.    
     
shift    
     
Shifte die drei Register $ R,AC1,AC0$ um 1 Bit nach rechts    


Hierbei bezeichnet $ AC0_{0}$ das letzte Bit des Registers $ AC0$. Die nicht mehr benötigten rechten Stellen des Inhalts von $ AC0$ gehen durch die Shift-Operation verloren, und pro Takt entsteht ein neues korrektes Bit des Ergebnisses in $ AC0$. Nach insgesamt $ n$ Schritten ist das komplette Produkt in den Registern $ R,AC1,AC0$ abgelegt.

Beispiel 2.1
Berechnen wir $ 12\cdot14$ in binärer Arithmetik mit obiger Architektur, so ergibt sich für die einzelnen Stufen des Algorithmus:

Multiplikator = $ (1100)_{2}$

\begin{displaymath}
\begin{array}{ccccc}
\mbox{z (nach Dec)} & \mbox{op} & \mbox...
...\vert001 & 1\vert101\vert001 & 0\vert 1010\vert 1000\end{array}\end{displaymath}


$\displaystyle 12\cdot14=(10101000)_{2}=128+32+8=168$

8 Organisationsplan eines von-Neumann-Rechners

1 Struktur eines Rechners

Wir wollen in diesem Kapitel einen Organisationsplan eines Rechners anschauen, nach dem real existierende Ein-Prozessor-Computer designt werden. Organisation bedeutet hierbei, dass wir uns zuerst nur für die logische Anordnung und das Zusammenspiel der einzelnen Komponenten eines Rechners interessieren.

In einem realen Rechner werden zu einem Zeitpunkt eine große Menge von Daten verarbeitet bzw. zwischen einzelnen Komponenten des Rechners hin und her transportiert. Der prinzipielle Aufbau eines sequentiellen Rechners sieht dabei wie folgt aus:

Abhängig vom aktuellen Input und einem aktuellen Zustand wird durch die Next-State-Logik ein Folgezustand berechnet und durch die Output-Logik ein Output erzeugt. Ein realer Rechner kann dabei durch eine Programmsteuerung sein Verhalten ändern.

Ein genaueres Modell eines solchen Rechners, welches auf Arbeiten von Burks, Goldstine und von Neumann zurückgehen, ist wie folgt gekennzeichnet:

  1. Ein (zentralgesteuerter) Rechner besteht aus den drei Grundbestandteilen

    Dazu kommen noch Verbindungen zwischen diesen Einheiten, sog. Busse. Die CPU übernimmt die Ausführung von Befehlen sowie die dazu erforderliche Ablaufsteuerung. Im Speicher werden Daten und Programme als Bitfolgen abgelegt. Die Ein-/Ausgabe-Einheit stellt die Verbindung zur Außenwelt her; über sie werden Programme und Daten ein- bzw. ausgegeben.

  2. Die Struktur des Rechners ist unabhängig von einem speziellen, zu bearbeitenden Problem. Dies wird erreicht, indem man für jedes neue Problem ein eigenes Programm im Speicher ablegt, welches dem Rechner sagt, wie er sich zu verhalten hat. Speziell dieser Aspekt hat zu der Bezeichnung (programmgesteuerter) Universalrechner geführt.
  3. Programme und von diesen benötigte Daten werden in demselben Speicher abgelegt. Dieser wiederum besteht aus Plätzen fester Wortlänge, die über eine feste Adresse einzeln angesprochen werden können.

Figure 8.1: Struktur eines von-Neumann-Rechners (vgl. Oberschelp, Vossen)
\includegraphics[%%
width=0.75\textwidth,
keepaspectratio]{Neumann1.eps}

Im Folgenden wollen wir die einzelnen Komponenten genauer betrachten:

CPU
Die CPU kann prinzipiell in zwei Teile unterteilt werden:

Figure 8.2: Detailliertes Bild einer CPU (vgl. Oberschelp, Vossen)
\includegraphics[%%
width=0.75\textwidth]{Neumann2.eps}

Speicher
Die zweite logische Komponente eines von-Neumann-Rechners ist der Speicher. Er besteht in der Regel aus zwei Teilen, dem ROM und dem RAM. Beim ROM (Read Only Memory) handelt es sich um einen Festwertspeicher, in dem eine Information einmal abgelegt wird, dann kann nur noch lesend auf den Speicher zugegriffen werden. Das ROM enthält z.B. Systemprogramme (BIOS). Als zweiten Teil des Speichers finden wir ein RAM (Random Access Memory), wo auf jede Speicherzelle wahlfreier lesender oder schreibender Zugriff erfolgen kann. Beide Speicherteile können sowohl Daten als auch Programme enthalten.

Ein-/Ausgabe Einheit
Die dritte Komponente des generellen Plans ist die I/O Einheit, die Schnittstelle des Rechners zum Benutzer. Über diese können Daten und Programme ein- bzw. ausgegeben werden.

Busse
Verbindung der drei Hauptkomponenten.

2 Arbeitsweise einer CPU

Wir wollen nun die Arbeitsweise einer Zentraleinheit, deren Aufbau wir bereits gesehen haben, genauer betrachten.

Die Bearbeitung eines speziellen Problems erfolgt gemäß einem Programm, welches eine Folge von Befehlen ist. Vor Beginn der Bearbeitung steht dieses zusammen mit den Daten, die es benötigt, im Speicher. Daraus leiten sich die Charakteristika des von-Neumann-Rechners ab:

  1. Zu jedem Zeitpunkt führt die CPU genau einen Befehl aus, und dieser kann (höchstens) einen Datenwert bearbeiten (diese Philosophie wird im Allgemeinen durch ''Single Instruction - Single Data'', kurz SISD abgekürzt).
  2. Alle Speicherworte (d.h. Inhalte der Speicherzellen) sind als Daten, Befehle oder Adressen brauchbar. Die jeweilige Verwendung eines Speicherinhalts richtet sich nach dem momentanen Kontext.
  3. Da Daten und Programme nicht in getrennten Speicherbereichen untergebracht werden, besteht grundsätzlich keine Möglichkeit, die Daten vor ungerechtfertigtem Zugriff zu schützen.
Eine Befehlsfolge ist eine Folge von Binärzahlen festen Formats, die nach dem sog. Maschinencode aufgebaut ist.

Zentrale Bedeutung bei jeder Berechnung kommt dem Akku des Datenprozessors zu. Grundsätzlich ist dieser bei jeder arithmetischen oder logischen Operation beteiligt. Daraus folgt unmittelbar, dass auf die explizite Angabe des Akku in vielen Befehlen verzichtet werden kann. Einstellige Operationen wie z.B. die Negation benötigen somit keinen Operanden (unter der Annahme, dass sich dieser im Akku befindet). Für zweistellige Operationen wie Addition oder Multiplikation reicht die Angabe des zweiten Operanden; dieser wird dann mit dem Inhalt des Akkus verknüpft, und das Ergebnis wird wieder im Akku abgelegt. Diesen Befehlstyp nennt man auch Ein-Adress-Befehl; es sei angemerkt, dass man aus Gründen der Vereinfachung der Assembler-Programmierung auch Zwei-, Drei- oder sogar Vier-Adress-Befehle erlauben kann, insbesondere wenn mehr als ein Universalregister vorhanden ist, prinzipiell reichen jedoch Befehle mit einer Adresse aus.

Diese Voraussetzungen bedingen nun den für einen von-Neumann-Rechner typischen Befehlsablauf: Da der Inhalt einer Speicherzelle als Bitfolge weder selbstbeschreibend noch selbstidentifizierend ist, muss der Rechner aufgrund des zeitlichen Kontextes selbst entscheiden, wie eine spezielle Bitfolge zu interpretieren ist. Technisch löst man dieses Problem, welches sich aus der von-Neumann-Philosophie ergibt, durch das sog. Zwei-Phasen-Konzept der Befehlsverarbeitung:

  1. In der sog. Interpretations- oder Fetch-Phase wird der Inhalt von PC nach MAR gebracht und der Inhalt dieser Adresse aus dem Speicher über MBR nach IR geholt. Der Rechner geht zu diesem Zeitpunkt davon aus, dass es sich bei dieser Bitfolge um einen Befehl handelt. Der Decodierer erkennt, um welchen Befehl und insbesondere um welchen Befehlstyp es sich handelt. Nehmen wir an, der aktuelle Befehl ist ein Memory-Reference-Befehl, der also -- im Gegensatz etwa zu einem Halt-Befehl -- einen zweiten Operanden aus dem Speicher benötigt, so weiß der Rechner, dass als nächstes dieser Operand aus dem Speicher geholt (unter erneuter Beteiligung von MAR) und in MBR abgelegt werden muss. Schließlich muss der Inhalt von PC aktualisiert werden.
  2. In der darauf folgenden Execution-Phase erfolgt die eigentliche Befehlsausführung und die Initialisierung der Fetch-Phase für den nächsten Befehl.
Bei diesem zweistufigen Ablauf, der streng seriell zu erfolgen hat, spielt die Zeit, die benötigt wird zur Interpretation des Befehls, zum Lesen des Operanden aus dem Speicher, zum Ausführen des Befehls und zum Ablegen des Ergebnisses im Speicher, eine große Rolle. Bei ersten Realisierungen eines von-Neumann-Rechners wie z.B. dem UNIVAC-System kostete die Befehlsausführung die meiste Zeit. Heute wird diese von den Speicherzugriffszeiten dominiert, d.h. die Ausführungszeit eines Befehls (durch die ALU) beträgt im Allgemeinen nur noch einen Bruchteil der Zeit, die benötigt wird, um einen Speicherinhalt zu lesen und über den Bus zur CPU zu übertragen bzw. umgekehrt. Daher spricht man bei dieser Kommunikation zwischen CPU und Speicher auch vom ``von Neumannschen Flaschenhals'' (engl.: Bottleneck). Wir kommen darauf unten zurück.

Eine Folge von Befehlen stellt ein Programm für einen Rechner dar. Ausgeführt werden die Befehle eines Programms im Allgemeinen in der Reihenfolge, in der sie (hintereinander) im Speicher abgelegt sind (und die durch den Programmierer bestimmt wird). Dazu wird während der Interpretationsphase eines Befehls der Inhalt von PC, der die Adresse des nächsten auszuführenden Befehls angibt, lediglich um eins erhöht. Eine Ausnahme bilden (bedingte oder unbedingte) Sprungbefehle (z.B. bei Schleifenenden oder Unterprogramm-Sprüngen); in diesen Fällen ist PC neu zu laden.

Die Fetch-Phase lässt sich somit wie folgt zusammenfassen:

MAR #MATH848# PC;
MBR #MATH849# MAR;
IR #MATH850# MBR;
DECODIERE IR;
FALLS KEIN SPRUNGBEFEHL
DANN { STELLE OPERANDEN BEREIT; PC #MATH851# PC }
SONST PC #MATH852# SPRUNGZIELADRESSE;

3 Der Speicher eines von-Neumann-Rechners

Der Haupt- oder Arbeitsspeicher eines von-Neumann-Rechners wird durch die Größe einer Speicherzelle (meist 8 Bit oder ein Vielfaches von 8 Bit, z.B. Halbwort oder Wort), die ,,Breite`` $ m$ des Speichers genannt wird, und durch die ,,Länge`` $ N$, die Anzahl der Speicherzellen insgesamt, gekennzeichnet. Die Länge $ N$ ist hierbei in der Regel eine große 2-er Potenz, d.h. $ N=2^{n}$. Diese Speicherkenngrößen werden durch die Auslegung der Register MAR und MBR der CPU beeinflusst. Umfasst das Memory Address Register $ n$-Bit, so können insgesamt $ N=2^{n}$ Speicheradressen linear adressiert werden. Das Memory Buffer Register muss mindestens die Größe der kleinsten adressierbaren Einheit besitzen.

Da heute, wie bereits erwähnt, die Ausführungszeit eines Befehls wesentlich geringer als die Speicherzugriffszeit ist, muss eine Beschleunigung der Verarbeitung auch am Speichermodell ansetzen. Hier haben sich folgende Kategorien von Speicher etabliert, mit denen der Prozessor in einer Top-Down Strategie kommuniziert:

Figure 8.3: Speicherhierarchie eines von-Neumann-Rechners
\includegraphics[]{Neumann3.eps}

4 Busse

Ein weiterer wichtiger Bestandteil des von-Neumann-Rechners sind die Busse, die für den Transport der Daten, Befehle und Adressen zwischen den einzelnen Komponenten verantwortlich sind. Prinzipiell wäre eine einzige Leitung zur Realisierung eines seriellen Busses ausreichend. Hierbei hat man allerdings den Nachteil der geringen Geschwindigkeit. Daher kommen im Allgemeinen parallele Busse zum Einsatz. Konzeptionell käme man mit einem einzigen Bus aus, da die Interpretationen des Datums als Adresse, Befehl oder Daten kontextabhängig ist. In der Regel wird jedoch zumindest zwischen einem Daten- und Adressbus unterschieden. Dies ist dadurch begründet, das zum einen der Adressbus nur unidirektional (von CPU zum Speicher) ausgelegt sein muss und zum anderen die Breite des Datenbusses nicht notwendig der Breite des Adressbusses entspricht. Die Größe der Busse ist ein weiteres Geschwindigkeitskriterium. Will man, wie oben bereits erwähnt, einen Speicher von $ N=2^{n}$ Zellen linear adressieren, so benötigt man einen Adressbus, der parallel $ n$-Bits übertragen kann. Die Breite des Datenbusses wiederum gibt Aufschluss über die Größe eines Speicherplatzes, der adressiert werden kann. So können z.B. mit einem 32 Bit breiten Datenbus 4 Bytes gleichzeitig übertragen werden, auch wenn z.B. die kleinste adressierbare Einheit 1 Byte beträgt. Die Auslegung der Busse korrespondiert direkt mit dem MAR und MBR Register des Prozessors. Es gilt: Breite des Datenbusses gleich Länge des MBR, Breite des Adressbusses gleich Länge des MAR.

Neben den zwei zentralen Bussen, dem Daten- und Adressbus, bestehen häufig weitere Busse für spezielle Aufgaben, wie z.B. I/O Operationen.

5 I/O Einheit und Steuerung durch Interrupts

Die Kommunikation des Rechners mit dem Anwender oder externen Medien wird durch eine Ein-/Ausgabeeinheit durchgeführt. Häufig wird diese I/O Einheit durch einen weiteren speziellen Bus mit dem System verbunden. Dieser eigenständige I/O Bus wird dann auch häufig nicht mehr von der CPU, sondern von einem speziellen I/O-Prozessor gesteuert.

Wenn wir davon ausgehen, dass die CPU jedoch die oberste Kontrolle über alle Abläufe im Rechner hat, so können sich bei I/O Operationen folgende Probleme ergeben:

  1. Die CPU ist in dem Moment, wo ein I/O Gerät Daten übertragen möchte, mit einer anderen Aufgabe beschäftigt.
  2. Das I/O Gerät ist wesentlich langsamer als die CPU, die CPU wird dann ggf. bei I/O Operationen unnötig lange blockiert.
Um diese Probleme abzufangen, existiert ein sogenannter I/O Controller, der die I/O Geräte ansteuert und die Daten in einem eigenen Puffer zwischenspeichern kann. Das eigentliche Endgerät kann dann nur noch mit dem I/O Controller Daten austauschen und nicht mehr direkt mit der CPU. Der I/O Controller wiederum verfügt dann über spezielle Steuerleitungen und Datenleitungen, mit denen er mit der CPU verbunden ist.

Der I/O Controller signalisiert der CPU, wenn er zur Übertragung von Daten zu oder von einem Endgerät bereit ist. Hierzu muss die CPU in gewissen Zyklen ein Statuswort vom I/O-Controller abfragen. Hierbei kommt es jedoch dazu, dass dieses Statuswort wesentlich häufiger abgefragt wird als es notwendig wäre. Dies führt zum sogenannten Konzept des Interrupt-gesteuerten I/O. Hierbei wird bei einem speziellen Ereignis (z.B. Controller bereit zum Senden) über eine spezielle Leitung ein spezielles Interruptsignal an den Prozessor gesendet, dieser unterbricht die momentane Bearbeitung, ruft einen Interrupt Handler auf, und führt anschließend das unterbrochene Programm weiter. Eine Eingabe könnte somit konkret wie folgt ablaufen:

  1. Das Endgerät ist bereit zur Übertragung von Daten zum Rechner, der I/O-Controller sendet ein Interrupt-Signal an die CPU.
  2. Die CPU unterbricht die momentane Programmbearbeitung, liest den I/O Controller Status aus, sendet dem Controller ein Start-Signal und setzt die unterbrochene Programmbearbeitung fort.
  3. Der Controller empfängt Daten vom Endgerät und speichert diese in seinem Puffer. Sobald der Puffer voll oder die Eingabe beendet ist, sendet der Controller wieder ein Interrupt-Signal an die CPU.
  4. Die CPU unterbricht wieder die momentane Programmbearbeitung, überträgt die Daten vom I/O Controller in den Speicher und setzt anschließend das unterbrochene Programm fort.
Bei diesen Konzepten hat die CPU stets nur eine Kontrollfunktion und keine eigentliche Berechnungsfunktion. Daher liegt es nahe, auch eine andere Klasse von Controllern einzusetzen, die unabhängig von der CPU Daten vom oder in den Speicher übertragen können. Diese Controller werden Direct Memory Access (DMA-) Controller genannt, und kommen z.B. bei Festplattencontrollern zum Einsatz. Hierbei muss allerdings ein Mechanismus vorhanden sein, der einen Zugriffskonflikt zwischen DMA-Controller und CPU behandelt. Ein solcher Mechanismus entzieht bei einem Konflikt der CPU für einige Taktzyklen den Zugriff zum Speicher und erlaubt dem DMA-Controller den vorrangigen Zugriff.

Beim Konzept der Interrupt-gesteuerten Aktionen unterscheidet man im wesentlichen vier verschiedene Interrupts.

Zusätzlich hierzu werden Interrupts in der Regel mit Prioritäten versehen, so dass bei einer momentan aktiven Interruptbehandlung ein neu auftretender Interrupt nur dann zur Unterbrechung der momentanen Behandlung führt, falls seine Priorität höher als die des momentan bearbeiteten ist. Ist die Priorität niedriger, so wird der Interrupt erst nach Beendigung der ersten Interruptroutine abgearbeitet. Mittels Prioritäten und Maskierung ist es möglich, nur gewissen Interrupts die Unterbrechung der eigentlichen Programmbearbeitung zu gestatten.

6 Erweiterungen des von-Neumann-Konzepts

Die heute im Einsatz befindlichen Ein-Prozessor-Systeme sind im wesentlichen nach dem beschriebenen von-Neumann-Konzept gestaltet. Es existieren jedoch verschiedene Ansätze zur Erweiterung des besprochenen Organisationsplanes. Eine Richtung der Erweiterung ist die Milderung des sog. von-Neumannschen Flaschenhalses, der wegen unterschiedlicher Zeiten für die Befehlsausführung und den Speicherzugriff auftritt. Wir haben hierzu bereits Ansätze unter Verwendung von hierarchischem Speicher mit schnellen Zugriffszeiten in Prozessornähe (Register, Cache) und langsameren Zugriffszeiten für den eigentlichen Hauptspeicher diskutiert.

Eine zweite Erweiterung betrifft nun die Verarbeitungsgeschwindigkeit innerhalb des Prozessors. Wir haben hierzu das Grundkonzept der Fetch- und Instruction-Phase diskutiert und waren hierbei von einer seriellen Abfolge der einzelnen Phasen und vor allem auch vom seriellen Ablauf der Phasen für verschiedene Befehle ausgegangen. Moderne Prozessoren versuchen, diesen seriellen Ablauf mit Pipelining Konzepten zu umgehen.

Der erste Ansatz ist das sogenannte einfache Befehlsphasen Pipelining, das in Abbildung 8.4 dargestellt ist.

Figure 8.4: Einfaches Befehlsphasen Pipelining
\includegraphics[%%
width=0.75\textwidth]{Neumann4.eps}

In dieser Befehlphasen-Pipeline werden die einzelnen Phasen versetzt zueinander ausgeführt, d.h. wenn die Befehls-Fetch-Phase des ersten Befehls abgeschlossen ist und die Daten-Fetch-Phase beginnt, wird bereits für einen zweiten Befehl die Befehls-Fetch-Phase begonnen. Dies kann auch mit einer höheren Schachtelungstiefe geschehen.

Ein zweiter Ansatz besteht in der weiteren Unterteilung der einzelnen Phasen. Damit wird in der Pipeline sogar eine Überlappung der ursprünglichen Phasen für aufeinander folgende Befehle erreicht, was eine weitere Schachtelungstiefe ermöglicht. Dieses Prinzip wird Superpipelining genannt und in der folgenden Abbildung graphisch gezeigt.

Figure 8.5: Prinzip des Superpipelining
\includegraphics[%%
width=0.75\textwidth,
keepaspectratio]{Neumann5.eps}

Schließlich besteht die Möglichkeit, mehrere Pipelines parallel zu betreiben, z.B. für Floatingpoint- und Integer-Berechnungen. Ein solcher Ansatz ist die im Folgenden dargestellte Superskalararchitektur. Hierbei wird zudem versucht, möglichst viele Teilkomponenten wie z.B. mehrfach vorhandene ALU's für Floating-Point und Integer-Arithmetik oder einen vorhandene Arithmetik Coprozessor parallel einzusetzen.

Figure 8.6: Prinzip einer Superskalararchitektur
\includegraphics[%%
width=0.75\textwidth,
keepaspectratio]{Neumann6.eps}

9 Eine kleine Programmiersprache

1 Syntaktische Beschreibungsmittel

Eine Programmiersprache ist nach genauen syntaktischen Regeln aufgebaut. Diese müssen so beschaffen sein, dass mit einem Automaten geprüft werden kann, ob ein Programm syntaktisch korrekt ist. 1958 wurde daher von John Backus eine formale Beschreibung der Syntax von ALGOL vorgenommen, die von Peter Naur überarbeitet wurde. Niklaus Wirth, der Schöpfer von Pascal, hat die BNF nochmals überarbeitet und nennt das Ergebnis erweiterte Backus-Naur-Form EBNF. Daher spricht man heute von der Backus-Naur-Form. Sie sind für eine Klasse von Sprachen gedacht, die in die Klassifizierung von Chomsky als kontextfreie Sprachen eingeordnet werden können. Dieser hatte eine aufsteigende Folge von Sprachen definiert.

Definition 1.1

Unter einer Chomsky-Grammatik versteht man ein Quadrupel $ G=(N,T,P,S)$. Dabei bezeichnet

Ferner ist $ \left(N\cup T\right)^{+}$ die Menge der nichtleeren endlichen Wörter mit Zeichen aus $ N\cup T$, während $ \left(N\cup T\right)^{*}$ zusätzlich noch das leere Wort $ \lambda$ enthält. Alle Mengen $ N$, $ T$, $ P$ sind endlich.

Definition 1.2

Sei $ G=(N,T,P,S)$ eine Chomsky-Grammatik. $ G$ heißt

Die Programmiersprachen werden nun im wesentlichen durch kontextfreie Sprachen definiert. Mit der BNF werden die Produktionen beschrieben. Wichtig ist der Unterschied zwischen der zu definierenden Sprache, die aus den Terminalsymbolen besteht, und der zu ihrer Beschreibung notwendigen Metasprache, zu der man die Nichtterminalsymbole benutzt.

Definition 1.3
Die Metazeichen der EBNF sind

Für die Menge $ E$ der EBNF-Terme gilt:

Beispiel 1.4
Ziffer = "0"|"1"|"2"|...|"9".
Buchstabe = "A"|...|"Z"|ä"|...|"z".
Name = Buchstabe {Buchstabe | Ziffer}.
VorzeichenloseZahl = Ziffer {Ziffer}.
Statt VorzeichenloseZahl kann auch Ziffernfolge benutzt werden.
Vorzeichen = "+" | "-".
Zahl = [Vorzeichen] VorzeichenloseZahl.
Unterstrich = "_".
Bezeichner = (Buchstabe | Unterstrich) {Buchstabe | Ziffer | Unterstrich}.

In der Folge werden wir für Bezeichner auch den englischen Ausdruck ident und für Zahl number benutzen.

Die EBNF kann man sehr leicht in eine graphische Notation übersetzen, die sogenannten Syntaxdiagramme.

Definition 1.5
Syntaxdiagramme bestehen aus folgenden Bestandteilen mit folgenden Regeln:

Jedes Syntaxdiagramm hat einen eindeutigen Namen. Ein erlaubter Weg beginnt am Eingang, folgt den Pfeilen, wobei Kästchen durchquert werden können. An Verzweigungen ist ein beliebiger Weg auswählbar. Die von einer Menge von Syntaxdiagrammen erzeugte Sprache kann man durch einen erlaubten Weg unter Notation der angetroffenen Terminalsymbole und rekursives Durchqueren der zu Nonterminalsymbolen gehörigen Diagramme erzeugen.

Definition 1.6 (Übersetzung von EBNF in Syntaxdiagramme)
1.
Jeder Regel in EBNF entspricht ein Syntaxdiagramm mit dem Namen der linken Seite der Regel.
2.
Die Übersetzung der rechten Regelseiten geschieht durch Rekursion über die Struktur der EBNF-Terme wie folgt:
a)
Nichtterminalsymbole V werden in ein eckiges Kästchen übersetzt.
\begin{picture}(1.6,1)(0,0.4)
\put(0,0.5){\vector(1,0){0.3}}
\put(0.3,0){\framebox (1,1){{V}}}
\put(1.3,0.5){\line(1,0){0.3}}
\end{picture}
b)
''w''-Terminalsymbole werden in ein rundes Kästchen übersetzt.
\begin{picture}(1.6,1)(0,0.4)
\put(0,0.5){\vector(1,0){0.3}}
\put(0.8,0.5){\circle{1.0}\makebox(0,0){w}}
\put(1.3,0.5){\line(1,0){0.3}}
\end{picture}
c)
$ (\alpha)$ wird wie $ \alpha$ übersetzt
d)
$ [\alpha]$ wird zu
\begin{picture}(1.6,0.3)(0,0.3)
\put(0,0.5){\vector(1,0){0.3}}
\put(0.3,0.5){\li...
...}
\put(1.45,0.3){\vector(0,1){0.2}}
\put(1.3,0.5){\line(1,0){0.3}}
\end{picture}
e)
$ \{\alpha\}$ wird zu
\begin{picture}(1.6,0.6)(0,0.4)
\put(0,0.5){\vector(1,0){0.3}}
\put(0.3,0.5){\li...
...
\put(0.5,0.7){\line(1,0){0.95}}
\put(0.5,0.7){\vector(0,-1){0.2}}
\end{picture}
f)
$ \alpha_1 \ldots \alpha_n$ wird in eine Kette von Teildiagrammen übersetzt, die sich als Übersetzungen der einzelnen $ \alpha_i$ ergeben, und durch Pfeile verbunden.
g)
$ \alpha_1 \vert \ldots \vert \; \alpha_n$ wird in eine Verzweigung in $ n$ Zweige übersetzt, die sich aus den Übersetzungen der einzelnen $ \alpha_i$ ergeben.

\begin{picture}(2.0,3.0)(0,-1.5)
\put(0,1.0){\vector(1,0){0.3}} \put(1.8,1.0){\l...
...akebox(0,0){\(\dots\)}}
\put(1.1,1.9){\makebox(0,0){\(\alpha_1\)}}
\end{picture}

2 Die Syntax von Mini-Pascal

Als Beispielsprache wählen wir nun einen kleinen Ausschnitt von Pascal, der Mini-Pascal heißen soll. Damit folgen wir der Darstellung von N. Wirth in seinem Buch Compilerbau beim Teubnerverlag.

Definition 2.1

Die kontextfreie Syntax von Mini-Pascal ist durch die folgende EBNF-Definition gegeben:

program = "program" ident ";" block".".
block = [constDecl] [varDecl] {procDecl}
        "begin" statement {";" statement} "end".
constDecl = "const" ident "=" number {";" ident "=" number} ";".
varDecl = "var" ident {"," ident} ":" "integer" ";".
procDecl = "procedure" ident ";" block ";".
statement = [varIdent ":=" expression | procIdent |
             "begin" statement {";" statement} "end" |
             "if" condition "then" statement |
             "while" condition "do" statement].
condition = expression ("=" | "<>" | "<" | "<=" | ">" | ">=") expression.
expression = ["+" | "-"] term {("+" | "-") term}.
term = factor {("*" | "div") factor}.
factor = constIdent | varIdent | number | "(" expression ")".
constIdent = ident.
procIdent = ident.
varIdent = ident.

Erklärungen:

In der Definition der Sprache finden wir als Terminalsymbole die Strukturierungszeichen : , ; . Dazu kommen die unären Vorzeichenoperatoren, die Operatoren für die Ganzzahloperationen +, -, *, div und die binären Vergleichsoperatoren. Durch die Schlüsselwörter const, var werden Konstanten und Variablen vom Typ Integer deklariert. Die Wörter begin und end strukturieren das Programm und die zugehörigen Prozeduren, die mit program und procedure eingeleitet werden. Weiter sind Kontrollstrukturen für wiederholte Anweisungen (while - do) und bedingte Anweisungen (if - then) vorgesehen. Schließlich müssen arithmetische Ausdrücke über die rekursive Definition expression - term - factor eingeführt werden, wobei die Regeln Punkt- vor Strichrechnung abgebildet sind. Schlüsselwörter sollen in der Regel nicht als Variablen- oder andere Bezeichner verwendet werden. Auf die Groß- oder Kleinschreibung kommt es nicht an. Weiterhin wollen wir verabreden, dass (* *) als Kommentarklammern gelten. Der hierin notierte Text gehört nicht zum Programm und wird einfach überlesen.

Allerdings müssen wir einräumen, dass unser Mini-Pascal keine reine kontextfreie Sprache ist.

Definition 2.2

Mini-Pascal ist eine echte Teilmenge von Pascal, das wir im nächsten Kapitel näher untersuchen werden. Dieses verfügt über eine Vielzahl weiterer Datentypen, die über integer hinausgehen. Dazu kommen Funktionen und Prozeduren, die die Peripherie der CPU und des Rechners betreffen, wie Ein- und Ausgabe (ReadLn, WriteLn). Diese beiden Prozeduren wollen wir der Einfachheit halber ohne zusätzliche syntaktische Definition verwenden. Mittels ReadLn werden $ n$ globale Variablen eingeführt, von denen die erste das Ergebnis des Rechenvorgangs liefern könnte, das dann mit WriteLn ausgebeben wird. Zusätzlich ist ein modularer Aufbau der Programme möglich.

Wir wollen noch einige weitere Regeln im Umgang mit der Syntax von Mini-Pascal ansprechen.

Beispiel 2.3
program Beispiel;
var A, B, C: integer; (* Damit sind  A, B, C globale Integervariablen *)
  
  procedure p;
  var A, B, D : integer;
  (* Hier sind die globalen Variablen  A, B unsichtbar und werden durch 
     die lokalen Variablen der Prozedur p ersetzt. C bleibt sichtbar, D 
     kommt als lokale Variable hinzu. *)

     procedure q;
     var C: integer;
     (* Ab hier ist die globale Variable C unsichtbar *)

     begin
       ....... 
       (* Es sind die Variablen A_p, B_p, C_q, D_p zugaenglich *)
     end; (* Ende der Prozedur q *)

  begin
    ...... 
    (* Es sind die Variablen A_p, B_p, C, D_p zugaenglich *)
  end; (* Ende der Prozedur p *)

(* Von jetzt sind nur noch die globalen Variablen A, B, C sichtbar *)
begin
.... 
  (* Hier steht nun das Hauptprogramm, in dem die Variablen A, B, C
     neue Werte per Zuweisung auch innerhalb von Kontrollstrukturen 
     erhalten und die Prozeduren p und q ueber ihre Namen aufgerufen 
     werden koennen. *)
end.

Definition 2.4
Mini-Pascal ist durch die folgenden Syntaxdiagramme definiert:

\includegraphics[%%
scale=0.75]{Pas_Syn1.eps}

\includegraphics[%%
scale=0.75]{Pas_Syn2.eps}

\includegraphics[%%
scale=0.75]{Pas_Syn3.eps}

\includegraphics[%%
scale=0.75]{Pas_Syn4.eps}

\includegraphics[%%
scale=0.75]{Pas_Syn5.eps}

\includegraphics[%%
scale=0.75]{Pas_Syn6.eps}

\includegraphics[%%
scale=0.75]{Pas_Syn7.eps}

\includegraphics[%%
scale=0.75]{Pas_Syn8.eps}

3 Semantik von Mini-Pascal$ ^{*}$

$ ^{*}$ Die folgenden beiden Unterkapitel werden in Informatik III ausführlich behandelt.

Man kann die Semantik einer Programmiersprache, d.h. die Interpretation ihrer Sprachelemente einmal auf operationelle Weise definieren. Dabei ist eine Maschine zugrunde gelegt, und man beschreibt, wie sich die Maschine bei Ausführung der Instruktionen oder des gesamten Programmes verhält. Dies ist bei maschinennahen Programmen, die wir in der Vorlesung Theoretische Informatik noch kennenlernen werden, sicherlich der beste Weg. Für höhere Programmiersprachen mit einer komplexen Struktur wird andererseits die denotationelle Semantik vorgezogen, bei der man die Sprachelemente in mathematische Objekte umsetzt, die dann als einzelne Bestandteile einer komplexen Theorie angesehen werden.

Wir wollen nun die einzelnen Bestandteile der Sprache Mini-Pascal ansprechen und ihre Semantik beschreiben.

Namensräume dienen dazu, bei Deklarationen den notwendigen Speicherplatz zu reservieren. Den Konstanten und Variablen vom Typ ganze_Zahl werden nun Adressen im Speicher zugeordnet, die durch natürliche Zahlen beschrieben sind. Die Gesamtheit der Werte, die auf diese Weise mit den Variablen in Verbindung gebracht werden, heißt Speicherzustand. Anweisungen bestehen nun letztlich aus der Zuordnung neuer Werte zu den Variablen und bewirken somit eine Zustandsänderung. Folglich ordnet man den einzelnen Bezeichnergruppen sogenannte Umgebungen (environments) zu.

Zur Definition der Semantik bedarf es daher einer Anzahl semantischer Bereiche, über denen semantische Funktionen operieren. Dabei handelt es sich um Mengen partiell definierter Abbildungen $ \left[A\rightarrow B\right]$ von $ A$ nach $ B$. $ \Omega$ bezeichne allgemein die überall undefinierte partielle Abbildung unabhängig von Urbild- und Bildbereichen.

Definition 3.1
Die semantischen Bereiche von Mini-Pascal

$ Adr:=$ N Adressen

$ Int:=$ Z ganze Zahlen

$ Bool:=$ $ \left\{ w,f\right\} $

$ Ide := A^{+},  A:=\{A,...,Z,a...,z,\_,0,...,9\}$.

Dabei beachte man die Einschränkung für den ersten Buchstaben. Gemeint sind die verschiedenen Identifier-Mengen.

Ähnliche Definitionen gelten für die Nichtterminale Program, Block, Condition, Expression etc. Hier sind jeweils die entsprechenden Programmabschnitte gemeint.

$ VEnv := \left[ Ide\rightarrow Adr\right] $ Variablenumgebung.
Sie ordnet einer Menge von Bezeichnern ihre jeweilige Adresse zu.

$ Store := \left[ Adr\rightarrow Int\right] $ Speicherzustände.
Ein Speicherzustand ordnet einer Menge von Adressen die darunter jeweils gespeicherten ganzzahligen Werte zu.

$ CEnv :=\left[ Ide\rightarrow Int\right] $ Konstantenumgebung.
Sie ordnet einer Menge von Bezeichnern die dadurch jeweils definierten Konstanten zu.

$ PEnv :=\left[ Ide\rightarrow \left[ Store\rightarrow Store\right] \right]$ Prozedurumgebungen.
Sie ordnen einer Menge von Prozedurbezeichnern die jeweilige Speichertransformation ihres Rumpfes zu.

$ Stat$ $ :=\left[ CEnv\times VEnv\times PEnv\times Store\rightarrow Store\right] $ Speichertransformation.
Die von einer ausführbaren Anweisung induzierte Transformation auf dem Speicher hängt außer vom Inhalt auch noch von diversen Umgebungen (vgl. Verdeckung) ab.

Die nun aufgeführten semantischen Funktionen sind alle partielle Funktionen. Wir haben die folgenden Typen:

$ C:\mathtt{constDecl}\times CEnv\rightarrow CEnv.$
Die Semantik einer Konstantendeklaration ist eine Konstantenumgebung.

$ V:\mathtt{VarDecl}\times VEnv\rightarrow VEnv.$
Die Semantik einer Variablendeklaration ist eine Variablenumgebung.

$ Pc:$ Proc $ \times CEnv\times VEnv\times PEnv\rightarrow PEnv.$
Die Semantik einer Prozedurdeklaration ist eine Prozedurumgebung. Dabei steht Proc für die Namen der ProcIdent.

$ E:$ expression $ \times $ $ CEnv\times VEnv\times Store\rightarrow Int.$
Die Semantik eines Ausdrucks ist eine ganze Zahl.

$ R:\mathtt{condition}\times CEnv\times VEnv\times Store\rightarrow Bool.$
Die Semantik einer Relation (Bedingung) ist ein Wahrheitswert.

$ S:\mathtt{statement}\rightarrow Stat$.
Die Semantik einer Anweisung ist eine Speichertransformation.

$ B:\mathtt{block}\rightarrow Stat.$
Die Semantik eines Blocks ist eine Speichertransformation aus $ \left[ Store\rightarrow Store\right] .$

$ Pr:\mathtt{program}\rightarrow \left[ Int^{n}\rightarrow Int\right]$.
Die Semantik eines Programms ist eine partielle Abbildung von $ Int^{n}$ nach $ Int$.

Diese semantischen Funktionen haben alle die Gestalt

$\displaystyle F\left[\alpha\right]\beta,$

wobei $ F$ der Name der Funktion ist, $ \alpha$ innerhalb der Klammer ein Mini-Pascal Programmstück, $ \beta $ eine Folge weiterer Argumente in klammerloser Notation.

Die einzelnen Funktionen werden nun in den folgenden Definitionen ausführlich erklärt:

Definition 3.2

Die Semantik der Const -Deklaration ist eine Funktion

$\displaystyle C:\mathtt{constDecl}\times CEnv\rightarrow CEnv.$

Sei $ \vartheta$ eine Funktion aus $ CEnv.$ Dann ist


    $\displaystyle C\left[const i_{1}=n_{1};...;i_{m}=n_{m};\right]\vartheta$  
  $\displaystyle :=$ $\displaystyle C_{1}\left[i_{m}=n_{m}\right]C_{1}\left[i_{m-1}=n_{m-1}\right]\ldots C_{1}\left[i_{1}=n_{1}\right]\vartheta,$  

wobei

$\displaystyle C_{1}\left[i=n\right]\vartheta:=\vartheta\left[i/n\right]$

ist. Dabei heißt die Notation $ \vartheta\left[i/n\right],$ dass die $ i$-te Speicherzelle von $ \vartheta$ durch $ n$ besetzt wird und alle anderen sich nicht ändern.

So wird die Konstantenumgebung bei jeder zusätzlichen Definition ergänzt und verlängert; zu Beginn war sie als $ \Omega$ definiert.

Definition 3.3
Die Semantik der Var -Deklaration ist eine Funktion

$\displaystyle V:\mathtt{varDecl}\times VEnv\rightarrow VEnv.$

Sei $ \varphi$ eine Funktion aus $ VEnv.$ Dann ist
    $\displaystyle V\left[\mathtt{var} i_{1},...,i_{m}:\mathtt{Integer};\right]\varphi$  
  $\displaystyle :=$ $\displaystyle V_{1}\left[i_{m}\right]V_{1}\left[i_{m-1}\right]...V_{1}\left[i_{1}\right]\varphi,$  

wobei

$\displaystyle V_{1}\left[i\right]\varphi:=\varphi\left[i/new\right]$

ist. Dabei bezeichnet $ new$ einen neuen Speicherplatz, der bislang noch nicht vergeben war und der nun der Variablen $ i$ zugeordnet wird.

Um die Semantik von Prozedurdeklarationen zu begreifen, müssen wir eine Semantik für Block vom Typ $ B[\alpha]$ voraussetzen. Diese Definition wird später nachgeholt. Das Problem ist hier eine zirkuläre Definition der Semantik von zwei Prozeduren, die sich gegenseitig aufrufen, die durch einen induktiven Ansatz oder zusätzliche IF-Anweisungen aufgelöst werden können.

Definition 3.4
Die Semantik der procedure-Deklaration ist erklärt durch eine Funktion

$\displaystyle Pc:$proc$\displaystyle \times CEnv\times VEnv\times PEnv\rightarrow PEnv.
$

Sei $ \pi$ eine Funktion aus $ PEnv$, $ \vartheta \in CEnv$, $ \varphi \in VEnv$ und $ \sigma \in Store$. Dann ist

$\displaystyle Pc\left[ \mathtt{procedure} p;\alpha; \right] \vartheta \varphi \pi :=\pi \left[ i/\mu \right],
$

wobei

$\displaystyle \mu \left( \sigma \right) :=B\left[ \alpha \right] \vartheta \varphi \pi \left[ i/\mu \right] \sigma.
$

Hier ist nach der Definition von $ PEnv$ zu beachten, dass dem Prozedurbezeichner $ i$ eine Speichertransformation $ \mu$ $ \in Stat=\left[Store\rightarrow Store\right]$ auf den Speicherzustand $ \sigma\in Store=\left[Adr\rightarrow Int\right]$ zuzuordnen ist. Dabei kann man ausnutzen, dass für den Zugriff auf nicht-lokale Variablen aus einer Prozedur heraus nicht die Aufrufumgebung, sondern die Deklarationsumgebung maßgebend ist.

Definition 3.5
Die Semantik von Ausdrücken ist erklärt durch eine Funktion

$\displaystyle E:$expression$\displaystyle \times CEnv\times VEnv\times Store\rightarrow Int.
$

Seien $ \vartheta \in CEnv,\varphi \in VEnv,\sigma \in Store.$ Unter Weglassung der Klammern setzen wir
$\displaystyle E\left[ \mathtt{ident}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
\sigma \left( \varphi \left( \mathtt...
...thtt{ident}\right) ,\text{ falls constIdent}
\end{array}\right.\end{displaymath}  
$\displaystyle E\left[ \mathtt{number}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ $\displaystyle \mathtt{number}$  
$\displaystyle E\left[ f_{1}*f_{2}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ $\displaystyle E\left[ f_{1}\right] \vartheta \varphi \sigma \cdot E\left[ f_{2}\right] \vartheta \varphi \sigma$  
$\displaystyle E\left[ f_{1}\text{ }\mathtt{div}\text{ }f_{2}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ $\displaystyle \left\lfloor E\left[ f_{1}\right] \vartheta \varphi \sigma /E\left[ f_{2}\right] \vartheta \varphi \sigma \right\rfloor$  
$\displaystyle E\left[ t_{1}+t_{2}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ $\displaystyle E\left[ t_{1}\right] \vartheta \varphi \sigma +E\left[ t_{2}\right] \vartheta \varphi \sigma$  
$\displaystyle E\left[ t_{1}-t_{2}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ $\displaystyle E\left[ t_{1}\right] \vartheta \varphi \sigma -E\left[ t_{2}\right] \vartheta \varphi \sigma$  
$\displaystyle E\left[ +t\right] \vartheta \varphi \sigma$ $\displaystyle :=$ $\displaystyle E\left[ t\right] \vartheta \varphi \sigma$  
$\displaystyle E\left[ -t\right] \vartheta \varphi \sigma$ $\displaystyle :=$ $\displaystyle -E\left[ t\right] \vartheta \varphi \sigma$  
$\displaystyle E\left[ (\text{\texttt{exp}}\mathtt{ression})\right] \vartheta \varphi \sigma$ $\displaystyle :=$ $\displaystyle E\left[ \text{\texttt{exp}}\mathtt{ression}\right] \vartheta \varphi \sigma$  

Dabei sind rechts immer die entsprechenden ganzen Zahlen notiert.

Definition 3.6
Die Semantik von Bedingungen ist erklärt durch eine Funktion mit den booleschen Werten $ w$ und $ f$.

$\displaystyle R:$condition$\displaystyle \times CEnv\times VEnv\times Store\rightarrow Bool.
$

Seien $ \vartheta \in CEnv,\varphi \in VEnv,\sigma \in Store.$ Unter Weglassung der Klammern setzen wir


$\displaystyle R\left[ e_{1}=e_{2}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
w,\text{ falls }E\left[ e_{1}\right]...
...\vartheta \varphi \sigma \\
f,\text{ sonst}
\end{array}\right.\end{displaymath}  
$\displaystyle R\left[ e_{1}<>e_{2}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
w,\text{ falls }E\left[ e_{1}\right]...
...\vartheta \varphi \sigma \\
f,\text{ sonst}
\end{array}\right.\end{displaymath}  
$\displaystyle R\left[ e_{1}<e_{2}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
w,\text{ falls }E\left[ e_{1}\right]...
...\vartheta \varphi \sigma \\
f,\text{ sonst}
\end{array}\right.\end{displaymath}  
$\displaystyle R\left[ e_{1}<=e_{2}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
w,\text{ falls }E\left[ e_{1}\right]...
...\vartheta \varphi \sigma \\
f,\text{ sonst}
\end{array}\right.\end{displaymath}  
$\displaystyle R\left[ e_{1}>e_{2}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
w,\text{ falls }E\left[ e_{1}\right]...
...\vartheta \varphi \sigma \\
f,\text{ sonst}
\end{array}\right.\end{displaymath}  
$\displaystyle R\left[ e_{1}>=e_{2}\right] \vartheta \varphi \sigma$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
w,\text{ falls }E\left[ e_{1}\right]...
...\vartheta \varphi \sigma \\
f,\text{ sonst}
\end{array}\right.\end{displaymath}  

Definition 3.7

Die Semantik von Anweisungen ist erklärt durch eine Funktion vom Programmtext der Anweisungen auf die Speichertransformationen. Wir gehen induktiv vor. Seien $ \vartheta\in CEnv,\varphi\in VEnv,\pi\in PEnv,\sigma\in Store.$ Wir geben hier nicht die Abbildung aus $ Stat$ an, sondern wenden sie gleich auf Elemente $ \vartheta\varphi\pi\sigma$ an. Dann ist das Ergebnis aus $ Store$.


$\displaystyle S[]\vartheta\varphi\pi\sigma$ $\displaystyle :=$ $\displaystyle \sigma$ Identität der Speicherabbildung  
$\displaystyle S[i:=e]\vartheta\varphi\pi\sigma$ $\displaystyle :=$ $\displaystyle \sigma\left[\varphi(i)/(E\left[e\right]\vartheta\varphi\sigma)\right]$  
$\displaystyle S[p]\vartheta\varphi\pi\sigma$ $\displaystyle :=$ $\displaystyle \pi(p)\vartheta\varphi\sigma$  
$\displaystyle S[\mathtt{begin} \alpha_{1};...;\alpha_{n} \mathtt{end}]\vartheta\varphi\pi\sigma$ $\displaystyle :=$ $\displaystyle S[\alpha_{n}]\vartheta\varphi\pi S[\alpha_{n-1}]\vartheta\varphi\pi\ldots S[\alpha_{1}]\vartheta\varphi\pi\sigma$  
$\displaystyle S[\mathtt{if} c \mathtt{then} \alpha]\vartheta\varphi\pi\sigma$ $\displaystyle :=$ $\displaystyle {. \atopwithdelims\{ S[\alpha]\vartheta\varphi\pi\sigma\text{, falls }E\left[c\right]\vartheta\varphi\sigma=w}{\sigma\text{ sonst.}}$  
$\displaystyle S[\mathtt{while} c \mathtt{do} \alpha]\vartheta\varphi\pi\sigma$ $\displaystyle :=$ $\displaystyle \left\{ \begin{array}{c}
\sigma\text{, falls }E\left[c\right]\var...
...ta\varphi\pi S[\alpha]\vartheta\varphi\pi\sigma,\text{ sonst}\end{array}\right.$  

Zeile 2 bedeutet eine Zuweisung Zahl zur Speicherzelle $ \varphi\left(i\right),$ $ \pi$ vom Typ $ Stat$ bildet als $ \pi(p)\vartheta\varphi\sigma$ auf $ Store$ ab. In der vierten Zeile liefern die Anwendungen

$\displaystyle S[\alpha_{1}]\vartheta\varphi\pi\sigma=\sigma_{1},S[\alpha_{i}]\vartheta\varphi\pi\sigma=\sigma_{i},i=2,\ldots,n,$

sukzessive neue Speicherzustände aus $ Store.$ Die folgenden Zeilen sehen eine Anwendung von $ \alpha$ auf $ \vartheta\varphi\pi\sigma$ per $ S[\alpha]\vartheta\varphi\pi\sigma$, falls die Bedingung erfüllt ist. In Zeile 6 kommt dazu eine rekursive Definition, die eventuell nicht terminiert.

Definition 3.8

Die Semantik von Blöcken ist erklärt durch eine Funktion

$\displaystyle B:\mathtt{block}\rightarrow Stat.$

Seien $ \vartheta,\vartheta_{i}\in CEnv,\varphi,\varphi_{i}\in VEnv,\pi=\pi_{0}\in PEnv,\sigma\in Store.$ Außerdem sei

$\displaystyle \Theta\in\mathtt{constDecl},\Phi\in\mathtt{varDecl},\Pi_{i}\in\mathtt{procDecl},i=1,...,m.$

Dann ist

$\displaystyle B[\Theta\Phi\Pi_{1}...\Pi_{m}\alpha]\vartheta\varphi\pi\sigma:=S\...
...rtheta_{m}\varphi_{m}...P\left[\Pi_{1}\right]\vartheta_{1}\varphi_{1}\pi\sigma.$

Hier wird das Blockschachtelungsprinzip verwirklicht und die $ P\left[\Pi_{i}\right]\vartheta_{i}\varphi_{i}\pi_{i-1}$ liefern jeweils mit den zugehörigen Werten $ \vartheta_{i}\in CEnv,\varphi_{i}\in VEnv$ ein neues $ \pi_{i}\in PEnv$, auf das schließlich unter Berücksichtigung der neuen Deklarationen der Programmtext aus dem Block mit seinen $ statements$ Anwendung findet und als Mitglied von $ Stat$ einen Speicherzustand ergibt.

Wir schließen mit der Semantik eines ganzen Mini-Pascalprogramms:

Definition 3.9
Sei $ \mathtt{pg}$ ein syntaktisch korrektes Mini-Pascalprogramm im Sinne der folgenden Definition:


pg $\displaystyle =$ $\displaystyle \mathtt{program}$ $\displaystyle pr;$  
    $\displaystyle \Theta$  
    $\displaystyle \Phi$  
    $\displaystyle \Pi_{1}...\Pi_{m}$  
    $\displaystyle \mathtt{begin}$  
    ReadLn($\displaystyle v_{1},...,v_{n});$  
    $\displaystyle \alpha$  
    $\displaystyle \mathtt{WriteLn}\left(e\right)$  
    $\displaystyle \mathtt{end.}$  

Dann ist mit den Setzungen aus Definition 9.3.8, $ \vartheta,\varphi,\pi$ undefiniert ($ =\Omega$) und mit dem anfänglichen Speicherzustand

$\displaystyle \sigma=\left(\varphi(v_{1})/z_{1},...,\varphi(v_{n})/z_{n}\right)$

die Semantik von pg erklärt durch eine Funktion Pr $ :\mathtt{program}\rightarrow\left[Int^{n}\rightarrow Int\right]$ mit

$\displaystyle \mathtt{Pr}\left[\mathtt{pg}\right]\left(z_{1},...,z_{n}\right):=...
...artheta_{m}\varphi_{m}...P\left[\Pi_{1}\right]\vartheta_{1}\varphi_{1}\pi\sigma$

Hier haben wir die vorige Definition auf den Block des Hauptprogramms angewendet: es wird die Anweisungsfolge $ \alpha$ ausgewertet in den jeweiligen Umgebungen der Konstanten-, Variablen- und Prozedurdeklarationen. Schließlich ist für die Auswertung nur noch der Wert der globalen Variablen $ e$ von Bedeutung, der einen Ausdruck in den Umgebungen des Hauptprogramms darstellt.

4 Übersetzung des Mini-Pascalprogramms in Maschinencode

Wir wollen nun eine passende Maschine postulieren, auf der wir die Programme unserer eingeführten Programmiersprache ausführen können. Wegen der rekursiven Struktur unseres Hauptinstruments, der Arithmetik expression, sowie der Möglichkeit von geschachtelten Prozeduren und Iterationen empfiehlt sich eine Maschine mit einem Datenstapel und einem Prozedurstapel. Dieser ist durch Aktivierungsblöcke gekennzeichnet, die für jeweils einen Aufruf einer Prozedur ihre Umgebung, d.h. die lokalen Variablen und die Rücksprungadresse aufnehmen. Der Datenstapel entspricht einer Menge von Registern und Hilfsspeichern. Stapelregister sind auf verschiedenen Prozessoren hardwaremäßig verwirklicht. Wir benötigen nur Lesen der beiden obersten Einträge, um binäre Operationen ausführen zu können. Die übrigen Elemente sind nicht zugänglich. Weiterhin ist es erlaubt, neue Elemente auf den Stapel zu legen (kellern) oder Elemente zu entfernen (entkellern). Demgegenüber muss es auf dem Prozedurstack möglich sein, alle Environments zu lesen, ohne sie zu entkellern. Nun soll die Stapelmaschine SM definiert werden:

Definition 4.1
Die Menge $ C$ der SM-Operationen ist definiert durch

$\displaystyle C:=C_{0}\cup C_{1}\cup C_{2}\cup C_{3},
$

wobei
$\displaystyle C_0$ $\displaystyle :=$ $\displaystyle \left\{ \mathtt{ADD,SUB,MUL,DIV,RET}\right\}$  
$\displaystyle C_1$ $\displaystyle :=$ $\displaystyle \left\{ \mathtt{CONST,JMP,JE,JL,JNE,JLE,JG,JGE}\right\}$  
$\displaystyle C_2$ $\displaystyle :=$ $\displaystyle \left\{ \mathtt{LOAD,STORE}\right\}$  
$\displaystyle C_3$ $\displaystyle :=$ $\displaystyle \left\{ \mathtt{CALL}\right\} .$  

Die Menge $ I$ der SM-Instruktionen ist definiert durch

$\displaystyle I:=\bigcup\limits_{i\in \left\{ 0,1,2,3\right\} }C_i\times \mathbb{Z}^i.
$

Hier sind die Operationen nach der Zahl ihrer Parameter geordnet. (Vergleiche Definition 9.4.3; beispielsweise ist die Addition eine reine Stack-Operation und benötigt keinen Parameter.)

Definition 4.2
Die Stapelmaschine SM ist gegeben durch die Komponenten Ein Zustand der SM ist ein Tripel $ (\delta, \pi, z)$ aus einem Zustandsraum $ U$ mit $ \delta \in D,\pi \in P$ und $ z\in Z.$ Für einen Prozedurstapel $ \pi$ und $ n,m\in \mathbb{N}$ bezeichnet $ \pi(n)$ den $ n$-ten Aktivierungsblock in $ \pi$. Die Komponenten von $ \pi(n)$ bezeichnen wir durch $ \pi(n)_{\vert RA}$, $ \pi(n)_{\vert SL}$, $ \pi(n)_{\vert LV}$. Die $ m$-te lokale Variable in $ \pi(n)$ ist durch $ \pi(n)_{\vert LV(m)}$ bezeichnet.

Der Datenstapel wird als

$\displaystyle \delta =z_{1}.z_{2}. . . .z_{n}
$

bezeichnet mit dem letzen Eintrag rechts. Der Prozedurstapel ist als

$\displaystyle \pi =\left\langle r_{1},s_{1}\left( l_{11},...,l_{1n_{1}}\right) ...
...t\langle r_{2},s_{2}\left( l_{21},...,l_{2n_{2}}\right) \right\rangle . . ,
$

notiert, wobei die Aktivierungsblöcke in eckigen Klammern stehen und durch Punkte getrennt sind.

Wir müssen nun zunächst die Semantik der SM-Instruktionen erklären und dann Hinweise zur Übersetzung eines Mini-Pascalprogramms in die Maschinensprache der SM-Maschine geben.

Beim Eintritt in den Code einer Prozedur bzw. des Hauptprogramms wird jeweils ein neuer Aktivierungsblock auf dem Prozedurstapel angelegt, der dort bis zur Beendigung dieses Programmteils verbleibt. Die Rücksprungadresse ist die Nummer der nächsten Instruktion hinter dem Unterprogrammaufruf CALL. Mittels der Rücksprunginstruktion RET kehrt man zur Weiterverarbeitung des Programmes nach Beendigung des Unterprogramms und Löschung des Aktivierungsblocks an diese Stelle zurück. Der statische Verweis SL ist die Nummer desjenigen Aktivierungsblocks im Prozedurstapel, der zu der syntaktisch nächstäußeren Prozedur bzw. dem Hauptprogramm gehört. Aus diesem Teil erfolgte der Aufruf zur gegenwärtigen Prozedur. Aufgrund der Sichtbarkeitsbedingngen können die in eine bestimmte Prozedur p eingeschachtelten inneren Prozeduren von Stellen außerhalb von p nicht aufgerufen werden. Beim Aufruf ist die Prozedur p noch nicht abgeschlossen, daher ist ihr Aktivierungsblock noch nicht gelöscht. Eigentlich wird hier eine Rückwärtskette definiert, die für den Zugriff auf die globalen Variablen und die lokalen Variablen der äußeren Prozeduren erforderlich ist.

Der Speicherplatz einer Variablen ist durch ein Paar nicht negativer Zahlen $ (l,o)$ definiert. Dabei bezeichnet $ l$ die Niveaudifferenz zwischen der Blockschachtelungstiefe der Anweisung, in der sie verwendet wird, und der Blockschachtelungstiefe ihrer Definitionsstelle. Diese Anzahl von Blöcken muss durchquert werden, bis der Block erreicht wird, in dem die Variable definiert wurde. Für eine frisch deklarierte lokale Variable ist demnach $ l=0$. Die zweite Zahl $ o$ ist ein Offset, der die relative Position der Variablen innerhalb ihres Aktivierungsblocks angibt.

Nunmehr müssen wir für alle SM-Instruktionen die stattfindenden Zustandsänderungen beschreiben.

Definition 4.3
Die Semantik einer SM-Instruktion $ i\left[ {}\right] :I\rightarrow \left[ U\rightarrow U\right] $ wird durch eine Zustandsänderung beschrieben, die sich für die einzelnen Instruktionen wie folgt darstellt:
$\displaystyle 1. \mathtt{ADD}$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta =d_1....d_n.x.y \\
z=i
\end{a...
...n{array}{l}
\delta =d_1....d_n.x+y \\
z=i+1
\end{array}\right]\end{displaymath}  
$\displaystyle 2.  \mathtt{SUB}$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta =d_1....d_n.x.y \\
z=i
\end{a...
...n{array}{l}
\delta =d_1....d_n.x-y \\
z=i+1
\end{array}\right]\end{displaymath}  
$\displaystyle 3. \mathtt{MUL}$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta =d_1....d_n.x.y \\
z=i
\end{a...
...ay}{l}
\delta =d_1....d_n.x\cdot y \\
z=i+1
\end{array}\right]\end{displaymath}  
$\displaystyle 4. \mathtt{DIV}$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta =d_1....d_n.x.y \\
z=i
\end{a...
...t\lfloor x/y\right\rfloor ,y\neq 0 \\
z=i+1
\end{array}\right]\end{displaymath}  
$\displaystyle 5. \mathtt{RET}$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\pi =...\left\langle r,s,\left( ...\r...
......\right) \right\rangle . \\
z=r^{\prime }
\end{array}\right]\end{displaymath}  
$\displaystyle 6. \mathtt{JMP}n$ $\displaystyle :$ $\displaystyle \left[ z=i\right] \rightarrow \left[ z=n\right]$  
$\displaystyle 7. \mathtt{JE}n$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta =d_1....d_n.x \\
z=i
\end{arr...
...=n,\text{ falls }x=0 \\
z=i+1,\text{ sonst}
\end{array}\right]\end{displaymath}  
$\displaystyle 8. \mathtt{JNE}n$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta =d_1....d_n.x \\
z=i
\end{arr...
...text{ falls }x\neq 0 \\
z=i+1,\text{ sonst}
\end{array}\right]\end{displaymath}  
$\displaystyle 9. \mathtt{JL}n$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta =d_1....d_n.x \\
z=i
\end{arr...
...=n,\text{ falls }x<0 \\
z=i+1,\text{ sonst}
\end{array}\right]\end{displaymath}  
$\displaystyle 10. \mathtt{JLE}n$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta =d_1....d_n.x \\
z=i
\end{arr...
...text{ falls }x\leq 0 \\
z=i+1,\text{ sonst}
\end{array}\right]\end{displaymath}  
$\displaystyle 11. \mathtt{JG}n$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta =d_1....d_n.x \\
z=i
\end{arr...
...=n,\text{ falls }x>0 \\
z=i+1,\text{ sonst}
\end{array}\right]\end{displaymath}  
$\displaystyle 12. \mathtt{JGE}n$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta =d_1....d_n.x \\
z=i
\end{arr...
...text{ falls }x\geq 0 \\
z=i+1,\text{ sonst}
\end{array}\right]\end{displaymath}  
$\displaystyle 13. \mathtt{CONST}c$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta =d_1....d_n \\
z=i
\end{array...
...gin{array}{l}
\delta =d_1....d_n.c \\
z=i+1
\end{array}\right]\end{displaymath}  

Für die Erklärung der letzten drei Instruktionen benötigen wir die Hilfsfunktion base: $ \mathbb{N}\times \mathbb{N}\rightarrow \mathbb{N}$, die entlang einer Kette von Verweisen den richtigen Aktivierungsblock einer Variablen und ihren Speicherplatz findet:
$\displaystyle \mathtt{base}(0,x)$ $\displaystyle :=$ $\displaystyle x$  
$\displaystyle \mathtt{base}(n+1,x)$ $\displaystyle :=$ $\displaystyle \mathtt{base}(n,\pi (x)_{\vert SL}).$  


$\displaystyle 14. \mathtt{LOAD } l, o$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta = d_1....d_n \\
\pi = \pi_1.\...
...)} \\
\pi =\pi _1.\pi _2...\pi _k \\
z=i+1
\end{array}\right]\end{displaymath}  
$\displaystyle 15. \mathtt{STORE } l, o$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\delta = d_1....d_n.x \\
\pi = \pi_1...
.......d_n \\
\pi = \pi^{\prime} \\
z = i + 1
\end{array}\right]\end{displaymath}  

wobei $ \pi ^{\prime }\left( \mathtt{base}\left( l,k\right) \right)_{\vert LV(o)}=x$ und $ \pi^{\prime }=\pi $ an den anderen Stellen gilt. Weiter bezeichne $ l$ die Niveaudifferenz zwischen der Blockschachtelungstiefe, in der die zu ladende/speichernde Variable definiert ist, und der Blockschachtelungstiefe, in der der Aufruf erfolgt. Die Zahl $ o$ ist der Offset, der die relative Position dieser Variablen innerhalb ihres Aktivierungsblocks angibt.


$\displaystyle 16. \mathtt{CALL }l,n,v$ $\displaystyle :$ \begin{displaymath}\left[
\begin{array}{l}
\pi =\pi _1.\pi _2...\pi _k \\
z=i
\end{array}\right] \rightarrow\end{displaymath}  
    \begin{displaymath}\left[
\begin{array}{l}
\pi =\pi _1.\pi _2...\pi _k.\left\lan...
...ace{0,0,...,0}}\right) \right\rangle \\
z=n
\end{array}\right]\end{displaymath}  

Der erste Parameter $ l$ von CALL ist die Niveaudifferenz zur aufrufenden Prozedur, die zur Ermittlung des statischen Verweises auf den neu zu schaffenden Aktivierungsblock erforderlich ist. Der zweite Parameter $ n$ ist die Adresse der aufzurufenden Prozedur, der dritte Parameter $ v$ gibt die Anzahl der lokalen Variablen dieser Prozedur an, die wir auf dem neuen Aktivierungsblock anlegen und willkürlich mit Null initialisieren.

Bevor nun das eigentliche Programm startet, werden die entsprechenden Speicherplätze mit den Eingabewerten gefüllt. Für das Programm wird zunächst ein einziger Aktivierungsblock mit Rücksprungadresse 0, statischem Verweis 0 und Platz für alle globalen Variablen bereitgestellt in Analogie zum CALL-Befehl. Hier werden die Startwerte mittels einer in-Instruktion eingebracht. Das Programm soll anhalten, wenn zum Ende des Hauptprogramms eine Anweisung RET 0 angegeben ist und diese ausgeführt wird. Der Ausgabewert ist dann der oberste und im Allgemeinen einzige verbleibende Wert auf dem Datenstapel, wenn das Programm angehalten hat.

Beispiel 4.4
program Beispiel;
var A, B, C : Integer;

procedure p;
var A, B, D: Integer;

  procedure q;
  var C: Integer;
  begin
  .....
    p; (* CALL 1, adr(p), 3 *)
  .....
  end;

begin
  C:=0; (* CONST 0; STORE 1,3 *)
  ....
  q; (* CALL 0,adr(q),1 *)
  .....
end ;

begin (* Hauptprogramm *)
  ....
  p; (* CALL 0, adr(p),3 *)
  .....
end.

Wir betrachten die Aufrufkette Hauptprogramm $ \mathtt{\rightarrow p\rightarrow q\rightarrow p.}$

Nach dem ersten Aufruf von p sieht der Prozedurstapel wie folgt aus:

$\displaystyle \stackrel{\pi (1)}{\overbrace{\left\langle 0,0,\left( a,b,c\right...
...erbrace{\left\langle r_{1},1,\left( a_{p},b_{p},d_{p}\right) \right\rangle },}
$

wobei $ a,b,c$ die Werte der Variablen sind. Der statische Verweis 1 in $ \pi (2)$ verweist auf $ \pi (1)$. Der Befehl STORE 1,3 aus der Übersetzung C:=0 , das ist die dritte Variable, wirkt entsprechend der Semantik von STORE auf $ \pi \left( \mathtt{base} (1,2)\right) _{\vert LV(3)}.$ Es gilt jedoch mit der rekursiven Definition von $ \mathtt{base}(n+1,x):=\mathtt{base}(n,\pi (x)_{\vert SL}),\mathtt{base}(0,x):=x$ und dem Wert $ 1$ an der Stelle $ SL$ von $ \pi (2)$


$\displaystyle \pi \left( \mathtt{base}(1,2)\right) _{\vert LV(3)}$ $\displaystyle =$ $\displaystyle \pi \left( \mathtt{base} (0,\pi (2)_{\vert SL})\right) _{\vert LV(3)}$  
  $\displaystyle =$ $\displaystyle \pi \left( \pi (2)_{\vert SL})\right) _{\vert LV(3)}$  
  $\displaystyle =$ $\displaystyle \pi (1)_{\vert LV(3)}.$  

Dies ist der Platz der Variablen C im Aktivierungsblock des Hauptprogramms. Zufällig wurde hier auf den nächsten Aktivierungsblock im Prozedurstapel zugegriffen. Hier fallen statische und dynamische Sichtbarkeit zusammen. Dies kann jedoch auch anders sein, wie die Situation nach dem Aufruf von q und dem zweiten Aufruf von p zeigt.

$\displaystyle \stackrel{\pi (1)}{\overbrace{\left\langle 0,0,\left( a,b,c\right...
...rbrace{\left\langle r_3,1,\left( a_{p2},b_{p2},d_{p2}\right) \right\rangle }}
$

STORE 1,3 wirkt jetzt wie folgt:


$\displaystyle \pi \left( \mathtt{base}(1,4)\right) _{\vert LV(3)}$ $\displaystyle =$ $\displaystyle \pi \left( \mathtt{base} (0,\pi (4)_{\vert SL})\right) _{\vert LV(3)}$  
  $\displaystyle =$ $\displaystyle \pi \left( \pi (4)_{\vert SL})\right) _{\vert LV(3)}$  
  $\displaystyle =$ $\displaystyle \pi (1)_{\vert LV(3)}.$  

Das ist derselbe Speicherplatz wie im vorigen Aufruf. Schauen wir uns noch die Angabe $ \pi (3)_{\vert SL}$ $ =2$ im Aufruf von q an. Er bewirkt, dass die Variablen A,B,D in $ \pi (2)$ gefunden werden.

Nunmehr wollen wir uns der Übersetzung von Mini-Pascal in SM-Code zuwenden. Dazu werden wir eine formale Spezifikation für ein Übersetzungsprogramm (Mini-Compiler) angeben. Im Zyklus der Informatikveranstaltungen widmet sich die Vorlesung Compilerbau dieser Aufgabe. Da es sich hier um recht komplexe Theorien handelt, die auf einer Vorlesung über Theoretische Informatik aufbauen, werden wir nur einzelne Aspekte der Aufgabe erläutern.

Compiler verarbeiten den Text eines Programmes, indem sie zunächst eine lexikalische, sodann eine syntaktische und semantische Analyse vornehmen. Nach Aufbau einer komplexen Baumstruktur wird schließlich ein äquivalentes Programm in der speziellen Sprache der Zielmaschine erzeugt. Wichtig ist es, dass bei der Komplexität der Programme alle diese Vorgänge automatisiert werden können. Wir wollen hier nur eine stark vereinfachte Sicht dieser Schritte vortragen und nur die direkte Umsetzung in den Maschinencode anschauen.

Compiler verarbeiten die Deklarationsteile der Programme, indem sie eine Symboltabelle erzeugen. Hier sind die Bezeichner und ihre Typattribute eingetragen. Darunter verstehen wir die Angabe, ob es sich um eine ConstDecl, VarDecl oder ProcDecl handelt sowie die Blockschachtelungstiefe der Definitionsstelle sowie die mit dem Bezeichner verbundene Adresse.

Diese Tabelle muss weiterhin verwaltet werden. In der folgenden Definition wollen wir den Zugriff auf diese Tabelle erläutern.

Definition 4.5
Die folgenden Funktionen seien für die Konstantenbezeichner c, die Variablenbezeichner v und die Prozedurbezeichner p definiert und beziehen sich auf die zur Zeit ihres Aufrufs sichtbaren Definitionen.

Nunmehr werden Funktionen Ecomp, SComp, PComp zur Übersetzung von Ausdrücken, Anweisungen und Programmen definiert, die SM-Programmteile liefern.

Definition 4.6
Hier beschreiben wir die Übersetzung von Ausdrücken:
$\displaystyle 1.  \mathtt{EComp(ident)}$ $\displaystyle :=$ $\displaystyle {. \atopwithdelims\{ \mathtt{CONST value(ident),}\text{falls \te...
...}}{\mathtt{LOAD}\text{ }\mathtt{lv-level(ident),offset(ident),}\text{ sonst} }$  
$\displaystyle 2.  \mathtt{EComp(number)}$ $\displaystyle :=$ $\displaystyle \mathtt{CONST}$ $\displaystyle \mathtt{number}$  
$\displaystyle 3.  \mathtt{EComp(\mathtt{f}_{1}*\mathtt{f}_{2})}$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
\mathtt{EComp}\text{ }\mathtt{(f}_{1...
... }\mathtt{(f}_{2}\mathtt{)} \\
\mathtt{MUL}
\end{array}\right.\end{displaymath}  
$\displaystyle 4.  \mathtt{EComp(\mathtt{f}_{1}}$ $\displaystyle \mathtt{div}$ $\displaystyle \mathtt{\mathtt{f}_{2})}$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
\mathtt{EComp}\text{ }\mathtt{(f}_{1...
... }\mathtt{(f}_{2}\mathtt{)} \\
\mathtt{DIV}
\end{array}\right.\end{displaymath}  
$\displaystyle 5.  \mathtt{EComp(\mathtt{f}_{1}}$ $\displaystyle \mathtt{+}$ $\displaystyle \mathtt{\mathtt{f}_{2})}$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
\mathtt{EComp}\text{ }\mathtt{(f}_{1...
... }\mathtt{(f}_{2}\mathtt{)} \\
\mathtt{ADD}
\end{array}\right.\end{displaymath}  
$\displaystyle 6.  \mathtt{EComp(\mathtt{f}_{1}}$ $\displaystyle \mathtt{-}$ $\displaystyle \mathtt{\mathtt{f}_{2})}$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
\mathtt{EComp}\text{ }\mathtt{(f}_{1...
... }\mathtt{(f}_{2}\mathtt{)} \\
\mathtt{SUB}
\end{array}\right.\end{displaymath}  
$\displaystyle 7.  \mathtt{EComp(+t)}$ $\displaystyle :=$ $\displaystyle \mathtt{EComp(t)}$  
$\displaystyle 8.  \mathtt{EComp(-}$ $\displaystyle \mathtt{\mathtt{f}_{2})}$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
\mathtt{CONST}\text{\texttt{ 0}} \\...
... }\mathtt{(f}_{2}\mathtt{)} \\
\mathtt{SUB}
\end{array}\right.\end{displaymath}  
$\displaystyle 9.  \mathtt{EComp((e))}$ $\displaystyle :=$ $\displaystyle \mathtt{EComp(e),}$e Ausdruck.  

Definition 4.7
Nun zu den Anweisungen:
$\displaystyle 1.  \mathtt{SComp}\left( \mathtt{i}:=\mathtt{e}\right)$ $\displaystyle :=$ $\displaystyle \left\{ {\mathtt{EComp(e)} \atopwithdelims.. \mathtt{STORE}\text{ }\mathtt{lv-level(i),offset(i)}}\right.$  
$\displaystyle 2.  \mathtt{SComp}\left( \mathtt{p}\right)$ $\displaystyle :=$ $\displaystyle \mathtt{CALL}$ $\displaystyle \mathtt{lv-level(p),adr(p),size(p)}$  
$\displaystyle 3.  \mathtt{SComp}\left( \mathtt{begin}\text{ }\alpha _{1};...;\alpha _{n}\text{ }\mathtt{end}\right)$ $\displaystyle :=$ \begin{displaymath}\left\{
\begin{array}{c}
\mathtt{SComp}\left( \alpha _{1}\rig...
... \\
\mathtt{SComp}\left( \alpha _{n}\right)
\end{array}\right.\end{displaymath}  
$\displaystyle 4.  $   if $\displaystyle \left( e_{1}\circ e_{2}\right)$   then $\displaystyle \alpha$    für $\displaystyle \circ$ $\displaystyle \in$ $\displaystyle \left\{ =,<,>,<>,<=,>,>=\right\}$     

wird wie folgt übersetzt: Wir erzeugen nun ein Stück Programmcode $ \tau $, welches ein Zahl auf dem Datenstapel hinterlässt, die durch ihre Vorzeichen die Größenverhältnisse zwischen $ e_1$ und $ e_2$ angibt:

\begin{displaymath}
\begin{array}{c}
\mathtt{EComp(e}_{\mathtt{1}}\mathtt{)}  ...
...t{EComp(e}_{\mathtt{2}}\mathtt{)} \\
\mathtt{SUB}
\end{array}\end{displaymath}

Bezeichne nun x die Adresse des ersten Befehls hinter der Übersetzung des Blocks $ \alpha$ im then-Zweig. Wir übersetzen dann in Abhängigkeit vom Vergleichsoperator $ \circ $ wie folgt

\begin{displaymath}
\begin{array}{c}
\tau \\
\mathtt{AAA}\text{ }\mathtt{x} \\
\mathtt{SComp}\left( \alpha \right)
\end{array}\end{displaymath}

mit der folgenden Korrespondenz für den SM-Befehl AAA
  $\displaystyle =$ $\displaystyle \longleftrightarrow \mathtt{JNE}, <>\longleftrightarrow \mathtt{JE},
 <\longleftrightarrow \mathtt{JGE},$  
  $\displaystyle <=$ $\displaystyle \longleftrightarrow \mathtt{JG}, >\longleftrightarrow \mathtt{JLE},
 >=\longleftrightarrow \mathtt{JL}.$  

$\displaystyle 5. $   while $\displaystyle \left( e_1\circ e_2\right)$   do $\displaystyle \alpha$    für $\displaystyle \circ \in$    $\displaystyle \left\{ =,<,>,<>,<=,>,>=\right\}$    $\displaystyle $

wird analog zur If-Anweisung übersetzt. Hier bezeichnet x die Adresse des zweiten Befehls hinter dem SM-Code für den Do-Zweig $ \alpha .$

An diesen wird grundsätzlich ein Rücksprungbefehl JMP y angehängt, wobei y die Adresse des ersten Befehls im Codesegment $ \tau $ ist. Dadurch ist gewährleistet, dass die Sequenz $ \alpha$ sooft ausgeführt wird, wie die Bedingung $ \left( e_{1}\circ e_{2}\right) $ erfüllt ist.

Bei echten Compilern wird zur Behandlung der Vorwärtssprünge das Sprungziel meist in einem zweiten Durchlauf beim Übersetzungvorgang eingetragen. In unserer Mini-Pascalübersetzung haben die Anlage und Freigabe der lokalen Variablen bei der Übersetzung von Blöcken die SM-Befehle CALL und RET übernommen.

Definition 4.8
Sei $ \beta =\Theta \Phi \Pi _1...\Pi _m \alpha$ ein syntaktisch korrekter Block in der Sprache Mini-Pascal. Dabei sei

$\displaystyle \Theta \in \mathtt{constDecl},  \Phi \in \mathtt{varDecl},  \Pi _i\in \mathtt{procDecl},  i=1,...,m,
$

und $ \alpha$ eine syntaktisch korrekte Anweisungsfolge. Zu den Prozedurdeklarationen mögen die Blöcke $ \beta _i,i=1,...,m$ gehören. Dann definieren wir

\begin{displaymath}
\begin{array}{c}
\mathtt{BComp(}\beta \mathtt{)}:=\mathtt{BC...
...
\mathtt{SComp(}\alpha \mathtt{)} \\
\mathtt{RET}
\end{array}\end{displaymath}

Da im Hauptprogramm noch Ein- und Ausgaben vorgenommen werden, müssen wir die Übersetzung von Programmen leicht modifizieren:

Definition 4.9
Sei $ \mathtt{pg}$ ein syntaktisch korrektes Mini-Pascalprogramm mit dem Hauptblock $ \beta $ wie in der vorangegangenen Definition und einer auf $ \alpha$ folgenden Anweisung WriteLn($ e$). Dann definieren wir

\begin{displaymath}
\begin{array}{c}
\mathtt{PComp(pg)}:=\mathtt{BComp(}\beta _{...
...} \\
\mathtt{EComp(}e\mathtt{)} \\
\mathtt{RET}
\end{array}\end{displaymath}

Dadurch verbleibt nach Abarbeitung des Programmrumpfs $ \alpha$ der Wert $ e$ auf dem Datenstapel.

Definition 4.10
Sei psm : N $ \rightarrow I$ ein SM-Programm. Die Einzelschrittfunktion $ \Delta \left[ \mathtt{psm}\right] $ ist eine Zustandstransformation

$\displaystyle \Delta \left[ \mathtt{psm}\right] :U\rightarrow U
$

mit

$\displaystyle \Delta \left[ \mathtt{psm}\right] \left( \delta ,\pi ,z\right) :=i\left[ \mathtt{psm}\left( z\right) \right] \left( \delta ,\pi ,z\right).
$

Sie beschreibt die Zustandsänderung auf den Stapeln und im Instruktionszeiger, die durch einen Programmschritt bewirkt wird. Die gesamte Interpretation des Programmes erhalten wir durch eine Iteration der Einzelschrittfunktion

$\displaystyle \Delta \left[ \mathtt{psm}\right] ^\infty :U\stackrel{*}{\rightarrow }U.
$

Für ein $ n\in \mathbb{N}$ ist die $ n$-stellige Eingabefunktion der SM erklärt als

$\displaystyle \mathtt{in}^{(n)}: \mathbb{Z}^n \rightarrow U
$

$\displaystyle \mathtt{in}^{(n)}\left( z_1,...,z_n\right) :=\left( \delta _0,\pi_0,z_0\right) .
$

Dabei wird sukzessive für $ i=1,...,n$ der Wert $ z_i$ der Variablen $ v_i$ mit einer dem CONST-Befehl analogen Instruktion von einem Peripheriegerät auf den Datenstapel geschrieben und der Prozedurstapel manipuliert, d.h. den Variablen $ v_1,...,v_n,$ die an den Offsetstellen $ 1,...n$ des ersten Aktivierungsblocks liegen, die Werte $ z_1,...,z_n$ mit dem STORE-Befehl zugeordnet. Sodann wird zur ersten Instruktion $ z_0$ des Programmrumpfs $ \alpha$ verzweigt. Der entstandene Prozedurstapel ist $ \pi_0,$der Datenstapel $ \delta _0$ ist leer.

Die Ausgabefunktion der SM ist erklärt als

$\displaystyle \mathtt{out}:U\rightarrow \mathbb{Z}$

$\displaystyle \mathtt{out}\left( \delta ,\pi ,z\right) :=d_1.
$

Sie überträgt den obersten Wert des Datenstapels an ein Peripheriegerät.

Es ergibt sich damit das folgende Programm

$\displaystyle \mathtt{Pr}\left[ \mathtt{pg}\right] \left( z_{1},...,z_{n}\right...
...nfty }\left( \mathtt{in}^{(n)}\left( z_{1},...,z_{n}\right) \right) \right) .
$

Schließlich müsste noch gezeigt werden, dass unsere Übersetzung tatsächlich die Semantik der Mini-Pascalprogramme korrekt widerspiegelt.

(Literatur: Herbert Klaeren: Vom Problem zum Programm. Teubner, Stuttgart 1991)

10 Von Mini-Pascal zu Pascal

Einige Vorbemerkungen

Die Sprache Pascal wurde seit den siebziger Jahren gerade im Bildungssektor häufig angewandt um in die Programmierung einzuführen. Ursprünglich war die Sprache dem Paradigma der strukturierten Programmierung verhaftet. Sie wurde im Laufe der Zeit auch um objektorientierte Konzepte erweitert.

Einen wesentlichen Beitrag zur Popularität von Pascal hat die Firma Borland geleistet, die mit ihren Turbo-Pascal (TP) - und Delphi-Compilern weitverbreitet ist. TP wurde vor allem durch die Verbindung von Editor und Compiler einschließlich Debugging bekannt.

Pascal wurde immer wieder erweitert. Mit dem Einzug des Unit-Konzepts, das modulares Programmieren zulässt, dem Einbezug von verallgemeinerten Datentypen, wie Objekt, sowie einer Reihe von Bibliotheken die die Programmierung grafischer Benutzungsoberflächen zulassen.

Inzwischen ist es etwas ruhiger um die Sprache Pascal geworden. Das liegt darin begründet, dass die Sprache in großen Softwareprojekten nur bedingt eingesetzt wird. Auf UNIX-Rechnerplattformen ist Pascal praktisch bedeutungslos, durch die Vorrangstellung von C. Mit dem Auftreten von neuen netzorientierten Sprachen wie Java ist ihr eine ernste Konkurrenz erwachsen.

Mit dem Free Pascal Compiler (FPC)[*] ist auch eine freie Variante des kommerziellen TP verfügbar, die zudem weitgehend plattformunabhängig ist. Daneben gibt es auch den GNU Pascal Compiler (GPC)[*], ebenfalls freie Software. FPC unterstützt den Borland Pascal dialect Borland und implementiert die Delphi Object Pascal language. GPC hat allerdings noch offene Punkte auf der TO-DO-Liste, die von FPC bereits implementiert werden, z.B. Strings, Units und objektorientierte Programmierung.[*]

1 Datentypen von Pascal

Eine Menge von Werten heißt Typ. Eine Typdeklaration weist einem Typ einen Namen zu und definiert ihn, dies kann z.B. durch Angabe einer Wertemenge geschehen.

Variablen sind Wörter, die einen nicht festgelegten Wert aus einer Menge von Werten eines Typs vertreten. Sie haben damit einen Typ. Sie können jeden Wert dieses Typs annehmen. Man darf sich den Wert abgelegt auf einem Werteplatz, der für Werte dieses speziellen Typs geeignet ist, vorstellen. Zugang erfolgt über eine Referenz zum Werteplatz, der durch den Variablennamen symbolisiert wird. Mit der Zuweisung eines Wertes zu einer Variablen wird dieser Wert auf dem zur Variablen gehörigen Werteplatz abgelegt. Pascal kennt die folgenden einfachen Datentypen:

Die hier aufgeführten Typenbezeichner stehen für Standardtypen, sind keine Schlüsselwörter und können daher umdefiniert werden. Die ersten drei Typen sind Ordinaltypen, da die Elemente ihrer Mengen total geordnet sind und mit einer Vorgänger- und Nachfolgerfunktion pred und succ, den Standardprozeduren Inc und Dec zur Inkrementierung und Dekrementierung, sowie Ord, das die Ordinalzahl des Arguments liefert, versehen sind.

Neben den einfachen Typen gibt es andere vordefinierte Typen, wie

Strukturierte Typen, wie Record, betreffen kartesische Produkte verschiedener Mengen $ A_{1}\times\ldots\times A_{n}$, der Mengentyp
       Mengentyp = "SET" "OF" OrdinalerTyp.
betrifft in den meisten Implementierungen leider nur Mengen mit höchstens 256 Elementen. Dagegen sind die Mengenoperatoren + (entspricht der Vereinigung), - (Komplementbildung), * (Durchschnitt) und in (für $ \in$) definiert. Für boolesche Typen sind die Booleschen Operatoren not, and, or und xor erklärt, die auch ihre bitweisen Äquivalente für Integer-Operandentypen haben.

Zeigertypen haben das EBNF-Diagramm

       ZeigerTyp = "^"Typbezeichner.

Variablen diesen Types enthalten die Speicheradresse eines Objekts vom Typ Typbezeichner erweitert um den Wert nil, der zu jedem Pointertyp gehört und auf keine Variable zeigt. Es gilt die folgende Korrespondenz:

^T ist ein Zeigertyp, Variablen dieses Typs zeigen auf Variablen vom Typ T,
v vom Typ ^T enthält die Adresse von v^ vom Typ T.

Mit Hilfe einer Typ-Deklaration zwischen den Konstanten- und Variablendeklarationen können eigene Typen definiert werden. Ein Beispiel hierfür sind Unterbereichstypen.

Durch die Aufzählung frei wählbarer Namen kann ein Wertebereich und damit ein Typ definiert werden. Dadurch werden die Werte dieses Typs gleichzeitig linear angeordnet.
       type himmelsrichtung = (nord, sued, west, ost); 
       type farbe = (rot, gelb, gruen, schwarz);
Ist O ein Ordinaltyp, in der Regel ein Unterbereichstyp, und T ein beliebiger Typ, so kann ein Datentyp von Feldern (Arrays) über T deklariert werden durch
       type A = array [O] of T;
Dieser Ansatz kann als Abkürzung von
       type matrix = array [O_1] of array ... of array [O_n] of T;
zu
       type matrix = array [O_1,...,O_n] of T;
d.h. zu einem mehrdimensionalen Feld erweitert werden.

Einer Deklaration mit $ n=3$, T = real, O_1 = O_2 = O_3 = 0..10 und

       var m : matrix;
kann dann eine Zuweisung m[i, j, k] := 3.5 folgen.

Beispiel 1.1
Wir wollen die Typdeklarationen für einen Kompass, ein Histogramm für die Häufigkeit von Kleinbuchstaben und eine Codetabelle zum Übersetzen eines Zeichensatzes in einen anderen angeben:
     kompass = array [1..3] of himmelsrichtung;
     Histogramm = array ['a' .. 'z'] of Integer;
     Codetabelle = array[char] of char;

Mittels eines Arrays über einem Teilbereichstyp von Charakters char kann auch ein String-Typ definiert werden. Die Mächtigkeit des Teilbereichstyps bestimmt dabei die Wortlängen. Das Short-Strings in Turbo-Pascal erlaubt eine maximale Länge von 255 Zeichen. Die Ansi-Strings in Delphi und FreePascal erlauben beliebig lange Zeichenketten. Es gibt Operatoren ('$ +$') , die zwei Strings verschmelzen zu einem neuen String, Funtionen, die einen Teilstring ab einer Position einer gewissen Länge auswählen, die die Länge einer Zeichenkette und die aktuelle Position eines Zeichens in einem String angeben. Prozeduren erlauben das Löschen innerhalb eines Strings, das Einfügen eines Strings in einen anderen, sowie die Umwandlung von Strings in ganze bzw. Gleitkommazahlen und ihre Umkehrung.

Während Felder Ansammlungen von Mengen gleichen Typs sind und damit dem kartesischen Produkt $ A^{n}$ entsprechen, leisten Verbünde dies für verschiedene Mengen.

Sind $ T_{1},\ldots,T_{n}$ Typen und $ v_{1},\ldots,v_{n}$ Bezeichner, so kann ein Verbund über $ T_{1}\times\ldots\times T_{n}$ als Datentyp durch

 
       verbund = record
                   v1 : T1;
                   v2 : T2;
                   ......
                   vn : Tn;
                 end;
deklariert werden. Für eine Variable v vom Typ T bezeichnet man die i-te Komponente mit v.vi als qualifizierter Ausdruck. Ersetzt man eine Anweisung alpha mit einem qualifizierten Bezeichner durch eine Anweisung
     with Recordvariablenliste do alpha ;
so kann man direkt auf vi zugreifen und spart Schreibarbeit.

Beispiel 1.2
       Datum = record
                 Tag : 1..31;
                 Monat : (Jan, Feb, Mar, Apr, Mai, Jun,
                          Jul, Aug, Sep, Okt, Nov, Dez);
                 Jahr : Integer
               end;

In der Folge wollen wir folgende Vereinbarung treffen:

     Konstante = ConstIdent | RealZahl | Zahl.
     Konstantenliste = Konstante {"," Konstante}.
     Bereichsliste = Konstante .. Konstante {"," Konstante .. Konstante}.
Eine interessante Erweiterung der bedingten Anweisungen zu einer Auswahlanweisung mit der Syntax
     Auswahl = "case" expression öf" 
                Konstantenliste | Bereichsliste 
                {"," Konstantenliste | Bereichsliste}
                ":" statement ";" 
                { Konstantenliste | Bereichsliste 
                {"," Konstantenliste | Bereichsliste}
                ":" statement ";"}
                ["else" statement] "end".
spielt eine wichtige Rolle bei der Erweiterung des Verbundtyps. Seine Semantik ist intuitiv verständlich. Stimmt expression mit einem Wert in der Konstantenliste überein oder liegt der Wert von expression innerhalb einer der Bereiche, so wird das darauf folgende Statement ausgeführt. Der else-Zweig ist fakultativ und wird nur dann durchlaufen, wenn keine der Fälle der Auswahlliste eintritt.

Zur Erweiterung des Verbundtyps wird die Datensatzliste nach dem Schlüsselwort record durch die folgende Konstruktion, den sog. varianten Teil, ergänzt:

     "case" selektorIdent ":" Selektortyp öf" 
            Konstantenliste ":" "(" Datensatzliste ")" ";"
            {Konstantenliste ":" "(" Datensatzliste ")" ";"}.

Beispiel 1.3
     type Stand = (verh, verw, gesch, ledig);
          Person = record
                     name : record vorname, nachname : string end;
                     Versnr : integer;
                     Geschlecht : (Mann, Frau);
                     Geburt : Datum;
                     Kinder : integer;
                     case stkomp : Stand of
                       verh, verw : (vd : Datum);
                       gesch : (gd : Datum);
                       ledig : (unabh : boolean);
                   end;

Felder und Verbünde sind statische Datenstrukturen; ihre Größe wird zur Übersetzungszeit festgelegt und bleibt unveränderlich. So ist es auch nicht möglich, ein array mit einem variablen Indexbereich zu vereinbaren. Zur Spezifikation dynamischer Datenstrukturen, deren Umfang während des Programmablaufes veränderlich ist, werden die Zeiger genutzt.

Eine Kombination mit einem Verbund führt zum Aufbau einer Liste.

Zur Erinnerung: Eine Variable v vom Zeigertyp ^T enthält die Adresse v^ vom Typ T. Durch die Anweisung new(v) wird freier Speicher vom Typ T angefordert und dessen Adresse der Variablen v zugewiesen. Der vorige Inhalt von v^ bleibt erhalten, ist aber über v nicht mehr zugänglich. Durch dispose(v) wird der v zugeordnete Speicher wieder freigegeben, v ist anschließend nil.

Wir wollen ein ausführlicheres Beispiel studieren:

Beispiel 1.4
     type Liste = ^Eintrag;
          Eintrag = record
                      Elem : Integer;
                      Nachfolger : Liste
                    end;
     var : anfang, neu, p, q : liste;

     begin
       anfang := nil; (* leere Liste erzeugen *)
       new(anfang);
       (* Es wird eine Variable anfang^ vom Typ Eintrag erzeugt *)
       anfang^.Elem := 2;
       anfang^.Nachfolger := nil;
         (* nun hat die Liste ein Element *)
         (* der Listenanfang ist erzeugt  *)
     end.
Dann wollen wir am Anfang ein neues Element anfügen:
     p := anfang;
     new(anfang);
     anfang^.Nachfolger := p;
     anfang^.Elem := 5;
Damit wurde die bisherige Liste an das neue Element angehängt.

Als nächste Aufgabe wollen wir die Liste durchlaufen und am Ende ein Element anhängen.

     p := Anfang;
     while p^.Nachfolger <> nil do 
       p := p^.Nachfolger;
     new(q);
     q^.Nachfolger := nil;
     q^.Elem := y;
     p^.Nachfolger := q; (* Eintrag an alte Liste gehaengt *)
Suchen eines Elementes elem = x in einer nichtleeren Liste:
     p := Anfang; found := false; leer := false;
     while not found and not leer do begin
       if (p <> nil) then begin 
         if p^.Elem = x then
           found := true 
         else 
           p := p^.Nachfolger 
       end
       else leer := true;
       if found then 
         writeln('gefunden') 
       else
         if leer then writeln('nicht vorhanden');
     end; (* while *)

Einfügen und Streichen von Elementen sind weitere wichtige Aufgaben. Da wir am Anfang und am Ende schon ein Element eingefügt haben, muss nur noch der Fall des Einfügens nach einem inneren Element q mit q^.elem = nach untersucht werden. Dabei muss der Zeiger dieses Elements auf das neue Element, und dessen Zeiger auf das Nachfolgeelement gerichtet werden.

Einfügen eines Elementes nb nach dem Element nach:

     p := anfang; found := false; leer := false;
     while not found and not leer do begin  
       if (p <> nil) then begin
         if p^.Elem = nach then
           found := true 
         else 
           p := p^.Nachfolger 
       end
       else leer := true;
       if found then 
         writeln('gefunden') 
       else
         if leer then writeln('nb nicht vorhanden');
     end; (* while *)
     if found then begin
       new(q);
       q^.Nachfolger := p^.Nachfolger;
       q^.Elem := nb;
       p^.Nachfolger := q 
     end;
Beim Löschen ist darauf zu achten, dass die Verknüpfungen nicht verloren gehen. So ist der Vorgänger des zu löschenden Elements mit dessen Nachfolger zu verknüpfen. Das verlangt allerdings beim Löschen des ersten wieder eine Sonderbehandlung. Dann kann das Listenelement mit dispose freigegeben werden.

Dateitypen bestehen aus einer linearen Folge von Komponenten eines wohldefinierte Typs:

     Dateityp = "file" ["of" Typ].
Wird der Komponententyp weggelassen, so handelt es sich um einen untypisierten File. Der Standarddateityp text bezeichnet eine Datei mit zeilenweise angeordneten Zeichenfolgen. In Standardpascal wird die Datei mittels der Standardprozedur rewrite für den Schreibvorgang initialisiert, die Standardprozedur put dient zum Beschreiben der aktuellen Komponente der Filevariablen mit dem Wert einer Puffervariablen, mit der booleschen Variablen eof(input) kann abgefragt werden, ob das Ende des Inputs erreicht ist. Mit reset(F) wird der Lesevorgang initialisiert und mit get(F) die einzelnen Komponenten der Filevariablen F gelesen und der Übergang zur nächsten Komponente bewirkt, bis eof(F)=true. Bei der Arbeit mit Dateien muss in Turbo-Pascal zunächst die Prozedur Assign(var F; S : String) aufgerufen werden. Sie ordnet dem internen Pascal-Filenamen F den externen Dateinamen S zu, so wie er z.B. auf der Festplatte abgespeichert ist. Nach der Arbeit sollte die Datei mit einem Close(F) geschlossen werden.

2 Kontrollstrukturen, Prozeduren und Funktionen

Neben den bereits für Mini-Pascal vorgestellten Kontrollstrukturen if - then und while - do besitzt Pascal die iterativen Strukturen

   Repeat-Anweisung = "Repeat" statement {";" statement } 
                      üntil" expression.
   For-Anweisung    = "for" Laufvariable ":=" expression ("to" | "downto")
                       expression "do" statement.
Zur Semantik ist zu sagen, dass im Gegensatz zur while-Anweisung bei der repeat-Anweisung die Gültigkeit des Booleschen Ausdrucks am Ende geprüft wird und die Iterationsstruktur bei Nichterfüllung verlassen wird. Die Anweisungsliste wird also mindestens einmal ausgeführt.

Bei der For-Anweisung muss die Laufvariable ordinalen Typs sein, ebenso der Ausdruck des Anfangs- und des Endwertes. Zu Beginn wird die Laufvariable auf den Anfangswert gesetzt und dann sukzessive um eins erhöht oder erniedrigt. Ist im ersten Fall der Endwert e größer oder gleich dem Anfangswert a, so wird die Anweisung e-a+1 mal ausgeführt. Innerhalb des Statements kann der Wert der Laufvariable nicht geändert werden, am Ende der Struktur ist er undefiniert.

Die If - then Anweisung ist in Pascal erweitert:

   If-Anweisung = "if" expression "then" statement 
                  ["else" statement].

Diese Anweisung ermöglicht die Auswahl einer von zwei Anweisungen in Abhängigkeit von einer Bedingung. Bei Schachtelungen dieser Anweisungen können Mehrdeutigkeiten mit dem else-Zweig entstehen: Ein else-Zweig wird dem nächstdavorstehenden If zugeordnet, wenn dieses noch kein else hat und nicht in einer Verbundanweisung oder repeat-Schleife steht.

Zusätzlich zu den Prozeduren in Mini-Pascal sind in Pascal Deklarationen der Form

   procedure P$\displaystyle (\pi_{1};\pi_{2};\ldots;\pi_{n});$

wobei jedes $ \pi_{i}$ entweder die Form i : T oder var i : T hat, erlaubt. Die Bezeichner i heißen formale Parameter und sind vom Typ T. Im ersten Fall sind es Wertparameter, im zweiten Referenzparameter. Eine derartige Prozedur kann durch die Anweisung

$\displaystyle \mathtt{P}(t_{1},\ldots,t_{n})$

aufgerufen werden, wobei $ t_{i}$ im ersten Fall ein beliebiger Ausdruck des Types T ist. Im zweiten Fall muss es dagegen eine Variable vom zugehörigen Typ sein. Die $ t_{i}$ heißen hier aktuelle Parameter. Bei Werteparametern wird der Prozedur der zuvor ausgewertete Ausdruckswert als Kopie, bei Referenzparametern die Adresse der Variablen (, die zuvor schon deklariert wurde,) an die Prozedur übergeben. Im letzten Fall kann der Wert der Variablen innerhalb der Prozedur verändert werden, Referenzvariablen können damit als Eingabe- und Ausgabevariablen für die Prozedur dienen, im ersten Fall ist keine bleibende "Anderung des Wertes der Variablen möglich. Hier ist der Wertparameter mit einer neuen lokalen Variable zu vergleichen, die jedoch schon initialisiert ist.

Eine Prozedurdeklaration, bei der anstelle eines Vereinbarungs- und Anweisungsteils das reservierte Wort forward folgt, verlangt, dass der eigentliche Prozedurblock zu einem späteren Zeitpunkt definiert wird. Diese Definition gibt den Kopf der Prozedur noch einmal wieder. Dazwischen ist die Deklaration weiterer Prozeduren und Funktionen möglich.

Beispiel 2.1
Program abwechselnd;
var i, h : integer;

procedure q (var k : integer); forward;

procedure p (var i, j : integer);
begin
  inc(i);
  j := i+5;
  if j < 10 then begin write(' q:', j); q(j) end;
end;

procedure q;
begin
  inc(k);
  write(' p:', k);
  p(i, k)
end;

begin
  i := 0; h := 1;
  write (' p:', h);
  p(i, h);
  writeln(' p:', h)
end.

(* Ergebnis: p:1 q:6 p:7 q:7 p:8 q:8 p:9 q:9 p:10 p:10 *)

Über einen Typ T, der entweder einfach, ein Aufzählungs-, Unterbereichs- oder Zeigertyp ist, wird eine Funktion mit Werten vom Typ T durch

   function f$\displaystyle (\pi_{1};\pi_{2};\ldots,\pi_{n})$: T;

deklariert. In einigen Pascal-Versionen sind auch String-Typen als Ausgabetyp erlaubt. Bezüglich der Parameterliste gelten die gleichen Aussagen wie für die Prozeduren. Sie kann auch leer sein mit der Deklaration
     function f: T;
Innerhalb des Funktionsrumpfes von f wird die Festlegung des Rückgabewertes durch eine Pseudozuweisung auf den Namen der Funktion getroffen,
     f := t; (* t vom Typ T *)
wobei t ein Ausdruck ist. Diese Festlegung muss erfolgen, sie kann sogar mehrmals vorgenommen werden. Eine deklarierte Funktion wird durch den Ausdruck

$\displaystyle \mathtt{f}(t_{1},\ldots,t_{n})$

aufgerufen, der die Funktion eines factors innerhalb einer expression hat. Im Funktionsrumpf kann die Funktion wiederum auch rekursiv aufgerufen werden.
     function rfak(n : word) : word;
     begin
       if n=0 then rfak := 1 else rfak := rfak(n-1) * n
     end;
Bei der Ausführung eines Pascal-Programms werden zu Beginn die Eingabegrößen vom Standardeingabefile Input, d.h. nach automatischer interner Zuordnung von der Tastatur mit dem Befehl read eingelesen und mit dem Befehl write in das Standardausgabefile output, d.h. auf dem Bildschirm ausgegeben. Getrennt werden die Daten durch Leerzeichen oder durch Zeilenendezeichen. Dabei bedeutet read(x1,...,xn), dass vom Standardfile Input die nächsten n Daten eingelesen werden.

readln dagegen übergeht den Rest der laufenden Zeile, es wird dann vom Anfang der nächsten Zeile gelesen. Dabei müssen die Eingabegrößen dem Typ der Variablen xi entsprechen. readln(x1,...,xn) simuliert Begin read(x1,...,xn) ; readln end; eine andere Eingabedatei kann durch Benennung einer Filevariablen F vor der Ausdrucksliste angesprochen werden.

Für die Ausgabeprozedur write gilt ähnliches; eine andere Ausgabedatei kann durch Benennung einer Filevariablen F vor der Ausdrucksliste angesprochen werden. Statt der Variablen dürfen auch gültige Stringausdrücke gesendet werden. Weiterhin ist es möglich, durch Formatanweisung die Ausgabe zu beeinflussen, beispielsweise gibt der Befehl writeln(Summe: 9: 2, ' DM'); den Real-Ausdruck Summe mit insgesamt 9 Stellen einschließlich Dezimalpunkt, davon 2 Nachkommastellen gerundet, und der Bezeichnung DM, getrennt durch eine Leerstelle, aus.

3 Unit-Konzepte und objektorientierte Programmierung

1 Allgemeiner Programmaufbau

Wir geben die EBNF-Notation für den allgemeinen Programmaufbau in leicht gestraffter Form an:

        Programm = Programmkopf ";"
                   [UsesAnweisung] Programmblock.
    Programmkopf = "Program" ProcIdent["(" Identliste ")"].
   Programmblock = Deklarationsteil "begin" statement 
                   {";" statement } "end".
Deklarationsteil = [ConstDecl] [TypeDecl] [VarDecl]
                   {ProcDecl | FuncDecl }.
   UsesAnweisung = üses" UnitIdent {";" UnitIdent } ";".

            Unit = Unitkopf ";" "interface" Interfaceteil 
                                "implementation" Implementationsteil
                                "begin" Initialisierungsteil "end" ";".
        Unitkopf = "Unit" UnitIdent.
Dabei erlaubt dieser Ansatz den Einsatz vorher kompilierter Module, der Units, die in der Uses-Anweisung mit ihrem Namen in das Hauptprogramm integriert werden. Standardmodule wie SYSTEM mit der Laufzeitbibliothek, das immer eingebunden ist, oder zur Bildschirm- und Tastatursteuerung CRT, zum Betriebssystem DOS, WINDOS, zur Graphikausgabe GRAPH, zum Auslagern von Programmteilen OVERLAY und zur Druckerausgabe PRINTER können mit eingebunden werden. Das Programmierhandbuch der Firma BORLAND TURBO-PASCAL x.x enthält nähere Einzelheiten.

Der Interfaceteil kann wieder mit einer Uses-Anweisung beginnen und enthält die nur nach außen sichtbaren Konstanten-, Typ- und Variablen-Befehlsteile, bei den Unterprogrammen stehen hier nur die Köpfe. Der Implementationsteil enthält die lokalen und privaten Größen, die nur im Modul benutzt werden. Hier werden auch die Deklarationen und Anweisungen der Unterprogramme nachgetragen, Prozedur- und Funktionsblöcke, deren Köpfe schon im Interface-Teil deklariert wurden, werden nur noch durch ihre Bezeichner eingeleitet. Im Initialisierungsteil werden dann Variablen und andere Größen des Moduls initialisiert. Der Modul wird wie ein normales Programm übersetzt und kann dann mit der Endung .tpu eingebunden werden. Dabei muss auch die Versionsnummer des Compilers beachtet werden. Die Unit Graph.tpu stellt ein vollständiges 2D-Graphikinterface zur Verfügung, das allerdings die nicht standardisierten XGA-Modi der Graphikkarten außer acht lässt. Hier sind die Windows-basierten Delphi-Systeme deutlich vorzuziehen.

2 Objektorientierte Programmierung

Im Datentyp Objekt (bzw. CLASS in anderen Programmiersprachen) werden Daten und Methoden, d.h. Unterprogramme zur Bearbeitung der Daten gemeinsam in einer Record-ähnlichen Struktur der Form

  Object Daten; Methoden end;
abgespeichert.

Objekte werden in Hierarchien verwaltet und können sich untereinander Botschaften zusenden.

In der Objektvereinbarung werden zunächst nur die Köpfe der Methoden angegeben, die Rümpfe folgen anschließend vergleichbar einer forward-Vereinbarung. Im Rumpf stehen dann alle Daten des Objektes zur Verfügung; sie brauchen nicht mehr in der Parameterliste übergeben werden.

Auch die Methoden werden in der üblichen Record-Notation

  ObjectIdent.MethodIdent
bezeichnet, was durch die with-Anweisung verkürzt werden kann. Durch die Angabe Object (ObjectTypIdent) kann ein neuer Objekttyp von einem bereits deklarierten abgeleitet werden. Dabei erbt er alle Komponenten, bestehend aus der Datenfeld- und Methodenliste, von seinem Vorgänger. Die Vererbung führt zu einem kompakteren und besser wartbaren Code, da Stammethoden auf abgeleitete Klassen von Objekten anwendbar sind. Werden in verschiedenen abgeleiteten Objekten gleichnamige Methoden vereinbart, so erfolgt bei statischen, d.h. nicht mit virtual gekennzeichneten Methoden die Auswahl aufgrund der Typvereinbarung der aktuellen Argumente. Dabei kann es zu Problemen kommen, wenn innerhalb einer Methode ein Aufruf einer anderen Methode in der Hierarchie zurückverfolgt werden muss. Dann sollten diese Methoden auch virtual sein, weil sie erst zur Laufzeit des Programms anhand der aktuellen Argumente passend in ihrem Kontext ausgewählt werden. Polymorphismus besagt in diesem Zusammenhang, dass eine Methode unter gleichem Namen mit unterschiedlichen Implementierungen in verschiedenen Objekten (Klassen) einer Klassenhierarchie vorkommt. Erst zur Laufzeit wird in Abhängigkeit vom aktuellen Typ entschieden, welche Implementierung aufgerufen wird. Enthält eine Objektvereinbarung virtuelle Methoden, so muss sie eine Konstruktormethode, eingeleitet mit constructor, enthalten, die vor dem ersten Aufruf Initialisierungen durchführt. Die Methode muss dann auch in allen abgeleiteten Objekten als virtuell gekennzeichnet sein. Objekte können mit den Prozeduren new und dispose dynamisch erzeugt, initialisiert und vernichtet werden. Daher gibt es neben der constructor- auch eine destructor-Methode. Weiterhin kann eine Datenkapselung mit private erfolgen, d.h., die auf private folgenden Methoden und Variablen werden vor dem Zugriff fremder Objektmethoden geschützt.

Wir wollen nun als Beispiel eine Klassenhierarchie für geometrische Primitive der Computergraphik entwerfen. Dabei sollen die Merkmale der Klassen so ausgewählt werden, dass Ähnlichkeiten zwischen den Klassen erkennbar sind und Vererbungsmechanismen Anwendung finden. Dazu definieren wir eine Klasse GrObjekt, die Methode und Variablen enthält, die für alle Graphikobjekte gültig sind. Hieraus wollen wir dann die konkreten Graphikobjekte ableiten.

Als Methoden aller Klassen wollen wir den Constructor Init, Methoden Zeichne, Sichtbar, Anzeigen, Loeschen und Verschiebe einführen. Für alle Klassen außer der Klasse Punkt kommt die Methode Skaliere hinzu und für alle Objekte, die Flächen repräsentieren, soll der Flächeninhalt durch eine Methode Flaeche berechnet werden. Damit sind zunächst die Ähnlichkeiten von Methoden zwischen den Klassen der unterschiedlichen geometrischen Figuren analysiert.

Die Ähnlichkeit von Merkmalen ergibt sich aus einer Untersuchung insbesondere der objektspezifischen geometrischen Merkmale:

Eine mögliche Klassenhierarchie für geometrische Figuren zeigt unser Bild:

\includegraphics[%%
width=1.0\textwidth]{GrObjekt.eps}

3 Implementierung

In einem Definitionsmodul werden die Merkmale und Methoden der verschiedenen Klassen exakt definiert und gleichzeitig die Hierarchiebeziehungen zwischen den Klassen festgelegt.

Program Geometrische_Figuren;
Type PunktRec = Record
                  x, y : Longint;
                End;

GrObjekt = CLASS 
(* In Borland Pascal muss das Schluesselwort *)
(* CLASS durch OBJECT ersetzt werden *)
  Bezugspunkt : PunktRec;
  Sichtbar : Boolean;

  Constructor Init (x, y : Longint);
  Destructor Done ;
  Procedure Verschiebe(dx, dy : Longint); 
  Procedure Zeichne; Virtual;
  Procedure Anzeigen;
  Procedure Loesche;
End; (* GrObjekt *);

Punkt = CLASS(GrObjekt)
  Constructor Init (x, y : Longint);
  Destructor Done ;
  Procedure Zeichne; Virtual;
End; (* Punkt *)

Strecke = CLASS(GrObjekt)
  P1 : Punkt;

  Constructor Init (xa, ya, xb, yb : Longint);
  Destructor Done ;
  Procedure Skaliere (F : Real);
  Procedure Zeichne; Virtual;
End; (* Strecke *)

Const MaxPunkte = 100;
Type PktVektor = Array[0..MaxPunkte] Of PunktRec;

PolygonzugS = CLASS(GrObjekt)
  Vek : PktVektor;

  Constructor Init (PV : PktVektor);
  Destructor Done ;
  Procedure Skaliere (F : Real);
  Procedure Zeichne; Virtual;
End; (* PolygonzugS *)

PolygonS = CLASS(PolygonzugS)
  Constructor Init (PV : PktVektor);
  Destructor Done ;
  Procedure Zeichne; Virtual;
  Function Flaeche : Real;
End; (* PolygonS *)

Dreieck = CLASS(PolygonS)
  Constructor Init (xa, ya, xb, yb, xc, yc : Longint);
  Destructor Done ;
  Procedure Zeichne; Virtual;
  Function Flaeche : Real;
End; (* Dreieck *)

Punktpointer = ^EinPunkt;
EinPunkt = Record
             P : PunktRec;
             Z : Punktpointer;
           End;

PolygonzugD = CLASS(GrObjekt)
  Vek : Punktpointer;

  Constructor Init (PP : Punktpointer);
  Destructor Done ;
  Procedure Skaliere (F : Real);
  Procedure Zeichne; Virtual;
End; (* PolygonzugD *)

PolygonD = CLASS(PolygonzugD)
  Constructor Init (PP : Punktpointer);
  Destructor Done ;
  Procedure Skaliere (F : Real);
  Procedure Zeichne; Virtual;
  Function Flaeche : Real;
End; (* PolygonS *)

Rechteck = CLASS(GrObjekt)
  Winkel : Real;
  Breite, Hoehe : Real;

  Constructor Init (xa, ya : Longint; W, B, H : Real);
  Destructor Done ;
  Procedure Skaliere (F : Real);
  Procedure Zeichne; Virtual;
  Function Flaeche : Real;
End; (* Rechteck *)

Quadrat = CLASS(Rechteck)
  Constructor Init (xa, ya : Longint; W, B: Real);
  Destructor Done ;
  Procedure Zeichne; Virtual;
End; (* Quadrat *)

Ellipse = CLASS(GrObjekt)
  Winkel : Real;
  RadiusA, RadiusB : Real;

  Constructor Init (xa, ya : Longint; W, Ra, Rb : Real);
  Destructor Done ;
  Procedure Skaliere (F : Real);
  Procedure Zeichne; Virtual;
  Function Flaeche : Real;
End; (* Ellipse *)

Kreis = CLASS(Ellipse)
  Constructor Init (xa, ya : Longint; Ra: Real);
  Destructor Done ;
  Procedure Zeichne; Virtual;
End; (* Kreis *)

........

END. (* GeomFiguren *)

Die Implementation der Methoden geschieht außerhalb der Definition der Klassen in einer separaten Prozedur- oder Funktionsdeklaration im Modul Implementation.

Dieser enthält z.B. den Konstruktor für die Methode Punkt.Init:

Constructor Punkt.Init (x, y : Longint);
Begin
  Bezugspunkt.x := x;
  Bezugspunkt.y := y;
  Sichtbar := true
End; (* Punkt.Init *)
oder auch
Procedure GrObjekt.Verschiebe (dx, dy: LONGINT);
Begin
  If sichtbar Then Begin
    Loesche;
    Bezugspunkt.x := Bezugspunkt.x + dx;
    Bezugspunkt.y := Bezugspunkt.y + dy;
    Zeichne
  End;
End;  (* Verschiebe *)

Die Namen der anderen Methoden sind praktisch selbsterklärend. Löschen und Anzeigen werden auf Zeichnen mit verschiedenen Farben zurückgeführt. Bei der Prozedur loesche muss allerdings darauf geachtet werden, dass bei der Implementation für den Kreis der Mittelpunkt nicht gelöscht wird. Dem wird durch die virtuelle Prozedur zeichne Rechnung getragen.

Literatur:

G. Bohlender, E. Kaucher, R. Klatte, Ch. Ullrich: Einstieg in die Informatik mit Pascal. BI-Wissenschaftsverlag, Mannheim 1993

Free Pascal Online Documentation: http://www.freepascal.org/docs.htm

4 C versus Pascal

Die Programmiersprache C ist für das Betriebssystem UNIX konzipiert worden. Doch aufgrund der großen Effizienz und der überaus günstigen Portabilität findet man diese Sprache auf fast jedem Rechner. C zeichnet sich durch hohe Ausführungsgeschwindigkeit und kompakten maschinennahen Code aus. Während das Betriebssystem des Macintosh in Pascal geschrieben wurde, war C bei der Entwicklung dafür gedacht, ein Betriebssystem (UNIX) nicht wie damals üblich in Assembler, sondern in einer Hochsprache zu schreiben. So entwickelte M. Ritchie 1972 aus der Sprache B(cpl) von Ken Thompson die Sprache C. Sie besitzt nur einen geringen Satz an Schlüsselwörtern, dafür aber eine große Anzahl von Bibliotheksfunktionen. Der Programmierer gibt C wie Pascal mit einem beliebigen Editor ein und speichert die Programme als ASCII-Datei. Diese Dateien übersetzt anschließend der Compiler. Mit Hilfe eines Linkers entsteht ein ausführbares Programm. Da es keine Formatierungsregeln gibt, kann der Programmierer den Quelltext des Programmes durch Leerzeichen und Zeilenumbrüche beliebig strukturieren.

Das Pascal-Programm beginnt mit dem Schlüsselwort PROGRAM gefolgt vom Programmnamen, dann werden im modernen TP ab Version 4 mit USES die Units eingebunden, daran schließt sich der Deklarationsteil an, in dem Konstanten, Typen und Variablen deklariert werden. Diese Deklarationen sind mit den Schlüsselwörtern CONST, TYPE, VAR eingeleitet. Im Programmblock steht sodann das Hauptprogramm als Verbundanweisung eingeschlossen von BEGIN ... END. Prozeduren und Funktionen nehmen die Unterprogramme auf. Letztere liefern einfache Ergebnistypen zurück. Dadurch sind Wertzuweisungen der Form

       ergebnis := funktion(wert)
möglich.

C-Programme lassen sich ebenso in kleinere Unterprogramme aufteilen. Die Funktion main hat dabei die Bedeutung, als erste Funktion aufgerufen zu werden. Das minimale C-Programm hat die Form main() {}. In C gibt es keine Prozeduren.

In den runden Klammern können Argumente der Funktion main stehen, die geschweiften Klammern dienen generell zur Strukturierung und enthalten hier die Anweisungen der Funktion main. Eine Besonderheit von C ist der Präprozessor. Er bearbeitet den Quelltext vor dem Compiler. Seine Aufgabe ist es, Konstanten zu ersetzen und Makros einzufügen. Präprozessorbefehle sind Steueranweisungen und beginnen mit #. Die Verwendung der Anweisung #include dateiname erlaubt die Einbeziehung von Standardbibliotheken, wie stdio.h, die für die Ein- und Ausgabefunktionen in Datei und String zuständig sind. (In Pascal gibt es dazu die Unit system.) Makroanweisungen zur Definition von Konstanten und Unterprogrammen beginnen mit #define. #define ENDE 1000 veranlasst den Präprozessor, alle im Programmtext folgenden ENDE durch 1000 zu ersetzen.

Eine Makroanweisung

     #define MAX(a, b) ((a>b) ? a : b)
definiert die Funktion MAX mit den zwei Parametern a und b. Der Operator ? : hat die gleiche Bedeutung wie eine If - Then - Else-Alternativanweisung in PASCAL und ist eine Abkürzung für
     if (Bedingung) Anweisung1 else Anweisung2;
Der Präprozessor ersetzt den Funktionsaufruf MAX innerhalb eines Programmes wieder durch den Text des Makros mit angepassten Parametern. Datentypen spielen dabei keine Rolle. Erst der Compiler erzeugt vom Datentyp abhängigen Assemblercode.

Merkmal beider Sprachen ist die Blockstruktur. Die Funktion von

       Begin  ...  End
nehmen in C die geschweiften Klammern ein. Das Semikolon als Abgrenzungssymbol ist beiden Sprachen gemeinsam. Die Gebrauch von goto und Labeln wie die Regeln für die Bildung von Bezeichnern sind weitgehend identisch, es existieren vergleichbare Datentypen. Allerdings sind die Namen oft verschieden: So entsprechen sich in C und Pascal
C Pascal
char Char
int Integer
enum Word
long Longint
float Single
double Double
allerdings ist in C auf die Klein- und Großschreibung zu achten: gehalt und Gehalt sind verschiedene Bezeichner. Der C-Typ char ist allerdings nicht standardisiert und eventuell vorzeichenbehaftet.

Bei der Deklaration von Typen zieht man in C den Typ vor:

       double summe;   /* Var summe : Double; */
       const float pi = 3.1415926;
Mit signed und unsigned kann festgelegt werden, ob Variablen Werte mit oder ohne Vorzeichen aufnehmen können.

Die vier Grundoperatorzeichen +, -, ., /, sind identisch. Allerdings hat man die folgenden
Unterschiede:

C Pascal
% Mod
» Shr
« Shl
Für die Ganzzahldivision gibt es keine direkte Entsprechung: Falls Dividend d und Divisor r ganzzahlig sind, so ist d/r die Ganzzahldivision. Ist der Dividend summe ein float, so hängt es vom Typ der Variablen erg ab, ob ein Typecasting erforderlich ist. Ist erg vom Typ int, so kann es unterbleiben. Ansonsten schreiben wir in C
     erg=(int) (summe/n);
anstelle von erg := summe Div n;. Mit dem Castoperator wird eine float-Zahl summe/n in den Typ int überführt.

In Pascal ist eine ähnliche Konstruktion möglich mit Typbezeichner (Variablenbezug), d.h.: Integer(erg).

Hier merkt man bereits einen weiteren wesentlichen Unterschied in der Zuweisung. C verwendet ein einfaches Gleichheitszeichen, muss dann allerdings in der Abfrage auf Gleichheit ein doppeltes Gleichheitszeichen ansetzen. Dazu ist in C der Ungleichheitsoperator durch != anstelle von $ <>$ ausgedrückt: Das !-Zeichen steht für nicht. Der Inkrementoperator in C ist ++, der Dekrementoperator -.

C Pascal
++beta Inc(beta)
-beta Dec(beta)
Hier spielt die Reihenfolge ++beta != beta++ eine Rolle, denn im ersten Fall wird der Wert des Operanden vor der Auswertung (z.B. einer Ausgabe) und im zweiten Fall nach der Auswertung erhöht.

Eine erweiterte Variante des In- und Dekrementoperators sind die Zuweisungsoperatoren in C. Sie erlauben, eine Variable um einen beliebigen Wert zu ändern.

     zahl += 5; /* zahl = zahl + 5 */
Analog existieren die Operationen -=, *=, /=, %=.

Beispiel:

     anzahl = 3; wert = 5; summe = 7;
ergibt für
     summe += wert * ++anzahl;
den Wert 27, da ++ stärker als * bindet. Andererseits führt
     summe += wert * (anzahl++)
auf summe = 22 und anzahl = 4.

In Pascal haben wir

     inc(anzahl); summe := summe + anzahl * wert;
bzw.
     summe := summe + anzahl * wert; inc(anzahl);
Kommen wir auf die logischen Operatoren zu sprechen: hier hat man bei boolesche Operatoren die Entsprechungen
C Pascal
! Not
&& And
|| Or
^ Xor
Beispiel: 10 && 2 = 1, da in C jeder Wert ungleich Null als True interpretiert wird, nur die Null liefert den Wert False.

Bei den binären Verknüpfungen gelten die folgenden Korrespondenzen:

C Pascal
^ Not
& And
| Or
Beispiel: 10 & 6 = 2. (Hier wird der UND-Operator bitweise angewandt.)

Auch die Binäroperatoren führen zu verallgemeinerten Zuweisungen.

Mit Type erzeugt man in Pascal-Programmen benutzereigene Datentypen. In C verwendet man dafür den Befehl typedef. Zusammengesetzte Datentypen führen in Pascal zur Recordstruktur, in C heißen sie Struktur.

Type Pkw = Record typedef struct
bezeichnung : String[40]; {
ccm : Word; char bezeichnung[41];
neupreis : double; unsigned int ccm;
End; double neupreis;
} pkw;
Var auto : Pkw; pkw auto;
(* Zugriff: *)
auto.ccm := 2476; auto.ccm = 2476;

Auch in der Behandlung varianter Records unterscheiden sich beide Sprachen leicht.

Eine Tabelle mit sechs Zeilen und zehn Spalten von double-Zahlen wird in C übrigens mit double tabelle[6][10] verabredet, Bitfelder sind ebenfalls möglich, die Anzahl der Bits werden durch Doppelpunkt getrennt hinter dem Namen angegeben.

In Pascal dient das Caret ^ als Kennzeichen eines Zeigers. Ein Zeigertyp definiert eine Menge von Werten, die auf dynamische Variablen eines Grundtyps zeigen. Eine Variable dieses Typs enthält die Speicheradresse eines Objekts vom Grundtyp. Mit der Prozedur New wird der dynamischen Variablen ein Speicherbereich zugewiesen und die Adresse dieses Bereichs in der Zeigervariablen abgelegt. Schließlich kann nun der durch zeiger beschriebene Bereich mit einem Wert belegt werden. Benötigt man den Speicherbereich nicht mehr, so kann er mit Dispose wieder freigegeben werden. Beispiel:

     Var zeiger : ^Integer; 
     ...
     New(zeiger);
     zeiger^ := 12;
     Dispose(zeiger);
In C läuft dies Verfahren identisch ab: das Kennzeichen ist hier das Zeichen *.

Mit int *zeiger wird ein Zeiger auf den Typ int installiert: zeiger weist auf eine Integer-Zahl. Mit dem Memory allocate-Befehl wird implementationsabhängig Speicherplatz entsprechend der Typspeichergröße von int von 2 Bytes zugewiesen, beschrieben und wieder freigegeben:

     zeiger = malloc(sizeof(*zeiger));
     *zeiger = 12;
     free(zeiger);
Während der Adressoperator in Pascal mit @ bezeichnet wird, benutzt C das Zeichen &.

Beispiel:

Var zeiger: pointer; zahl: double; double zahl, *pointer;
zahl := 2.78; zahl = 2.78;
zeiger := @zahl; pointer = &zahl;
Ein auf einen strukturierten Typ weisender Zeiger wird dann mit struct pkw *pfeil deklariert und pfeil = &auto; lässt jetzt einen Zugriff mit
   (*pfeil).ccm = 3276;  /* bzw. */
   pfeil->ccm = 3276;
zu.

Kommen wir zur Behandlung der Kontrollstrukturen, von denen wir die Alternative schon besprochen haben. In Verallgemeinerung dazu haben wir die Case-Anweisung:

Case Ausdruck Of switch (Ausdruck)
Werte : Vbanweisung; {case Konstante: Vbanweisung; [break;]
......; ......
Else Vbanweisung; default: Vbanweisung;
End; }
Lässt man das break weg, so werden alle folgenden Alternativen ebenfalls bearbeitet, da die Konstante nur als Sprunglabel zählt.

Eine Bemerkung zu den iterativen Konstrukten

For Varia := Startwert To for(Varia=Startwert;
Endwert Do Varia <= Endwert; Varia++)
Vbanweisung; Vbanweisung;

(Hier steht Vbanweisung für Verbundanweisung und Varia für Variable). Allerdings ist die Syntax der For-Anweisung in C wesentlich leistungsfähiger und von der Syntax:

     for (Anweisung; Ausdruck; Anweisung) Vbanweisung;
Die erste Anweisung dient der Initialisierung, der Ausdruck zur Terminierung, die zweite Anweisung zur In- bzw. Dekrementierung, die Verbundanweisung ist bei erfüllter Bedingung auszuführen.

Beispiel:

     for(puts("[A]betaetigen!"); getchar() != 'A'; putchar('*'))   ... ;
Weiterhin hat man die Konstrukte:

     do Anweisung while(Bedingung);
     Anweisung; 
     while(Bedingung) Anweisung;

die der Repeat- bzw. While-Anweisung entsprechen.

In Pascal unterscheiden sich die beiden Unterprogrammtypen nur durch eine eventuelle Ausgabe; wird ein Wert an die aufrufende Funktionsroutine zurückgegeben, handelt es sich um eine Funktion, sonst um eine Prozedur.

In C genügt die Funktion allen Anforderungen. Für den Fall, dass sie kein Ergebnis zurückgibt, erhält sie den Datentyp void zugewiesen. Routinen, die keine Parameter benötigen, dürfen in Pascal ohne runde Klammern aufgerufen werden, in C ist dies jedoch nicht der Fall, da hier Funktionsnamen als Adresse betrachtet werden, an der das Unterprogramm abgelegt ist. Die runden Klammern signalisieren, dass die Routine an der Adresse aufgerufen werden soll.

Kommen wir zu Ein- und Ausgaberoutinen. Die Standardroutinen in Pascal lauten WriteLn und ReadLn.

Beispiel:

     WriteLn('Die Summe ist ', summe,' DM');
Bei Fließkommazahlen sind zusätzlich Formatierungshinweise zulässig:
     r := 49349857.99;  Writeln('R: ', r:10:4, ' DM');
erzeugt
     R: 49349857.9900 DM
Auch beim Einlesen von Daten mit der ReadLn-Prozedur werden die Datentypen automatisch beachtet. Bei Eingabetyp Integer führt das Lesen von Buchstaben zu einer Fehlermeldung.

In C sind zwei universelle Ein- und Ausgabefunktionen vorhanden: scanf und printf. Die Endung f deutet auf formatierte Ein- und Aufgabe hin. Welche Art von Daten mit printf ausgegeben werden, bestimmt der sogenannte Formatstring, in dem mit Steuerzeichen % und Folgezeichen der Datentyp festgelegt wird

     c : char,
     d : integer,
     ld: longinteger,
     h : short, 
     u : unsigned, 
     f : float, 
     e : wissenschaftliche Fliesskommazahl,
  x, X : hex-Zahl, 
     o : octal, 
     s : string.
Ebenso können Steuerzeichen eingegeben werden:
     \a: Signalton, 
     \b: backspace, 
     \f: Seitenvorschub, 
     \n: neue Zeile,
     \r: Wagenruecklauf, 
     \0: Endkennung Zeichenkette, 
     \': Anfuehrungszeichen, 
     \\: Backslash.
Beispiel: printf("Bruch: %f DM \n", 1.0/3.0);

Bei der Funktion scanf werden die Eingabezeichen analog formatiert und auf Variablen, die hinter dem Formatstring durch ihre Adressen angegeben sind, verteilt.

Zur Bearbeitung von Dateien weist Pascal einer Variablen den Dateinamen mit dem Assign-Befehl zu:

     Assign(handle, dateiname);
Mit der Deklaration der Variablen handle wird auch gleichzeitig der Datentyp der Datei festgelegt:
     Var: handle: File Of Char;
Alle weiteren Lese- oder Schreiboperationen erfordern zur Identifizierung nur noch den Bezeichner handle:
     Read(handle, zeichen);
Die Ein- und Ausgabefunktionen von C sind kein integrierter Bestandteil der Programmiersprache, sondern in Bibliotheken abgelegt, die im Lieferumfang des C-Compilers enthalten sind.

Die Standard-I/O-Funktionen arbeiten zeichenorientiert mit sogenannten Streams, Strömen von Zeichen, die entweder gelesen oder geschrieben werden. Die Verwaltung der Streams läuft über einen Handle, der die ständige Verbindung zur Datei oder zum Gerät herstellt. Vor dem ersten Zugriff auf eine Datei wird diese per Funktionsaufruf geöffnet. Die Funktion liefert dabei einen Handle für die Datei zurück. Alle weiteren Zugriffe erfolgen mit Hilfe des Handles. Einige Handles stehen immer ohne extra Öffnung zur Verfügung:
stdin: Standardeingabe per Tastatur
stdout: Standardausgabe (auf Bildschirm)
stdprn: Standardausgabe (auf Drucker)
stderr: Ausgabe von Fehlermeldungen.
Alle Handles sind umleitbar. Die zugehörigen Routinen sind in der include-Datei stdio.h deklariert. Die zur Verwaltung benötigte Struktur FILE hat folgendes Aussehen:

     typedef struct
       {short level;
        unsigned flags;
        char fd;
        unsigned charhold;
        short bsize;
        unsigned char *buffer;
        unsigned char *curp;
        unsigned istemp;
        short token;
       } FILE;

Zuerst erhält das Programm von der Fopen-Funktion ein File-handle, das vom Typ FILE ist. Dann kann zum Lesen geöffnet werden.

     FILE *handle;
     char *dateiname; 
     handle = fopen(dateiname, "r");
     /* Oeffnen zum lesen */
Für das Lesen und Schreiben werden dann die Funktionen fprintf() und fscanf() herangezogen:
Beispiel: fscanf(handle, "%ld", &eingabe);

11 Logiksprachen und Prolog

1 Was ist logische Programmierung?

Nach unseren Vorbereitungen in unserem Einführungskapitel machen wir einen ersten Versuch, diese Frage zu beantworten: Es handelt sich um einen Berechnungsformalismus, der die folgenden Prinzipien vereinigt:

In der logischen Programmierung benutzt man deklarativ repräsentiertes Wissen in Form einer Wissensbasis unabhängig vom auszuführenden Programm. Eine Frage wird in Form einer Behauptung gestellt und dann die Wissensbasis durchsucht, bis ein Objekt gefunden ist, das die Aussage erfüllt. Dabei werden Formeln der Prädikatenlogik als Anweisungen der Programmiersprache benutzt. Es spielen die Hornklauseln die entscheidende Rolle.

L. Sterling und E. Shapiro sagen:

Ein Logik-Programm ist eine Menge von Axiomen oder Regeln, die Relationen zwischen Objekten definiert. Die Berechnung des logischen Programms ist eine deduktive Folgerung des Programms. Ein Programm definiert eine Menge von Folgerungen, welche gleichzeitig sein Inhalt ist. Die Kunst der logischen Programmierung ist es, kurze und elegante Programme zu konstruieren, die die geforderte Bedeutung haben.

Den zentralen Bestandteil bildet eine applikationsunabhängige Interferenz-Maschine. Diese virtuelle Maschine ist eine Herleitungsprozedur, die nach einer vorgegebenen Suchstrategie die Wissensbasis durchsucht, um eine Schlussfolgerung auf eine Anfrage mit Hilfe der Deduktion zu ziehen, und die vorgibt, nach welcher Methode Folgerungen gezogen werden sollen, damit die gestellte Frage als richtig oder falsch beantwortet werden kann. Interferenzregeln sind die Resolution oder der Modus Ponens, als Suchstrategien werden Standard-Suchverfahren in einem geeigneten Graphen genutzt.

Auf den Benutzer kommen zwei Aufgaben zu. Als erstes stellt er dem System Anfragen, um den Wahrheitswert gewisser Aussagen oder Relationen zu finden. Allerdings muss ein Programmierer vorher Wissen zu dem Problem in der Wissensbasis abgelegt haben.

Wir geben ein Beispiel:

     mensch (sokrates) // Faktum - Aussage
     sterblich (X) wenn mensch (X) // Regel - Aussage ueber Aussage
     ? sterblich (sokrates)? // Anfrage
     yes // Antwort
Mittels einer Funktion ''Erklärung'' kann sich der Benutzer alle benutzten Fakten und Regeln bei der Entscheidungsfindung aufzeigen lassen.

Wir fassen noch einmal die wichtigsten Punkte zu Logiksprachen zusammen:

2 Von Logik zu Prolog

Die Prädikatenlogik erster Stufe bildet die Basis für Prolog. Ihre Formeln werden in Klauselform überführt. Diese sind auf Hornklauseln, also auf Klauseln mit höchstens einem positiven Literal beschränkt. Prolog stellt sich dann als ein Beweiser für Hornklausel-Logik dar.

Zur Erinnerung (vgl. Kapitel 1.4.3): Hornklauseln werden in der Form

$\displaystyle P:-Q_{1},Q_{2},\ldots,Q_{n}$ (Prozedurklausel)

notiert. Dies steht für

$\displaystyle Q_{1}\wedge Q_{2}\wedge\ldots\wedge Q_{n}\rightarrow P$ bzw. äquivalent $\displaystyle \neg Q_{1}\vee\neg Q_{2}\vee\ldots\vee\neg Q_{n}\vee P.$

Dabei steht das Komma für die Konjunktion. Hornklauseln enthalten nur eine Atomformel als Konklusion im Kopf auf der linken Seite und mehrere durch $ \wedge$ verknüpfte Atomformeln als Prämissen im Rumpf auf der rechten Seite. Sie stellen in Prolog die Regeln dar und können als Prozeduren abgearbeitet werden. Hornklauseln ohne Rumpf (Prämissen) stellen Fakten dar. Diese beiden Typen von Klauseln machen ein Prologprogramm aus und heißen daher Programmklauseln. Hornklauseln ohne Kopf (Konklusion) sind die Anfragen, die zu beweisenden Aussagen; leere Hornklauseln sind immer falsch und entsprechen in Prolog dem Fail.

Damit bestehen Prolog-Programme aus Aussagen und Tatsachen, nicht aus Algorithmen, Datentypen, Verzweigungen, Iterationen und Wertzuweisungen, wie sie von der imperativen Programmierung her bekannt sind. Während hier das Schema

Programm = Algorithmus + Datenstruktur gilt, heißt es für Prolog
Programm = Logik + Steuerung.

Es existieren für den Programmierer keine Kontrollstrukturen im eigentlichen Sinn. Er muss sie durch Rekursion nachbilden und kann lediglich durch die Reihenfolge seiner deklarierten Regeln Einfluss auf die Lösungsfindung durch den Interpreter/Compiler finden.

Das Prolog-System kann bei Erfolg oder Scheitern von Anfragen alle Berechnungen rückgängig machen und nach einer Alternative suchen. Somit kann das Prolog-System durch Einsatz der Interferenzkomponente feststellen, ob eine in einer Anfrage enthaltene Aussage Bestandteil der Wissensbasis (ableitbar) ist oder nicht (Closed world assumption). Nichtableitbarkeit bedeutet allerdings nicht Falschheit.

Prolog-Programme bestehen aus

Fakten (über Objekte und deren Beziehungen)
Regeln (Beziehungen zwischen Aussagen)
Anfragen (über Objekte, die in den Fakten und Regeln vorkommen)

Fakten

Hans ist Großvater von Anne über seinen Sohn Peter.

Prädikat : grossvater-über (hans, peter, anne).

Regeln

Prolog kann mit Hilfe von Regeln aus bereits bestehenden Fakten neue Fakten ableiten. Eine Regel bewirkt, dass der Wahrheitswert eines Faktums von denen eines anderen oder mehrerer abhängig ist.

Wir geben ein Beispiel:

elternteil (hans, peter), elternteil (peter, anne).

Regel:

grosselternteil (hans, anne) :-
elternteil (hans, peter), elternteil (peter, anne).

Es handelt sich hier um eine Wenn-Dann-Regel.

Der abgeleitete Fakt steht auf der linken Seite vor :-, das Komma auf der rechten Seite steht für ein logisches Und. (Ein Semikolon würde ein logisches Oder bedeuten.) Jede Regel muss mit einem Punkt abgeschlossen werden.

Der eigentliche Sinn einer Regel besteht jedoch in einer mehr allgemeinen Beschreibung. Dazu werden die konstanten Objekte durch Platzhalter ersetzt.

grosselternteil (Person, Enkel) :-
elternteil (Person, Kind), elternteil (Kind, Enkel).

Der Regelkopf grosselternteil (Person, Enkel) ist genau dann für eine konkrete Belegung der Variablen Person (z.B. hans) und Enkel (z.B. durch anne) ableitbar, wenn sich jedes der beiden durch , verknüpften Prädikate elternteil (Person, Kind) und elternteil (Kind, Enkel) des Regelrumpfs aus der Wissensbasis ableiten lässt. Dies ist der Fall, wenn es eine Besetzung der Variablen Kind gibt, so dass für die gewählten Konstanten sowohl elternteil (Person, Kind) (also elternteil (hans, peter)) als auch elternteil (Kind, Enkel) (also elternteil (peter, anne)) in der Wissensbasis vorhanden sind.

Wir notieren ein ausführliches Beispiel:

     elternteil (hans, peter).
     elternteil (karin, peter).
     elternteil (otto, susi).
     elternteil (frieda, susi).
     elternteil (peter, thomas).
     elternteil (peter, anne).
     elternteil (peter, gabi).
     elternteil (susi, thomas).
     elternteil (susi, anne).
     elternteil (susi, gabi).
     elternteil (gabi, klaus).
     grosselternteil (Person, Enkel) :-
          elternteil (Person, Kind), elternteil (Kind, Enkel).

Anfragen

Nachdem nun die Wissens- und Regelbasis übergeben ist, können Anfragen, die mit dem Zeichen ?- beginnen und mit einem Punkt enden, an das System gestellt werden. Es wird nach dem Wahrheitswert eines Faktums gefragt.

Beispiel: ?- elternteil (susi, gabi).

Die Interferenzkomponente durchsucht nun die Wissensbasis, ob das Prädikat dem Prolog-System bekannt ist oder sich ableiten lässt. Es wird dazu ein gleichnamiges Prädikat in der Wissensbasis gesucht. Bei Erfolg werden die Argumente des Goal-Prädikats sowie ihre Anzahl miteinander verglichen (Pattern-Matching). Sind die Argumente identisch, so wird die Antwort Yes, anderenfalls, bei Nichtauffindung des Goalprädikats, die Antwort No ausgegeben.

Die angegebene Anfrage wird mit Yes beantwortet, die Anfrage
?- elternteil (hans, susi).
jedoch mit No und
?- elternteil (hans, peter), elternteil (otto, susi).
ebenfalls mit Yes.

Dies ist die einfachste Form der Benutzung einer Wissensbasis. Eine zweite Form ermöglicht die Ausgabe von Objekten. Will man z.B. alle Eltern des Kindes anne ermitteln, so wird eine Anfrage der Form ?- elternteil (Person, anne). gestellt. Die Antwort wäre dann

     Person = peter
     Person = susi
Hier ist Person eine Variable. Sie wird durch alle Objekte aus Fakten der Wissensbasis ersetzt, die auf die Anfrage passen. Der Prozess des Ersetzens von Variablen (Gleichmachen von Termen durch Ersetzen von Variablen durch andere Argumente (Objekte)) heißt Unifikation.

Genauso ist es möglich, alle Eltern-Kind-Beziehungen mit der Anfrage
?-elternteil (Person_1, Person_2).
auszugeben.

Wenn die Anfragen als Argumente nur Variablen enthalten, so heißen sie unqualifiziert, in den vorher aufgeführten Beispielen teilqualifiziert bzw. qualifiziert.

Eine anonyme Variable wird durch eine einzelnen Unterstrich _ dargestellt. An dieser Stelle kann ein beliebiges Objekt ohne Bedeutung für die weitere Bearbeitung eingesetzt werden.

Nun soll die Anfrage nach einer Grosselternbeziehung gestellt werden:

     ?- elternteil (peter, Person), elternteil (Person, klaus).
Die Interferenzkomponente durchsucht die Wissensbasis nach einem Prädikat, das zum ersten Teil elternteil (peter, Person) der obigen Anfrage passt. Findet sie ein Muster, welches zum ersten Teil der Anfrage passt, z.B. Person = thomas, so wird die Variable Person mit dem Objekt thomas gebunden (diesen Vorgang nennt man Instanziieren). Letzterer Vorgang muss eindeutig für alle Vorkommen dieser Variablen geschehen. Nun versucht die Interferenzkomponente, das zweite Teilziel mit der Suche von elternteil (thomas, klaus) in der Wissensbasis nachzuweisen. Da dieses Teilziel nicht erreichbar ist, setzt das Prolog-System einen Mechanismus in Gang, der Backtracking heißt. Die Interferenzkomponente geht in der Folge der zu bearbeiteten Teilziele einen Schritt zurück und versucht, elternteil (peter, Person) anders zu beweisen. Die Bindung der Variablen Person an thomas wird dabei wieder gelöst.

Nun wird ein weiteres Muster gesucht und das bereits gefundene verworfen. Dafür kommt Person = anne infrage. Auch hier scheitert nach Instanziierung Person = anne der Nachweis der Wahrheit des zweiten Teilziels. Ein nochmaliges Backtracking führt auf Person = gabi. Mit dieser Bindung wird für das zweite Teilziel die Wissensbasis durchsucht und elternteil (gabi, klaus) aufgefunden. Da beide Teilziele bewiesen sind, ist das Goal

     elternteil (peter, Person), elternteil (Person, klaus)
erfüllt.

Noch eine Abschlussbemerkung:

In der neuen Regel

     nachfahre (Person_1, Person_2) :- elternteil (Person_2, Person_1).
     nachfahre (Person_1, Person_2) :- elternteil (Person_2, Kind), 
                                          nachfahre (Person_1, Kind).
haben wir die Möglichkeit einer rekursiv definierten Regel kennengelernt.

3 Syntax und Semantik von Prolog-Programmen

Syntax bezeichnet die Gesamtheit der grammatischen Regeln einer Sprache, die darüber entscheiden, ob die Sätze eines Textes richtig geformt sind.

Dabei bleibt die Bedeutung außer acht. Beispiel: Gestern werden wir nach Hause gehen.

Dagegen ist Semantik die Lehre von der Bedeutung der Elemente von Zeichensystemen.

Ergebnis des Abbildungsprozesses der realen Welt auf ein Prolog-Programm sind Objekte und Prädikate.

Einfache Objekte sind Konstanten und Variablen, komplexere Objekte sind Listen oder Bäume, die auch Terme genannt werden. Die Objekte sind über Relationen miteinander verknüpft, die in Fakten und Regeln abgebildet werden. Die Prädikate werden durch ihren Prädikatsnamen (Funktor) eindeutig bezeichnet und haben eine wohldefinierte Stelligkeit.

Die folgenden Zeichen sind die Prolog definiert:

Atome sind Prädikate, Operatoren oder Objekte und setzen sich aus Buchstaben, Ziffern, Bindestrich und Unterstrich oder Sonderzeichen zusammen. Ihre Namen beginnen mit einem kleinen Buchstaben und dürfen auch in Hochkommata eingeschlossen werden. In diesem Fall sind beliebige Zeichen erlaubt. Operatorennamen sind im Allgemeinen aus Sonderzeichen aufgebaut. Zahlen werden im Allgemeinen als Atome betrachtet.

Konstanten haben im Gegensatz zu Variablen feste Werte und erscheinen als Atome oder Zahlen.

Variablen dienen zur Bezeichnung von nicht instanziierten Argumenten von Prädikaten oder Funktoren. Sie unterscheiden sich von Atomen in folgenden Punkten:

Sie beginnen mit einem Großbuchstaben oder Unterstrich.

Der Bindestrich ist nicht zulässig, da er als arithmetische Subtraktion interpretiert werden könnte.

Eine anonyme Variable (Dummi, Platzhalter) wird mit einem Unterstrich bezeichnet und bei der Lösungsfindung nicht instanziiert. Bei der Verwendung mehrerer Platzhalter sind diese völlig unabhängig voneinander.

Variablen gelten als gebunden, wenn gleiche Variablen in einer konjugierten Klausel benutzt werden. Wurde einer mehrfach verwendeten Variablen noch kein Wert zugewiesen, so geschieht das zeitweise während der Beweisprozedur durch das System (Instanziierung), um Referenzwerte für andere Aussagen zu haben:

     aussage (X) :- aussage1 (X), aussage2 (X).
heißt konkret, dass die Variable X innerhalb der Klausel immer den gleichen Wert hat.

Steht an der Stelle des Kommas ein Semikolon, so ist die Variable X nicht gebunden. Sie muss in diesem Fall nicht unifiziert werden.

Unter einer Struktur versteht man einerseits Prädikate, durch Kommata getrennte Terme, d.h. Konstanten, Variablen oder Funktionssymbole, oder auch komplexe Objekte wie Bäume oder Listen.

Aus Termen lassen sich in Prolog Sprachkonstrukte bilden wie Konstanten, Variablen, Strukturen, Listen, die wir später noch ausführlicher behandeln.

In Prolog existieren verschiedene Operatoren, um Werte von Variablen zu ändern, gleichzusetzen oder zu vergleichen. Sie können nur in sinnvollen Zusammenhängen (nach Wertzuweisung oder bei Vergleichbarkeit) Anwendung finden.

Die Relation Gleichheit prüft, ob Terme gleichgemacht (unifiziert) werden können, d.h. freie Variablen Werte annehmen können, so dass die Terme gleich sind. Daher gelten zwei Terme für das System als gleich, wenn sie gleich sind oder unifiziert werden können.

Konstanten sind gleich, wenn sie identisch sind (gleicher Name, zeichenweise).

Variablen sind gleich, wenn sie den gleichen Namen haben, sie die gleichen Werte haben oder beide frei sind, bzw. falls eine gebunden ist, die freie zur belegten unifiziert werden kann. Sie sind gleich zu Konstanten, wenn sie ihren Inhalt haben oder dazu unifiziert werden können.

Strukturen sind gleich, wenn sie den gleichen Funktor und die gleiche Stelligkeit haben sowie die Argumente gleich sind.

Das Matching hat die Wertveränderung einer Variablen zur Folge, wenn sie bei Unifikation instanziiert werden muss.

Wir geben zwei Beispiele:

     datum (Tag, dezember, 1993) = datum (31, Monat, Jahr).
Die Aussagen werden unifiziert durch Instanziierung von:

Tag auf 31, Monat auf dezember, Jahr auf 1993.

     dreieck (punkt (1,1), A, punkt (2,3)) = 
        dreieck (X, punkt (4,Y), punkt (2,Z)).
führt zur Instanziierung: X = punkt (1,1), A = punkt (4,Y), Z = 3 und anschließendes Matching.

4 Rekursive Regeln

In Prolog ist eine Regel rekursiv, wenn in ihr das Prädikat, durch das die Regel definiert wird, wieder aufgerufen wird.

Rekursive Problemlösungen dienen häufig der effizienten Programmierung, wie man an vielen Beispielen sieht, die mit dynamischen Strukturen (Liste, Baum, Graph) zu tun haben.

Wir hatten bereits ein Beispiel angegeben und schließen sogleich ein weiteres an:

     Regel1: vorfahr (X, Z) :- elternteil (X, Z).
     Regel2: vorfahr (X, Z) :- elternteil (X, Y), vorfahr (Y, Z).
Wissensbasis:
     elternteil (heike, robert).
     elternteil (thomas, robert).
     elternteil (thomas, lisa).
     elternteil (robert, anna).
     elternteil (robert, petra).
     elternteil (petra, jakob).
     ?- vorfahr (heike, Z).
Lösungen:
     Z = robert, Z = anna, Z = petra, Z = jakob
     ?- vorfahr (thomas, petra).
     Yes
     ?- elternteil (thomas, petra).
     No
Hier ist die Closed-World Annahme interessant: Jede Feststellung q über eine Beziehung ist genau dann wahr, wenn das Programm P q impliziert, ansonsten ist q unwahr.

Wir gehen also davon aus, dass wir alle wahren Aussagen für die Vorfahren von heike hergeleitet haben. Anders ausgedrückt:

Wenn man schließen kann, dass A nicht aus P folgt, so gilt $ \lnot$A, wobei A ein Grundatom ist.

Durch den Ablauf der Ableitbarkeitsprüfung wird die prozedurale Bedeutung eines Prolog-Programmes bestimmt. Da der Interferenz-Algorithmus bei einer veränderten Reihenfolge der Klausel oft auch einen anderen Verlauf nimmt, kann sich die prozedurale Bedeutung des Programmes ändern.

Die deklarative Bedeutung betrifft die Prädikate in den Klauseln. Sie besteht in der Fragestellung, ob ein Goal und nicht wie ein Goal ableitbar ist.

Wir wollen diese Betrachtungen an einigen Beispielen illustrieren und stellen die Abbruchbedingung im Programm an die zweite Stelle. Nun gibt es Goals, bei denen die Ableitungsprüfung nicht zu Ende führt:

Wissensbasis:

     elternteil (heike, robert).
     elternteil (thomas, robert).
     elternteil (thomas, lisa).
     elternteil (robert, anna).
     elternteil (robert, petra).
     elternteil (petra, jakob).
Beispiel:
     ?- vorfahr (thomas, petra).
     vorfahr (X, Z):- elternteil (X, Z).
     vorfahr (X, Z) :- elternteil (X, Y), vorfahr (Y, Z).

Regelwerk 1

\includegraphics[%%
width=1.0\textwidth,
keepaspectratio]{Prolog1.eps}

Der obige Ableitungsbaum zeigt die Gültigkeit.

     vorfahr (X, Z) :- elternteil (X, Z).
     vorfahr (X, Z) :- vorfahr (Y, Z), elternteil (X, Y).
Dieses Regelwerk führt ebenfalls zur Anwort True.

Der Lösungsweg sieht etwa folgendermaßen aus:

?- elternteil (thomas, petra). false - Instanziierung misslingt

Versuche zweite Regel durch Setzung Y = robert. Eingesetzt lautet sie

     vorfahr (thomas, petra) :- vorfahr (robert, petra),
        elternteil (thomas, robert).
Also sind zwei Teilgoals zu erbringen, das zweite ist erfüllt, wir fragen nach dem ersten.

Es ist vorfahr (robert, petra) nachzuweisen aus der Wissensbasis.

Dies folgt jedoch aus Regel 1.

\includegraphics[%%
width=1.0\textwidth,
keepaspectratio]{Prolog2.eps}

Stellt man jedoch die beiden Regeln um, so führt dies in eine Endlosschleife, da immer wieder ein neues Subgoal vorfahr (Y $ ^{^{\prime}...^{\prime}}$, petra) erzeugt wird. Dieses Subgoal muss auf der Wissensbasis mit einem Regelkopf verglichen werden. Dazu werden immer neue Variablen eingeführt.

Ein Kreisschluss lässt sich dadurch vermeiden, dass man die Klausel mit Abbruchkriterium (nicht-rekursive Regel) an die Spitze stellt.

5 Listen

Eine Liste ist eine geordnete endliche Folge von Objekten eines Typs.

In Prolog setzen sich die Komponenten einer Liste zusammen aus dem Kopf (erstes Element) und dem Rumpf (Rest der Liste). Trennzeichen ist in diesem Fall das Zeichen |. Man kann eine Liste auch angeben, indem man die Elemente aufzählt und durch Kommata trennt. Wir haben die Darstellungsmöglichkeiten:

     [a, b, c, d]; [a | [b | [c | [d | []]]]];
     a.b.c.d.nil; *(a,*(b,*(c,*(d,[])))).
Neben den beiden ersten Notationen gibt es zwei weitere, die Punkt- und die Funktornotation. Letztere ist gekennzeichnet durch das Funktorsymbol * vor der runden Klammer, Trennung der Listenelemente durch Komma und Abschluss mit der leeren Liste [].

In Prolog kann man nur auf das erste Element einer Liste und dann auf den Listenrumpf zugreifen. Die Verarbeitung von Listen erfolgt rekursiv.

Während des Matching werden die Listen mit Konstanten belegt.

Nun werden einige Anfragen diskutiert. Die einfachste ist die nach der Mitgliedschaft in einer Liste:

     mitglied (X, [X | _]). // Fakt
     mitglied (X, [_ | Rumpf]) :- mitglied (X, Rumpf). // Regel
Kommt X nicht im Kopf vor, so wird der Rumpf rekursiv abgebaut.

Bei der Bearbeitung der Liste wird X entweder unifiziert, oder X ist nicht Mitglied der Liste.

Weitere Beispiele:

     ?- mitglied (kirsche, [apfel, birne, kirsche, ananas]). Yes
     ?- mitglied (ananas, [apfel, [kirsche, birne, ananas]]). No
Die Antwort auf die zweite Anfrage ist so zu verstehen: Die eingegebene Liste besteht aus zwei Elementen, getrennt durch ein Komma. Das erste Element dieser Liste ist der Eintrag apfel, das zweite Element ist wiederum eine Liste, gekennzeichnet durch die eckigen Klammern, die selbst aus den drei Elementen kirsche, birne und ananas besteht. Bei der Frage, ob ananas Mitglied der Liste ist, erfolgt der Vergleich mit den beiden Elementen. Da ananas weder mit dem ersten Element noch mit der Liste als zweites Element übereinstimmt, erfolgt die Antwort No.

Operationen auf Listen:
Zunächst erklären wir das Anhängen einer Liste an eine andere.

     append ([], Liste, Liste).
     append ([Kopf | Alte_Liste], Liste, [Kopf | Neue_Liste ]) :-
     append (Alte_Liste, Liste, Neue_Liste).
Anfrage:
     ?- append ([a, b], [c, d], [a, b, c, d]). Yes
     ?- append ([X, a, b, c], [d], Gesamtliste).
     Gesamtliste = [X, a, b, c, d].
     ?- append ([X | [a, b, c]], [d], Gesamtliste).
     Gesamtliste = [X, a, b, c, d].
Im dritten Beispiel ist die Liste [X, a, b, c] nur komplizierter aufgeschrieben: Der Kopf der Liste steht links vom Trennzeichen |, rechts davon der Rest der Liste. Da der Kopf nur ein einzelnes Element ist, und der Rumpf eine einfache Liste ist, besteht die Gesamtliste gerade aus den Elementen X, a, b und c.

Wir erklären die Arbeitsweise an einem Beispiel:

     append ([apfel, birne, kirsche], [ananas], obst).
Zunächst wird die zweite Klausel angewandt und der Listenkopf nacheinander mit den Werten apfel, birne, kirsche instanziiert. Ist dann die erste Liste leer, so kann die erste Klausel angewandt werden, und wir erreichen Obst = [ananas]. Dann erfolgen die Rücksprünge und die zweiten Klauseln können ausgewertet werden und die Köpfe werden nacheinander an Obst gebunden, so dass letztlich Obst = [apfel, birne, kirsche, ananas] entsteht.
     mitglied (X, Liste) :- append (Liste1, [X | Liste2], Liste).
ist eine alternative Definition der Mitgliedschaft.

Dabei wird die Liste so aufgespalten, dass X in der Mitte steht.

Weitere Operationen sind:

     hinzufuegen (X, Liste, [X | Liste]).
und Löschen. Das spezielle Element X wird beim ersten Auftreten gelöscht:
     loesche (_ , [], []).
     loesche (X, [X | Rumpf], Rumpf) :- !.
     loesche (X, [Y | Rumpf], [Y | Rumpf1]) :- loesche (X, Rumpf, Rumpf1).
Die erste Zeile dient dem Abbruch, wenn die Liste leer ist, in der zweiten Zeile wird X gelöscht, wenn es im Kopf der Liste gefunden wird. Das Programm wird über ! abgebrochen mit Abwicklung der Rücksprünge.

In der dritten Zeile wird rekursiv weitergeschoben.

Man kann das Prädikat so modifizieren, dass alle spezifischen X-Elemente gelöscht werden.

Teil- und Inversliste

 sublist (S, L) :- append (Liste1, Liste2, L), append (S, Liste3, Liste2).
Dann gibt
 
     ?- sublist (S, [a, b, c]).
alle Teillisten aus [a, b, c] aus.

Bilden wir noch ein Prädikat zur Invertierung:

     invert ([], []).
     invert ([Erst | Rest], Liste) :- invert (Rest, Zwischen_Liste), 
                   append (Zwischen_Liste, [Erst], Liste).
Hier werden zunächst alle Kopfelemente abgeschnitten durch Neuaufruf von invert auf der rechten Seite der Regel. Sodann werden die append-Prädikate wirksam und die invert können von innen nach außen ausgeführt werden.

Gibt man als Ziel

     ?- invert ([a, b, c], Liste).
ein, so erhält man
     invert ([], []). 
     invert ([c], [c]). 
     invert ([b, c], [c, b]). 
     invert ([a, b, c], [c, b, a]).
Es werden sukzessive die Zwischenlisten ZL2 = [], ZL1 = [c], ZL = [c, b] und Liste = [c, b, a] erzeugt. Wir wollen dieses Problem genauer studieren und lassen das folgende Prolog-Programm laufen:
   trace 
   domains 
   symbolslist = symbol* 
   predicates 
   append(symbolslist, symbolslist, symbolslist) 
   sublist(symbolslist, symbolslist). 
   invert(symbolslist, symbolslist). 
   clauses 
   sublist(S, L) :- append(_, List2, L), append(S, _, List2). 
   append([], B_list, B_List). 
   append([Head | Tail_old], B_List, [Head|Tail_new]) 
               :- append ( Tail_old, B_List, Tail_new). 
   invert ([], []). 
   invert ( [Erst | Rest], Liste )
               :- invert (Rest, Zliste), append (Zliste, [Erst], Liste).
Die folgenden Bilder zeigen die Anfangsphase und den Ergebnisbildschirm; die trace-Operation bewirkt, dass jede Anwendung einer Teilregel einzeln dokumentiert ist. Die einzelnen Schritte sind in einer kleinen Animation wiedergegeben.

\includegraphics[%%
width=0.75\textwidth,
height=0.30\textheight,
keepaspectratio]{ScrShot1.eps}
\includegraphics[%%
width=0.75\textwidth,
height=0.30\textheight,
keepaspectratio]{ScrShot2.eps}
\includegraphics[%%
width=0.75\textwidth,
height=0.30\textheight,
keepaspectratio]{ScrShot3.eps}

6 Fail und Cut-Operatoren

Die Operatoren fail und cut gehören zu den sogenannten Ausführungs-Kontroll-Operatoren. Durch sie ist es möglich, den Ablauf eines Prolog-Programmes zu steuern. Mit dem Fail-Operator wird ein Backtracking erzwungen, der Cut-Operator erzwingt einen Abbruch des Backtrackings, die Suche nach weiteren Möglichkeiten wird unterbunden.

Backtracking

Ziel des Backtracking ist es, eine gefundene Teillösung eines gestellten Problems zu einer Gesamtlösung auszubauen. Dabei wird versucht, die Teillösung Schritt um Schritt zu erweitern. Gelingt dies nicht, so befindet man sich in einer Sackgasse, und es ist notwendig, einen oder mehrere Teilschritte rückgängig zu machen.

Strategie bei der Lösungssuche:

Wir untersuchen ein Beispiel:
     1. student (heinz).
     2. student (tina).
     3. student (marc).
     4. wohnt (tina, leipzig).
     5. wohnt (heinz, stuttgart).
     6. wohnt (marc, stuttgart).
     ?- student (X), wohnt (X, stuttgart).
Bei der Lösung geht Prolog folgendermaßen vor:

Es wird versucht, das erste Sub-Goal student (X) abzuleiten. Als erstes Fakt findet Prolog

     1. student (heinz)
und instanziiert die Variable X auf heinz.

Nun wird das zweite Sub-Goal auf Wahrheit untersucht.

Das Fakt 5. wohnt (heinz, stuttgart). erlaubt es, die gestellte Anfage mit X = heinz zu beantworten.

Durch Eingabe eines Semikolons kann nach dieser Ausgabe erreicht werden, dass Prolog nun nach weiteren Möglichkeiten sucht, das zweite Sub-Goal zu erfüllen, um bei Existenz weitere Lösungen herauszugeben.

Dabei wird die Wissensbasis ab 5. weiter durchlaufen (und von Beginn an zyklisch).

Es wird keine weitere Lösung gefunden. Daher wird ein Backtracking-Schritt gemacht und die Variable X freigegeben.

Die zweite Regel setzt X auf tina. Beim Versuch, das zweite Sub-Goal zu erfüllen, scheitert Prolog, und es erfolgt ein erneutes Backtracking mit Freigabe und Instanziierung X = marc nach Regel 3. Damit kann unter Benutzung der Regel 6 auch das zweite Subgoal erfüllt werden. Alle weiteren Backtracking-Schritte führen zu keiner neuen Lösung.

Die Lösungssuche kann auch über einen UND-ODER-Baum dargestellt werden. Wir untersuchen ein weiteres Beispiel mit den folgenden Fakten und Regeln:

     mag (heinz, computer).
     mag (heinz, tiere).
     mag (tina, tiere).
     mag (tina, biologie).
     interessiert (Jemand, biologie) :- 
             mag (Jemand, tiere), mag (Jemand, biologie).
 
     ?- interessiert (Jemand, biologie).

\includegraphics[%%
width=1.0\textwidth]{Prolog3.eps}

Der erste UND-ODER Baum zeigt die Instanziierungen zur Erfüllung des ersten Sub-Goals und das Scheitern der Erfüllung des zweiten. Die oberen beiden Kanten bezeichnen die UND-Verknüpfung, die Knoten der Fakten sind als ODER-Knoten gezeichnet.

Die Instanziierung mit Jemand = heinz führt jedoch nicht zu einer Herleitung des zweiten Teilgoals. Nach dem 8. Schritt tritt ein Backtracking ein. Nun wird Jemand befreit und mit dem dritten Fakt tina instanziiert. Die Unifizierung gelingt dann schließlich im 15. Schritt. Prolog antwortet mit Jemand = tina.

Mit dem Fail-Operator kann Backtracking erzwungen werden. Wir erläutern dies an einem Beispiel:

     mag (heinz, computer).
     mag (marc, sport).
     mag (tina, tiere).
     mag (hermann, biologie).
     mag (lili, mathematik).
     interessiert :- mag (Person, Fakt), 
        write('Student: '), display(Person), nl,
        write('interessiert: '), display(Fakt), nl.
     ?- interessiert
liefert
     Student: heinz 
     interessiert: computer
Diese Lösung wird sofort gefunden, so dass weitere mögliche Kombinationen nicht ausgegeben werden. Wenn man die Prüfung des Prädikats allerdings so beeinflussen könnte, dass als Ergebnis false herausgegeben würde, so würden alle Kombinationen ausgegeben.

Diese Möglichkeit ist nun durch den Fail-Operator gegeben. Durch Anhängen des Operators an den rechten Teil der Regel

     interessiert :- mag (Person, Fakt), 
        write('Student: '), display(Person), nl, 
        write('interessiert: '), display(Fakt), nl, fail.
Die Überprüfung des Prädikats schließt damit immer mit fail ab, Prolog sucht nach weiteren Lösungen. Dadurch werden alle Kombinationen ausgegeben. Bei Auftreten von fail wird die Regel immer als falsch gewertet, jeder Instanziierungs- und Unifizierungsversuch schlägt fehl.

Kommen wir zum Cut-Operator:

Er wird durch ein Ausrufungszeichen ! notiert und gehört zu den Ausführungs-Kontroll-Operatoren.

Wird der Cut-Operator das erste Mal als zu beweisendes Teilziel angetroffen, so gilt er als bewiesen, hinterlässt aber auf dem das Backtracking kontrollierenden Stack einen Vermerk mit einem Verweis auf das Teilziel, das zur Aktivierung der Klausel mit dem Cut-Operator geführt hat. Sollte später eine Fehlschlagsfolge über den Stack im Backtracking diesen Vermerk antreffen, so löst dies einen Fehlschlag des vermerkten Teilziels aus.

Alternativen für das Teilziel werden für dieses und die davor liegenden nicht mehr betrachtet.

Der Cut-Operator kann an drei verschiedenen Stellen eingesetzt werden.

Zusammenfassung der Funktionen zum Cut-Operator

Damit dient der Cut-Operator zur Erreichung eines Abbruches bei initiiertem Backtracking in Suchaktionen.

Dies ist nötig, wenn die einzig mögliche Lösung gefunden ist, die richtige Regel gefunden ist oder eine negative Abbruchbedingung (zusammen mit Fail) erfüllt wird.

Durch den Einsatz des Cut-Operators kann die deklarative Bedeutung eines Prolog-Programmes geändert werden. Der Cut-Operator verstärkt die Unvollständigkeitstendenz von Prolog, da ja erfolgreiche Pfade, die in einem Cut von der Alternativensuche ausgeschlossenen Teil des Beweisbaums vorkommen, für die Wiederholungsprozeduren unerreicht bleiben. Das soll an einem einfachen Beispiel erklärt werden:

     P :- a, b.
     P :- c.
Damit ist die Alternative in Aussagenlogik zu schreiben als $ P=\left(a\wedge b\right)\vee c$.

Dabei kommt es auf die Reihenfolge in der Alternative nicht an. Wird nun die erste Klausel in

     P :-  a, !, b.
verändert, so entspricht diesem $ P=\left(a\wedge b\right)\vee\left(\lnot a\wedge c\right)$.

Erst wenn a nicht ableitbar ist, wird die zweite Alternative untersucht. Stellt man die Klauseln um, so wird die richtige Entsprechung erreicht.

Die beiden Operatoren ! und fail bewirken, dass ein Beweis nicht nur abgebrochen wird, sondern auf alle Fälle misslingt, was auch mit Negation as Failure bezeichnet wird.

Wir schließen mit einem Beispiel ab. Hier werden Tiere über die Eigenschaften definiert, dass sie Lebewesen, aber keine Blumen sind:

    1. lebewesen (rose).
    2. lebewesen (hund).
    3. blume (rose).
    4. tiere (X) :- blume (X), !, fail.
    5. tiere (X) :- lebewesen (X).
    ?- tiere (rose).
    No
    ?- tiere (X).
    No
    ?- tiere (hund).
    Yes.
Nur wenn Prolog nicht über das cut in der ersten Regel läuft, die mit einem fail endet, und das ist in der dritten Anfrage der Fall, wird die zweite Regel benutzt, und die Antwort lautet Yes.

7 Standard-Prädikate (Built-in Predicates)

Das Prolog-System stellt einige vordefinierte Standard-Prädikate zur Verfügung, die vom Programmierer genauso genutzt werden können wie diejenigen, die er sich selbst geschaffen hat. Durch Verwendung dieser Prädikate sieht er z.B. Ein- und Ausgabe-Funktionen auf alle Prolog-Systeme gleich, unabhängig von der verwendeten Hardware.

Oftmals sind diese Prädikate auch nicht durch die reine Logik zu beschreiben; z.B. arithmetische Prädikate sind ohne die Kenntnisse der Zahlen nicht beschreibbar. Die Zahlen wiederum sind vom System, der Hardware, abhängig (Wertebereiche). Es ist somit nicht möglich, die Zahlen durch Prolog zu beschreiben. Die Standard-Prädikate erstrecken sich über viele Bereiche des Prolog-Systems. Sie lassen sich je nach Funktion in mehrere Klassen unterteilen:

8 Literatur

H. Kleine Büning und S. Schmitgen: PROLOG. Teubner, Stuttgart 1988

U. Schöning: Logik für Informatiker. Spektrum-Verlag Mannheim 2000

R. Yasdi: Logik und Programmieren in Logik. Prentice Hall, München 1995

12 Funktionale Programmierung

1 Einführung in funktionale Programmiersprachen

Nachdem wir in den vorigen Kapiteln bereits imperative und logische Programmiersprachen kennengelernt haben, wollen wir uns nun einer wichtigen dritten Gruppe, den funktionalen Programmiersprachen widmen. Zu dieser Familie gehören Lisp, Scheme, ML (Meta language), Gofer, Haskell, Hope. Miranda ist eine der bekanntesten funktionalen Programmiersprachen, die in Bildung und Forschung eingesetzt wird. Es handelt sich bei Miranda um eine Programmierumgebung für allgemeine Zwecke, bei der ein Compiler in einem interaktiven System eingebettet ist (unter UNIX), das Zugriff auf einen Bildschirm-Editor, ein Online-Handbuch und eine Schnittstelle zum Betriebssystem bietet. Auch verteilte Compilierung wird unterstützt. Während diese Sprache zu Beginn der neunziger Jahre einen großen Stellenwert genoss, ist nun Haskell mehr in den Vordergrund getreten. Viele Informationen zu diesen Programmiersprachen befinden sich auf der Home-Page des Lehrstuhls Informatik II (Prof. Indermark) an der RWTH-Aachen. Eine kleine Sprache ist Gofer, die auch auf DOS-PCs läuft.

Zunächst soll ein Überblick über den Sprachumfang einer funktionalen Programmiersprache gegeben werden. Wir wählen hier exemplarisch Miranda, wie auch im Werk von Bird und Wadler, markieren aber die Unterschiede zu Gofer und Haskell.

Der grundlegende Unterschied zwischen funktionalen und imperativen Sprachen besteht darin, dass funktionale Sprachen sich mit der funktionalen Beschreibung einer Lösung für ein Problem befassen, während imperative Sprachen sich mit der Erteilung von Befehlen an einen Rechner beschäftigen.

Bei letzteren listet das Programm detailliert eine Folge von Aktionen auf, die der Computer ausführen muss. Dazu muss der Programmierer die Problemlösung beschreiben, sie in einen sequentiellen Befehlssatz umsetzen und auch die Speicherverwaltung mittels der Typdefinitionen übernehmen. Den Variablen werden Werte zugewiesen, die sich ändern können. Über dynamische Variablen werden Listen definiert. Hier sind Plätze zu reservieren und wieder freizugeben. All dies ist fehleranfällig. Die Verwendung von globalen und lokalen Variablen, ein oft eingeschränkter Funktionsbegriff ziehen weitere Probleme nach sich.

Funktionale Sprachen sind Beispiele eines deklarativen Programmierstils, in dem ein Programm eine Beschreibung eines zu lösenden Problems und verschiedener dafür geltender Beziehungen liefert. Die Umwandlung in eine Liste von Anweisungen, die auf dem Rechner ablaufen, liegt in der Verantwortung der Sprachimplementation.

Syntax und Semantik funktionaler Sprachen tendieren zur Einfachheit, führen zu knappen und effizienten Programmen.

Wir wollen ein kleines Beispiel in einer imperativen Sprache anführen:

     j:= 1;
     for i:= 1 to LineLength do
       if line[i] < 10 then 
         begin newline[j]:= line[i]; j:= j+1 end;
Dieses Programmfragment wählt aus einer Liste alle Zahlen mit dem Wert kleiner 10 aus.

Würde man dynamische Listen vorsehen, wäre der Aufwand noch höher.

Das entsprechende Programm einer funktionalen Programmiersprache lautet:

     filter (lessthan 10) number_sequence
Hier wird kein Speicher verwaltet, keine Liste über Arrays definiert, Initialisierung und Inkrementierung fallen weg, der funktionale Stil ist auch flexibler:

Dasselbe Programm zur Auswahl aller Elemente ungleich "fred" aus einer Zeichenkette:

     filter (notequal "fred") string_sequence
Funktionale Sprachen basieren auch nicht auf einem speziellen Hardwaremodell. Das erleichtert Realisierungen fern von der von-Neumann-Architektur, wie Multiprozessoren und Parallel-Verarbeitung. Sie sind auch der Mathematik näher.

Wir wollen uns hier auf eine rein funktionale Sprachdefinition ohne imperative Merkmale beschränken. Solche Sprachen haben eine einfache und klare Semantik und werden in den Bereichen Beweissysteme und Spezifikationen benutzt.

Alle diese Sprachen behandeln Funktionen und Daten gleich, bieten automatische Speicherverwaltung, Typflexibilität, Polymorphismus, Mustererkennung, Listenbeschreibung, partielle Funktionen, kontextabhängig verzögerte Auswertung von Funktionen.

2 Operatoren, Bezeichner, Typen

Funktionale Programmiersprachen wie Miranda werden mittels eines Schlüsselwortes (hier: mira) im Editor gestartet und können durch Eingabe eines Kürzels (/quit bzw. /q) verlassen werden. Bei Gofer rufen wir das Programm auf und sehen uns vor einer Eingabeaufforderung mit dem Kürzel ?.

Es wird interaktiv eine Eingabe in Form eines zu berechnenden Ausdruckes oder eines Befehls /... (bzw. :... in Gofer) erwartet. Alle Ausdrücke und Befehle, die auf die Eingabeaufforderung hin eingegeben werden, müssen durch ein <return> abgeschlossen werden.

Ausdrücke müssen syntaktisch korrekt sein, ansonsten gibt es eine Fehlermeldung.

Dabei gilt die übliche Syntax für Ausdrücke, die wir schon von der Programmiersprache PASCAL her kennen.

Beispiele:

     MIRANDA (2+3)*5
     25
     MIRANDA 3 > 4
     False
Gofer benutzt hier zum Beispiel auch die Schreibweise
(*) ( (+) 2 3 ) 5

Miranda hat den üblichen Satz festeingebauter Funktionen zur Verfügung, wie sqrt. Bei Gofer sind je nach benutztem Prelude, das ist eine Datei, in der die Standardfunktionen und Operatoren vereinbart werden (vergleichbar einer Unit in PASCAL), unterschiedliche Operationen möglich. So unterstützt es beim einfachen Prelude keine floating-point Operationen, beim Standard-Prelude können +, -, *, /, PrimPlusFloat etc. verwendet werden.

Programme werden im Editor (/e, bzw. :e) in Form von Skriptdateien erstellt. Hier ist es möglich, einem syntaktisch korrekten Ausdruck einen Namen zu geben:

     days = ((4*30) + (7*31)+ 28)
     hours = 24
     Miranda days
     365
(Wir übergeben days an die Eingabeaufforderung, innerhalb einer gültigen Skriptdatei definiert.)

Der Standardname von Programmfiles in Miranda ist script.m. In Gofer wird die im Editor erstellte Datei script per :l script geladen.

Jeder gültige Ausdruck kann mittels Bezeichner = Ausdruck an einen Bezeichner gebunden werden. Derselbe Name darf aber nicht zweimal verschiedene Verwendung finden. Bezeichner beginnen mit einem Buchstaben, können dann Zahlen, Unter- und einfache Anführungsstriche enthalten. Bezeichner mit beginnenden Großbuchstaben sind in Gofer Konstruktoren vorbehalten.

     hours_in_year = (days*hours)
Wir verfügen über die klassischen einfachen Datentypen, wie boolesche Werte (bool) und Zahltypen (num).
     Miranda integer 3.0
     False
Typen können aber auch durch Aufzählung ihrer Elemente neu definiert werden.

Funktionale Programmiersprachen sind streng typisiert, falsche Matchings werden angezeigt:

     Miranda 2 div True
     type error in expression
     cannot unify bool with num
Ein Typchecking findet während des Compiliervorgangs statt. Dabei werden Syntax- und Typfehler angezeigt.

Korrekte Programme können ausgeführt werden.

Wir können Integer- und Realzahlen, sowie die Operationen +, -, *, /, div, mod, ^ verwenden.

     Miranda 4.0^-1
     0.25.
     Miranda 365 div 7.0 
     Program error: fractional number where integer expected (div)
In Gofer schreibt man ? 4 / 3 oder ? div 4 3 oder ? 4 `div` 3 (Akzent gravis).

Für die Verknüpfungsreihenfolge der Operatoren gelten die üblichen Regeln der Hierarchie, Klammern können diese präzisieren oder ändern, gewisse Operatoren sind assoziativ oder kommutativ.

Operatoren können überladen werden, wie $ <$ für Zahlen und Zeichenketten, allerdings sind Mischungen verschiedener Operatoren nicht möglich.

Funktionale Programmiersprachen unterscheiden zwischen Zeichen und Zeichenketten:

Ein Zeichen ist ein einzelnes Eingabesymbol, gekennzeichnet durch den Typnamen char. Es gibt jedoch kein leeres Zeichen.

Zeichen sind die ASCII-Zeichen wie 'A' oder Sonderzeichen \n für Newline, \' für Anführungszeichen, \065 für A. Miranda besitzt Typumwandlungsfunktionen code und decode von Zeichen zu Zahlen und umgekehrt, in Gofer sind es die schon aus Pascal her bekannten ord und chr.

Zeichenketten werden durch eine beliebige Folge von Zeichen dargestellt, die von doppelten Anführungszeichen eingeschlossen und durch den Typ [char] gekennzeichnet sind. Daher gibt es einen Unterschied zwischen 'a' und ä".

Operationen und Funktionen mit Zeichenketten sind ++ (Konkatenation), - (Subtraktion, Gofer:  
), # (Länge, length in Gofer), ! x (Indizierung, d.h. es wird, bei Null beginnend, das Teilzeichen mit der angegebenen Nummer x extrahiert; Gofer benutzt !! x).

     Miranda äaa" ++ "bbbb" ++ ""
     aaabbbb
     Miranda äabcd" -- "bacr" 
               || Die geloeschten Zeichen sind die rechts stehenden 
     ad
     Miranda # "blabla" 
     6
     Miranda äbc" ! 1  || Gofer benutzt !! 
     'b'
     shownum   || wandelt Zahlen in Zeichenketten um
Kommentare in Gofer beginnen mit -, in Miranda mit ||.

Boolesche Werte ergeben sich als Resultate relationaler Operatoren und können die Werte True oder False haben.

Ferner finden die logischen Operatoren ~(nicht), & (Konjunktion), $ \mathtt{\vee}$ (Disjunktion) Verwendung, Gleichheit wird mit = geprüft.

In Gofer treten an diese Stelle not, &&, $ \vert$$ \vert$. Das Prüfen auf Gleichheit geschieht mit ==.

% latex2html id marker 23478
$ \begin{tabular}{\vert l\vert l\vert}
\texttt{Mira...
...$<$}}\texttt{3)}\text{=}\texttt{(\symbol{126}(\symbol{126}True))}
\end{tabular}$

Zeichenketten können bezüglich lexikographischer Ordnung verglichen werden:

     Miranda "B" <  "Al"
     False
Steht in einer Skriptdatei folgende Vereinbarung:
 
     x = 0.0
     y = 0.2
so ergibt sich
     Miranda abs(x-y) < 0.4
     True
     Miranda (x=0) v ((y div x) > 23)
     True
oder in Gofer:
     ? abs(x-y) > 2 where x = 5; y = 2
     True
Den Typ eines Ausdruckes kann man in Miranda mittels nachgestelltem :: bestimmen.

Komplexere Datentypen werden mit Tupeln eingeführt: date = (13, "April", 1066).

Tupel stellen das kartesische Produkt der zugrundeliegenden Komponenten dar.

Die Menge der Werte, die ein Typ haben kann, wird als seine Domäne gekennzeichnet. Theoretisch ist die Domäne des Typs int die Menge der ganzen Zahlen, praktisch ist sie durch den Prozessortyp eingeschränkt. Liefert eine Berechnung ein Ergebnis durch Bereichsüberschreitung außerhalb der Domäne, so wird es durch das spezielle Zeichen $ \perp$, den bottom, repräsentiert. Unser Beispiel betrifft die Domäne (num, [char], num). Zur Vereinfachung kann dieser Typ auch neu benannt werden: Datum == (num, [char], num).

Tupel können auf Gleichheit (Äquivalenz) und Ungleichheit geprüft werden.

     Miranda date = (abs(-13), "April", 1065 + 15 mod 7)
     True.
Das zusammengesetzte Format eines Tupels kann auf der linken Seite einer Bezeichnerdefinition benutzt werden:
     (day, month, year) = (13, "April", 1066)
So können day, month und year als einzelne Bezeichner verwendet werden. Weiterhin dürfen Tupel geschachtelt werden.

3 Funktionen

Miranda verfügt über eingebaute und vordefinierte Funktionen, erlaubt aber auch die Definition eigener Funktionen. Wichtige Werkzeuge sind Mustererkennung und Rekursion.

Funktionen werden innerhalb der Skriptdatei definiert. Das geschieht nach folgendem Schema:

     funktion_name parameter_name = funktion_rumpf.
Dabei besteht die linke Seite aus zwei Bezeichnern. Hier unterscheidet man wie bei PASCAL zwischen formalem und aktuellem Parameter. Funktionsrümpfe sind gültige Ausdrücke, Konstanten, Funktionsaufrufe etc.
     twice x = x*2
     Miranda twice 2
     4
     Miranda twice ::
     num -> num
     Miranda twice
     <function>
Letzteres sagt aus, dass twice eine Funktion und kein Wertbezeichner oder gar noch nicht definiert ist.

Funktionen können umbenannt werden per tw = twice. Weitere Beispiele sind

     isupper c = (c >= 'A') & (c <= 'Z')
     toupper c = decode ((code c) - (code 'a') + (code 'A'))
Für sehr lange Funktionsdefinitionen gibt es eine Abseitsregel für die rechte Seite nach dem Gleichheitszeichen:

        Start der Aktion in der ersten Zeile,
        nächste Zeile darf nicht links vom Start der Aktion in der ersten Zeile stehen.

Bei der Auswertung geschachtelter Funktionen kommt es entscheidend auf die Klammersetzung an. Dazu wird zur Auswertung des Funktionsrumpfes eine neue Kopie des Funktionskörpers erstellt, in der die formalen Parameter durch eine Kopie der aktuellen Parameter ersetzt sind.

Der Wert der aktuellen Parameter wird nicht beeinflusst.

Das Argument der Funktion wird nur berechnet, wenn es erforderlich ist (call-by-name). Wenn darüber hinaus ein Argument mehr als einmal innerhalb eines Funktionskörpers benutzt wird, wird es höchstens einmal berechnet (call by need, lazy-evaluation).

Im Allgemeinen kann die Berechnung von Funktionsauswertungen als eine Folge von Ersetzungen und Vereinfachungen betrachtet werden.

Das Problem der Funktionen mit mehreren Parametern wird von Miranda mit zwei Ansätzen gelöst, Curryfunktionen und Tupel. Wir behandeln zunächst Tupel und beginnen mit einem Beispiel.

     ismybirthday date = (date = (1, "April", 1956))
     Miranda ismybirthday ::
     (num, [char], num) -> bool
Der formale Parameter ist ein Aggregattyp. In Gofer heißen die Typen Int, Bool und [Char].
     timestamp (time, message) = message ++ üm" ++ (show time) ++ "Uhr"
Damit ist die Quelldomaine (num, [char]), die Zieldomäne [char].

Im Gegensatz zu PASCAL kann auch der Zieltyp ein Aggregattyp sein:

     quotrem (x, y) = ((x div y), (x mod y))
     Miranda quotrem ::
     (num, num) -> (num, num)
Oder in Gofer:
    ? quotrem (37, 13) where quotrem (x 'div' y, x 'mod' y)
    (2, 11)
Parameterlose Funktionen werden oft mit einem expliziten Nullparameter () angegeben.

Kommen wir zu polymorphen Funktionen:

Sie definieren Operationen mit Tupeln ohne Berücksichtigung der Typen ihrer Komponenten.

     myfst (x, y) = x
     mysnd (x, y) = y
Der Typ von myfst unterscheidet sich von den vorher definierten Funktionen dadurch, dass er ein Wertepaar mit zwei beliebigen Typen übernimmt und den Wert des ersten Typs zurückgibt. (myfst ist identisch mit der eingebauten Funktion fst.) Daher gibt das System an:
     mysnd ::
     (*, **) -> **
Dabei wird eine Funktion polymorph in den Teilen der Eingabe bezeichnet, in denen sie nicht auswertet und damit für beliebige Typen arbeitet.
     g (x, y) = (-y, x)
     Miranda g ::
     (*, num) -> (num, *).
Eine etwas andere Sicht haben wir bei der folgenden Funktionsdefinition, hier zur Abwechslung aus Gofer:
     (+): Int -> Int -> Int.
In anderen Worten, (+) ist eine Funktion, die ein ganzzahliges Argument nimmt, und einen Wert vom Typ (Int -> Int) zurückgibt. Zum Beispiel ist (+) 5 eine Funktion, die einen ganzzahligen Wert n nimmt und 5+n zurückgibt. Daher ist (+) 5 4 äquivalent zu 5 + 4 und ergibt 9.

1 Mustererkennung

Mustererkennung ermöglicht alternative Definitionen von Funktionen in Abhängigkeit vom Format des aktuellen Parameters (vergleichbar dem varianten Rekord in PASCAL).

Die allgemeine Schablone zur Mustererkennung ist:

     funktion_name muster_1 = funktion_rumpf_1
       ...........................
     funktion_name muster_N = funktion_rumpf_N
Wenn die Funktion auf ein Argument angewandt wird, liest Miranda die Definitionen sequentiell von oben beginnend, bis der tatsächliche Parameter auf eines der Muster passt.

Wir notieren weitere Beispiele:

     not True = False
     not False = True
     Stundenplan "Montag" = "Arbeiten"
     Stundenplan "Dienstag" = "Uebungen"
     Stundenplan "Sonntag" = "Ausruhen"
     Stundenplan jedentag = "Sonstetwas"
Muster dürfen aus Konstanten, Tupeln, booleschen Variablen oder formalen Parameternamen bestehen.
     decrement (n+1) = n
     both_equal (x, x) = True
     twenty_four_hour (x, ä.m") = x
     twenty_four_hour (x, "p.m") = x + 12
Mustererkennung kann durch Wächter mit den Schlüsselwörtern leistungsfähiger gemacht werden:
     whichsign n = "Positive", if n > 0
                 = "Zero", if n=0
                 = "Negative", if n < 0
                 = "Negative", otherwise
Die allgemeine Syntax in Gofer lautet folgendermaßen:
     f x1 x2 ... xn | condition1 = e1
                    | condition2 = e2 
             ...........
                    | conditionm = em
Die Semantik einleuchtend: Wenn eine Bedingung conditioni wahr ist, nimmt f den Ausdruck ei an. Otherwise als Bedingung ist am Ende ebenfalls erlaubt und tritt ein, wenn die Bedingungen vorher alle nicht erfüllt sind.

Für alle bisherigen Beispiele konnte Miranda die Typen der Funktionsparameter aus dem Kontext erschließen. Bei der internen Funktion show zur Umwandlung in Zeichenketten wird dieser jedoch überladen, und so ist eine Deklaration

     wrong_echo x = (show x) ++ (show x)
mit einem Typfehler verbunden.

Abhilfe bringt eine Typdefinition:

     funktion_name :: parameter_typ -> ziel_typ
        right_echo :: num -> [char]
      right_echo x = (show x) ++ (show x)
Auch ein Polytyp * kann zur Typeinschränkung benutzt werden:
     third_same :: (*, *, *) -> *
     third_same (x, y, z) = z
     third_any :: (*, **, ***) -> ***
     third_any (x, y, z) = z
     Miranda third_any (1, 3, 'e')
     'e'
(In Gofer werden die *-Bezeichner durch kleine Buchstaben ersetzt.)

2 Einfache rekursive Funktionen

Wenn eine Funktion auf ein Argument angewandt wird, verursacht das Vorkommen eines Funktionsnamens im Funktionskörper, dass eine neue Kopie der Funktion erstellt wird und auf ein neues Argument angewendet wird. Dabei wird die gleichzeitige Auswertung eines Ausdruckes solange aufgeschoben, bis die Anwendung des rekursiven Aufrufes einen Funktionswert zurückgibt.

Wichtig bei der Rekursion ist die Abbruchbedingung. Ansonsten identifiziert man mehrere Stile, z.B. Stack- oder akkumulierende Rekursion.

Bei ersterer werden die Argumente aller Berechnungen gestapelt, bis das Abbruchmuster auftritt.

     printdots :: num -> [char]
     printdots 0 = ""
     printdots n = "." ++ (printdots (n-1)), if n > 0
                 = error printdots : "negative Eingabe", otherwise
In Gofer sieht die Definition etwas anders aus:
     pd 5 where pd :: Int -> [Char]; pd n 
                           | n==0 = [] 
                           | n > 0 = "." ++ (pd(n-1))
Hier nun ein Beispiel für die akkumulierende rekursive Funktion x + y:
     plus :: (num, num) -> num
     plus (x, 0) = x
     plus (x, y) = plus (x+1, y-1)
Es wird auf den Stack verzichtet. Stattdessen wird in einem Register so lange akkumuliert, bis eine Abbruchbedingung erfüllt ist. Im vorigen Beispiel enthält das Register dann sogar das Ergebnis.

Wir wollen die Fakultätsfunktion noch auf verschiedene Weisen in Gofer formulieren:

     fact1 n = if n==0 then 1 else n * fact1 (n-1)

     fact2 n | n==0 = 1
             | otherwise = n * fact2 (n-1) 

     fact3 0 = 1
     fact3 n = n * fact3 (n-1)
Einige abschließende Bemerkungen:

Funktionale Programmiersprachen haben keinen Zuweisungsoperator, der den Wert einer Speicherstelle ändern kann. Demgegenüber sind Zuweisungen in imperativen Programmiersprachen sehr wichtig: Sie dienen der

Demgegenüber stellt die Eingabe eines Programmes hier den aktuellen Parameter der obersten Funktion dar. Die Ausgabe ist das Ergebnis der obersten Funktion. Die Wiederholungssteuerung wird durch Rekursion erledigt.

Die Ergebnisse von Zwischenrechnungen müssen nicht gespeichert werden. Der Mechanismus der Speicherverwaltung von Miranda verbirgt Speicherbelegung und -freigabe vor dem Programmierer:

Wir geben ein Beispiel zur Vertauschung zweier Werte. Als erstes ist die Realisation in C mit einer Hilfsvariable, dann die Realisation in C mit einem strukturiertem Typ und zum Schluss dieselbe Funktion in Miranda angegeben:
Erste Realisiation in C:

   void swap(int *xp, int *yp)
   { 
     int t;
     t = *xp;
     *xp = *yp;
     *yp = t;  
   }
Zweite Realisiation in C:
   typedef struct 
   {
     int x ; int y;
   } PAIR;

   PAIR swap(PAIR arg)
   {
     PAIR result;
     result.x = arg.y;
     result.y = arg.x;
     return result;
   }
Realisation in Miranda:
  swap(x, y) = (y, x)
Die Programmsteuerung geschieht also folgendermaßen:

Ein Beispiel zu einem kleinen Projekt zur Zahl- in Stringumsetzung:
     string == [char]
     int_to_string :: num -> string
     int_to_string n = "-" ++ pos_to_string (abs n), if n < 0
                     = pos_to_string n, otherwise
     pos_to_string :: num -> string
     pos_to_string n = int_to_char n, if n < 10
                     = pos_to_string (n div 10)
                     ++ (int_to_char (n mod 10)), otherwise
     int_to_char :: num -> string
     int_to_char n = show (decode (n + code '0'))

4 Listen

Die bisher untersuchten Datentypen num, char und bool haben nur einen skalaren Wert, während zusammengesetzte Typen wie Tupel und Zeichenkette Aggregattypen sind.

Wir wollen nun den Typ Liste vorstellen auf der Basis einer rekursiven Definition. Funktionen, die Listen bearbeiten, sind von Natur aus rekursiv. Dabei sollen fünf gebräuchliche Methoden analysiert werden.

Man kann sich eine Liste als eine ineinander geschachtelte Folge von Kisten vorstellen mit einer zentralen leeren Kiste, während die umgebenden Kisten jeweils ein Datenelement und eine kleinere Kiste enthalten.

Um das erste Element zu untersuchen, muss die äußere Kiste geöffnet werden. Darin ist neben diesem Element auch der Rest der Liste in einer weiteren Kiste enthalten.

Man benötigt nun Operationen zum Erzeugen einer Liste, zur Zerlegung derselben und zum Erkennen der leeren Kiste [], der der Polytyp [*] zugewiesen wird.

Eine Liste ist daher entweder leer oder ein Element eines gegebenen Typs zusammen mit einer Liste von Elementen desselben Typs.

Listen können im Aggregatformat und im Konstruktformat notiert werden, d.h. einzelne Elemente werden durch Kommata getrennt in eckigen Klammern angegeben oder wir haben die Darstellung kopf : rest, wobei kopf ein einzelnes Element und rest eine Liste ist. Die leere Liste wird durch ein Paar eckiger Klammern dargestellt []. Bei Listen mit einem oder mehreren Elementen ist die leere Kiste im Aggregatformat enthalten, nur Datenelemente werden explizit angegeben: [1]. Weitere Beispiele:

     right = [1, 2, 3, 4]
     Miranda [(1, 2), (3, 1), (6, 5)] ::
     [(num, num)]
     Miranda ["Blabla", "Sieh da"] ::
     [[char]]
Listen mit Elementen verschiedener Typen sind unzulässig.

Zulässig ist aber leer = [[], [], []] vom Typ [[*]].

Miranda stellt eine Reihe eingebauter Listenfunktionen zur Verfügung, die zum Aufbau, zur Zerlegung, zur Kombination, zur Listenlänge und zur Indizierung dienen.

Wir geben nun Beispiele von Skriptdateien:

     aliste = ä" : []
     bliste = "fed" : "cb" : aliste
     cliste = "cc" : ["c", äb"]
     (erstes_element : weitere_elemente) = [3, 2, 1] 
            || Mustervergleich 
     Miranda erstes_element
     3
     Miranda weitere_elemente
     [2, 1]
     Miranda 4 : 3 : 2 : []
     [4, 3, 2]
Das letzte Beispiel ist interessant, weil die Liste mit dem Operator : im Konstruktformat zur Verfügung gestellt ist, jedoch im Aggregatformat ausgegeben wird. Wichtig ist, dass die Elemente vom Typ num in eine Liste [] vom Polytyp [*] eingebracht werden und alle Listenelemente den gleichen Typ haben.

Weitere Beispiele sind:

     list1 = 1 : [] || ergibt [1]
     list2 = 1 : [1] || ergibt [1, 1]
     list3 = [1] : [1] : [] 
            || ergibt [1] : [[1]] = [[1],[1]], da : rechtsassoziativ ist.
Der Typ von : ist
     Miranda : ::
     * -> [*] -> [*]
Dies ist im Sinne der Linksverknüpfung zu lesen (*, [*]) -> * und stellt einen Abbildungstyp dar, der im nächsten Kapitel über Currying besprochen wird.

Kommen wir zum Append-Operator ++:

     Miranda ++ ::
     [*] -> [*] -> [*]
Letzteres ist im Sinne der Linksverknüpfung zu lesen ([*], [*]) -> [*].
     Miranda [1] ++ [2, 3]
     [1, 2, 3]
Es können nur Listen gleichen Typs miteinander verknüpft werden. Der Operator ++ ist kein Konstruktor, da er keine eindeutige Darstellung einer Liste liefert:
     ["A", "A"] = ersteListe ++ zweiteListe.
Hier kann ersteListe sowohl [], ["A"] oder auch ["A", "A"] sein.

Zum Zerlegen von Listen hat Miranda die Operationen hd (head) und tl (tail):

     Miranda hd [1, 2, 3, 4]
     1
     Miranda tl [1, 2, 3, 4]
     [2, 3, 4]
Der hd-Operator ist vom Typ [*] -> *, der tl-Operator vom Typ [*] -> [*].
     Miranda (tl [1])
     []
     Miranda (tl [1]) ::
     [num]
Wir notieren noch: (hd anylist) : (tl anylist) = anylist, hd und tl auf die leere Liste angewendet erzeugen Fehler.

Weitere Listenoperationen sind die Subtraktion -, # (Listenlänge) und ! (Indizierung): [1, 2, 3] ! 2 liefert das Ergebnis 3.

Bei der Indizierung wird nach ! die Nummer des gesuchten Elements angegeben, wobei die Numerierung bei Null beginnt.

Die Eingabe von Listen kann durch die Punkt-Punkt Schreibweise vereinfacht werden, die schon von PASCAL her bekannt ist:

     Miranda [-2 .. 2]
     [-2, -1, 0, 1, 2]
     Miranda [1, 7 .. 49]
     [1, 7, 13, 19, 25, 31, 37, 43, 49]
     Miranda [-2 .. -4]
     []
     Miranda [3, 1 .. -5]
     [3, 1, -1, -3, -5]
     Miranda hd (tl (tl (tl [3, 1 .. -5])))
     -3
Interessant sind Listen von Zeichen, vorher schon als Zeichenketten eingeführt:
     Miranda ['s' : 'a' : 'm' : 'p' : 'l' : 'e' : []) ::
     [char]
Eine äquivalente Schreibweise ist
     ['s', 'a', 'm', 'p', 'l', 'e'].
Listen und Tupel sind aggregierte Datenstrukturen. Alle Elemente einer Liste müssen daher demselben Typ angehören. Tupelelemente können demgegenüber verschiedene Typen haben. Listen und Tupel können gemischt werden:
     richtige_tupel = ([1, 2, 3], [True, False])
     richtige_liste = [(12, "Juni", 1793), (13, "April", 1066)]
Eine Liste ist rekursiv definiert, ein Tupel nicht.

Tupel können bei einer Äquivalenzprüfung nur gegen Tupel mit gleich zusammengesetztem Typ abgeglichen werden, Listen verschiedener Länge können verglichen werden.

1 Einfache Listenfunktionen

Die folgenden Listenfunktionen sind einfach zu definieren:

     isempty :: [*] -> bool 
     isnotempty :: [*] -> bool
     isempty anylist = false
Andere Listenfunktionen sind rekursiv, weil die Liste selbst eine rekursive Struktur ist.

Die Konstruktion geht nach folgendem Schema vor:

template [] = Endwert
template (front : rest) = etwas mit ''front'' tun und das Ergebnis mit einem rekursiven Aufruf auf dem Restargument kombinieren

Wir geben ein Beispiel:

     nlist == [num]
     sumlist :: nlist -> num
     sumlist [] = 0
     sumlist (front : rest) = front + sumlist rest
Nun notieren wir eine Schablone für eine akkumulierende rekursive Funktion:

main [] = Abbruchbedingung oder Fehlermeldung
main any = aux (any, (Anfangswert des Akkumulators))
aux ([], accumulator) = accumulator
aux ((front : rest), accumulator) = aux (rest, etwas mit ''front'' und ''accumulator'' tun))

Das folgende Beispiel ist akkumulierend rekursiv, nutzt aber statt eines expliziten Akkumulators den Anfang der Liste, um das aktuelle Maximum zu speichern.

     numlist == [num]
     listmax :: numlist -> num
     listmax [] = error "listmax - empty list"
     listmax (front : []) = front
     listmax (front : next : rest)
     = listmax (front : rest), if front > next
     = listmax (next : rest), otherwise
Nun zwei polymorphe Funktionen mit Listen:
     length :: [*] -> num
     length [] = 0
     length (front : rest) = 1 + length rest
     mymember :: ([*], *) -> bool
     mymember ([], item) = False
     mymember ((item : rest), item) = True
     mymember ((front : rest), item) = mymember (rest, item)
Man bemerkt, dass sich der Miranda-Code direkt aus der mathematischen Spezifikation ergibt. Weiterhin wird eine Definition der Funktion benötigt für die

Manchmal sind diese Funktionen jedoch erst nach einem längeren Analyseprozess hinzuschreiben:

Wir geben ein Beispiel einer Neudefinition der Standardfunktion reverse:

     myreverse :: [*] -> [*]
     myreverse [] = []
     myreverse (front : rest) = myreverse (rest) ++ [front]
startswith erkennt, ob eine Liste Unterliste einer anderen ist:
     startswith :: ([*], [*]) -> bool
     startswith ([], anylist) = True
     startswith (anylist, []) = False
     startswith ((front1 : rest1), (front2 : rest2)) 
                = startswith(rest1, rest2) & (front1 = front 2)
Wir wollen nun noch ein Sortierbeispiel mit Miranda lösen, und zwar Insert-Sort:

Man beginnt mit einer unsortierten und einer leeren Liste, die als sortiert gelten kann, und fügt nacheinander die Elemente der unsortierten Liste in die sortierte ein, ohne die Sortierung zu zerstören.

     nlist == [num]
     isort :: nlist -> nlist
     isort anylist = xsort (anylist, [])
     xsort :: (nlist, nlist) -> nlist
     xsort([], sortedlist) = sortedlist || Abbruchbedingung 
     xsort (front : rest, sortedlist)
               = xsort(rest, insert (front, sortedlist))
     insert :: (num, nlist) -> nlist
     insert (item, []) = [item]
     insert (item, front : rest)
               = (item : front : rest), if item <= front
               = front : insert (item, rest), otherwise
Man beachte, dass man im Prelude von Gofer vergebene Namen nicht noch einmal verwendet. Dazu gehören zum Beispiel insert und qsort.

2 Rekursion

Stackrekursive Funktionen haben ein wachsendes Stadium des Stacks, wo die Berechnung zurückgestellt wird, und ein abnehmendes, in der das Endergebnis berechnet wird.

Occurs zählt zum Beispiel das Auftreten eines Elements in einer Liste.

     occurs :: ([*], *) -> num
     occurs ([], item) = 0
     occurs((item : rest), item) = 1 + occurs (rest, item)
     occurs ((front : rest), item) = 0 + occurs (rest, item)
Diese Lösung ist nicht sehr elegant, weil sie für das Nichtauftreten des Elementes künstlich den Wert 0 auf den Stack legt. Günstiger ist die filterrekursive Variante, die ähnlich lautet wie die obige, aber die Null weglässt. Dabei ist klar, dass im Falle des Nichtauftretens nur eine Enddefinition mit der Null und der leeren Liste erfolgt.

Eine andere Version führt zusätzlich einen Akkumulator ein:

     occurs :: ([*], *) -> num
     occurs (anylist, item) = xoccurs (anylist, item, 0)
     xoccurs :: ([*], *, num) -> num
     xoccurs ([], item, total) = total
     xoccurs ((front : rest), item, total)
       = xoccurs (rest, item, total + 1), if front = item
       = xoccurs (rest, item, total), otherwise
Kommen wir zu endrekursiven Funktionen:

Hier wird das Beispiel der endrekursiven Funktion mylast gezeigt, bei der die Berechnung niemals ausgesetzt ist. Die Funktion berechnet das letzte Element einer Liste.

     mylast [*] -> *
     mylast [] = error mylast : "leere Liste"
     mylst (front: []) = front
     mylast (front : rest) = mylast rest
Betrachten wir schließlich wechselseitige Rekursion:
     string == [char]
     nasty_skipbrackets :: string -> string
     nasty_skipbrackets [] = []
     nasty_skipbrackets ('(' : rest)
                      = nasty_inbrackets rest
     nasty_skipbrackets (front : rest)
                      = front : nasty_skipbrackets rest
     nasty_inbrackets :: string -> string
     nasty_inbrackets []
                      = error "text ends inside a bracket pair"
     nasty_inbrackets (')' : rest)
                      = nasty_skipbrackets rest
     nasty_inbrackets (front : rest)
                      = nasty_inbrackets rest
Diese Funktion löscht jeden Text in Klammern aus einer gegebenen Zeichenkette. Fehlt die schließende Klammer, wird der Text bis zum Ende gelöscht. Der Aufruf
     nasty_skipbrackets (ä(b)cd(e)")
liefert acd als Ergebnis.

Hier handelt es sich um eine wechselseitige Rekursion. Will man diese vermeiden, so gewährleistet das die folgende Version:

     string == [char]
     skipbrackets :: string -> string
     skipbrackets [] = []
     skipbrackets ('(': rest)
                = skipbrackets (inbrackets rest)
     skipbrackets (front : rest)
                = front : skipbrackets rest
     inbrackets :: string -> string
     inbrackets []
                = error "text ends inside a bracket pair"
     inbrackets (')' : rest)
                = rest
     inbrackets (front : rest)
                = inbrackets rest

5 Curryfunktionen und Funktionen höherer Ordnung

Dieser Abschnitt verdeutlicht, dass Funktionen sogar als Parameter an andere Funktionen übergeben werden können; eine Funktion, die eine andere Funktion als Argument akzeptiert oder eine Funktion als Ergebnis hat, wird als Funktion höherer Ordnung akzeptiert.

Durch Currying

     curry :: ((a, b) -> c) -> a -> b -> c 
     curry f a b = f (a, b)
können Funktionen mit mehreren Parametern definiert werden, ohne ein Tupel zu verwenden. Eine Curryfunktion besitzt zudem die Eigenschaft, nicht auf alle Parameter gleichzeitig angewendet werden zu müssen. Man kann sie partiell anwenden, um eine neue Funktion zu bilden, die dann als Parameter an eine Funktion höherer Ordnung übergeben werden kann. Die Funktionskomposition wird dazu verwendet, partielle Funktionen zu verketten.

Wir geben ein Beispiel:

     get_nth any [] = error "get_nth"
     get_nth 1 (front : any) = front
     get_nth n (front : rest) = get_nth (n-1) rest
Die Typindikation ist dann
     Miranda get_nth ::
     num -> [*] -> *
Letztere Typindikation ist als num -> ([*] -> *) zu lesen. Dies führt zur Anwendung neuer Funktionen, wie
     getsecond = get_nth 2.
     Miranda getsecond ::
     [*] -> *
     Miranda getsecond [ä", "AA", "xD", "SxS"]
     AA
Ein weiteres Beispiel ist:
     plus :: num -> num -> num
     plus x y = x+y
     inc = plus 1
Ein anderer Ansatz wäre der Mechanismus der Operatorsektion: (+)1 2 steht für 1+2 und ergibt die neue Funktion inc = (+) 1 und analog twice = (*) 2

Die folgenden Namen für die Präfix-Curryfunktionen der Operatoren machen die Anwendung verständlicher:

     plus (+)
     minus (-)
     times (*)
     divide (/)
     and (&)
     or (v)
     append (++)
     cons (:)
     equal (=)
     notequal (~=)
     greaterthan (>)
     lessthan (<)
Der eingebaute Punktoperator . (compose) nimmt zwei Funktionen als Parameter auf: quad x = (twice . twice) x. Ein weiteres Beispiel ist die Verwendung eines Operators in einer Funktionskomposition.
     Miranda ((+2) . (*3)) 3
     11
Wir wollen nun eine Funktion angeben, die jede dyadische Funktion, also jede Funktion, die von zwei Argumenten abhängt, in das Curryformat umwandelt:
     make_curried :: ((*, **) -> ***) -> * -> ** -> ***
     make_curried ff x y = ff (x, y)
     maxnum :: (num, num) -> num
     maxnum (x, y) = x, if x>y
                  = y, otherwise
     newmaxnum = make_curried maxnum
     newmaxnum 2 3 = ((newmaxnum 2) 3)
Für die Lösung umfassender Programmierübungen muss der Programmierer einen modularen Ansatz verwenden.

Die Organisation von Umgebungen mit Hilfe des where-Mechanismus in Miranda und durch Listenvergleiche stellt für den Programmierer ein Mittel zur Gruppierung voneinander abhängiger Funktionen und Namen zu einem zusammengehörenden Programmblock dar. Der Programmierer kann die Sichtbarkeit von Funktionsnamen definieren. Dadurch kann er Teile des Programmes bestimmen, in denen diese Namen verwendet werden können. Hierbei handelt es sich um eine Form der Kapselung. Dazu kommt die Möglichkeit der verzögerten Auswertung und unendlicher Listen.

Wir definieren die unendliche Liste der Quadrate ungerader natürlicher Zahlen

     oddsquares = [x*x | x <- [1, 3 .. ]].
Dann ergibt
     Miranda take 3 oddsquares
     [1, 9, 25]
Ein weiteres Beispiel mit unendlichen Listen ist die folgende rekursive Definition:
 
     oddsquares = xodds 1 where xodds n = (n*n) : xodds (n+2)
Wird eine Skriptdatei übersetzt, so erstellt das System eine Umgebung, die alle Beschreibungen der Skriptdatei sowie alle Definitionen der Standardumgebung umfasst. Durch jede Definition wird ein Wert an einen Bezeichner gebunden. Wird ein Objekt definiert, so hat es Zugriff auf alle anderen Bindungen der Umgebung. Bei jeder späteren Überarbeitung wird dieses Verfahren wiederholt; die alten Definitionen werden entfernt, eine neue Umgebung mit den neuen und den Standarddefinitionen erstellt.

Bei der Definition einer Funktion besitzt ihr Rumpf eine neue Umgebung, der aus der oben definierten Umgebung (der geerbten) und den Namen der formalen Parameter der Funktion besteht (, die nur beim Aufruf mit tatsächlichen Werten verknüpft werden). Diese neue Umgebung gilt nur für den Rumpf und beeinflusst nachfolgende Funktionsaufrufe nicht.

Um den Wert eines Bezeichners zu bestimmen, der im Rumpf einer Funktion erscheint, wird eine von zwei Regeln angewendet.

Der Bereich des Programmtextes, in dem eine Bindung gilt, wird als deren Gültigkeitsbereich bezeichnet. Die oben gezeigten Regeln sind daher auch als Gültigkeitsbereichsregeln bekannt.

Außerhalb des Gültigkeitsbereiches einer bestimmten Bindung besitzt der Name dieser Bindung keinen Wert und jede Referenz auf diesen Bezeichner führt zu einem Fehler. Wird ein Bezeichner in einer Umgebung eines Funktionsrumpfs wiederverwendet, so besitzt der einen Wert, der aber von Werten außerhalb abweichen kann. Wir geben ein Beispiel:

     ten = 10
     both_bound xy = x+y+1 || Gueltigkeitsbereich von x und y
     not_bound x = x + y + 1 || ündefined name y"
     also_both_bound x = x + ten + 1 || Gueltigkeitsbereich von x
Die Fehlermeldung bedeutet, dass Miranda in der Umgebung der Funktion not_bound keine Bedeutung für y finden kann, obwohl es in der lokalen Umgebung einer anderen Funktion eine Bedeutung für y gibt. Im obigen Fall war ein Wert mit dem Bezeichner y verknüpft, aber in der Funktion both_bound gebunden.

Reservierte Namen können nicht erneut gebunden werden, Funktions- und Typennamen sollten aus Stilgründen nicht erneut gebunden werden.

Die Notwendigkeit, den Gültigkeitsbereich von Funktionen und Bezeichnern einzuschränken, hilft Namenskonflikte zu vermeiden und eng verwandte Funktionen zu verbinden.

Ersterer Punkt wird einsichtig, wenn mehrere Personen an einem Programm arbeiten.

Wir zeigen hier das Beispiel qsort:

     qsort :: (* -> * -> bool) -> [*] -> [*]
     qsort order []
           = []
     qsort order (front : rest)
           = qsort order low ++ [front] ++ qsort order high
             where
             (low, high) = split(order front) rest
                           where
                           split pred []
                             = ([], [])
                           split pred (front : rest)
                             = (front : low, high), if pred front
                             = (low, front : high), otherwise
                               where
                               (low, high) = split pred rest
Hierbei ist order die gewünschte Sortierung, meistens gilt $ \mathtt{order}\in\{<,>\}$. Es sind mehrere where-Blöcke geschachtelt (Gofer benutzt hier zur Strukturierung where {..}), und low sowie high sind in verschiedenen Zusammenhängen gebraucht.

Abschließend wollen wir noch ein weiteres Beispiel-Programm, diesmal in Gofer, betrachten. Es löst das sogenannte Acht-Damen-Problem, welches die Aufgabe stellt, auf einem Schachbrett acht Damen so zu verteilen, dass sie sich gegenseitig nicht schlagen können. Eine Lösung kann in Form einer Permutation der Ziffern 1 - 8 angegeben werden. Hierbei bezeichnet die Ziffer an der $ n$-ten Stelle ( $ 1\leq n\leq8$) die Spalte des Schachbretts, an der in der $ n$-ten Zeile eine Dame steht.

Die Graphik zeigt eine Lösung und zwar 1 5 8 6 3 7 2 4, eine weitere ist 6 4 7 1 8 2 5 3.

\includegraphics[]{schach.eps}

Das folgende Programm ermittelt alle 92 Lösungen:

queens 0          = [[]]
queens (m+1)      = [ p++[n] | p<-queens m, n<-[1..8], safe p n ]
safe p n          = all not [ check (i,j) (m,n) | (i,j) <- zip [1..] p ]
                    where m = 1 + length p
check (i,j) (m,n) = j==n || (i+j==m+n) || (i-j==m-n)
Hierbei nimmt die Funktion zip aus den beiden Listen [1,..] und p ein Element und macht daraus ein Paar.
Ersetzen wir die zweite Zeile durch
queens (m+1)      = [ p++[n] | p<-queens m, n<-[1..8]\\p, safe p n ]
so benötigen wir 40 % weniger Ressourcen, da bereits gefundene Positionen nicht mehr berücksichtigt zu werden brauchen.

6 Literatur

R. Bird, Ph. Wadler: Introduction to Functional Programming. Prentice Hall, New York 1988

Ch. Clack, C. Myers, E. Poon: Programmieren in Miranda. Prentice Hall, München 1996

P. Thiemann: Grundlagen der funktionalen Programmierung. Teubner, Stuttgart 1994

Bibliography

1
Gert Böhme: Einstieg in die Mathematische Logik. Hanser, München 1981

2
Volker Claus und Andreas Schwill: Schülerduden Informatik. Dudenverlag, Mannheim 1997

3
Herbert Klaeren: Vom Problem zum Programm. Teubner, Stuttgart 1991

4
Uwe Schöning: Logik für Informatiker. 4. Auflage. Spektrum-Verlag, Mannheim 2000

5
Ramin Yasdi: Logik und Programmieren in Logik. Prentice Hall, München 1995

6
Wolfgang Coy: Aufbau und Arbeitsweise von Rechenanlagen. Vieweg, Braunschweig 1992

7
Walter Oberschelp und Gottfried Vossen: Rechneraufbau und Rechnerstrukturen. 8. Auflage. Oldenbourg Verlag, M"unchen 2000

8
G. Bohlender, E. Kaucher, R. Klatte, Ch. Ullrich: Einstieg in die Informatik mit Pascal. BI-Wissenschaftsverlag, Mannheim 1993

9
Kathleen Jensen, Niklaus Wirth: PASCAL user manual and report : revised for the ISO Pascal standard. 3. Auflage. Springer, New York [u.a.] 1985

10
Herbert Klaeren: Vom Problem zum Programm. Teubner, Stuttgart 1991

11
Michael Sonnenschein: Programmieren in PASCAL : Sprachstandard und Turbo-PASCAL. 2. Auflage. H"uthig, Heidelberg 1989.

12
Turbo Pascal 6.0 Dokumentation: Benutzerhandbuch, Programmierhandbuch, Referenzhandbuch. Borland 1990

13
Hans Kleine B"uning und Stefan Schmitgen: PROLOG. Teubner, Stuttgart 1988

14
Uwe Schöning: Logik für Informatiker. 4. Auflage. Spektrum-Verlag, Mannheim 1996

15
Ramin Yasdi: Logik und Programmieren in Logik. Prentice Hall, München 1995

16
Richard Bird, Philip Wadler: Introduction to Functional Programming. Prentice Hall, New York 1988

17
Chris Clack, Colin Myers, Ellen Poon: Programmieren in Miranda. Prentice Hall, München 1996

18
Peter Thiemann: Grundlagen der funktionalen Programmierung. Teubner, Stuttgart1994

About this document ...

Informatik A:
Rechnerstrukturen und Programmierparadigmen

This document was generated using the LaTeX2HTML translator Version 2K.1beta (1.48)

Copyright © 1993, 1994, 1995, 1996, Nikos Drakos, Computer Based Learning Unit, University of Leeds.
Copyright © 1997, 1998, 1999, Ross Moore, Mathematics Department, Macquarie University, Sydney.

The command line arguments were:
latex2html -white -scalable_fonts -split 2 -reuse 2 -ldump -show_section_numbers skript

The translation was initiated by Andre Schaefer on 2003-04-17



next_inactive up previous
Andre Schaefer 2003-04-17