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