Rechnerstrukturen und Programmierparadigmen

Werbung
Informatik A:
Rechnerstrukturen und
Programmierparadigmen
Prof. Dr. Norbert Fuhr
SS 2003
Universität Duisburg-Essen,
Abteilung Duisburg
Fakultät 5
Autor des Skriptes:
Prof. Dr. Wolfram Luther.
Zuletzt editiert von:
André Schaefer
mailto:[email protected]
Inhaltsverzeichnis
1 Einführung in die Grundlagen der Informatik
1.1 Ziele der Informatik . . . . . . . . . . . . . . . .
1.2 Teilgebiete der Informatik . . . . . . . . . . . . .
1.3 Rechnerentwicklung . . . . . . . . . . . . . . . .
1.3.1 Problemorientierte Programmiersprachen
1.4 Grundlagen der Logik . . . . . . . . . . . . . . .
1.4.1 Geschichtlicher Überblick . . . . . . . . .
1.4.2 Aussagenlogik . . . . . . . . . . . . . . . .
1.4.3 Prädikatenlogik . . . . . . . . . . . . . . .
1.4.4 Literatur . . . . . . . . . . . . . . . . . .
2 Schaltfunktionen
2.1 Zahldarstellung . . . . . . . . . . . . . .
2.1.1 Komplementdarstellungen . . . .
2.1.2 Darstellung von Gleitpunktzahlen
2.2 Boolesche Algebra . . . . . . . . . . . .
2.3 Schaltfunktionen . . . . . . . . . . . . .
2.4 Schaltnetze . . . . . . . . . . . . . . . .
2.5 Ringsummennormalform . . . . . . . . .
3 Schaltnetze und ihre Optimierung
3.1 Beispiele für Schaltnetze . . . . . . .
3.2 Vereinfachung von Schaltnetzen . . .
3.2.1 Das Verfahren von Karnaugh
3.2.2 Das Verfahren von Quine und
3.3 Fehlerdiagnose von Schaltnetzen . .
3.4 Hazards in Schaltnetzen . . . . . . .
. . .
. . .
nach
. . .
. . .
. . .
. . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
. . . . . . . . .
. . . . . . . . .
IEEE 754/854
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
McCluskey
. . . . . . .
. . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
6
6
8
9
12
14
14
15
21
25
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
26
26
27
29
30
32
38
41
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
45
45
48
48
53
58
63
4 Schaltwerke
68
4.1 Flip–Flops . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
4.2 Sequentielle Schaltungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
4.3 Lineare Schaltkreise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
3
5 Programmierbare Logische Arrays (PLA)
80
5.1 Aufbau und Arbeitsweise eines PLA . . . . . . . . . . . . . . . . . . . . . 80
5.2 Programmierung eines PLA, universelles PLA . . . . . . . . . . . . . . . . 84
5.3 Anwendungen von PLA’s . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
6 Bemerkungen zum Entwurf von VLSI Schaltungen
88
6.1 Komplexität von VLSI Schaltungen . . . . . . . . . . . . . . . . . . . . . . 89
6.2 Layout von VLSI–Schaltungen — H–Bäume . . . . . . . . . . . . . . . . . 95
7 Grundlegende Additions– und Multiplikationsalgorithmen und Schaltungen
7.1 Addition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
7.1.1 Von–Neumann–Addierwerk . . . . . . . . . . . . . . . . . . . . .
7.1.2 Carry–Look–ahead Addition . . . . . . . . . . . . . . . . . . . . .
7.2 Multiplikation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
97
97
98
100
102
8 Organisationsplan eines von–Neumann–Rechners
8.1 Struktur eines Rechners . . . . . . . . . . . .
8.2 Arbeitsweise einer CPU . . . . . . . . . . . .
8.3 Der Speicher eines von–Neumann–Rechners .
8.4 Busse . . . . . . . . . . . . . . . . . . . . . .
8.5 I/O Einheit und Steuerung durch Interrupts .
8.6 Erweiterungen des von–Neumann–Konzepts .
9 Eine
9.1
9.2
9.3
9.4
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
104
104
107
109
110
111
113
kleine Programmiersprache
Syntaktische Beschreibungsmittel . . . . . . . . . . . . . .
Die Syntax von Mini–Pascal . . . . . . . . . . . . . . . . .
Semantik von Mini–Pascal∗ . . . . . . . . . . . . . . . . .
Übersetzung des Mini–Pascalprogramms in Maschinencode
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
115
115
118
123
129
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
10 Von
10.1
10.2
10.3
Mini–Pascal zu Pascal
Datentypen von Pascal . . . . . . . . . . . . . . . . . .
Kontrollstrukturen, Prozeduren und Funktionen . . . .
Unit–Konzepte und objektorientierte Programmierung
10.3.1 Allgemeiner Programmaufbau . . . . . . . . .
10.3.2 Objektorientierte Programmierung . . . . . . .
10.3.3 Implementierung . . . . . . . . . . . . . . . . .
10.4 C versus Pascal . . . . . . . . . . . . . . . . . . . . . .
11 Logiksprachen und Prolog
11.1 Was ist logische Programmierung? . . . . . . .
11.2 Von Logik zu Prolog . . . . . . . . . . . . . . .
11.3 Syntax und Semantik von Prolog–Programmen
11.4 Rekursive Regeln . . . . . . . . . . . . . . . . .
11.5 Listen . . . . . . . . . . . . . . . . . . . . . . .
11.6 Fail und Cut–Operatoren . . . . . . . . . . . .
4
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
140
140
148
151
151
152
155
159
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
168
168
169
173
176
179
183
11.7 Standard–Prädikate (Built–in Predicates) . . . . . . . . . . . . . . . . . . 191
11.8 Literatur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 192
12 Funktionale Programmierung
12.1 Einführung in funktionale Programmiersprachen .
12.2 Operatoren, Bezeichner, Typen . . . . . . . . . . .
12.3 Funktionen . . . . . . . . . . . . . . . . . . . . . .
12.3.1 Mustererkennung . . . . . . . . . . . . . . .
12.3.2 Einfache rekursive Funktionen . . . . . . . .
12.4 Listen . . . . . . . . . . . . . . . . . . . . . . . . .
12.4.1 Einfache Listenfunktionen . . . . . . . . . .
12.4.2 Rekursion . . . . . . . . . . . . . . . . . . .
12.5 Curryfunktionen und Funktionen höherer Ordnung
12.6 Literatur . . . . . . . . . . . . . . . . . . . . . . . .
5
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
193
193
195
198
201
202
205
208
210
212
217
1 Einführung in die Grundlagen der
Informatik
1.1 Ziele der Informatik
Informatik
• ist die Wissenschaft von der systematischen Verarbeitung von Informationen. Sie
befasst sich mit dem Beschaffen, Erfassen, Strukturieren, Bearbeiten, Verteilen und
Speichern von Daten. Dazu entwickelt sie konstruktiv-formale und operationale
Kalküle auf der Basis von künstlichen Sprachen.
• ist Grundlage zur Konzeption, Erstellung, Verifikation, Bewertung und Anwendung
großer Informations– und Kommunikationssysteme, ihrer Hardware und Software.
• schlägt eine Brücke zwischen technisch-naturwissenschaftlichem und geisteswissenschaftlichem Bereich und spielt in Wirtschafts–, Rechts– und Gesellschaftswissenschaften wie auch in Kunst, moderner Philosophie und Linguistik eine herausragende Rolle.
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
6
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
7
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:
• Programmieren, besonders im Sinne kreativen, explorativen Problemlösens und
Gestaltens, sollte als wichtige Basiserfahrung aktiv vermittelt werden. Dabei sind
grundlegende Prinzipien des Programmierens (Spezifizieren, Parametrisieren, Modularisieren, Kontrollieren) darzustellen und am konkreten Beispiel zu unterrichten.
• Der Computer als Werkzeug zur Informationserzeugung, –verwaltung, ihrer Verarbeitung, Darstellung, Übermittlung und Speicherung sollte einen gebührenden
Platz einnehmen.
• Das Kennenlernen der theoretischen Grundlagen der klassischen Anwendungen
(Programmiersysteme, Textverarbeitung, Graphikprogramm, Tabellenkalkulation,
Datenbanken, Computeralgebrasysteme, Kommunikationsprogramme) und ihr Einsatz im jeweiligen Kontext geistiger Tätigkeiten ist wichtig. Grundlagen des Rechneraufbaus und der Konzeption von Rechner– und Kommunikationsnetzen, ihrer
Hard– und Software sind zu vermitteln.
Grundlegende Prinzipien der Programmierung und den Aufbau eines Rechners wollen
wir in dieser Vorlesung vertieft behandeln.
1.2 Teilgebiete der Informatik
Die Informatik teilt sich in die folgenden vier Teilgebiete auf:
• Theoretische Informatik
Mathematische Grundlagen der Informatik (Logik, Algebra, Graphentheorie, Kombinatorik) – Automatentheorie – Künstliche Sprachen – Theorie der Berechenbarkeit – Maschinelles Beweisen
• Praktische Informatik
Compilerbau – Programmiersprachen – Datenbanktheorie – Softwareengineering –
Geometrische Datenverarbeitung – Künstliche Intelligenz – Verteilte Systeme
8
Abbildung 1.1: Pascalsche Addiermaschine
• Technische Informatik
Betriebssysteme – Rechnerarchitektur – Hardware verteilter Systeme – Mikroprozessoren – Kommunikationssysteme – Rechnernetze
• Angewandte Informatik
Bildverarbeitung – Neuroinformatik – Bioinformatik – Wirtschaftsinformatik –
Computerlinguistik – CAD, CAE, etc.
1.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.
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
9
Abbildung 1.2: Babaggerechner
wird sodann an einer Ausgabeeinheit angezeigt. Moderne Eingabegeräte sind Tastatur,
Maus, Graphiktablett, Scanner, Ausgabegeräte dagegen Monitor, Drucker oder eine Datei.
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.
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
10
Abbildung 1.3: Prozessor P6
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
11
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.3.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.
12
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
13
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.
1.4 Grundlagen der Logik
1.4.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.
14
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.
1.4.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, . . .) 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 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
¬ ( ), die jeder Aussage das Negat mit dem entgegengesetzten Wahrheitswert zuordnet.
15
Atomare Formeln (Aussagenvariablen) oder deren Negate heißen Literale L ∈ {A, ¬A}.
Nunmehr erklären wir wichtige zweistellige Operationen. Die Konjunktion mit dem Konjungator ∧ (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:
∧
w
f
w f
w f
f f
z.B. erhält man w ∧ w = w, was man am mittleren Kästchen ablesen kann.
Die Disjunktion mit dem Disjungator ∨ (oder, or) ordnet zwei Aussagen ihr Disjungat
zu. Dabei ergibt sich die erste der folgenden Tabellen:
∨
w
f
w f
w w
w f
→
w
f
←→
w
f
w f
w f
w w
w f
w f
f w
⊕
w
f
w f
f w
w f
z.B. erhält man f ∨ 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 ¬, ∨, ∧ gebildet werden.
Die Subjunktion mit dem Subjungator → (wenn – dann) ordnet zwei Aussagen ihr Subjungat zu. Auch hier haben wir die Tabelle notiert, aus der wir entnehmen, dass f → w
bzw. f → 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 ←→ (genau dann – wenn) ordnet zwei Aussagen ihr
Bijungat, die Antivalenz mit dem Junktor ⊕ bzw. ←→
6
(entweder – oder, xor) ordnet
zwei Aussagen die Summe ihrer Wahrheitswerte modulo zwei zu.
Für die Junktoren gilt die folgenden Bindungshierarchie: ¬ bindet stärker als ∧, dieses
stärker als ∨, ∨ stärker als →, → stärker als ←→.
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
(A ∨ ¬B ∨ ¬C) ∧ ¬C ∧ A ∧ (¬A ∨ ¬C) .
Eine aussagenlogische Form liegt in konjunktiver Form (KF) vor, wenn sie die Konjunktion von mehreren Klauseln ist:
 

ni
n
^
_
F = 
Li,j  .
i=1
j=1
16
Sie liegt in disjunktiver Form (DF) vor, wenn sie die Disjunktion mehrerer durch Konjungatoren verknüpften Literale ist:

 
ni
n
^
_
Li,j  .
F = 
i=1
j=1
Beispiel 1.4.1
F1 = (A ∨ B ∨ ¬C) ∧ (¬B ∨ C) KF
F2 = (A ∧ B ∧ ¬C) ∨ (¬B ∧ C) 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 ² F . (Es ist allgemein wahr,
wie z.B. A ∨ ¬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 1.4.2
Zu zeigen ist ² ((P → Q) ∧ P ) → Q.
a)
P
f
f
w
w
Q P → Q (P → Q) ∧ P
f
w
f
w
w
f
f
f
f
w
w
w
((P → Q) ∧ P ) → Q
w
w
w
w
b) Äquivalent sind die folgenden Formeln
((P → Q) ∧ P ) → Q
¬ ((¬P ∨ Q) ∧ P ) ∨ Q
((P ∧ ¬Q) ∨ ¬P ) ∨ Q
((P ∨ ¬P ) ∧ (¬Q ∨ ¬P )) ∨ Q
(w ∧ (¬P ∨ ¬Q)) ∨ Q
(¬P ∨ ¬Q) ∨ Q
¬P ∨ w
w
17
c) Überführung in konjunktive Form
((P → Q) ∧ P ) → Q
¬ ((¬P ∨ Q) ∧ P ) ∨ Q
((P ∧ ¬Q) ∨ ¬P ) ∨ Q
((P ∨ ¬P ) ∧ (¬Q ∨ ¬P )) ∨ Q
((P ∨ ¬P ∨ Q) ∧ (¬Q ∨ Q ∨ ¬P ))
Bei der Herleitung benutzen wir folgende Definitionen und Ergebnisse:
Zwei Formeln F und G heißen äquivalent ≡, wenn sie für alle passenden Belegungen den
gleichen Wahrheitswert haben: ² G ←→ F . Für jede Formel F gibt es eine äquivalente
Formel in KF oder DF. Es gelten folgende Äquivalenzen:
Tabelle 1.4.3
(F ∧ F )
(F ∧ G)
((F ∧ G) ∧ H)
((F ∨ G) ∨ H)
(F ∧ (F ∨ G))
(F ∧ (G ∨ H))
(F ∨ (G ∧ H))
¬¬F
¬ (F ∨ G)
¬ (F ∧ G)
F →G
F ∧G
F ∧G
≡
≡
≡
≡
≡
≡
≡
≡
≡
≡
≡
≡
≡
F, (F ∨ F ) ≡ F
Idempotenz
(G ∧ F ) , (F ∨ G) ≡ (G ∨ F )
Kommutativität
(F ∧ (G ∧ H)) ,
(F ∨ (G ∨ H))
Assoziativität
F, (F ∨ (F ∧ G)) ≡ F
Absorption
((F ∧ G) ∨ (F ∧ H)) ,
((F ∨ G) ∧ (F ∨ H))
Distributivität
F
Doppelnegation
(¬F ∧ ¬G) ,
(¬F ∨ ¬G)
deMorganscheRegeln
¬F ∨ G
bedingteEliminierung
F, F ∨ G ≡ G,
falls F unerfüllbar
G, F ∨ G ≡ F,
falls F Tautologie
Ist S eine Menge von Formeln oder Prämissen {F1 , . . . , Fk } und F eine Formel, so ist F
eine logische Konsequenz von S, in Zeichen S ² F , wenn jede Belegung von S, die ein
Modell von S ist, auch ein Modell von F ist. Dies kann auch in der Form


k
^
²
Fj → F 
j=1
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:
18
Tabelle 1.4.4
F
F
F ∧G
F ∧G
(F → G) ∧ (G → H)
G ∧ (G → F )
¬F ∧ (G → F )
²
²
²
²
²
²
²
F ∨G
G→F
F
G↔F
F →H
Transitivität
F
Modus Ponens (Schlussregel)
¬G
Modus Tollens
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 Inferenzregeln, wie den Modus Ponens und
den Modus Tollens oder auch die Transitivitätsregel.
Beispiel 1.4.5 (Transitivität)
A
B
B1
B2
r =( a ist eine gerade Zahl und b ist eine gerade Zahl)
(a + b ist eine gerade Zahl)
(a = 2n und b = 2m, n, m ganze Zahlen)
(a + b = 2k, k ganze Zahl)
A
A → B1
B1 → B 2
B2 → B
B
a und b sind gerade Zahlen
Dann gibt es Zahlen n, m mit a = 2n und b = 2m
Aus a = 2n und b = 2m folgt a + b = 2(n + m) = 2k
Aus a + b = 2k folgt a + b ist eine gerade Zahl
Mit der Transitivität gilt B
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 (T ` AS F ),
eine logische Konsequenz aus T ist (T ² F ). 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 ¬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,
19
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 ∨ A → A,
AS2: A → (A ∨ B),
AS3: (A ∨ B) → (B ∨ A),
AS4: (A → B) → ((C ∨ A) → (C ∨ B)),
der Definition A → B ≡ ¬A ∨ 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 1.4.6
Wir leiten(F ∨ F ) ≡ F bzw. ² F ∨ F ←→ F mit Hilfe der oben angegebenen Mittel her.
Dies ist äquivalent zu
² ((F ∨ F → F ) ∧ (F → F ∨ F )) mit A ∧ B := ¬ (¬A ∨ ¬B) .
Wir zeigen somit die Allgemeingültigkeit von(F ∨ F → F ) und von (F → F ∨ F ):
(F ∨ F )
F
F
→
→
→
F
(F ∨ G)
(F ∨ F )
AS1 .
AS2 ,
Ersetzungsregel (Variable G durch Formel F ) .
Bemerkung:
Im vorigen Beispiel haben wir die Junktoren ∧ und ←→ mit Hilfe der Junktoren ¬ und
∨ 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
20
die Negation ¬P vor, so lassen sich diese Klauseln unter Weglassung dieser Variablen zu
einer Klausel zusammenfassen:
P ∨ A 1 ∨ A2 ∨ . . . ∨ A n
¬P ∨ B1 ∨ B2 ∨ . . . ∨ Bm
A1 ∨ A2 ∨ . . . ∨ An ∨ B1 ∨ B2 ∨ . . . ∨ Bm Resolution
Dann lässt sich das Verfahren folgendermaßen beschreiben:
Es sei die Aussage A zu beweisen.
Setze das negierte Theorem ¬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 1.4.7
T = {A ∨ B, A → ¬B, ¬A}. Leite B her. Wir formen in KF um:
T = {A ∨ B, ¬A ∨ ¬B, ¬A} ,
oder in der Schreibweise als Klauselmenge
T = {(A, B) , (¬A, ¬B) , ¬A} ,
und untersuchen die Klauselmenge
{(A, B) , (¬A, ¬B) , ¬A, ¬B}
auf Unerfüllbarkeit:
B ist ein Resolvent von (A, B) und ¬A. Der Resolvent von B und ¬B ist aber die leere
Menge, und damit enthält die Klauselmenge die leere Menge und ist unerfüllbar. Somit
ist B bewiesen.
1.4.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, →, . . .) sind jedoch nicht nur
Literale (Aussagenvariablen und deren Negation) sondern auch Prädikate (Eigenschaften von Objekten, P (a1 , . . . , an )) mit Argumenttermen erlaubt. Terme sind Konstanten,
21
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 ∪, ∩, ⊂, Komplement korrespondieren mit den Verknüpfungen
des Aussagenkalküls ∨, ∧, →, ¬.
Die Aussage
”Wenn x die Eigenschaft P besitzt, so besitzt y die Eigenschaft Q.”
wird formalisiert zu P x → Qy. Wichtig sind auch die Quantoren: der Existenzquantor
(∨, ∃), der Allquantor (∧, ∀). 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 1.4.8
Gegeben sei die Formel
F : ∀xP (f (x, a), x).
Die betrachtete Domäne sei die der natürlichen Zahlen N := {1, 2, 3, . . .}. Der Konstanten
a ordnen wir die Zahl 1 zu. Unter der Funktion f (x, a) verstehen wir die Multiplikation
x·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 ∈ N gilt x · 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 ² F .
Zu unseren bekannten Schlussregeln der Aussagenlogik gesellen sich weitere Schlussregeln:
Tabelle 1.4.9
∀xF
² ∃xF für eine nichtleere Domäne
∀xF ∨ ∀xG ² ∀x(F ∨ G)
∃x(F ∧ G) ² ∃xF ∧ ∃xG
22
Wir erhalten auch weitere logische Äquivalenzen:
Tabelle 1.4.10
¬∀xF
≡ ∃x¬F
¬∃xF
≡ ∀x¬F
∀x∀yF
≡ ∀y∀xF
∃x∃yF
≡ ∃y∃xF
∀x(F ∧ G) ≡ ∀xF ∧ ∀xG
∃x(F ∨ G) ≡ ∃xF ∨ ∃xG
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 1.4.11 (Peano–Axiome)
1. 1 ist eine natürliche Zahl: P 1
Hier benötigen wir das einstellige Prädikat P x: x ist eine natürliche Zahl.
2. Zu jeder natürlichen Zahl gibt es eine natürliche Zahl als deren Nachfolger:
∀x (P x → ∃y (P y ∧ Qxy)) .
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: ¬∃x (P x ∧ Qx1).
4. Verschiedene natürliche Zahlen haben auch verschiedene Nachfolger:
∀x1 ∀x2 ∀y1 ∀y2 (P x1 ∧ P x2 ∧ Qx1 y1 ∧ Qx2 y2 ∧ ¬(x1 = x2 ) → ¬(y1 = y2 )) .
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 N der natürlichen Zahlen identisch
(vollständige Induktion):
∀M (M 1 ∧ ∀x∀y (P x ∧ P y ∧ M x ∧ Qxy → M y) → (M = N)) .
23
Dazu führt man die Prädikatenvariable M ein sowie das Prädikat M x: x ist Element
von M . Ferner findet die Konstante 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
P : −Q1 , Q2 , . . . , Qn (Prozedurklausel)
notiert. Dies steht für
Q1 ∧ Q2 ∧ . . . ∧ Qn → P bzw. äquivalent ¬Q1 ∨ ¬Q2 ∨ . . . ∨ ¬Qn ∨ 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 ∧ 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 1.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:
24
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
F = {K1 , K2 , . . . , Kn , N1 , . . . , Nm },
wobei Ki die Programmklauseln und Ni die negativen Klauseln sind. Eine SLD–Resolution
ist eine Herleitung der leeren Klausel: Ausgehend von j ∈ {1, 2, . . . , m} sowie der Zielklausel Nj wird dann mittels einer Folge {i1 , . . . , il } mit Ki1 bis Kil zunächst Nj mit Ki1
resolviert. Dabei entsteht ein Zwischenresultat, das nur negative Klauseln enthält, und
dann in jedem Schritt ν mit Kiv , v = 1, . . . , 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 ∈ F , so dass die leere Klausel durch
diese lineare SLD–Resolution aus F basierend auf K hergeleitet werden kann.
1.4.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.
25
2 Schaltfunktionen
2.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 Eb = {0, 1, . . . , b − 1} basieren.
Beispiel 2.1.1
Wichtige b–adische Zahlensysteme sind die folgenden:
a) Dezimalsystem mit E10 = {0, 1, 2, . . . , 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 E2 = {0, 1}, welches im Computer zum Einsatz
kommt.
Oktalsystem mit E8 = {0, 1, . . . , 7}.
Hexadezimalsystem mit E16 = {0, 1, . . . , 9, A, B, C, D, E, F }. Hierbei ist E16 eigentlich das Alphabet {0, 1, . . . , 9, 10, . . . , 15}, anstelle von Ziffern 10, 11, . . . werden
aber generell bei Alphabeten mit b > 9 neue, einstellige Symbole eingeführt. Gerade
diese Zweierpotenzbasen haben in der Informatik eine besondere Bedeutung.
Satz 2.1.2 (b–adische Darstellung von Fixpunktzahlen)
Sei b ∈ N mit b > 1. Dann ist jede Fixpunktzahl z (mit n Vorkomma– und k Nachkommastellen) mit 0 ≤ z · bk ≤ bn+k − 1 (und n ∈ N, k ∈ N0 ) eindeutig als Wort der Länge
n + k über Eb darstellbar durch
z=
n−1
X
zi bi , zi ∈ {0, 1, . . . b − 1}.
i=−k
26
Abkürzend wird z geschrieben als z = (zn−1 zn−2 . . . z0 .z−1 z−2 . . . 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, . . . , bn − 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 2.1.3 (Polyadische Darstellung natürlicher Zahlen)
Es sei
(bn )n∈N
eine Folge natürlicher Zahlen mit
bn > 1
für alle
n∈N
. Dann gibt es für jede
P natürliche
Qi−1 Zahl z genau eine Darstellung der Form
z = (zN . . . z0 )P D = N
z
i=0 i
j=0 bj = z0 + z1 b0 + z2 b1 b0 + . . . + zN bN −1 . . . b0
mit 0 ≤ zi < bi für i = 0, . . . , N .
2.1.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 2.1.4 (Komplementdarstellungen für n+k Fixpunktzahlen)
Sei x = (xn−1 . . . x0 .x−1 . . . x−k )2 einen + k Dualzahl in Fixpunktdarstellung.
a) Das Einerkomplement K1 (x) ist definiert als
K1 (x) = (xn−1 xn−2 . . . x0 .x−1 . . . x−k )2
wobei xi = 0 ↔ xi = 1, und xi = xi .
b) Das Zweierkomplement K2 (x) ist definiert als
K2 (x) = (xn−1 xn−2 . . . x0 .x−1 . . . x−k )2 + 1 ulp = K1 (x) + 1 ulp (modulo 2n )
.
27
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 2.1.5 (b–Komplement für n+k Fixpunktzahlen)
Sei x = (xn−1 . . . x0 .x−1 . . . x−k )b ∈ {0, 1, . . . , b − 1}n+k eine n + k Fixpunktdarstellung
zur Basis b .
a) Das (b − 1)–Komplement Kb−1 (x) ist definiert als
Kb−1 (x)
=
(x̃n−1 x̃n−2 . . . x̃0 .x̃−1 . . . x̃−k )b ,
x̃i := (b − 1) − xi , i = −k, . . . , n − 1.
b) Das b–Komplement Kb (x) ist definiert als
Kb (x) = (x̃n−1 x̃n−2 . . . x̃0 .x̃−1 . . . 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 = 2n verschiedene Bitmuster.
• Eine positive Zahl x wird nun dargestellt als
+x = x.
• Eine negative Zahl −x wird nun dargestellt als
-x = N - x.
Um Mehrdeutigkeiten auszuschließen, muss nun noch festgelegt werden, dass alle Darstellungen, deren höchstwertiges Bit gesetzt ist, als negative Zahlen interpretiert werden.
Beispiel 2.1.6
Bei einer Wortlänge von n = 8 Bits lauten die Darstellungen von +92 und -92 im Einer–
bzw. Zweierkomplement:
Komplement
Einer–
Zweier–
+92
dual 01011100
hexadezimal 5C
dual 01011100
hexadezimal 5C
28
-92
dual 10100011
hexadezimal A3
dual 10100100
hexadezimal A4
Zur Ausführung einer Subtraktion der Form x − y ist im Zweierkomplement lediglich
K2 (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 2.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:
+ K2 (92):
(00101101)2
(10100100)2
(11010001)2 = K2 (z)
mit z = x−y. Rückumwandeln nach dem gleichen Algorithmus ergibt: |z| = (00101110) 2 +
1 = (00101111)2 = 32+8+4+2+1 = 47, somit ergibt sich das korrekte Ergebnis z = −47.
2.1.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
↓
Exponent
S el−1 el−2 . . . . . . . . . e1 e0
Mantisse
fm−1 fm−2 . . . . . . . . . . . . f1 f0
Exponentencharakteristik: E = (el−1 el−2 . . . . . . e1 e0 )2
Mantisse: M = (fm−1 fm−2 . . . . . . f1 f0 )
Dargestellte Zahl:
(−1)S · (1.M )2 · 2(E−EBias )
(−1)S · (0.M )2 · 21−EBias
(−1)S · 0
(−1)S · ∞
NaN
1 ≤ E ≤ 2 · EBias
E = 0, M 6= 0
E = 0, M = 0
E = 2 · EBias + 1, M = 0
E = 2 · EBias + 1, M =
6 0
normalisiert
denormalisiert
NullmitVorz.
∞mitVorz.
NotaNumber
Die Konstanten l und m bestimmen die Genauigkeit, der Summand EBias 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.
29
Die im IEEE Standard festgelegten Genauigkeiten „single“ und „double“ haben folgende
Parameter:
Genauigkeit
single: 32 Bit
double: 64 Bit
Exponent
l= 8
l = 11
Mantisse
m = 23
m = 52
EBias
127
1023
Bereich
1.5 · 10−45 . . . 3.4 · 1038
5.0 · 10−324 . . . 1.7 · 10308
Stellen
7–8
15 – 16
Beispiel 2.1.8
Die Darstellung der Zahl 1.0 ergibt sich also in normalisierter Darstellung wie folgt:
single
Exponent
0
1.0 = 1.0 ∗ 2 = 1.0 ∗ 2
127−127
Mantisse
}|
{
z }| { z
=
ˆ 0 01111111 00000000000000000000000
= $3F800000
double
Exponent
0
1.0 = 1.0 ∗ 2 = 1.0 ∗ 2
1023−1023
Mantisse
}|
{ z
}|
{
z
=
ˆ 0 01111111111 |00000...............00000
{z
}
52 Nullen
= $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:
6.5 = 22 · 1.625
µ
¶
1
0
1
= 2 · 1+ + 2 + 3
2 2
2
µ
¶
1
0
1
129−127
= 2
· 1+ + 2 + 3
2 2
2
2
= 2129−127 · (1.101)2
=
ˆ 0 10000001 10100000000000000000000
= $40D00000.
2.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.
30
Seien x, y ∈ B. Erklärt man auf B die folgenden drei Verknüpfungen
x ∪ y := Max(x, y),
x ∩ y := Min(x, y),
x := 1 − x,
so ist (B, ∪, ∩, ) 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 ∪ y = y ∪ x, x ∩ y = y ∩ x
2. Assoziativgesetze: (x ∪ y) ∪ z = x ∪ (y ∪ z), (x ∩ y) ∩ z = x ∩ (y ∩ z)
3. Verschmelzungsgesetz: (x ∪ y) ∩ x = x, (x ∩ y) ∪ x = x
4. Distributivgesetze: x ∩ (y ∪ z) = (x ∩ y) ∪ (x ∩ z), x ∪ (y ∩ z) = (x ∪ y) ∩ (x ∪ z)
5. Komplementgesetz: x ∪ (y ∩ y) = x, x ∩ (y ∪ y) = x
6. x ∪ 0 = x, x ∩ 0 = 0, x ∩ 1 = x, x ∪ 1 = 1
7. de Morgansche Regeln: x ∪ y = x ∩ y, x ∩ y = x ∪ y
8. x = x ∪ x = x ∩ x = x
Satz 2.2.1
Für B = {0, 1} ist (B, ∪, ∩, ) 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
x
y
0
0
1
0
1
0
1
1
x∪y
0
1
1
1
linke Seite
(x ∪ y) ∩ x
0
0
1
1
rechte Seite
x
0
0
1
1
Im Folgenden wollen wir die Verknüpfungen der Booleschen Algebra wie folgt identifizieren: Maximum ∪ mit der Addition + (logisch OR) und Minimum ∩ mit der Multiplikation
· (logisch AND).
31
Bemerkung:
Die Potenzmenge P(A) einer Menge A, d.h. die Menge aller Teilmengen von A, ist mit
den Mengenoperationen ∪, ∩ und = C = Komplement, 0 = ∅, 1 = A, eine boolesche
Algebra.
2.3 Schaltfunktionen
Definition 2.3.1
Eine Funktion F : B n → B m heißt Schaltfunktion.
Eine totale Schaltfunktion F liefert zu den n Inputs (I) m eindeutige Outputs (O).
Beispiel 2.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
b1 b2 . . . b16 b17 . . . b32 , 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 →
B 17 . Analog kann man die Multiplikation als Schaltfunktion darstellen. Hier erhält man
M : B 32 → B 32 mit b1 . . . b16 b17 . . . b32 → c1 . . . c32 .
Beispiel 2.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 →
B 480 .
Einen wichtigen Spezialfall einer Schaltfunktion erhalten wir für m = 1:
Definition 2.3.4
Eine Schaltfunktion f : B n → B heißt n–stellige Boolesche Funktion.
Mit dieser Definition können wir nun eine beliebige Schaltfunktion F : B n → B m mit
F (x1 , . . . , xn ) = (y1 , . . . , ym ) als Vektor von Booleschen Funktionen auffassen. Setzen wir
für 1 ≤ i ≤ m die Boolesche Funktion fi : B n → B definiert durch
fi (x1 , . . . , xn ) = yi ,
so ist F für alle x1 , . . . , xn ∈ B darstellbar als
F (x1 , . . . , xn ) = (f1 (x1 , . . . , xn ), f2 (x1 , . . . , xn ), . . . , fm (x1 , . . . , xn )).
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.
32
Tabelle 2.2:
x
0
1
f0 (x)
0
0
f1 (x)
0
1
f2 (x)
1
0
f3 (x)
1
1
Tabelle 2.3:
x
0
0
1
1
(1)
(2)
(3)
y
0
1
0
1
x·x
≡0
f0
0
0
0
0
x·y
Min
∧
f1
0
0
0
1
x·y
>
6→
f2
0
0
1
0
x
x
x
f3
0
0
1
1
x·y
<
6←
f4
0
1
0
0
y
y
y
f5
0
1
0
1
⊕
6=
6
↔
f6
0
1
1
0
x+y
Max
∨
f7
0
1
1
1
Tabelle 2.4:
x
0
0
1
1
(1)
(2)
(3)
y
0
1
0
1
x+y
1 - Max
↓
f8
1
0
0
0
x⊕y
=
↔
f9
1
0
0
1
y
1−y
¬y
f10
1
0
1
0
x+y
≥
←
f11
1
0
1
1
33
x
1−x
¬x
f12
1
1
0
0
x+y
≤
→
f13
1
1
0
1
x·y
1 - Min
↑
f14
1
1
1
0
x+x
≡1
f15
1
1
1
1
Einige der obigen Funktionen werden auch mit Namen versehen:
f1
Konjunktion (AND)
f6
Antivalenz (Exclusive OR, XOR)
f7
Disjunktion (OR)
f8
Peircescher Pfeil (Not Or, NOR)
f9
Äquivalenz
f14 Shefferscher Strich (Not And, NAND)
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 2.3.5
n
Für jedes n ∈ N gibt es 22 verschiedene Boolesche Funktionen f : B n → B.
Beweisidee: Für n Argumente existieren insgesamt 2n verschiedene Belegungen. Für
n
jede Belegung wiederum kommen die Funktionswerte 0 und 1 vor, als insgesamt 2 2
verschiedene Funktionen.
Für Schaltfunktionen gilt allgemeiner:
Satz 2.3.6
n
Für m, n ∈ N gibt es 2m2 verschiedene Schaltfunktionenf : B n → 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 2.3.7
Sei i = (i1 i2 . . . in )2 von i. i heißt genau dann einschlägiger Index zu f : B n → B, wenn
f (i1 , i2 , . . . , in ) = 1 gilt.
Definition 2.3.8
Sei i = (i1 i2 . . . in )2 eines Index i, f : B n → B Boolesche Funktion. Dann heißt die
Funktion mi : B n → B definiert
durch mi (x1 , x2 , . . . , xn ) := xi11 xi22 . . . xinn i–ter Minterm
½
xj falls ij = 1
i
von f . Hierbei ist xjj :=
. d.h. es gilt mi = 1 genau dann, wenn das
xj falls ij = 0
Argument die duale Codierung von i ist.
Beispiel 2.3.9
Gegeben sei die folgende Boolesche Funktion f : B 3 → B durch die Wertetabelle
34
i
0
1
2
3
4
5
6
7
x1
0
0
0
0
1
1
1
1
x2
0
0
1
1
0
0
1
1
x3
0
1
0
1
0
1
0
1
f (x1 , x2 , x3 )
0
0
0
1
0
1
0
1
Die einschlägigen Indizes sind somit 3, 5, 7. Die zugehörigen Minterme sind m 3 (x1 , x2 , x3 ) =
x1 x2 x3 , m5 (x1 , x2 , x3 ) = x1 x2 x3 und
m7 (x1 , x2 , x3 ) = x1 x2 x3 .
Satz 2.3.10 (Darstellungssatz für Boolesche Funktionen)
Jede Boolesche Funktion f : B n → B ist eindeutig darstellbar als Summe der Minterme
ihrer einschlägigen Indizes, d.h ist I ⊆ {0, 1, . . . , 2n − 1} die Menge der einschlägigen
Indizes zu f , so gilt:
X
f=
mi .
i∈I
Keine andere Mintermsumme stellt f dar.
Beweis Zu zeigen ist: (1) Existenz einer solchen Darstellung und (2) die Eindeutigkeit.
1) Existenz
P
Wir zeigen, dass die Funktionen f und i∈I mi für alle Argumente den gleichen
Funktionswert besitzen. Sei dazu j ∈ {0, 1, . . . , 2n − 1} und (j1 j2 . . . jn )2 dessen .
Betrachte die Fälle
a) f (j1 , . . . , jn ) = 1.
P Hieraus folgt, j ist einschlägiger Index von f und somit
j ∈ I. Daher ist i∈I mi = 1.
b) f (j1 , . . . , jn ) = 0. Hieraus folgt, j ist kein einschlägier Index von f und somit
j 6∈ I. Dann kommt j aber nicht in der Indexmenge bei der Summation von
P
mj liefert für das Argument j den Wert
i∈I mi vor, aber nur der Minterm
P
1, womit also in diesem Fall i∈I mi = 0 gilt.
Damit stellen sowohl f als auch
ist gezeigt.
P
i∈I
mi dieselbe Funktion dar und die Existenz
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 ⊆ {0, 1, . . . , 2 n −
35
1}, mit I 6= J und
f=
X
mi =
i∈I
X
mj
j∈J
. Wegen I 6= J existiert dann mindestens ein Index k, der o.B.d.A. in I enthalten
ist aber nicht in J. Sei (k1 . . . kn )2 dessen . Dann gilt:
X
mi (k1 . . . kn ) = 1, da k ∈ I,
i∈I
X
mj (k1 . . . kn ) = 0, da k 6∈ J.
j∈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.
Definition 2.3.11
Die eindeutige Darstellung einer Booleschen Funktion als Mintermsumme laut Satz 2.1.2
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 2.3.12
Im Fall unseres Beispiels 2.3.9 ergibt sich die DNF:
f (x1 , x2 , x3 ) = m3 + m5 + m7 = x1 x2 x3 + x1 x2 x3 + x1 x2 x3
.
Aus der Darstellung einer Booleschen Funktion in DNF lässt sich unmittelbar folgern,
dass jede Boolesche Funktion durch die zweistelligen Grundfunktionen + und · sowie mit
der einstelligen Funktion dargestellt werden kann. Eine solcher Satz ausgezeichneter
Funktionen hat eine besondere Bezeichnung:
Definition 2.3.13
Ein System B = {f1 , . . . , fn } Boolescher Funktionen heißt (funktional) vollständig, wenn
jede Boolesche Funktion allein durch Einsetzungen bzw. Kompositionen von Funktionen
aus B dargestellt werden kann.
Es gilt:
Satz 2.3.14
a) {+, ·, } ist funktional vollständig.
36
b) {+, } ist funktional vollständig.
c) {·, } 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 2.3.15
Sei i ein Index von f : B n → B, und sei mi der i–te Minterm von f . Dann heißt die
Funktion
Mi : B n → B
, definiert durch
Mi (x1 , . . . , xn ) := mi (x1 , . . . , xn ) = xi11 + . . . + xinn
i–ter Maxterm von f .
Beispiel 2.3.16
Im Fall des Minterms m3 aus obigem Beispiel ergibt sich also
M3 (x1 , x2 , x3 ) = m3 (x1 , x2 , x3 ) = x1 x2 x3 = x1 + x2 + x3
.
In Analogie zu Mintermen gilt dann: Ein Maxterm Mi nimmt genau dann den Wert 0
an, wenn das Argument (x1 . . . xn ) die von i ist.
Dies liefert den folgenden Satz:
Satz 2.3.17
a) Jede Boolesche Funktion f : B n → 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 ) = DNF(f ).
Beispiel 2.3.18
Die konjunktive Normalform für unser Beispiel 2.3.9 ergibt sich somit als:
f (x1 , x2 , x3 ) = M0 · M1 · M2 · M4 · M6
= (x1 + x2 + x3 ) · (x1 + x2 + x3 ) · (x1 + x2 + x3 )
·(x1 + x2 + x3 ) · (x1 + x2 + x3 ).
37
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.
2.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:
Hieraus lässt sich für unser Beispiel
f (x1 , x2 , x3 ) = x¯1 x2 x3 + x1 x¯2 x3 + x1 x2 x3
folgendes Schaltnetz entwickeln:
38
x1
x2
x3
1
1
&
&
&
&
&
&
³1
³1
f(x1,x2,x3)
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.
39
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 ⊆ N. Ist K ⊆ P ×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 (pi , pj ) und (pj , pi ) zu einer ungerichteten Kante {pi , pj } zusammengefasst. Ein
n–Tupel w = (p1 , p2 , . . . , pn ) von Punkten aus P heißt dann Weg in G, falls für alle
i = 1, . . . , n − 1 die ungerichtete Kante {pi , pi+1 } zu G gehört. Ist p1 = pn , 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.
40
Definition 2.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:
Für einen DAG, und somit auch für Schaltnetze, gilt folgender Satz:
Satz 2.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 6= ∅, |P | < ∞. Angenommen, G hat keinen
Input. Sei dann p1 ein beliebiger Punkt von G. Dann hat p1 mindestens einen Vorgänger
p2 und dieser wiederum einen Vorgänger p3 usw. Da G endlich ist und laut Annahme in
jeden Punkt mindestens eine Kante hineinführt, gilt irgendwann p i = pj 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.
2.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.
41
Wir gehen hierzu davon aus, dass für eine Boolesche Funktion f : B n → B die Menge
der einschlägigen Indizes mit I bezeichnet sei, und dass somit f in der DNF als
f=
X
mi
i∈I
gegeben ist. In dieser Darstellung ist also immer höchstens ein Summand gleich 1. Hieraus
resultiert der folgende Satz:
Satz 2.5.1 (Ringsummennormalform, RNF)
Sei f : B n → B und I = {α1 , . . . , αk } die Menge der einschlägigen Indizes zu f . Dann
gilt:
f = m α1 ⊕ m α2 ⊕ . . . ⊕ m αk .
Der Operator ⊕ bezeichnet die XOR–Verknüpfung, die auch als Antivalenz, Addition
modulo 2 oder Ringsumme bezeichnet wird.
Beweis f liege in DNF vor, d.h.
f=
k
X
m αi .
i=1
Sei x ∈ B n . Dann sind zwei Fälle zu unterscheiden:
1.) f (x) = 0 ⇒ alle Summanden in der DNF und RNF sind gleich 0.
2.) f (x) = 1 ⇒ genau ein Summand in der DNF ist gleich 1, nämlich derjenige, dessen
Index α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.
Der folgende Satz stellt einige Eigenschaften der Ringsumme zusammen, die uns dann
erlauben werden, ein komplementfreies vollständiges System anzugeben.
Satz 2.5.2
Für alle x, y, z ∈ B gilt:
a) x ⊕ 1 = x, x ⊕ 0 = x
b) x ⊕ x = 0, x ⊕ x = 1
c) x ⊕ y = y ⊕ x (Kommutativität)
42
d) x ⊕ (y ⊕ z) = (x ⊕ y) ⊕ z (Assoziativität)
e) x · (y ⊕ z) = x · y ⊕ x · z (Distributivität bzgl. ·)
f) 0 ⊕ 0 ⊕ . . . ⊕ 0 = 0
½
1 falls n ungerade
g) 1| ⊕ 1 ⊕
{z. . . ⊕ 1} = 0 falls n gerade
n−−mal
Satz 2.5.3 (Komplementfreie Ringsummennormalform, Reed–Muller Form)
Jede Boolesche Funktion f : B n → B ist eindeutig darstellbar als Polynom (Multinom) in
den Variablen x1 , x2 , . . . , xn mit den Koeffizienten a0 , a1 , . . . , a1...n ∈ B. Die Darstellung
ist wie folgt:
f
= a0
⊕ a 1 x1 ⊕ a 2 x2 ⊕ . . . ⊕ a n xn
⊕ a12 x1 x2 ⊕ . . . ⊕ an−1,n xn−1 xn
..
.
⊕ a1...n x1 x2 · . . . · xn .
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 xi durch xi ⊕ 1 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 Sumn
manden haben kann, es also 22 verschiedene Polynome gibt, genauso viel, wie Boolesche
Funktionen existieren.
Beispiel 2.5.4
Die Umwandlung einer DNF in eine komplementfreie Ringsummenform ergibt sich für
unser Beispiel wie folgt:
f (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 x3 + x1 x2 x3
= x 1 x2 x3 ⊕ x 1 x2 x3 ⊕ x 1 x2 x3
= (x1 ⊕ 1)x2 x3 ⊕ x1 (x2 ⊕ 1)x3 ⊕ x1 x2 x3
= x 1 x2 x3 ⊕ x 2 x3 ⊕ x 1 x2 x3 ⊕ x 1 x3 ⊕ x 1 x2 x3
= x 2 x3 ⊕ x 1 x3 ⊕ x 1 x2 x3 .
Also gilt:
a0 = a1 = a2 = a3 = a12 = 0; a13 = a23 = a123 = 1.
43
Als unmittelbare Folgerung der beiden letzten Sätze ergibt sich der folgende Satz:
Satz 2.5.5
[a)]{⊕, ·, } ist funktional vollständig.
[b)]{⊕, ·, 1} ist funktional vollständig.
44
3 Schaltnetze und ihre Optimierung
3.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 {+, ·, } darstellen. Als Wertetabelle der XOR Funktion ergab sich:
x
0
0
1
1
y x⊕y
0
0
1
1
0
1
0
1
woraus sich als DNF unmittelbar die Darstellung x ⊕ y = xy + xy ergibt.
Mittels NAND–Verknüpfungen stellt sich die XOR–Funktion wie folgt dar:
x ⊕ y = xy + yx = xy + yx = xy · yx = x(x + y) · y(x + y) = x · xy · y · xy (4 Gatter)
= xyy · yxx (5 Gatter)
Dies ergibt das folgende Schaltnetz:
45
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 → B n+1 gelöst werden. Nehmen wir einmal
als realistischen Wert n = 16 an, so hätte die resultierende Funktion f : B 32 → B 17
eine Funktionstafel mit ca. 3.4 · 1010 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.
• 1) Addition von zwei Bit ohne Übertrag aus vorheriger Stelle:
Wenn wir die Summe z = x + y bestimmen wollen, so ergibt sich die folgende
Funktionstafel für die Summe z und den Übertrag c zur nächsten Stelle:
x
0
0
1
1
y
0
1
0
1
z
0
1
1
0
c
0
0
0
1
woraus sofort die Darstellungen
z = x ⊕ y; c = x · y
folgen. Ein Schaltnetz hierfür wird Halbaddierer (HA) genannt und ist folgendermaßen aufgebaut:
46
Realisierung eines Halbaddierers
Mit Hilfe eines solchen Halbaddierers können wir nun aber nur die niederwertigste
Stelle unserer Summe bestimmen, da hier kein eingehender Übertrag auftritt. Für
die anderen Stellen benötigen wir eine Erweiterung des Schaltnetzes.
• 2) Addition von zwei Bit mit Übertrag aus vorheriger Stelle:
Ist ein Übertrag aus der vorherigen Stelle bei der Addition mit zu berücksichtigen,
so ist eine Summe der Form z = x + y + c−1 zu bestimmen, und hierfür wiederum das Summen– und Übertragsbit zu berechnen. Hier ergibt sich somit folgende
Funktionstafel:
x y c−1 z c
0 0 0 0 0
0 0 1 1 0
0 1 0 1 0
0 1 1 0 1
1 0 0 1 0
1 0 1 0 1
1 1 0 0 1
1 1 1 1 1
Für die Summe gilt:
z = x ⊕ y ⊕ c−1 .
Der Übertrag zur nächsten Stelle ergibt sich dann zu:
c = x · y + c−1 (x ⊕ y),
was zu folgendem Schaltnetz führt, welches bereits Gebrauch von oben hergeleiteten
Halbaddierern macht:
Realisierung eines Volladdierers mittels zweier Halbaddierer
47
3.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 xi , im anderen x̄i vor,
und alle anderen Variablen sind gleich, durch ihren gemeinsamen Teil ersetzt werden
können, z.B. x1 x2 x3 + x1 x2 x3 = x1 x3 . 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 3.2.1
Für die Boolesche Funktion f : B 4 → B mit
f (x1 , x2 , x3 , x4 ) = x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4
ergibt sich die folgende Vereinfachung:
3.2.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 → B mit n ∈ {3, 4} gedacht:
Definition 3.2.2 (Karnaugh–Diagramm)
Ein Karnaugh Diagramm zu f : B n → B mit n ∈ {3, 4} ist eine graphische Darstellung
der Funktionstabelle von f durch eine 0 − 1–Matrix der Größe 2 × 4 für n = 3 bzw.
4 × 4 für n = 4, deren Zeilen mit den möglichen Belegungen von x1 (n = 3) bzw. x1 x2
(n = 4) und deren Spalten mit den Belegungen von x2 x3 (n = 3) bzw. x3 x4 (n = 4)
beschriftet sind. Die Reihenfolge der Beschriftung erfolgt dabei so, dass sich zwei zyklisch
benachbarte Zeilen bzw. Spalten in genau einer Komponente unterscheiden:
48
Das Karnaughverfahren arbeitet dann wie folgt:
• Trage alle Einsen des Funktionsergebnisses in die zugehörige Stelle des Diagramms
ein.
• Zwei zyklisch benachbarte Einsen können wegen der gewählten Anordnung mit
Hilfe der Resolutionsregel zusammengefasst werden und der entstehende Term hat
genau eine Variable weniger. Verallgemeinert bedeutet dies, dass rechteckige 2 r ×
2s –Blöcke (r, s ∈ {0, 1, 2}) von zyklisch benachbarten Einsen 2r × 2s Mintermen
entsprechen, die sich paarweise höchstens in r + s Variablen unterscheiden. Durch
wiederholte Resolution lässt sich dieser Block zum gemeinsamen Bestandteil aller
dieser Minterme vereinfachen.
• Überdecke jede Eins daher mindestens einmal mit einem Block.
• Wähle möglichst große Blöcke der Kantenlänge 2r × 2s . Die Blöcke dürfen hierbei
über die Kanten und Ecken des Diagramms gebildet werden. Jede Eins kann durch
beliebig viele Blöcke überdeckt werden.
• Lies die Terme, die zu den gebildeten Blöcken gehören, am Diagramm ab. Ein Block
mit 2k Einsen liefert hierbei einen disjunktiven Term mit 2n−k Faktoren.
Beispiel 3.2.3
Auf unser obiges Beispiel angewendet ergibt sich also folgendes Karnaugh–Diagramm:
49
Wir erhalten also
f (x1 , x2 , x3 , x4 ) = x2 x4 + x1 x3 x4 .
Beispiel 3.2.4
Für die Boolesche Funktion f : B 4 → B mit
f (x1 , x2 , x3 , x4 ) = x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4
+x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4
(3.1)
(3.2)
lassen sich alle Einsen durch zwei Viererblöcke überdecken, so dass sich folgende Vereinfachung der Funktion ergibt:
f (x1 , x2 , x3 , x4 ) = x2 x4 + x2 x4
50
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
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 2n 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 3.2.5
1 falls x ∈ {1, 5, 8, 9}
Sei f für x ∈ {0, . . . , 9} definiert durch f (x) :=
.
0 sonst
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 → B, bei der 6 Argumente nicht auftreten. Im Folgenden sehen
wir die zugehörigen Karnaugh–Diagramme ohne und mit Ausnutzung der Don’t cares.
51
Im ersten Fall ergibt sich
f (x1 , x2 , x3 , x4 ) = x1 x3 x4 + x1 x2 x3 .
Mit Ausnutzung der Don’t cares erhalten wir jedoch die kürzere Darstellung
f (x1 , x2 , x3 , x4 ) = x3 x4 + x1 .
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 3.2.6
Für die Funktion
f (x1 , x2 , x3 , x4 ) = x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4
ergibt sich das folgende Diagramm:
52
Hieraus erhalten wir also die Ringsummenform
f (x1 , x2 , x3 , x4 ) = x4 ⊕ x1 x2 x4 ⊕ x1 x2 x3 x4
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
0
1
2
3
4
5
6
7
8
9
Gray–Code zu x
0000
0001
0011
0010
0110
0111
0101
0100
1100
1000
Alternativer Code
0000
0001
0011
0010
0110
1110
1010
1011
1001
1000
Wie wir sehen, gibt es natürlich unterschiedliche Möglichkeiten, einen Gray–Code zu
erzeugen.
3.2.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 ≥ 6 ü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 ≥ 6 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 3.2.7
Eine Boolesche Funktion f : B n → B liegt in disjunktiver Form vor, wenn f als Summe
von Termen
k
X
f=
m
e i, k ≥ 1
i=1
53
dargestellt ist. Ein Term m
fi hat dabei die Form
m
ei =
.
l
Y
α
xijj , l ≥ 1
j=1
Die DNF ist somit eine disjunktive Form, bei der alle m
e 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 3.2.8
Sei f : B n → 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:
(i) Für d ≡ xαi11 · xαi22 · . . . · xαitt : K(d) := t − 1
(ii) Für d ≡ m
e1 + m
e2 + ... + m
e k : K(d) := (k − 1) +
Pk
e i ).
i=1 K(m
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 → B eine die Funktion darstellende disjunktive Form d, so dass deren Kosten minimal sind, d.h. es gilt:
K(d) =
min
disjunktive Formen d0 ,
die f darstellen
K(d0 ).
Definition 3.2.9
Sei f : B n → B eine Boolesche Funktion. Ein Term µ heißt Implikant von f , kurz µ ≤ f ,
falls für alle x ∈ B n gilt:
µ(x) = 1 ⇒ f (x) = 1.
Ein Implikant µ heißt Primimplikant, falls keine echte Verkürzung von µ ebenfalls Implikant von f ist.
Bemerkungen:
1. Minterme zu einschlägigen Indizes einer Funktion f sind Implikanten von f .
2. Ist µ Implikant von f und m ein Minterm von f derart, dass µ eine Verkürzung
von m ist, so gilt m ≤ µ, d.h. m ist Implikant von µ.
54
3. Im Karnaugh–Diagramm entsprechen rechteckige Blöcke von Einsen den Implikanten und die maximalen Blöcke den Primimplikanten.
Satz 3.2.10
Sei f : B n → B eine Boolesche Funktion, f 6≡ 0. Ist d = µ1 + µ2 + . . . + µk eine
Darstellung von f als disjunktive Form mit minimalen Kosten, so sind die µ i , i = 1, . . . , k
, Primimplikanten von f .
Beweis Da f eine Disjunktion der µi ist, ist jedes µi eine Implikant von f . Nehmen
wir an, ein µi ist kein Primimplikant, so existiert eine Verkürzung ν von µ i , so dass ν
Implikant von f ist. Ersetzt man in obiger disjunktiver Darstellung d µ i durch ν, so
erhält man eine neue disjunktive Darstellung von f , wobei K(ν) < K(µ i ) gilt, d.h. die
neue Darstellung hat geringere Kosten, was ein Widerspruch zur Minimalität von d ist.
Unser Vereinfachungsproblem lässt sich somit auf die folgenden zwei Schritte reduzieren:
1.) Bestimme alle Primimplikanten von f .
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 3.2.11 (Quine & McCluskey)
Eingabe: Funktionstabelle (i, f (i)), i ∈ B n , f : B n → B
Ausgabe: P I(f ), Darstellung von f durch Primimplikanten
Schritt 1: Bestimme Primimplikanten
1.) Berechne Qn := Menge der Minterme aller einschlägigen Indizes von f .
Setze j := n; P I(f ) := ∅ .
2.) Solange Qj 6= ∅ führe aus:
a)
b)
c)
d)
j := j − 1
Qj := {µ | ∃l : xl , xl 6∈ µ; µxl , µxl ∈ Qj+1 }
Pj+1 := {µ | µ ∈ Qj+1 ; es ex. keine Verkürzung von µ in Qj }
P I(f ) := P I(f ) ∪ Pj+1
Schritt 2: Kostenminimale Darstellung durch Primimplikanten
Problem: Auswahl der geeigneten Primimplikanten
Vorgehen: Matrixverfahren (Überdeckungsmatrix Primimplikanten ↔ Minterme)
55
Der Schritt 1 des Algorithmus liefert die Menge der Primimplikanten und hat eine worst–
case–Laufzeit von O(3n n2 ).
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 3.2.12
Gegeben sei eine Funktion f : B 4 → B als DNF in der Form
f = x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4
Die Gruppierung nach Anzahl der negativen Terme ergibt folgende Tabelle:
56
Gruppe
1
Q4
2
P4
3
4
1
Q3
2
3
1
P3
Q2 = P 2
P3
3
Minterm
x 1 x2 x3 x4
x1 x2 x3 x4
x1 x2 x3 x4
x1 x 2 x 3 x 4
x1 x2 x3 x4
x1 x 2 x 3 x 4
x1 x 2 x 3 x 4
x 1 x2 x3 x4
x2 x3 x4
x1 x2 x3
x1 x2 x4
x 1 x2 x4
x2 x3 x4
x1 x3 x4
x 1 x2 x3 x4
x1 x2 x3
x2 x4
x1 x3 x4
einschlägiger Index
1011
1101
1110
0110
1100
0100
0000
1011
*110
110*
11*0
01*0
*100
0*00
1011
110*
*1*0
0*00
Minterm–Nummer
11
13
14
6
12
4
0
11
6, 14
12, 13
12, 14
4, 6
4, 12
0, 4
11
12, 13
4, 6, 12, 14
0, 4
Verwendet man alle Primimplikanten, so ergibt sich die Darstellung
f = x1 x2 x3 x4 + x1 x2 x3 + x2 x4 + x1 x3 x4 .
Vergleicht man die Kosten dieser Darstellung mit der der DNF, so ergibt sich hierfür
K(d) = 11 und K(DN F ) = 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 aij
gleich eins gesetzt, falls der i–te Primimplikant ein Implikant des j–ten Minterms ist. Für
unser Beispiel ergibt sich:
Minterm
Primimplikant
x1 x2 x3 x4
x1 x2 x3
x2 x4
x1 x3 x4
0
4
6
11
12
13
14
0
0
0
1
0
0
1
1
0
0
1
0
1
0
0
0
0
1
1
0
0
1
0
0
0
0
1
0
57
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:
• Bestimme alle Primimplikanten mit dem Verfahren von Quine–McCluskey
• Wähle aus der Menge der Primimplikanten sukzessive solche aus, die möglichst
viele einschlägige, noch nicht überdeckte Minterme neu überdecken.
Bei dieser Heuristik werden alle absolut notwendigen Primimplikanten ausgewählt, und
es ist bekannt, dass auch im ungünstigsten Fall gilt:
Anzahl der durch die Heuristik ausgewählten Primimplikanten
≤ 1 + ln m
Anzahl der minimal erforderlichen Primimplikanten
wobei mit m die Anzahl der einschlägigen Minterme bezeichnet sei.
3.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:
• a) Es tritt im gegebenen Schaltungsnetz höchstens ein Fehler auf.
• b) Der Defekt, welcher einen Fehler verursacht, ist ein gerissener Draht.
58
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:
Wir definieren nun zu den Drahtnummern i = 1, . . . , 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.
59
Aus f (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 x3 + x1 x2 x3 ergibt sich dann:
f1 (x1 , x2 , x3 ) = 0x2 x3 + x1 x2 x3 + x1 x2 x3 = x2 x3 + x1 x3
f2 (x1 , x2 , x3 ) = 0x2 x3 + x1 x2 x3 + x1 x2 x3 = x1 x3
f3 (x1 , x2 , x3 ) = x1 x2 x3 + 0x2 x3 + x1 x2 x3 = x2 x3
f4 (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 x3 + 0x2 x3 = x1 x2 x3 + x1 x2 x3
f5 (x1 , x2 , x3 ) = x1 0x3 + x1 x2 x3 + x1 x2 x3 = x1 x3
f6 (x1 , x2 , x3 ) = x1 x2 x3 + x1 0x3 + x1 x2 x3 = x2 x3 + x1 x3
f7 (x1 , x2 , x3 ) = x1 x2 x3 + x1 0x3 + x1 x2 x3 = x2 x3
f8 (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 x3 + x1 0x3 = x1 x2 x3 + x1 x2 x3
f9 (x1 , x2 , x3 ) = x1 x2 0 + x1 x2 x3 + x1 x2 x3 = x1 x3
f10 (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 0 + x1 x2 x3 = x2 x3
f11 (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 x3 + x1 x2 0 = x1 x2 x3 + x1 x2 x3
f12 (x1 , x2 , x3 ) = 0x3 + x1 x2 x3 + x1 x2 x3 = x1 x3
f13 (x1 , x2 , x3 ) = x1 x2 x3 + 0x3 + x1 x2 x3 = x2 x3
f14 (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 x3 + 0x3 = x1 x2 x3 + x1 x2 x3
f15 (x1 , x2 , x3 ) = 0 + x1 x2 x3 + x1 x2 x3 = x1 x3
f16 (x1 , x2 , x3 ) = x1 x2 x3 + 0 + x1 x2 x3 = x2 x3
f17 (x1 , x2 , x3 ) = 0 + x1 x2 x3 = x1 x2 x3
f18 (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 x3 + 0 = x1 x2 x3 + x1 x2 x3
Zu diesen Fehlerfunktionen (hier f1 bis f18 ) wird die Wertetabelle, die sogenannte Ausfalltafel erstellt.
Wir erhalten in unserem Beispiel die Ausfalltafel in Tabelle 3.3.
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:
f1 = f 6 = f
f2 = f5 = f9 = f12 = f15
f3 = f7 = f10 = f13 = f16
f4 = f8 = f11 = f14 = f18
Die zu den Spalten der Ausfallmatrix in Tabelle 3.3 gehörenden Fehlerfunktionen fi
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.
60
x1 x2 x3
0 0 0
0 0 1
0 1 0
0 1 1
1 0 0
1 0 1
1 1 0
1 1 1
f1
0
0
0
1
0
1
0
1
f2
0
0
0
0
0
1
0
1
f3
0
0
0
1
0
0
0
1
f4
0
0
0
1
0
1
0
0
f5
0
0
0
0
0
1
0
1
f6
0
0
0
1
0
1
0
1
f7
0
0
0
1
0
0
0
1
f8
0
0
0
1
0
1
0
0
f9
0
0
0
0
0
1
0
1
x1 x2 x3 f10 f11 f12 f13 f14 f15 f16 f17 f18
0 0 0
0
0
0
0
0
0
0
0
0
0 0 1
0
0
0
0
0
0
0
0
0
0 1 0
0
0
0
0
0
0
0
0
0
0 1 1
1
1
0
1
1
0
1
0
1
1 0 0
0
0
0
0
0
0
0
0
0
0
1
1
0
1
1
0
0
1
1 0 1
1 1 0
0
0
0
0
0
0
0
0
0
1 1 1
1
0
1
1
0
1
1
1
0
Tabelle 3.1: Ausfalltafel
x1 x2 x3 f1 f2 f3 f4 f17
0 0 0 0 0 0 0
0
0 0 1 0 0 0 0
0
0 1 0 0 0 0 0
0
0 1 1 1 0 1 1
0
1 0 0 0 0 0 0
0
1 0 1 1 1 0 1
0
0
1 1 0 0 0 0 0
1 1 1 1 1 1 0
1
Tabelle 3.2: Ausfallmatrix
61
Zeilen–Nr. x1 x2 x3 f ⊕ f1 f ⊕ f2 f ⊕ f3 f ⊕ f4 f ⊕ f17
0
0 0 0
0
0
0
0
0
1
0 0 1
0
0
0
0
0
2
0 1 0
0
0
0
0
0
3
0 1 1
0
1
0
0
1
4
1 0 0
0
0
0
0
0
5
1 0 1
0
0
1
0
1
6
1 1 0
0
0
0
0
0
7
1 1 1
0
0
0
1
0
Tabelle 3.3: Fehlermatrix
Das Ziel ist es nun, aus der Menge aller möglichen Eingaben (hier 23 = 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. f1 ). 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.3.1
Sei f : B 3 → B definiert durch
f (x1 , x2 , x3 ) = x1 x3 + x2
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 x1 .
62
Allgemein gilt:
Definition 3.3.2
Sei f : B n → B eine Boolesche Funktion. Ein n–Tupel a ∈ B n heißt dann Test. Zwei
Tests a und b bilden ein f –Testpaar zu xi , wenn sich a und b nur genau an der Stelle i
unterscheiden und f (a) 6= f (b) gilt. Eine minimale Testmenge ist dann eine Menge T von
Tests, so dass zu jeder Variablen xi , von der f tatsächlich abhängt, ein Testpaar {a, b}
existiert mit {a, b} ⊆ T und die minimal bzgl. Mengeninklusion unter allen möglichen
Testmengen ist.
In obigem Beispiel ergeben sich also die folgenden Testpaare:
x1 : (0, 0, 1) und (1, 0, 1)
x2 : (1, 0, 0) und (1, 1, 0)
(1, 0, 1) und (1, 1, 1)
(0, 0, 0) und (0, 1, 0)
x3 : (0, 0, 0) und (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.
3.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).
63
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 3.4.1
Sei f (x1 , x2 , x3 ) = x1 x3 + x2 x3 . Es gilt:
f (1, 1, 0) = 1
f (1, 1, 1) = 1
f (1, 0, 0) = 0
f (1, 0, 1) = 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:
(1, 1, 0) → (1, 0, 0) → (1, 0, 1) oder
(1, 1, 0) → (1, 1, 1) → (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 3.4.2
Sei f : B n → B eine Boolesche Funktion und seien a0 ∈ B k (1 ≤ k ≤ n), a1 ∈ B n−k , a =
({a0 , a1 }), b = ({a0 , a1 }), wobei die Mengenklammern bedeuten, dass die Komponenten
von a0 und a1 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 a01 ∈ B n−k so, dass für c = ({a0 , a01 }) gilt: f (a) 6= f (c)
64
Funktionshazards für Funktionen f : B n → 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 3.4.3
Gegeben sei eine Funktion f : B 4 → B durch folgendes Karnaugh–Diagramm
Betrachten wir nun die Inputwechsel, die zu Funktionswerten 1 gehören, so ergeben sich
folgende Hazards:
0000
0000
0000
0000
0000
1100
1100
1100
1000
1000
1000
1000
↔
↔
↔
↔
↔
↔
↔
↔
↔
↔
↔
↔
0101
1100
1101
0111
1111
0101
1111
0111
0101
0111
1101
1111
65
unvermeidbar
vermeidbar
vermeidbar
unvermeidbar
vermeidbar
vermeidbar
vermeidbar
vermeidbar
unvermeidbar
vermeidbar
vermeidbar
vermeidbar
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 3.4.4
Gegeben sie die Boolesche Funktion f (x1 , x2 , x3 ) = x1 x3 + x2 x3 , die durch folgendes
Schaltnetz realisiert sei:
x1
x2
x3
B
A
1
&
&
C
D
E
³1
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.
66
Definition 3.4.5
Sei f : B n → B eine Boolesche Funktion, S ein Schaltnetz, welches f realisiert, und
a, b ∈ 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 3.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.
67
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.
4.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 = x + P an,
am Ausgang P dagegen P = y + Q.
xt
&
Q
&
P
yt
Q t + D t = x t + Pt
Pt + D t = yt + Q t
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 ∆t, so ergibt sich
Qt+∆t = xt + P t , Pt+∆t = y t + Qt+∆t ,
anderenfalls jedoch gerade umgekehrt
Qt+∆t = xt + P t+∆t , Pt+∆t = y t + Qt .
68
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:
xt
yt
Pt+∆t = y t + Qt
Qt+∆t = xt + P t+∆t
Pt+2∆t = y t + Qt+∆t
Qt+2∆t = xt + P t+2∆t
0
0
1
1
0
1
0
1
1
Qt
1
Qt
1
1
0
Qt
1
0
1
Qt
1
1
0
Qt
Schaltet dagegen Q zuerst, so ergibt sich das folgende Bild:
xt
yt
Qt+∆t = xt + P t
Pt+∆t = y t + Qt+∆t
Qt+2∆t = xt + P t+∆t
Pt+2∆t = y t + Qt+2∆t
0
0
1
1
0
1
0
1
1
1
Pt
Pt
1
0
1
Pt
1
1
0
Pt
1
0
1
Pt
Man erkennt das folgende Verhalten:
• Nach zwei Takten ist die Ausgabe eingeschwungen und hängt nur noch von der
Schaltreihenfolge und den Eingabewerten x, y ab.
• Die Eingaben (0, 0) führen zu denselben Ausgaben unabhängig von der Schaltungsreihenfolge der Gatter.
• Während für die beiden Eingaben (1, 0) und (0, 1) immer P = Q gilt, muss diese
letzte Bedingung gefordert werden, damit das Ergebnis bei der Eingabe von (1, 1)
von der Schaltreihenfolge unabhängig ist.
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 = 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) → (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, Q = 0 und ist deshalb unzulässig. Alle
69
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:
x = S + C = SC, y = R + C = RC,
und die Nebenbedingung S · R = 0, denn S · R = 1 entspricht x = y = 0.
Für die Ausgangsfunktion finden wir
Qt+2∆t = xt + P t+∆t
= x t + y t Qt
= SC + RCQt
¡
¢
= SC + R + C Qt
¢
¡
= SC + RQt + CQt
= C · (S + RQt ) + CQt .
C ist ein Taktsignal, das periodisch zwischen 0 und 1 hin und herschaltet. Wir erkennen,
dass bei C = 0 der Wert von Qt erhalten bleibt. Bei C = 1 schaltet das RS–Flip–Flop,
und es gilt das folgende Verhalten beim Übergang vom Zustand n ' t und n+1 ' t+2∆t:
a) Übergangsfunktion:
Qn+1 = S + (R · Qn ), Nebenbedingung: S · R = 0
b) Wahrheitswertetabelle:
R
S
Qn+1
0
0
1
1
0
1
0
1
Qn
1
0
1∗
Q
n+1
n
Q
0
1
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:
S
&
S
Q
&
Q
C
C
&
&
Q
R
R
70
Q
Hierbei gibt das Symbol am Takteingang Aufschluss über das Schaltverhalten des Flip–
Flops. Es bedeuten hierbei:
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
Qn+1
0
0
1
1
0
1
0
1
Qn
0
1
n
Q
Q
n+1
n
Q
1
0
Qn
In der folgenden Schaltung haben wir dann:
R(J, K, Qn ) = KQn ,
n
S(J, K, Qn ) = JQ ,
n
Qn+1 (J, K, Qn ) = JQ + KQn .
Genauer gilt die folgende Tabelle:
J
0
0
0
0
1
1
1
1
K
0
0
1
1
0
0
1
1
Qn
0
1
0
1
0
1
0
1
Qn+1
0
1
0
0
1
1
1
0
S
0
−
0
0
1
−
1
0
R
−
0
−
1
0
0
0
1
Aktion
lesen
lesen
löschen
löschen
setzen
setzen
invertieren
invertieren
71
J
&
S
J
Q
Q
C
C
K
&
R
Q
K
Q
Wahrheitswertetabelle des D–FF:
D
Qn+1
0
1
0
1
Q
n+1
1
0
4.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 .
72
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 4.2.1
Eine sequentielle Maschine M = (E, S, Z, δ, γ, s0 ) (Mealy–Automat) ist beschrieben
durch ein Eingabealphabet E = {e1 , . . . , er }, einer Menge von Zuständen S = {s0 , . . . , sn }
mit einem ausgezeichneten Zustand s0 , einem Ausgabealphabet Z = {z1 , . . . , zn } sowie
der partiell definierten Übergangsfunktion δ : E × S → S und der Ausgabefunktion
γ : E × S → Z.
Beispiel 4.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:
Es ergibt sich somit für die Zustände und Eingaben/Ausgaben folgende Übergangstabelle:
73
S
0
0
0
0
1
1
1
1
E
00
01
10
11
00
01
10
11
δ(s, e)
0
0
0
1
0
1
1
1
γ(s, e)
0
1
1
0
1
0
0
1
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:
Qn
0
0
1
1
Qn+1
0
1
0
1
J
0
1
-
K
1
0
Wendet man dies hier an, so ergibt sich für die Ansteuerung:
S
0
0
0
0
1
1
1
1
E
00
01
10
11
00
01
10
11
δ(s, e)
0
0
0
1
0
1
1
1
74
J
0
0
0
1
-
K
1
0
0
0
Beispiel 4.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 ∈ {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:
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 , q1 , q2 der
Flip–Flops abhängen.
Bezeichnen wir den momentanen Ausgabewert eines FF mit Q, und den Wert am Ausgang im nächsten Takt als Q0 , so erhalten wir aus dem Übergangsverhalten der FF die
folgenden Ansteuerungen der Eingänge, wobei ”*” wiederum ein Don’t care bedeutet.
Q
0
0
1
1
Q0
0
1
0
1
75
R
∗
0
1
0
S
0
1
0
∗
Wenden wir dies nun auf den obigen Ringzähler modulo 6 an, so erhalten wir folgende
Tabelle:
e
0
0
0
0
0
0
1
1
1
1
1
1
q0
0
0
0
0
1
1
0
0
0
0
1
1
q1
0
0
1
1
0
0
0
0
1
1
0
0
q2
0
1
0
1
0
1
0
1
0
1
0
1
q00
0
0
0
1
1
0
1
0
0
0
0
1
q10
0
1
1
0
0
0
0
0
0
1
1
0
q20
1
0
1
0
1
0
1
0
1
0
1
0
R0
∗
∗
∗
0
0
1
0
∗
∗
∗
1
0
S0
0
0
0
1
∗
0
1
0
0
0
0
∗
R1
∗
0
0
1
∗
∗
∗
∗
1
0
0
∗
S1
0
1
∗
0
0
0
0
0
0
∗
1
0
R2
0
1
0
1
0
1
0
1
0
1
0
1
S2
1
0
1
0
1
0
1
0
1
0
1
0
Hieraus ergeben sich die folgenden Ansteuergleichungen (unter Ausnutzung der don’t
cares):
R0 = eq0 q 2 + eq0 q2 = q0 · (e ⊕ q2 )
S0 = e q 0 q1 q2 + eq 0 q 1 q 2
R1 = eq1 q2 + eq1 q 2 = q1 · (e ⊕ q2 )
S1 = e q 0 q 1 q2 + eq0 q 2
R2 = q2
S2 = q 2
Als Schaltung ergibt sich dann:
q0
q1
S
T FF 0
T
R
R
q2
S
S
FF 1
T
FF 2
R
Takt
e
1
&
=1
&
&
³1
&
³1
76
&
&
4.3 Lineare Schaltkreise
Definition 4.3.1
Ein linearer Schaltkreis über einem Körper K ist ein Schaltwerk, welches aus den folgenden drei Grundbausteinen aufgebaut ist:
a) Addierer, wobei am Ausgang die Summe der beiden Körperelemente, die an den
Eingängen angelegt sind, anliegt.
b) Skalar–Multiplizierer, wobei das Körperelement am Eingang mit einem festen Element a multipliziert wird.
c) Delay, welches ein eingegebenes Körperelement für die Länge eines Taktes speichert.
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 4.3.2
a) E sei endliches Alphabet, E n die Menrge aller n–Tupel, ~0 ∈ E. Jede Teilmenge
C ⊆ E n mit (0, 0, . . . , 0) ∈ C heißt Code über E, x ∈ C Codewort.
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 4.3.3
Sei E endlicher Körper, Z : E n → E n , Z(x0 , x1 , . . . , xn−1 ) = (xn−1 , x0 , x1 , . . . , xn−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) = a0 +a1 x+. . .+am xm mit Koeffizienten aus einem Körper
F und am 6= 0 betrachten, so gilt der Divisionsalgorithmus für Polynome und man kann
insbesondere modulo des Polynoms xn − 1 rechnen. Ordnen wir jedem Codewort aus C
in natürlicher Weise ein Polynom wie folgt zu: (a0 , . . . , am )=a
ˆ 0 + a1 x + . . . + am xm , so
entspricht ein Shift (nach links) des Codewortes einer Multiplikation mit x, wobei im Fall
m = n − 1 modulo xn − 1 gerechnet werden muss. Dann gilt der folgende Satz:
Satz 4.3.4
C ⊆ E n ist ein zyklischer Code genau dann, wenn es ein Polynom g gibt, welches Teiler
von xn − 1 ist und für das die Beziehung u ∈ C ↔ g|u gilt.
(g = g0 + g1 x + . . . + gn−k xn−k erzeugt damit einen linearen zyklischen Code.)
77
Codierung:
a0 , . . . , ak−1
→
·g(x)
→
·1/g(x)
→
a0 + a1 x + . . . + ak−1 xk−1
f0 , . . . , fn−1 (Codewort)
a0 + a1 x + . . . + ak−1 xk−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) = a0 + a1 x + a2 x2 + . . . + ak−1 xk−1 mit einem festen Polynom h(x) =
h0 + h1 x + h2 x2 + . . . + hn−k xn−k multipliziert werden, so wird dies durch die folgende
Schaltung realisiert:
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 ak−1 der Reihe nach eingegeben. Ist der erste
Koeffizient eingegeben, so erhalten wir am Ausgang den Wert ak−1 · hn−k . Nach einem Takt steht im ersten Delay ak−1 und am Input ak−2 . Der Ausgang liefert dann
ak−2 · hn−k + ak−1 · hn−k−1 , also den zweithöchsten Koeffizienten des Ergebnisses. Nachdem der letzte Koeffizient a0 eingegeben ist, folgen wieder Nullen, der letzte Koeffizient
des Produktes ergibt sich nach n Takten somit zu a0 · h0 . Wenn wir über dem Körper
E = {0, 1} arbeiten, so entfallen die Multiplikationsglieder und alle Addierer, die zu
Koeffizienten hi = 0 gehören. Die Addierglieder sind dann Ringsummen–Addierer.
Beispiel 4.3.5
Sei h(x) = 1 ⊕ x3 ⊕ x4 ⊕ x5 . Hierfür ergibt sich dann folgender linearer Schaltkreis
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:
78
Beispiel 4.3.6
Sei f (x) = f2 x2 + f1 x + f0 und h(x) = h1 x + h0 . Dann gilt:
f1 − f2hh1 0
f2
x+
f (x) : h(x) =
h1
h1
mit Rest
f0 − h 0 ·
f1 −
f 2 h0
h1
h1
Hieraus ergibt sich:
−1 −1
f (x) : h(x) = f2 h−1
1 x + (f1 − f2 h0 h1 )h1 + Rest
Die Division geht auf, falls der Rest gleich Null ist. Allgemein erhält man als höchsten
Koeffizienten des Quotienten den Faktor h−1
n−k , als Koeffizient der zweithöchsten Potenz
den Faktor h−2
usw.
Zudem
wird
in
jedem
Schritt mit einer Differenz multipliziert. Den
n−k
Vorzeichenwechsel erhält man durch Multiplikation mit negativen Zahlen.
Im allgemeinen Fall leistet der folgende lineare Schaltkreis die Division:
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 fn−1 das Ende des Registers
erreicht und es wird fn−1 · h−1
n−k ausgegeben. Nach dem nächsten Takt erhält man dann
−1
(fn−2 − fn−1 hn−k−1 h−1
)h
n−k n−k usw.
Im Fall des Booleschen Körpers ergeben sich analoge Vereinfachungen wie bei der Multiplikation, wie wir an folgendem Beispiel exemplarisch erkennen.
Beispiel 4.3.7
Sei h(x) = 1 ⊕ x ⊕ x4 . Wegen hi = −hi und hn−k = 1 (Beachte K = B) ergibt sich dann
folgender linearer Schaltkreis
79
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.
5.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.
80
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.
81
Die Realisierung der PLA Bausteintypen mittels Gattern sieht dann so aus:
x
Identer
x
y
x+y
x
x
Multiplizierer x
y
³1
y
y
Addierer
x
y
y
&
Negat-Multiplizierer
1
y
&
x·y
x·y
Beispiel 5.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 → B 2 mit
F (x, y, z) = (yz + xyz, xz + xyz) = (u, v) wie folgt in diesem PLA verschalten:
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
82
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:
Vereinfachend lässt sich nun ein PLA mit n Eingängen, m Ausgängen und k Spalten
als (n + m) × 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
14×96+8×96 = 2112 Bausteine verdrahtet. Von den insgesamt 2 8·2 verschiedenen Funktionen F : B 14 → B 8 konnten mit diesem Baustein insgesamt 28·96 = 2768 = 1.5 · 10231
verschiedene solcher Funktionen geschaltet werden, falls sie in DNF vorliegen.
Generell kann man festhalten:
Satz 5.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
83
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.
5.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:
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 · 2112 = 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
84
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.
5.3 Anwendungen von PLA’s
Eine erste Anwendung von PLA’s ist der bereits erwähnte Festwertspeicher, das ROM.
Wollen wir etwa 2n Worte der Länge m speichern, können wir den Speicher als eine
m × 2n Matrix auffassen, deren Spalten die Adressen von 0 bis 2n − 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 2n Informationen speichern zu können, n Zeilen in der
UND–Ebene. Die Anzahl der Spalten muss 2n betragen.
Beispiel 5.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.
85
Allgemein sieht also die Realisierung eines ROM’s über ein PLA wie folgt aus:
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:
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.
86
Ein derartiger Addierer könnte also wie folgt aussehen:
87
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 m2 , 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
MSI
LSI
VLSI
Small Scale Integration
Medium Scale Integration
Large Scale Integration
Very Large Scale Integration
≤ 10 Gatter pro Chip
> 10 und ≤ 102 Gatter pro Chip
> 102 und ≤ 105 Gatter pro Chip
> 105 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.
88
Metall-Ebene
Isolation
Polysilizium-Ebene
Diffusions-Ebene
Isolation
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.
6.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” λ > 0 hiervon abweichen. Der Abstand von zwei Gitterlinien ist dann ein
kleines Vielfaches, z.B. 5λ. Bereits 1975 war ein Wert von λ = 6 · 10 −6 m erreichbar,
1997 war eine Verkleinerung auf weniger als 0.65µm − 0.35µm erreicht. Zu dieser Zeit
aktuelle Prozessoren wie Pentium 200MMX oder PowerPC 604e waren in 0.25µm Technologie gefertigt (zum Vergleich: ein menschliches Haar ist etwa 400µm dick). Ein für
den Servereinsatz konzipierter Microprozessor wie der Intel PentiumPro verfügte z.B.
auf einer Fläche von 306 mm2 über 20.5 Millionen Transistoren (davon 15 Mill. für den
integrierten L2 Cache), war in 0.5µm Technologie gefertigt und hatte 387 Pins.
Heute aktuelle Prozessoren werden in 0.13µ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 λ. Die Chipfläche sei mit A bezeichnet und ist dann ein Vielfaches
89
von λ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 κ bezeichnen.
Die in einen VLSI–Baustein hinein– und herausführenden Kontakte sind groß im Vergleich zu der inneren Strukturgröße λ, so z.B. (100λ)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.
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 ∈ B n einen Output F (x) ∈ 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:
90
Je größer die Komplexität von F , desto größer ist
a) A · T bzw.
b) A · T 2 .
Dabei geben wir A und T in Einheiten einer Grundfläche A0 und einer Grundzeit T0 an.
Der folgende Satz gibt eine erste, einfache untere Schranke an, die allerdings nicht besonders scharf ist.
Satz 6.1.1
Sei F : B n → B m eine Schaltfunktion und k = max{n, m}. Dann gilt für einen Modellschaltkreis (MSK), der F berechnet:
A · T ≥ 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:
T ≥
k
, d. h. A · T ≥ k.
A
Ein zweiter Satz gibt nun eine Schranke für A · T 2 :
Satz 6.1.2
Sei C ein MSK der Fläche A, welcher n Dualzahlen der Stellenzahl k (k ≥ dld n + 1e) in
T Zeittakten sortiert. Dann gibt es eine Konstante K > 0 so, dass
A · T 2 ≥ K · n2
gilt.
Dabei ist
K=
1
9 · L 2 · κ2
wobei L die Anzahl der Ebenen und κ 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 →
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
91
Anzahl ab.
Genauer betrachten wir zwei Fälle:
1. Fall:
T ist groß, etwa T ≥ n3 . Wegen A ≥ 1 folgt dann unmittelbar:
A · T2 ≥ 1 ·
n2
1
= K · n2 mit K =
9
9
2. Fall:
Sei T < n3 , d.h. auf jeden Port von C werden weniger als n3 Input– bzw. Output–Bits
entfallen. Für die Sortierfunktion S : B nk → B nk hat jeder Input die Form
(x1 , ..., xk , xk+1 , ..., x2k , x2k+1 , ..., xnk )
und jeder Output die Form
(y1 , ..., yk , yk+1 , ..., y2k , y2k+1 , ..., ynk ).
Von den Output–Bits betrachten wir die n niederwertigsten y k , y2k , . . . , ynk 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:
Ein solcher Schnitt lässt sich immer konstruieren, falls durch einen Port weniger als n3
Bit die Schaltung verlassen. Man beginnt dazu mit einer senkrechten Scanlinie am linken
92
Rand und führt diese nach rechts, bis erstmals mindestens n3 der niederwertigsten Bits
die Schaltung links vom Schnitt verlassen. Liegen weniger als 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 n3 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 n2 gelesen, dies sei
o.B.d.A. die linke Seite. Wir haben somit folgende Situation:
xk , x2k , x3k , . . . , x n2 k werden links gelesen,
yi1 k , yi2 k , yi3 k , . . . , yi n k werden rechts geschrieben.
3
Der MSK liest Input — sortiert — schreibt Output. Jede Eingabe α zerfällt dann in einen
Teil αL , der links vom Schnitt S gelesen wird, und αR , der rechts vom Schnitt gelesen
wird. Sei dann β = (βL , βR ) eine weitere Eingabe, die mit α rechts übereinstimmt. Dann
sagen wir, dass α und β die Schaltung C täuschen, falls für die zu berechnende Funktion
F gilt:
F (αL , αR ) = F (β) im rechten Teil.
Eine Menge X ⊆ B nk heißt dann Unterscheidungsmenge für C, falls je zwei Elemente
α, β die Funktion S nicht täuschen.
Man kann nun zeigen, dass es bzgl. des konstruierten Schnittes eine Unterscheidungsmenge A gibt mit mindestens 2n/3 Elementen. Damit muss für die ersten n/3 unteren
Input–Bits Information über den Schnitt fließen, da sonst zwei Eingaben α und β 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 κ 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 · κ · L Bits. Hat der Schaltkreis einen
Zeitbedarf von T Takten, so können maximal h · κ · L · T Bits insgesamt über den Schnitt
fließen, einen solchen gesamten Informationsfluss nennt man dann eine Schnittsequenz.
Da deren Länge h · κ · L · T ist, gilt für die Anzahl c von Schnittsequenzen:
c ≤ 2h·κ·L·T
93
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 |X| ≥ 2n/3 gibt, erhalten wir für die Anzahl der
Schnittsequenzen c ≥ 2n/3 . Somit gilt also insgesamt:
2h·κ·L·T
≥ c ≥ 2n/3
n
⇔h·T ·L ≥
3κ
Wegen w ≥ h folgt weiter:
w·T ·L≥
n
3κ
Nach Multiplikation erhalten wir dann insgesamt:
h · w · T 2 · L2 ≥
⇔ A · T2 ≥
n2
9κ2
n2
,
9 · L 2 · κ2
was zu zeigen war.
Beispiel 6.1.3
Ist z.B. κ = L = 10, so gilt
n2
n2
' 5
4
9 · 10
10
Angenommen, der Sortierchip hat die Fläche AA0 = 1 cm2 , dessen Einheitsquadrate A0
im Gitter die Kantenlänge 5λ, mit λ = 6 · 10−6 m, haben, so gilt:
A · T2 ≥
(5λ)2 = 900 · 10−12 m2
10−4 m2
⇒ A=
' 105
900 · 10−12 m2
Falls der Schaltkreis in der Lage ist, n eingegebene k–stellige Dualzahlen zu sortieren, so
benötigt er z.B. für n = 1012 und k ' 41 die Zeit:
1024
= 1014
105 · 105
≥ 107
T2 ≥
⇒T
Bei einer Taktdauer T0 von 10−8 Sekunden ergibt sich somit:
T T0 ≥
107
sec = 0.1 sec.
108
94
6.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:
1
x1
Level 1
x2
Level 2
x3
Level 3
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.
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.
95
Beispiel 6.2.1
Betrachten wir die Funktion
f (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 x3 + x1 x2 x3 .
Wir erhalten dann den folgenden H–Baum:
1
f
x2
x1
x3
x1 x2 x3
0 ·
x1 x2 x3
· 4
x3
x1 x2 x3
·
1
x1x2 x3
5 ·
+
x3
+
x2
·
x1 x2
·
x1
x1 x2
x1 x2
·
·
x2
x1 x2
x3
·
x1
·
· 3
x1 x2 x3
7 ·
x1 x2 x3
2
·
x1 x2 x3
6
·
x1 x2 x3
96
x3
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.
7.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:
........
........
yn-1 xn-1
y2
y1
c-1 y0
x2
x1
x0
VA
zn-1
........
c
z2
z1
z0
97
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.
y3 x3
y2 x2
y1 x1
y0 x0
HA
HA
HA
HA
HA
HA
HA
D
HA
HA
D
D
HA
D
D
D
z3
z2
z1
z0
³1
z4
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 · 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 · n · (n + 1) Halbaddierer und
0.5 · (n − 1) · n Delays.
7.1.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.
98
Eine Schaltung hierfür sieht folgendermaßen aus:
A3
A2
A1
A0
D
D
D
D
HA
HA
HA
HA
P3
D
P2
D
P1
D
P0
D
U
D
³1
&
³1
D
S
Zuerst werden alle Stellen parallel addiert. Dies geschieht für jede Stelle mit einem Halbaddierer, der das Resultat Ai ⊕ Pi wieder in die entsprechende Zelle des Akkus und den
Übertrag Ai · Pi nach Pi+1 bzw. in das Übertragsdelay schreibt. In P0 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 7.1.1
Das n–Bit von–Neumann–Addierwerk addiert zwei Summanden durchschnittlich in ld n+
1 Schritten.
Betrachten wir zur Arbeitsweise ein Beispiel.
Beispiel 7.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:
99
Zeile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
S
0
1
1
1
1
0
1
0
1
1
0
1
0
1
0
U
0
0
1
1
1
1
0
1
0
1
1
0
1
0
0
PufferInhalt
dual
P 3 P2 P1 P0
0000
1101
0010
0100
1000
0000
1010
0000
1111
1110
0000
1001
0000
0000
0000
PufferInhalt
dezimal
0
13
2
4
8
0
10
0
15
14
0
9
0
0
0
AkkuInhalt
dual
A3 A2 A1 A0
0000
1011
0110
0100
0000
1000
1100
0110
1111
0000
1110
1010
0011
0000
0000
AkkuInhalt
dezimal
Schritt
0
11
22
20
16
24
12
22
15
16
30
10
19
0
0
1
2
3
4
5
1
2
1
2
3
1
2
1
2
7.1.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 (yk . . . y0 ) und (xk . . . x0 ). Weiter
seien die Überträge mit (rk . . . r0 ) bezeichnet, wobei man sich unter r0 den einlaufenden
Übertrag aus der vorherigen Gruppe vorstellen kann. Die Überträge lassen sich dann bei
100
gegebenem r0 rekursiv wie folgt bestimmen:
r1 := x0 y0 + r0 (x0 ⊕ y0 )
r2 := x1 y1 + r1 (x1 ⊕ y1 ) = x1 y1 + x0 y0 (x1 ⊕ y1 ) + r0 (x0 ⊕ y0 )(x1 ⊕ y1 )
..
..
.
.
ri := xi−1 yi−1 + ri−1 (xi−1 ⊕ yi−1 )
=
xi−1 yi−1 + xi−2 yi−2 (xi−1 ⊕ yi−1 ) + xi−3 yi−3 (xi−2 ⊕ yi−2 )(xi−1 ⊕ yi−1 )
i−1
Y
+ · · · + r0
(xj ⊕ yj )
j=0
Führen wir folgende Abkürzungen ein:
½
pi := xi ⊕ yi =
½
1
gi := xi yi =
0
1 Übertrag setzt sich fort
0 Übertrag wird aufgezehrt
Übertrag generiert
Übertrag absorbiert,
so ist pi = 1, falls xi und yi verschieden sind, und somit muss der Übertrag ri weitergeleitet werden (Propagation). Sind xi und yi beide gleich Eins, so wird ein neuer Übertrag
ri+1 erzeugt (Generation), der unabhängig vom einlaufenden Übertrag r i entsteht. Nur
im Fall von xi = yi = 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:
r0
gegeben
r1 := g0 + r0 p0
r2 := g1 + g0 p1 + r0 p0 p1
..
..
.
.
ri := gi−1 + gi−2 pi−1 + gi−3 pi−2 pi−1 + . . . + r0 p0 p1 · . . . · pi−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 pi 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.
101
Beispiel 7.1.3
Die Addition der folgenden Bitgruppen zeigt die Propagation und Generation der Gruppenüberträge:
7.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.
Multiplikator
Akku AC1
R
Akku AC0
Bit AC00
Addierer
Der Algorithmus läuft dann für die Multiplikation natürlicher Zahlen wie folgt:
Gesucht ist das Produkt A · 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:
102
add
AC00 = 0
tue nichts
AC00 = 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 AC00 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 7.2.1
Berechnen wir 12 · 14 in binärer Arithmetik mit obiger Architektur, so ergibt sich für die
einzelnen Stufen des Algorithmus:
Multiplikator = (1100)2
z (nach Dec) op R|AC1|AC0 v. Add R|AC1|AC0 v. Shift R|AC1|AC0 n. Shift
init = 4
0|0000|1110
3
0|0000|1110
0|0000|1110
0|0000|0111
2
+
0|0000|0111
0|1100|0111
0|0110|0011
1
+
0|0110|0011
1|0010|0011
0|1001|0001
0
+
0|1001|0001
1|0101|0001
0|1010|1000
12 · 14 = (10101000)2 = 128 + 32 + 8 = 168
103
8 Organisationsplan eines
von–Neumann–Rechners
8.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
• Zentraleinheit (engl.: Central Processing Unit, kurz CPU)
• Speicher
• Ein–/Ausgabe–Einheit
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.
104
Abbildung 8.1: Struktur eines von–Neumann–Rechners (vgl. Oberschelp, Vossen)
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.
Im Folgenden wollen wir die einzelnen Komponenten genauer betrachten:
CPU
Die CPU kann prinzipiell in zwei Teile unterteilt werden:
• Datenprozessor (Befehlsausführung)
Verarbeitung der Daten, d.h Ausführen von Berechnungen. Komponenten des Datenprozessors sind:
– Rechenwerk, die sog. Arithmetisch–Logische Einheit (engl.: Arithmetic Logical
Unit, kurz ALU)
– (mindestens) drei Register zur Aufnahme von Operanden (vgl. z.B. von–
Neumann–Addierwerk)
◦ Akkumulator A (häufig kurz Akku genannt)
◦ ein Multiplikator–Register MR (z.B. zur Aufnahme von Multiplikationsergebnissen)
◦ ein Link–Register L (häufig einstellig, zur Aufnahme z.B. eines Additionsübertrags)
◦ Puffer–Register (engl. Memory–Buffer–Register) MBR, über das die Kommunikation mit dem Speicher abgewickelt wird
• Befehlsprozessor (Ablaufsteuerung)
Befehle entschlüsseln und deren Ausführung steuern. Er besteht aus folgenden Registern:
– Der aktuell bearbeitete Befehl befindet sich im Befehlsregister (engl.: Instruction Register) IR
105
Abbildung 8.2: Detailliertes Bild einer CPU (vgl. Oberschelp, Vossen)
– Die Adresse des Speicherplatzes, der als nächstes anzusprechen ist, ist im
Speicheradressregister (engl.: Memory Address Register) MAR abgelegt.
– Die Adresse des nächsten auszuführenden Befehls wird darüber hinaus im
Befehlszähler (engl.: Program Counter) PC gespeichert.
– Die Entschlüsselung eines Befehls erfolgt durch einen separaten (Befehls–)
Decodierer, die Steuerung der Ausführung schließlich durch ein Steuerwerk.
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.
106
Busse
Verbindung der drei Hauptkomponenten.
8.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:
107
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 ← PC;
MBR ← <MAR>;
IR ← MBR;
decodiere IR;
falls kein Sprungbefehl
dann { stelle Operanden bereit; PC ← PC +1 }
sonst PC ← Sprungzieladresse;
108
Abbildung 8.3: Speicherhierarchie eines von–Neumann–Rechners
8.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 = 2n . 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 = 2n 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:
• Register innerhalb der CPU, die sehr schnell angesprochen werden können und der
CPU unmittelbar als Speicherzellen zugewiesen sind. Moderne Prozessoren verfügen über eine höhere Anzahl (z.B. 16) von Universalregistern, die eine ähnliche
Funktionalität aufweisen wie der Akkumulator.
• Die zweite Ebene stellt im Allgemeinen ein sogenannter Cache Speicher dar, der
109
ebenfalls eine geringe Zugriffszeit besitzt. Die Verwendung eines solchen Speichers
folgt der Idee der „90:10–Regel“, die besagt, dass 90% aller Zugriffe nur auf 10%
der Daten erfolgt. Liegen dann die richtigen Daten im Cache, so kann sehr schnell
hierauf zugegriffen werden. Der Cache ist vielfach in zwei Kategorien unterteilt:
– L1–Cache, der direkt im Prozessorkern untergebracht ist. Da dieser sehr teuer
ist, wird meist nur ein L1–Cache in der Größe von 16 – 32 KByte verwendet,
der häufig noch in Befehls– und Daten–Cache unterteilt ist.
– L2–Cache, der außerhalb des Prozessors liegt, der allerdings geringe Zugriffszeiten von etwa 10–12 ns aufweist. Gängige Größen sind hier 256, 512 oder
1024 KByte.
Es existieren allerdings auch Prozessoren, die einen L1–Cache in Größe von z.B.
512 KByte besitzen (Pentium–Pro). Deren Herstellung ist aber extrem aufwändig,
die Prozessoren sind daher vergleichsweise teuer.
• Die nächste Stufe der Hierarchie ist der Hauptspeicher, der in Form von dynamischem RAM (Fast–Page–Mode oder EDO RAM mit einer Zugriffszeiten von 50 –
60 ns, oder SDRAM mit Zugriffszeiten von 7 – 12 ns) ausgeführt ist. Hier sind
Größenordnungen von mehreren Megabyte (16, 32, 64, 128, . . .) durchaus üblich.
• Schließlich stehen in der Regel Hintergrundspeicher in Form von Magnetplatten
mit wahlfreiem Zugriff (Festplatten mit Größen von mehreren Gigabyte), Nur–Lese–Speicher wie das CD–ROM (640 MByte), wiederbeschreibbare CD–ROM (CD–
RW), die Digital–Versatile–Disk (DVD) mit mehreren Gigabyte sowie Floppy–
Disketten (1.44, 2.88, 100 MByte) zum einfachen Datenaustausch und Bandspeichermedien, die eine sequentielle Datenspeicherung erlauben und in Allgemeinen
als Backupmedien eingesetzt werden, zur Verfügung. Bei Backupmedien sind Kapazitäten von 50 GByte und mehr pro Datenband erreichbar.
8.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
110
oben bereits erwähnt, einen Speicher von N = 2n 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.
8.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:
111
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.
• Ein externer Interrupt wird außerhalb der CPU erzeugt und kommt typischerweise
von einem I/O–Controller, der hierdurch eine Übertragung einleitet. Im Gegensatz hierzu werden auch innerhalb der CPU die sogenannten internen Interrupts
erzeugt, die z.B. auftreten, wenn Programmfehler, wie etwa eine Division durch
Null, vorliegen. In diesem Fall kann der Interrupt eine Fehlerbehandlungsroutine,
sogenannte Traps, anstoßen.
• Ein maskierbarer Interrupt kann (vorübergehend) außer Kraft gesetzt werden. Hierdurch kann z.B. bei Programmen, die ohne Unterbrechung ablaufen müssen, ein zwischenzeitlicher I/O verhindert werden. Falls ein maskierter Interrupt auftritt, wird
dieser erst nach Beendigung des eigentlichen Programms ausgeführt, bzw. dann,
wenn die Maskierung aufgehoben wird. Im Gegensatz hierzu führen die unmaskierbaren Interrupts stets zu einer Unterbrechung des momentanen Programms.
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
112
Abbildung 8.4: Einfaches Befehlsphasen Pipelining
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.
8.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.
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.
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 dar-
113
Abbildung 8.5: Prinzip des Superpipelining
Abbildung 8.6: Prinzip einer Superskalararchitektur
gestellte 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.
114
9 Eine kleine Programmiersprache
9.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 9.1.1
Unter einer Chomsky–Grammatik versteht man ein QuadrupelG = (N, T, P, S). Dabei
bezeichnet
• N die Menge der Nichtterminalsymbole,
• T die Menge der Terminalsymbole, T ∩ N = ∅,
ª
©
• P die Menge der Produktionen P ⊂ ϕ → ψ| ϕ ∈ (N ∪ T )+ , ψ ∈ (N ∪ T )∗ ,
• S das Startsymbol.
Ferner ist (N ∪ T )+ die Menge der nichtleeren endlichen Wörter mit Zeichen aus N ∪ T ,
während (N ∪ T )∗ zusätzlich noch das leere Wort λ enthält. Alle Mengen N , T , P sind
endlich.
Definition 9.1.2
Sei G = (N, T, P, S) eine Chomsky–Grammatik. G heißt
• Typ–3–Grammatik oder rechtslinear, wenn alle Produktionen von einer der Formen
A → λ, A → a, A → aB (a ∈ T, A, B ∈ N )
sind.
115
• Typ–2–Grammatik oder kontextfrei, wenn alle Produktionen von der Form
A → ψ, (A ∈ N, ψ ∈ (N ∪ T )∗ )
sind.
• Typ–1–Grammatik oder kontextsensitiv, wenn alle Produktionen von der Form
ϕ1 Aϕ2 → ϕ1 ψϕ2 , (A ∈ N, ϕ1 , ϕ2 , ψ ∈ (N ∪ T )∗ ) , ψ 6= λ,
sind.
Ferner darf zusätzlich S → λ definiert sein. S darf dann aber sonst auf keiner
rechten Seite auftreten.
• Typ–0–Grammatik oder allgemeine Chomsky–Grammatik, wenn die Form der Produktionen nicht weiter eingeschränkt ist.
• Für eine Chomsky–Grammatik G ist die Menge aller Wörter w, die durch eine
Ableitungsfolge mit Hilfe der Produktionen P aus G erzeugt werden können, die
durch G erzeugte Sprache.
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 9.1.3
Die Metazeichen der EBNF sind
• das Definitionszeichen =,
• das Alternativzeichen |,
• die Anführungszeichen ” ”,
• die Wiederholungsklammern {} – mehrfache (auch nullfache) Wiederholung,
• die Optionsklammern [] – null– oder einfaches Auftreten,
• die Gruppenklammern (),
• der Punkt – zur Beendigung einer Regel.
Für die Menge E der EBNF–Terme gilt:
• Nichtterminalsymbole bestehen aus einer Folge von Buchstaben und Ziffern beginnend mit einem Buchstaben
116
• ”w” mit einer beliebigen Folge w von Symbolen ist ein Terminalsymbol.
• Für α ∈ E ist auch (α), [α], {α} ∈ E.
• Für α1 , . . . , αn ∈ E sind auch α1 | . . . |αn ∈ E, α1 α2 . . . αn ∈ E. Eine EBNF–
Definition besteht dann aus einer endlichen Menge aus EBNF–Regeln der Form
V = α, wobei V ein Nichtterminalsymbol ist und α ein EBNF–Term.
Beispiel 9.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 9.1.5
Syntaxdiagramme bestehen aus folgenden Bestandteilen mit folgenden Regeln:
• Runde Kästchen für die Terminalsymbole,
• eckige Kästchen für die Nonterminalsymbole,
• Verbindungen aus Linien und Pfeilen.
• An jedem Kästchen endet und beginnt genau ein Pfeil, in der Regel gegenüberliegend.
• Es gibt genau eine Linie, die oben links am Eingang in das Diagramm hineinführt.
• Es gibt genau eine Linie, die unten rechts am Ausgang aus dem Diagramm herausführt.
• Linien dürfen nur verzweigen und sich wieder treffen, aber sich nicht kreuzen.
117
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 9.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. - V
¾»
b) ”w”–Terminalsymbole werden in ein rundes Kästchen übersetzt. - w
½¼
c) (α) wird wie α übersetzt
α
d) [α] wird zu
6
e) {α} wird zu - ? α
6
f) α1 . . . αn wird in eine Kette von Teildiagrammen übersetzt, die sich als Übersetzungen der einzelnen αi ergeben, und durch Pfeile verbunden.
g) α1 | . . . | αn wird in eine Verzweigung in n Zweige übersetzt, die sich aus den
Übersetzungen der einzelnen αi ergeben.
α1
...
-
...
?
6
αn
9.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 9.2.1
Die kontextfreie Syntax von Mini–Pascal ist durch die folgende EBNF–Definition gegeben:
118
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 9.2.2
• Die Deklarationen constDecl, varDecl und block bilden eine Einheit. Die hierfür
deklarieren Bezeichner ident müssen alle verschieden sein.
• Alle Bezeichner können innerhalb der eingeschachtelten Blöcke nochmals deklariert
werden. In diesem Fall überlagert die innere Definition die äußere, d.h. der im
äußeren Block deklarierte Bezeichner wird vorübergehend unsichtbar.
• Jeder Bezeichner constIdent, varIdent oder procIdent, der in einem statement
119
oder factor eines Blocks vorkommt, muss vorher so deklariert worden sein, dass
er die entsprechende Identität erhalten hat.
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 ausgegeben 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.
• Das Semikolon dient als Trennungszeichen zwischen Anweisungen. Anweisungen
vor einem end benötigen keines mehr. Da eine leere Anweisung erlaubt ist, ist
Setzen des Semikolons jedoch ohne Relevanz. Hinter einer condition steht jedoch
kein Semikolon.
• Pascal hat ein Blockschachtelungsprinzip, das bedeutet, dass Bezeichner, die in
einem Block deklariert worden sind, auch in den inneren Blöcken gelten (Sichtbarkeit). Durch Neudeklaration werden die Bezeichner zeitweise unsichtbar. Somit haben Bezeichner und zugehörige Variablen und Prozeduren eine Lebensdauer. Nach
Verlassen ihres Deklarationsblockes können die zugehörigen Speicherbereiche wieder freigegeben werden, während beim Betreten eines Blockes den hier deklarierten
lokalen Variablen neue Speicherplätze zuzuweisen sind.
Beispiel 9.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 *)
120
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.
121
Definition 9.2.4
Mini–Pascal ist durch die folgenden Syntaxdiagramme definiert:
varDecl
procDecl
var
,
ident
;
procedure ident
:=
varIdent
statement
:
ident
block
integer
;
expression
procIdent
begin
if
statement
condition
while
condition
;
statement
end
then statement
do
statement
122
;
9.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 [A → B] von A nach B. Ω bezeichne allgemein die überall undefinierte partielle Abbildung unabhängig von Urbild– und Bildbereichen.
Definition 9.3.1
Die semantischen Bereiche von Mini–Pascal
Adr := N Adressen
Int := Z ganze Zahlen
Bool := {w, f }
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.
V Env := [Ide → Adr] Variablenumgebung.
Sie ordnet einer Menge von Bezeichnern ihre jeweilige Adresse zu.
123
Store := [Adr → Int] Speicherzustände.
Ein Speicherzustand ordnet einer Menge von Adressen die darunter jeweils gespeicherten
ganzzahligen Werte zu.
CEnv := [Ide → Int] Konstantenumgebung.
Sie ordnet einer Menge von Bezeichnern die dadurch jeweils definierten Konstanten zu.
P Env := [Ide → [Store → Store]] Prozedurumgebungen.
Sie ordnen einer Menge von Prozedurbezeichnern die jeweilige Speichertransformation
ihres Rumpfes zu.
Stat := [CEnv × V Env × P Env × Store → Store] 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 : constDecl × CEnv → CEnv.
Die Semantik einer Konstantendeklaration ist eine Konstantenumgebung.
V : VarDecl × V Env → V Env.
Die Semantik einer Variablendeklaration ist eine Variablenumgebung.
P c : Proc×CEnv × V Env × P Env → P Env.
Die Semantik einer Prozedurdeklaration ist eine Prozedurumgebung. Dabei steht Proc
für die Namen der ProcIdent.
E : expression × CEnv × V Env × Store → Int.
Die Semantik eines Ausdrucks ist eine ganze Zahl.
R : condition × CEnv × V Env × Store → Bool.
Die Semantik einer Relation (Bedingung) ist ein Wahrheitswert.
S : statement → Stat.
Die Semantik einer Anweisung ist eine Speichertransformation.
B : block → Stat.
Die Semantik eines Blocks ist eine Speichertransformation aus [Store → Store] .
P r : program → [Intn → Int].
Die Semantik eines Programms ist eine partielle Abbildung von Int n nach Int.
Diese semantischen Funktionen haben alle die Gestalt
F [α] β,
wobei F der Name der Funktion ist, α innerhalb der Klammer ein Mini–Pascal Programmstück, β eine Folge weiterer Argumente in klammerloser Notation.
Die einzelnen Funktionen werden nun in den folgenden Definitionen ausführlich erklärt:
124
Definition 9.3.2
Die Semantik der Const –Deklaration ist eine Funktion
C : constDecl × CEnv → CEnv.
Sei ϑ eine Funktion aus CEnv. Dann ist
C [const i1 = n1 ; ...; im = nm ; ] ϑ
:= C1 [im = nm ] C1 [im−1 = nm−1 ] . . . C1 [i1 = n1 ] ϑ,
wobei
C1 [i = n] ϑ := ϑ [i/n]
ist. Dabei heißt die Notation ϑ [i/n] , dass die i–te Speicherzelle von ϑ 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 Ω definiert.
Definition 9.3.3
Die Semantik der Var –Deklaration ist eine Funktion
V : varDecl × V Env → V Env.
Sei ϕ eine Funktion aus V Env. Dann ist
V [var i1 , ..., im : Integer; ] ϕ
:= V1 [im ] V1 [im−1 ] ...V1 [i1 ] ϕ,
wobei
V1 [i] ϕ := ϕ [i/new]
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[α] 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.
125
Definition 9.3.4
Die Semantik der procedure–Deklaration ist erklärt durch eine Funktion
P c : proc × CEnv × V Env × P Env → P Env.
Sei π eine Funktion aus P Env, ϑ ∈ CEnv, ϕ ∈ V Env und σ ∈ Store. Dann ist
P c [procedure p; α; ] ϑϕπ := π [i/µ] ,
wobei
µ (σ) := B [α] ϑϕπ [i/µ] σ.
Hier ist nach der Definition von P Env zu beachten, dass dem Prozedurbezeichner i
eine Speichertransformation µ ∈ Stat = [Store → Store] auf den Speicherzustand σ ∈
Store = [Adr → Int] 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 9.3.5
Die Semantik von Ausdrücken ist erklärt durch eine Funktion
E : expression × CEnv × V Env × Store → Int.
Seien ϑ ∈ CEnv, ϕ ∈ V Env, σ ∈ Store. Unter Weglassung der Klammern setzen wir
½
σ (ϕ (ident)) , falls varIdent
E [ident] ϑϕσ :=
ϑ (ident) , falls constIdent
E [number] ϑϕσ := number
E [f1 ∗ f2 ] ϑϕσ := E [f1 ] ϑϕσ · E [f2 ] ϑϕσ
E [f1 div f2 ] ϑϕσ := bE [f1 ] ϑϕσ/E [f2 ] ϑϕσc
E [t1 + t2 ] ϑϕσ := E [t1 ] ϑϕσ + E [t2 ] ϑϕσ
E [t1 − t2 ] ϑϕσ := E [t1 ] ϑϕσ − E [t2 ] ϑϕσ
E [+t] ϑϕσ := E [t] ϑϕσ
E [−t] ϑϕσ := −E [t] ϑϕσ
E [(expression)] ϑϕσ := E [expression] ϑϕσ
Dabei sind rechts immer die entsprechenden ganzen Zahlen notiert.
Definition 9.3.6
Die Semantik von Bedingungen ist erklärt durch eine Funktion mit den booleschen Werten w und f .
R : condition × CEnv × V Env × Store → Bool.
Seien ϑ ∈ CEnv, ϕ ∈ V Env, σ ∈ Store. Unter Weglassung der Klammern setzen wir
126
R [e1 = e2 ] ϑϕσ :=
R [e1 <> e2 ] ϑϕσ :=
R [e1 < e2 ] ϑϕσ :=
R [e1 <= e2 ] ϑϕσ :=
R [e1 > e2 ] ϑϕσ :=
R [e1 >= e2 ] ϑϕσ :=
½
w, falls E [e1 ] ϑϕσ = E [e2 ] ϑϕσ
f, sonst
½
w, falls E [e1 ] ϑϕσ < E [e2 ] ϑϕσ
f, sonst
½
½
½
½
w, falls E [e1 ] ϑϕσ 6= E [e2 ] ϑϕσ
f, sonst
w, falls E [e1 ] ϑϕσ ≤ E [e2 ] ϑϕσ
f, sonst
w, falls E [e1 ] ϑϕσ > E [e2 ] ϑϕσ
f, sonst
w, falls E [e1 ] ϑϕσ ≥ E [e2 ] ϑϕσ
f, sonst
Definition 9.3.7
Die Semantik von Anweisungen ist erklärt durch eine Funktion vom Programmtext
der Anweisungen auf die Speichertransformationen. Wir gehen induktiv vor. Seien ϑ ∈
CEnv, ϕ ∈ V Env, π ∈ P Env, σ ∈ Store. Wir geben hier nicht die Abbildung aus Stat
an, sondern wenden sie gleich auf Elemente ϑϕπσ an. Dann ist das Ergebnis aus Store.
S[]ϑϕπσ := σ Identität der Speicherabbildung
S[i := e]ϑϕπσ := σ [ϕ(i)/(E [e] ϑϕσ)]
S[p]ϑϕπσ := π(p)ϑϕσ
S[begin α1 ; ...; αn end]ϑϕπσ := S[αn ]ϑϕπS[αn−1 ]ϑϕπ . . . S[α1 ]ϑϕπσ
½
S[α]ϑϕπσ, falls E [c] ϑϕσ = w
S[if c then α]ϑϕπσ :=
σ sonst.
½
σ, falls E [c] ϑϕσ = f
S[while c do α]ϑϕπσ :=
S[while c do α]ϑϕπS[α]ϑϕπσ, sonst
Zeile 2 bedeutet eine Zuweisung Zahl zur Speicherzelle ϕ (i) , π vom Typ Stat bildet als
π(p)ϑϕσ auf Store ab. In der vierten Zeile liefern die Anwendungen
S[α1 ]ϑϕπσ = σ1 , S[αi ]ϑϕπσ = σi , i = 2, . . . , n,
sukzessive neue Speicherzustände aus Store. Die folgenden Zeilen sehen eine Anwendung
von α auf ϑϕπσ per S[α]ϑϕπσ, falls die Bedingung erfüllt ist. In Zeile 6 kommt dazu
eine rekursive Definition, die eventuell nicht terminiert.
Definition 9.3.8
Die Semantik von Blöcken ist erklärt durch eine Funktion
127
B : block → Stat.
Seien ϑ, ϑi ∈ CEnv, ϕ, ϕi ∈ V Env, π = π0 ∈ P Env, σ ∈ Store. Außerdem sei
Θ ∈ constDecl, Φ ∈ varDecl, Πi ∈ procDecl, i = 1, ..., m.
Dann ist
B[ΘΦΠ1 ...Πm α]ϑϕπσ := S [α] C [Θ] ϑV [Φ] ϕP [Πm ] ϑm ϕm ...P [Π1 ] ϑ1 ϕ1 πσ.
Hier wird das Blockschachtelungsprinzip verwirklicht und die P [Π i ] ϑi ϕi πi−1 liefern jeweils mit den zugehörigen Werten ϑi ∈ CEnv, ϕi ∈ V Env ein neues πi ∈ P Env, 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 9.3.9
Sei pg ein syntaktisch korrektes Mini–Pascalprogramm im Sinne der folgenden Definition:
pg = program pr;
Θ
Φ
Π1 ...Πm
begin
ReadLn(v1 , ..., vn );
α
WriteLn (e)
end.
Dann ist mit den Setzungen aus Definition 9.3.8, ϑ, ϕ, π undefiniert (= Ω) und mit dem
anfänglichen Speicherzustand
σ = (ϕ(v1 )/z1 , ..., ϕ(vn )/zn )
die Semantik von pg erklärt durch eine Funktion Pr: program → [Int n → Int] mit
Pr [pg] (z1 , ..., zn ) := E [e] C [Θ] ϑV [Φ] ϕS [α] C [Θ] ϑV [Φ] ϕP [Πm ] ϑm ϕm ...P [Π1 ] ϑ1 ϕ1 πσ
128
Hier haben wir die vorige Definition auf den Block des Hauptprogramms angewendet: es
wird die Anweisungsfolge α 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.
9.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 9.4.1
Die Menge C der SM–Operationen ist definiert durch
C := C0 ∪ C1 ∪ C2 ∪ C3 ,
wobei
C0 := {ADD, SUB, MUL, DIV, RET}
C1 := {CONST, JMP, JE, JL, JNE, JLE, JG, JGE}
C2 := {LOAD, STORE}
C3 := {CALL} .
Die Menge I der SM–Instruktionen ist definiert durch
[
I :=
Ci × Z i .
i∈{0,1,2,3}
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.)
129
Definition 9.4.2
Die Stapelmaschine SM ist gegeben durch die Komponenten
• Datenstapel D, definiert durch D := Z∗ .
• Prozedurstapel P, definiert durch P := A∗ ,wobei die Menge A der Aktivierungsblöcke definiert ist als
A := N × N × Z∗ .
Die ersten beiden Einträge in jedem Aktivierungsblock sind die Rücksprungadresse
RA und der statische Verweis SL. Der Vektor aus ganzen Zahlen dient zur Aufnahme der lokalen Variablen und wird mit LV bezeichnet.
• Instruktionszähler Z, definiert durch Z := N.
• Programmspeicher S, definiert durch S := I N .
Ein Zustand der SM ist ein Tripel (δ, π, z) aus einem Zustandsraum U mit δ ∈ D, π ∈ P
und z ∈ Z. Für einen Prozedurstapel π und n, m ∈ N bezeichnet π(n) den n–ten Aktivierungsblock in π. Die Komponenten von π(n) bezeichnen wir durch π(n) |RA , π(n)|SL ,
π(n)|LV . Die m–te lokale Variable in π(n) ist durch π(n)|LV (m) bezeichnet.
Der Datenstapel wird als
δ = z1 .z2 . . . .zn
bezeichnet mit dem letzen Eintrag rechts. Der Prozedurstapel ist als
π = hr1 , s1 (l11 , ..., l1n1 )i . hr2 , s2 (l21 , ..., l2n2 )i . . ,
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
130
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 9.4.3
Die Semantik einer SM–Instruktion i [] : I → [U → U ] wird durch eine Zustandsänderung
131
beschrieben, die sich für die einzelnen Instruktionen wie folgt darstellt:
¸
¸
·
·
δ = d1 ....dn .x + y
δ = d1 ....dn .x.y
→
1. ADD :
z =i+1
z=i
·
¸
·
¸
δ = d1 ....dn .x.y
δ = d1 ....dn .x − y
2. SUB :
→
z=i
z =i+1
·
¸
·
¸
δ = d1 ....dn .x.y
δ = d1 ....dn .x · y
3. MUL :
→
z=i
z =i+1
·
¸
·
¸
δ = d1 ....dn .x.y
δ = d1 ....dn . bx/yc , y 6= 0
4. DIV :
→
z=i
z =i+1
¸
¸
·
·
0
0
π = ... hr, s, (...)i .
π = ... hr, s, (...)i . hr , s , (...)i ...
→
5. RET :
z = r0
z=i
6. JMPn : [z = i] → [z = n]


¸
·
δ = d1 ....dn
δ = d1 ....dn .x
→  z = n, falls x = 0 
7. JEn :
z=i
z = i + 1, sonst


·
¸
δ = d1 ....dn
δ = d1 ....dn .x
8. JNEn :
→  z = n, falls x 6= 0 
z=i
z = i + 1, sonst


¸
·
δ = d1 ....dn
δ = d1 ....dn .x
→  z = n, falls x < 0 
9. JLn :
z=i
z = i + 1, sonst


·
¸
δ = d1 ....dn
δ = d1 ....dn .x
10. JLEn :
→  z = n, falls x ≤ 0 
z=i
z = i + 1, sonst


·
¸
δ = d1 ....dn
δ = d1 ....dn .x
11. JGn :
→  z = n, falls x > 0 
z=i
z = i + 1, sonst


·
¸
δ = d1 ....dn
δ = d1 ....dn .x
12. JGEn :
→  z = n, falls x ≥ 0 
z=i
z = i + 1, sonst
¸
·
¸
·
δ = d1 ....dn
δ = d1 ....dn .c
13. CONSTc :
→
z =i+1
z=i
Für die Erklärung der letzten drei Instruktionen benötigen wir die Hilfsfunktion base:N×
N → N, die entlang einer Kette von Verweisen den richtigen Aktivierungsblock einer
Variablen und ihren Speicherplatz findet:
base(0, x) := x
base(n + 1, x) := base(n, π(x)|SL ).
132



δ = d1 ....dn
14. LOAD l, o :  π = π1 .π2 ...πk  → 
z=i



δ = d1 ....dn .x
15. STORE l, o :  π = π1 .π2 ...πk  → 
z=i

δ = d1 ....dn .π (base (l, k))|LV (o)

π = π1 .π2 ...πk
z =i+1

δ = d1 ....dn

π = π0
z =i+1
wobei π 0 (base (l, k))|LV (o) = x und π 0 = π 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.
16. CALL l, n, v :
·
π = π1 .π2 ...πk
z=i
¸
→
+ 
v
z }| {
 π = π1 .π2 ...πk . i + 1, base (l, k) , 0, 0, ..., 0 




z=n

*

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 9.4.4
program Beispiel;
var A, B, C : Integer;
procedure p;
var A, B, D: Integer;
procedure q;
var C: Integer;
133
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 → p → q → p.
Nach dem ersten Aufruf von p sieht der Prozedurstapel wie folgt aus:
π(2)
π(1)
}|
{ z
}|
{
z
h0, 0, (a, b, c)i . hr1 , 1, (ap , bp , dp )i,
wobei a, b, c die Werte der Variablen sind. Der statische Verweis 1 in π(2) verweist auf
π(1). Der Befehl STORE 1,3 aus der Übersetzung C:=0 , das ist die dritte Variable, wirkt
entsprechend der Semantik von STORE auf π (base(1, 2))|LV (3) . Es gilt jedoch mit der
rekursiven Definition von base(n + 1, x) := base(n, π(x)|SL ), base(0, x) := x und dem
Wert 1 an der Stelle SL von π(2)
¡
¢
π (base(1, 2))|LV (3) = π base(0, π(2)|SL ) |LV (3)
¡
¢
= π π(2)|SL ) |LV (3)
= π(1)|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.
π(1)
π(2)
π(3)
π(4)
z
}|
{ z
}|
{ z }| { z
}|
{
h0, 0, (a, b, c)i . hr1 , 1, (ap1 , bp1 , dp1 )i .hr2 , 2, (cq )i . hr3 , 1, (ap2 , bp2 , dp2 )i
134
STORE 1,3 wirkt jetzt wie folgt:
¡
¢
π (base(1, 4))|LV (3) = π base(0, π(4)|SL ) |LV (3)
¡
¢
= π π(4)|SL ) |LV (3)
= π(1)|LV (3) .
Das ist derselbe Speicherplatz wie im vorigen Aufruf. Schauen wir uns noch die Angabe
π(3)|SL = 2 im Aufruf von q an. Er bewirkt, dass die Variablen A,B,D in π(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 9.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.
• level (v), level (p) Blockschachtelungstiefe der Definition
• offset (v) Relative Position von v innerhalb des Aktivierungsblocks
• value (c) Wert von c
135
• adr (p) Anfangsadresse von p im Programmspeicher
• size (p) Anzahl der lokalen Variablen von p
• lv Blockschachtelungstiefe des gerade übersetzten Programmstücks.
Nunmehr werden Funktionen Ecomp, SComp, PComp zur Übersetzung von Ausdrücken,
Anweisungen und Programmen definiert, die SM–Programmteile liefern.
Definition 9.4.6
Hier beschreiben wir die Übersetzung von Ausdrücken:
1. EComp(ident) :=
2. EComp(number) :=
3. EComp(f1 ∗ f2 ) :=
4. EComp(f1 div f2 ) :=
5. EComp(f1 + f2 ) :=
6. EComp(f1 − f2 ) :=
7. EComp(+t) :=
8. EComp(− f2 ) :=
9. EComp((e)) :=
½
CONST value(ident),falls constIdent=ident
LOAD lv − level(ident), offset(ident), sonst
CONST number

 EComp (f1 )
EComp (f2 )

MUL

 EComp (f1 )
EComp (f2 )

DIV

 EComp (f1 )
EComp (f2 )

ADD

 EComp (f1 )
EComp (f2 )

SUB
EComp(t)

 CONST 0
EComp (f2 )

SUB
EComp(e),e Ausdruck.
Definition 9.4.7
Nun zu den Anweisungen:
½
EComp(e)
STORE lv − level(i), offset(i)
2. SComp (p) := CALL lv − level(p), adr(p), size(p)

 SComp (α1 )
...
3. SComp (begin α1 ; ...; αn end) :=

SComp (αn )
4. if (e1 ◦ e2 ) then α für ◦ ∈ {=, <, >, <>, <=, >, >=}
1. SComp (i := e) :=
136
wird wie folgt übersetzt: Wir erzeugen nun ein Stück Programmcode τ , welches ein
Zahl auf dem Datenstapel hinterlässt, die durch ihre Vorzeichen die Größenverhältnisse
zwischen e1 und e2 angibt:
EComp(e1 )
EComp(e2 )
SUB
Bezeichne nun x die Adresse des ersten Befehls hinter der Übersetzung des Blocks α im
then–Zweig. Wir übersetzen dann in Abhängigkeit vom Vergleichsoperator ◦ wie folgt
τ
AAA x
SComp (α)
mit der folgenden Korrespondenz für den SM–Befehl AAA
=
←→ JNE, <>←→ JE, <←→ JGE,
<= ←→ JG, >←→ JLE, >=←→ JL.
5. while (e1 ◦ e2 ) do α für ◦ ∈ {=, <, >, <>, <=, >, >=}
wird analog zur If–Anweisung übersetzt. Hier bezeichnet x die Adresse des zweiten
Befehls hinter dem SM–Code für den Do–Zweig α.
An diesen wird grundsätzlich ein Rücksprungbefehl JMP y angehängt, wobei y die Adresse
des ersten Befehls im Codesegment τ ist. Dadurch ist gewährleistet, dass die Sequenz α
sooft ausgeführt wird, wie die Bedingung (e1 ◦ e2 ) 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 9.4.8
Sei β = ΘΦΠ1 ...Πm α ein syntaktisch korrekter Block in der Sprache Mini–Pascal. Dabei
sei
Θ ∈ constDecl, Φ ∈ varDecl, Πi ∈ procDecl, i = 1, ..., m,
und α eine syntaktisch korrekte Anweisungsfolge. Zu den Prozedurdeklarationen mögen
die Blöcke βi , i = 1, ..., m gehören. Dann definieren wir
BComp(β) := BComp(β1 )
...
BComp(βm )
SComp(α)
RET
137
Da im Hauptprogramm noch Ein– und Ausgaben vorgenommen werden, müssen wir die
Übersetzung von Programmen leicht modifizieren:
Definition 9.4.9
Sei pg ein syntaktisch korrektes Mini–Pascalprogramm mit dem Hauptblock β wie in
der vorangegangenen Definition und einer auf α folgenden Anweisung WriteLn(e). Dann
definieren wir
PComp(pg) := BComp(β1 )
...
BComp(βm )
SComp(α)
EComp(e)
RET
Dadurch verbleibt nach Abarbeitung des Programmrumpfs α der Wert e auf dem Datenstapel.
Definition 9.4.10
Sei psm : N→ I ein SM–Programm. Die Einzelschrittfunktion ∆ [psm] ist eine Zustandstransformation
∆ [psm] : U → U
mit
∆ [psm] (δ, π, z) := i [psm (z)] (δ, π, z) .
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
∗
∆ [psm]∞ : U → U.
Für ein n ∈ N ist die n–stellige Eingabefunktion der SM erklärt als
in(n) : Zn → U
in(n) (z1 , ..., zn ) := (δ0 , π0 , z0 ) .
Dabei wird sukzessive für i = 1, ..., n der Wert zi der Variablen vi mit einer dem CONST–
Befehl analogen Instruktion von einem Peripheriegerät auf den Datenstapel geschrieben
und der Prozedurstapel manipuliert, d.h. den Variablen v1 , ..., vn , die an den Offsetstellen
1, ...n des ersten Aktivierungsblocks liegen, die Werte z1 , ..., zn mit dem STORE–Befehl
zugeordnet. Sodann wird zur ersten Instruktion z0 des Programmrumpfs α verzweigt.
Der entstandene Prozedurstapel ist π0 , der Datenstapel δ0 ist leer.
Die Ausgabefunktion der SM ist erklärt als
out : U → Z
out (δ, π, z) := d1 .
138
Sie überträgt den obersten Wert des Datenstapels an ein Peripheriegerät.
Es ergibt sich damit das folgende Programm
³
´´
³
Pr [pg] (z1 , ..., zn ) := out ∆ [Pcomp (pg)]∞ in(n) (z1 , ..., zn ) .
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)
139
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)1 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)2 , 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. 3
10.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.
1
http://www.freepascal.org
http://www.gnu-pascal.de
3
Siehe dazu Planned Features auf der GPC Seite http://www.gnu-pascal.de/gpc/Planned-Features.html
2
140
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:
• Integer: Der semantische Bereich ist ein gewisser implementierungsabhängiger
Ausschnitt aus den ganzen Zahlen. Die größte Zahl dieses Ausschnitts ist die vordefinierte Konstante MaxInt. Verwandte Typen aus Pascaldialekten sind byte, das
die Zahlen 0..255 umfasst, word, das dieselbe Mächtigkeit wie Integer hat, sich
aber auf nichtnegative Zahlen beschränkt, und LongInt, das die doppelte Speicherkapazität von Integer benötigt, meistens 4 Byte, ShortInt nur die Hälfte.
• Boolean: Der semantische Bereich ist die Menge der Wahrheitswerte, die True und
False heißen.
• Char: Der semantische Bereich ist die Menge der darstellbaren Zeichen der Referenzmaschine und damit implementierungsabhängig. Meist umfassen sie die Zeichen mit ASCII–Code 32 bis 127. Konstanten von diesem Typ werden als ’A’ etc.
geschrieben.
• Real: Hier ist der semantische Bereich eine Menge rationaler Zahlen, die wiederum implementierungsabhängig ist. Die ähnlichen Typen single, double und
extended folgen der IEEE–Norm aus Kapitel 2.1.2, wobei extended über 80 Bit
verfügt.
Hier ist die Syntax einer Real–Zahl gegeben durch
RealZahl = ["+" | "-"] Ziffernfolge
("." Ziffernfolge | Skalierungsfaktor|
"." Ziffernfolge Skalierungsfaktor).
Skalierungsfaktor = ("E" | "e") ["+" | "-"] Ziffernfolge.
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: Array (Vektor), Record (Verbund), Objekt (Klasse, Erweiterung des Sprachstandards), Set (Mengen) und File–Typen (Datei),
141
• String–Typ (Zeichenketten zwischen ’ ’),
• Zeiger–Typ (z.B. Zeiger auf Adressen von einem vorgegebenen Typ oder untypisiert
Pointer),
• Prozedur–Typen als Erweiterung des Sprachstandards.
Strukturierte Typen, wie Record, betreffen kartesische Produkte verschiedener Mengen
A1 × . . . × An , 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 ∈) 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.
• Sind c und d Konstanten eines Ordinaltyps T und gilt c ≤ d, so kann ein Unterbereichstyp
type U = c..d;
deklariert werden. Der entsprechende semantische Bereich ist die Menge aller u ∈ T
mit c ≤ u ≤ d.
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);
142
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 10.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 An entsprechen, leisten Verbünde dies für verschiedene Mengen.
Sind T1 , . . . , Tn Typen und v1 , . . . , vn Bezeichner, so kann ein Verbund über T1 × . . . × Tn
als Datentyp durch
143
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 10.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
144
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 10.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 10.1.4
type Liste = ^Eintrag;
Eintrag = record
Elem : Integer;
Nachfolger : Liste
end;
var : anfang, neu, p, q : liste;
145
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
146
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
147
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.
10.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(π1 ; π2 ; . . . ; πn );
148
wobei jedes π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
P(t1 , . . . , tn )
aufgerufen werden, wobei ti 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 10.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)
149
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(π1 ; π2 ; . . . , π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
f(t1 , . . . , tn )
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.
150
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.
10.3 Unit–Konzepte und objektorientierte Programmierung
10.3.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
151
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.
10.3.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
152
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:
• Die Klasse GrObjekt soll ein Flag sichtbar enthalten, sowie die Methoden zum
Verschieben, Anzeigen, Löschen und Zeichnen definieren. Die Methode zum Zeichnen ist dabei abhängig vom jeweiligen Objekt und muss daher virtuell definiert
werden, d.h. sie ist in jeder abgeleiteten Klasse an das konkrete Objekt anzupassen.
Alle Graphikobjekte besitzen einen Bezugspunkt im Weltkoordinatensystem, an
dem ein lokales Koordinatensystem liegt, in dem das Objekt beschrieben ist. Die
Verschiebung eines Objektes kann realisiert werden, indem nur der Bezugspunkt
(Nullpunkt des lokalen Koordinatensystems) verschoben wird. Daher kann die Methode zum Verschieben von Objekten schon in der Klasse GrObjekt implementiert
werden. Eine weitere Variable für alle Objekte ist damit ein Punkt im Weltkoordinatensystem als Bezugspunkt.
• Das Graphikobjekt Punkt kann durch einen X– und einen Y–Koordinatenwert in
einem zweidimensionalen Koordinatensystem definiert werden. Das Objekt wird
aus GrObjekt abgeleitet und liegt im Bezugspunkt.
• Strecken werden durch zwei Punkte definiert. Deshalb enthält eine Klasse Strecke
neben dem Bezugspunkt einen Variable vom Typ Punkt. Die Merkmale von Strecke
werden also um einen Punkt bzw. dessen beiden Koordinatenwerte erweitert.
153
• Polygonzüge werden durch ihre Eckpunkte und deren Reihenfolge definiert. Als
Merkmale einer Klasse Polygonzug sind deshalb alle Eckpunktkoordinaten bzw.
eine Liste von Eckpunktkoordinaten, die auch eine Reihenfolge festlegt, sowie ihre
Anzahl aufzunehmen. In der Klassenhierarchie soll sowohl eine Klasse mit einer
statischen als auch eine Klasse mit einer dynamischen Implementierung der Eckpunktliste enthalten sein (PolygonzugS bzw. PolygonzugD).
• Ein Polygon ist ein geschlossener Polygonzug, bei dem Anfangs– und Endpunkt
gleich sind. Ein Polygon kann somit von einem Polygonzug (statisch und dynamisch) abgeleitet werden. (Polygon ist insbesondere ein Polygonzug)
• Dreiecke sind spezielle Polygone und können als eine Unterklasse von Polygon gebildet werden.
• Beliebige Rechtecke, die im Bezugspunkt des Graphikobjektes liegen, sollen durch
die Angabe von zwei Längen und einem Winkel festgelegt sein. Der Winkel wird
dabei durch die X–Achse des lokalen Koordinatensystems und der Seite, die im
Gegenuhrzeigersinn die erste ist, gebildet. Diese Seite soll die Länge Breite besitzten. Die zweite, im Nullpunkt des lokalen Systems darauf im Gegenuhrzeigersinn
senkrecht stehende Seite soll die Länge Hoehe besitzen. Wir müssen also bei der Ableitung aus der Klasse GrObjekt weitere Merkmale für den Winkel und die beiden
Längen hinzufügen.
• Quadrate können, da sie ein Spezialfall von Rechtecken sind, als Unterklasse der
Klasse Rechtecke eingeführt werden. Für ihre Definition genügt – unter den gleichen Voraussetzungen wie bei Rechtecken – die Angabe einer Länge.
• Ellipsen sollen ähnlich wie Rechtecke durch zwei Längen, die Längen der beiden
Halbachsen, und einem Winkel spezifiziert werden. Die Ellipse hat ihren Mittelpunkt im Bezugspunkt (Nullpunkt des lokalen Systems), der Winkel wird analog
zum Rechteck durch die X–Achse und die im Gegenuhrzeigersinn erste Halbachse
eingeschlossen. Die zweite Halbachse steht auf dieser im Gegenuhrzeigersinn senkrecht. Die Klasse Ellipse hat wie ein Rechteck die zusätzlichen Merkmale eines
Winkels und zweier Längen, wird aber trotzdem direkt aus GrObjekt abgeleitet,
da Rechteck und Ellipse in keiner semantischen Beziehung zueinander stehen.
• Kreise sind spezielle Ellipsen, bei denen der Winkel keine Rolle spielt und die
Längen der Halbachsen gleich sind. Deshalb können wir eine Klasse Kreis als Unterklasse der Klasse Ellipse implementieren.
Eine mögliche Klassenhierarchie für geometrische Figuren zeigt unser Bild:
154
10.3.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 *)
155
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;
156
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)
157
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.html
158
10.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
159
#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
char
int
enum
long
float
double
Pascal
Char
Integer
Word
Longint
Single
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:
160
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
++beta
–beta
Pascal
Inc(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;
161
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
bezeichnung : String[40];
ccm : Word;
neupreis : double;
End;
Var auto : Pkw;
(* Zugriff: *)
auto.ccm := 2476;
typedef struct
{
char bezeichnung[41];
unsigned int ccm;
double neupreis;
} pkw;
pkw auto;
auto.ccm = 2476;
162
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;
zahl := 2.78;
zeiger := @zahl;
double zahl, *pointer;
zahl = 2.78;
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;
pfeil->ccm = 3276;
/* bzw. */
163
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
Werte : Vbanweisung;
......;
Else Vbanweisung;
End;
switch (Ausdruck)
{case Konstante: Vbanweisung; [break;]
......
default: Vbanweisung;
}
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
Endwert Do
Vbanweisung;
for(Varia=Startwert;
Varia <= Endwert; Varia++)
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.
164
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 :
d :
ld:
h :
u :
f :
e :
x, X :
o :
s :
char,
integer,
longinteger,
short,
unsigned,
float,
wissenschaftliche Fliesskommazahl,
hex-Zahl,
octal,
string.
Ebenso können Steuerzeichen eingegeben werden:
165
\a:
\b:
\f:
\n:
\r:
\0:
\’:
\\:
Signalton,
backspace,
Seitenvorschub,
neue Zeile,
Wagenruecklauf,
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:
166
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);
167
11 Logiksprachen und Prolog
11.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:
• Er benutzt Logik als Sprache zur Formulierung von Sachverhalten.
• Er benutzt Inferenz–Prozeduren, um dieses Wissen zu befragen, zu benutzen und
zu vermehren.
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 Inferenz –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.
Inferenzregeln 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:
168
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:
• Anwendungsgebiete: Expertensysteme
• Programmiersprachen: PROLOG, LISP, PL/1
• Vorteile: Explizite Darstellung von Wissen; große formale Ausdruckskraft; Herleitung neuen Wissens; Verwendung mathematischer Prinzipien; gut definierte Syntax
und Semantik; Inkrementelle Programmentwicklung; Warum – Warum nicht – Erklärung
• Nachteile: Ungewohnte Denkweise; sehr komplexe Systeme; problematische Schnittstellen bei der Datenmanipulation; Reihenfolge der Definitionen, der Klauseln und
der Literale innerhalb von Klauseln hat Einfluss auf die Abarbeitung eines Logikprogrammes wie Prolog; Darstellung der Negation durch Negation as Failure
Regel führt bei der von Prolog verfolgten Suchstrategie bisweilen zu inkorrekten
Antworten; die alleinige Fixierung auf die Prädikatenlogik ist kritikwürdig.
11.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
P : −Q1 , Q2 , . . . , Qn (Prozedurklausel)
notiert. Dies steht für
Q1 ∧ Q2 ∧ . . . ∧ Qn → P bzw. äquivalent ¬Q1 ∨ ¬Q2 ∨ . . . ∨ ¬Qn ∨ 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 ∧ 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
169
(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 Inferenzkomponente 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).
• Die Namen von Prädikaten und konstanten Argumenten müssen mit Kleinbuchstaben beginnen.
• Die Namen variabler Argumente sind durch einen Großbuchstaben an erster Position gekennzeichnet.
• An der ersten Stelle eines Fakts steht stets der Prädikatenname, danach folgen in
runde Klammern gesetzt die Argumente.
• Die Anzahl der Argumente ist beliebig und kann auch Null sein.
• Die einzelnen Argumente sind durch Kommata voneinander getrennt.
• Die Reihenfolge der Argumente eines Prädikates ist von Bedeutung und nicht veränderbar.
170
• Prädikate gleichen Namens aber unterschiedlicher Stelligkeit stellen verschiedenartige Fakten dar.
• Jedes Fakt muss mit einem Punkt abgeschlossen werden.
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
elternteil
elternteil
elternteil
elternteil
elternteil
elternteil
(hans, peter).
(karin, peter).
(otto, susi).
(frieda, susi).
(peter, thomas).
(peter, anne).
(peter, gabi).
171
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 Inferenzkomponente 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.
172
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 Inferenzkomponente 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 Inferenzkomponente, 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 Inferenzkomponente 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.
11.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.
173
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:
• Großbuchstaben A, B, ... , Z
• Kleinbuchstaben a, b, ... , z
• Ziffern 0, 1, 2, ..., 9
• Sonderzeichen:
! | # | $ | % | ^ | & | * | ( | ) | _ | + | ~ | { | } | : |
" | < | > | ? | - | = | ’ | [ | ] | ; | ‘ | , | | | . | /
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).
174
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.
175
11.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
elternteil
elternteil
elternteil
elternteil
elternteil
?- vorfahr
(heike, robert).
(thomas, robert).
(thomas, lisa).
(robert, anna).
(robert, petra).
(petra, jakob).
(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 ¬A, wobei A ein Grundatom
ist.
Durch den Ablauf der Ableitbarkeitsprüfung wird die prozedurale Bedeutung eines Prolog–
Programmes bestimmt. Da der Inferenz–Algorithmus bei einer veränderten Reihenfolge
der Klausel oft auch einen anderen Verlauf nimmt, kann sich die prozedurale Bedeutung
des Programmes ändern.
176
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
elternteil
elternteil
elternteil
elternteil
elternteil
(heike, robert).
(thomas, robert).
(thomas, lisa).
(robert, anna).
(robert, petra).
(petra, jakob).
Beispiel:
?- vorfahr (thomas, petra).
vorfahr (X, Z):- elternteil (X, Z).
vorfahr (X, Z) :- elternteil (X, Y), vorfahr (Y, Z).
Regelwerk 1
Der obige Ableitungsbaum zeigt die Gültigkeit.
177
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.
Stellt man jedoch die beiden Regeln um, so führt dies in eine Endlosschleife, da immer
0 0
wieder ein neues Subgoal vorfahr (Y ... , petra) erzeugt wird. Dieses Subgoal muss
178
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.
11.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
179
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]).
180
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
invert
invert
([], []).
([c], [c]).
([b, c], [c, b]).
([a, b, c], [c, b, a]).
181
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.
182
11.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:
• Prolog versucht, die Sub–Goals von links nach rechts zu beantworten. Erst wenn
183
ein Sub–Goal mit Yes beantwortet ist, wird zum nächsten Subgoal übergegangen.
• Wird eine Variable bei der Untersuchung eines Sub–Goals instanziiert, so ist die
Variable auch in den restlichen Sub–Goals an den Wert gebunden.
• Die Suche nach einem einzelnen Sub–Goal geschieht in der Wissensbasis von oben
nach unten.
• Kann ein Sub–Goal nicht mit Yes beantwortet werden, wird das Verfahren des
Backtrackings angewendet. Das bedeutet, dass Prolog zum vorhergehenden Sub–
Goal zurückkehrt, bei der Beantwortung dieses Sub–Goals instanziierte Variablen
wieder zurückinstanziiert und nach einer anderen Lösung für dieses Sub–Goal sucht.
Existiert diese, so wird wieder versucht, dass nächste Sub–Goal zu beweisen. Anderenfalls wird ein weiterer Schritt des Backtrackings durchgeführt.
• Bei manchen Systemen wird nach der Lösungsfindung nach einer Anfrage mit Variablen und der Ausgabe des Ergebnisses ein weiterer Lauf gestartet. Das entspricht
einem initiierten Backtracking vom letzten Sub–Goal der Anfrage aus. (Dies kann
z.B. durch Eingabe eines Semikolons am Ende der erfolgten Ausgabe geschehen.)
Wir untersuchen ein Beispiel:
1.
2.
3.
4.
5.
6.
?-
student (heinz).
student (tina).
student (marc).
wohnt (tina, leipzig).
wohnt (heinz, stuttgart).
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.
184
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).
185
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.
186
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–KontrollOperatoren.
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.
187
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.
• 1. Steht er am Ende einer Anfrage oder Regel, dann erfolgt die Instanziierung der
Variablen nur einmal, die Lösungssuche bricht ab, es findet kein weiteres Backtracking statt.
Beispiel 11.6.1
mag (heinz, computer).
mag (marc, sport).
mag (tina, tiere).
mag (hermann, biologie).
mag (lili, mathematik).
?- mag (Person, Fakt), !.
Antwort: Person = heinz, Fakt = computer
Die Suche nach anderen Möglichkeiten wird unterbunden. Die Variableninstanziierungen werden nicht aufgehoben, der Abbruch ist erzwungen.
Beispiel 11.6.2
mag (heinz, computer).
mag (marc, sport).
mag (tina, tiere).
mag (hermann, biologie).
mag (lili, mathematik).
interessiert (Person, Fakt) :- mag (Person, Fakt),
write(’Student: ’), display(Person), nl,
write(’interessiert: ’), display(Fakt), nl, !.
?- interessiert (Person, Fakt)
liefert auch hier
Student: heinz
interessiert: computer
• 2. Steht der cut am Anfang des Prämissenteils einer Regel, so werden bei Erfüllung der gesamten Cut–Klausel für eine Instanziierung der Variablen keine weiteren
Regeln untersucht.
Beispiel 11.6.3
mag (heinz, computer).
mag (marc, sport).
mag (tina, tiere).
mag (lili, mthematik).
188
interessiert (Person, Fakt) :- !, mag (Person, Fakt), junge (Person).
interessiert (Person, Fakt) :- mag (Person, Fakt).
junge (heinz).
junge (marc).
?- interessiert (Person, Fakt).
liefert
Person: heinz
interessiert: computer.
und
Person: marc
interessiert: sport.
Da die zweite Regel nicht mehr in Betracht gezogen werden, fallen die weiteren
Lösungen für tina und lili weg.
• 3. Steht der Cut zwischen den Prämissen einer Anfrage oder einer Regel, dann
erfolgt das Backtracking innerhalb der Anfrage oder Regel nur bis zum Cut. Außerdem wird die Entscheidung, welches Fakt für das Prädikat geprüft werden soll,
eingefroren. Alternative Regeln und Fakten werden nicht mehr geprüft.
Beispiel 11.6.4
student (heinz).
student (tina).
mag (tiere).
mag (biologie).
?- student (X), !, mag (Y).
Antwort:
student = heinz
student = heinz
mag = tiere
mag = biologie
Durch die Anfrage an das System muss Prolog drei Subgoals erfüllen, wobei das
mittlere beim Antreffen als bewiesen gilt. Einmal getroffene Instanziierungen für
Variablen zum Beweis des ersten Teilziels (hier X) werden eingefroren und nicht
mehr aufgehoben. Die Variable Y wird auf tiere instanziiert, im Backtracking
freigegeben und dann mit biologie instanziiert. Der dann folgende Schritt der
Freigabe von X ist durch den Cut–Operator verhindert.
Zusammenfassung der Funktionen zum Cut–Operator
• Der Cut–Operator kommt nur im Prämissenteil einer Regel vor (im Rumpf).
189
• Der Cut–Operator in einer Regel zwischen mit und verbundenen Aussagen wirkt
trennend. Schlägt die Prüfung eines weiteren Prädikats nach dem Cut–Operator
fehl, so findet das Backtracking nur noch bis zum Cut–Operator statt. Ein Backtracking zu einem Prädikat, das links vom Cut–Operator steht, ist unmöglich.
• Der Cut–Operator als eingebauter Operator ist immer bewiesen. Gelingt der Beweis also bis zu ihm, so gilt die Klausel als bewiesen. Daraus folgt, dass keine
Alternativregel für die Instanziierung einer Variablen mehr untersucht wird, wenn
die Instanziierung dieser Variablen in einer Regel die Cut–Klausel erfüllt.
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 = (a ∧ b) ∨ 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 = (a ∧ b) ∨ (¬a ∧ c).
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.
2.
3.
4.
lebewesen (rose).
lebewesen (hund).
blume (rose).
tiere (X) :- blume (X), !, fail.
190
5. tiere
?- tiere
No
?- tiere
No
?- tiere
Yes.
(X) :- lebewesen (X).
(rose).
(X).
(hund).
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.
11.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:
• I/O–Prädikate: Mittels der I/O Prädikate kann Prolog auf die Peripherie zugreifen.
Es werden z.B. Eingaben über die Tastatur und Zugriffe auf die externen Speicher
möglich.
• Arithmetische Prädikate: Diese Prädikate können für arithmetische Operationen in
Prolog benutzt werden, z.B. die Ausführung der Grundrechenarten.
• Wissensbasis–Prädikate: Durch diese Prädikate–Gruppe kann die Wissensbasis von
Prolog zur Laufzeit verändert werden, etwa durch Hinzufügen oder Entfernen von
Fakten.
• Strukturprädikate: Mit dieser Art von Prädikaten lassen sich die Datenstrukturen
von Prolog untersuchen. Strukturprädikate erlauben z.B. das Testen von Atomen.
• Ausführungs–Kontroll–Prädikate: Diese Prädikate erlauben eine Beeinflussung des
Ablaufs der Bearbeitung innerhalb eines Prolog–Programms, z.B. cut und fail.
191
• Sonstige Prädikate: Prädikate mit verschiedenen sonstigen Funktionen, z.B. Vergleichsprädikate. Diese Prädikate bilden Quasi–Standards für Prolog–Systeme, können sich jedoch bei den verschiedenen Prolog–Dialekten in Syntax oder Semantik
unterscheiden. Daher empfiehlt es sich, im Handbuch des Prolog–Systems nachzuschauen. Die hier angesprochenen Prädikate bilden nur eine Auswahl der heutzutage
verfügbaren Prädikate auf Prolog–Systemen. Sie bilden sozusagen den kleinsten gemeinsamen Nenner. Wir wollen es daher bei dieser allgemeinen Übersicht belassen.
11.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
192
12 Funktionale Programmierung
12.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.
193
• Der Programmierer braucht sich nicht um die Speicherbelegung zu kümmern. Es
gibt keine Zuordnungsanweisung von Werten an Speicherstellen, sondern Werten
werden von Ausdrücken Namen zugeordnet. Diese Namen werden dann in anderen
Ausdrücken benutzt oder als Parameter an Funktionen übergeben.
• Name oder Ausdruck haben damit einen eindeutigen Wert, die referentielle Transparenz, der sich niemals ändert. Berechnungen mit denselben Argumenten ergeben
immer dasselbe Ergebnis. Der Code ist sicherer und auch im gleichen Kontext wiederbenutzbar.
• Funktionen und Werte werden als mathematische Objekte behandelt, die mathematisch–logischen Regeln gehorchen.
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.
194
12.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.
195
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.
196
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), ∨ (Disjunktion) Verwendung, Gleichheit wird mit = geprüft.
In Gofer treten an diese Stelle not, &&, ||. Das Prüfen auf Gleichheit geschieht mit ==.
Miranda (2<3)=(~(~True))
Zeichenketten können bezüglich lexikographischer Ordnung verglichen werden:
Miranda "B" <
False
"Al"
Steht in einer Skriptdatei folgende Vereinbarung:
x = 0.0
y = 0.2
197
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
⊥, 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.
12.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:
198
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
199
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:
200
(+): 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.
12.3.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
201
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.)
12.3.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.
202
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
• Steuerung von Ein– und Ausgabewerten (Puffer)
203
• Steuerung von Schleifen und Iterationen
• Speicherung von Zwischenergebnissen
• Steuerung der Statusregister – Historie – Prozessorzustand
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)
204
Die Programmsteuerung geschieht also folgendermaßen:
• Iteration durch Rekursion
• Auswahl durch Mustermatching
• Sequenzierung durch: Verschachtelung von Funktionsaufrufen
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’))
12.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
205
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 : ::
* -> [*] -> [*]
206
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:
207
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.
12.4.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:
208
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
• [] leere Liste
• die Standardliste (front : rest)
• eventuell spezielle n–elementige Liste, wie bei listmax im Fall n = 1 (front :
[])
Manchmal sind diese Funktionen jedoch erst nach einem längeren Analyseprozess hinzuschreiben:
Wir geben ein Beispiel einer Neudefinition der Standardfunktion reverse:
209
myreverse :: [*] -> [*]
myreverse [] = []
myreverse (front : rest) = myreverse (rest) ++ [front]
startswith erkennt, ob eine Liste Unterliste einer anderen ist:
startswith
startswith
startswith
startswith
:: ([*], [*]) -> bool
([], anylist) = True
(anylist, []) = False
((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.
12.4.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)
210
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
211
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
12.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
212
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
213
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).
214
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.
• Jeder formale Parametername bezieht seinen Wert von dem eigentlichen Parameter,
auf den die Funktion angewendet wird. Mit anderen Worten, die lokale Umgebung
ignoriert etwaige vorausgegangene Bindungen an diesen Bezeichner in der geerbten
Umgebung. Der Name wird in die Funktion eingebunden.
• Jeder andere im Funktionskörper auftretende Name bezieht seinen Wert aus der
geerbten Umgebung. Er ist in dieser Funktion frei.
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)
215
= 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 order ∈ {<, >}. 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 ≤ n ≤ 8) 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.
Das folgende Programm ermittelt alle 92 Lösungen:
216
queens 0
queens (m+1)
safe p n
= [[]]
= [ p++[n] | p<-queens m, n<-[1..8], 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.
12.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
217
Literaturverzeichnis
[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 TurboPASCAL. 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
218
[17] Chris Clack, Colin Myers, Ellen Poon: Programmieren in Miranda. Prentice Hall,
München 1996
[18] Peter Thiemann: Grundlagen der funktionalen Programmierung. Teubner, Stuttgart1994
219
Herunterladen