Höllische Programmiersprachen Seminar im Wintersemester 2014/2015 Typsystem vs. Laufzeitimplementation Andrea Müller Technische Universität München 18.12.2014 Zusammenfassung 1 Diese Ausarbeitung untersucht die Auswirkungen eines Typsystems einer Programmiersprache auf das Laufzeitsystem und die Folgen für das Programm. Hierbei wird sowohl auf die verschiedenen Typisierungen eingegangen, als auch ihre Vor- und Nachteile abgewägt. Unterstützt und verdeutlicht wird die Ausführung des Themas durch praktische Beispiele und Programmcodeauszügen. 2 Inhaltsverzeichnis 1 Einleitung: Typsysteme 1.1 Was sind Typsysteme? . . . . . . . . . . . . . 1.2 Einordnung von Typsystemen . . . . . . . . . 1.2.1 Starke und Schwache Typisierung . . . 1.2.2 Statische und Dynamische Typisierung 1.2.3 Explizite und Implizite Typisierung . 2 Hauptteil: Verbindung von Typ- und 2.1 Typisierte Sprachen in der Praxis . . 2.1.1 Java . . . . . . . . . . . . . . 2.1.2 C . . . . . . . . . . . . . . . . 2.1.3 Weitere typisierte Sprachen . 2.2 Vor- und Nachteile . . . . . . . . . . 3 Schluss: Fazit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 4 4 4 5 5 Laufzeitsystem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5 5 6 8 10 11 . . . . . . . . . . . . . . . . . . . . . . . . . 13 3 1 Einleitung: Typsysteme In der Informatik sind Programmiersprachen das zentrale Werkzeug. Durch sie kann der Programmierer mit dem Computer kommunizieren, Programme erstellen und Algorithmen ausdrücken. Eine Hochsprache soll dem Programmierer diese Arbeit erleichtern. Häufig wird von einem Typsystem bestimmt, welche verwendbaren Programmiertechniken existieren. In manchen Fällen kann das Laufzeitsystem dieses Niveau allerdings nicht halten. Dies wirkt sich dann an einigen Stellen negativ auf die Möglichkeiten der Hochsprache aus und schränkt die Qualität des Programms ein. Im Folgenden werden verschiedene Typsysteme vorgestellt und besonders auf ihre Eigenschaften und deren Folgen eingegangen. 1.1 Was sind Typsysteme? Viele Programmiersprachen stellen ein Typsystem zur Verfügung, welches Ausdrücke zur Übersetzungszeit anhand ihrer Struktur klassifiziert, um semantisches Fehlverhalten dieser Ausdrücke zu vermeiden. Dafür werden die Typregeln einer Programmiersprache spezifiziert, indem das Typsystem eine formale Grammatik für die Syntax dieser Sprache aufstellt. Dabei sind die Regeln unabhängig von den Algorithmen zur Typüberprüfung im Compiler, da sie zur Definition einer Sprache gehören. Verschiedene Compiler können dadurch für das selbe Typsystem mehrere typüberprüfende Algorithmen benutzen. Typisierte, im Gegensatz zu nicht typisierten Sprachen, führen also Programme nicht nur strikt aus, sondern prüfen ihre Beständigkeit [3]. 1.2 Einordnung von Typsystemen Programmiersprachen können in mehrere, sich überschneidende Kategorien aufgeteilt werden. Im Folgenden werde ich diese kurz aufzeigen. 1.2.1 Starke und Schwache Typisierung Als stark typisiert lassen sich alle Sprachen bezeichnen, die sicherstellen, dass der Zugriff auf alle Daten und Objekte typkonform bleibt[5]. Will man den Datentyp eines Objekts also manuell ändern, muss dies über Umwege geschehen. Dabei können die Daten, die im Speicher abgelegt werden, ausschließlich durch ihren ursprünglichen Datentyp interpretiert werden. Hierbei stellen stark typisierte Sprachen oftmals kein, oder nur wenige Mechanismen zur Datentypänderung bereit.Als Beispiel können die Sprachen Haskell, Java und Lisp genannt werden[5]. Im Gegensatz dazu weist eine schwach typisierte Sprache Lücken auf. Sprachen, die in diese Kategorie fallen, sind zum Beispiel C, C++ und Perl. Bei ihnen kann der Datentyp jederzeit uminterpretiert werden. Dies geschieht meistens mit type casts, der das Speicherabbild der Variable nicht verändert sondern schlicht uminterpretiert [5]. Die Verantwortung über die Sinnhaftigkeit des Programms obliegt dem Programmierer. Es gibt allerdings auch Ausnahmen, wie 4 beispielsweise eine Integer-Float-Konversion, bei der der C-Compiler eine Transformation des entsprechenden Objekts durchführt bevor die Zuweisung zu einer Variable geschieht. Diese Einteilung enthält allerdings Überschneidungen, da es auch möglich ist von Programmen mit starken Sprachen aus Funktionen aus schwachen Sprachen auszuführen. Als Beispiel sei hier das Java Native Interface genannt, welches C-Funktionen bereitstellt. 1.2.2 Statische und Dynamische Typisierung Eine Sprache heißt statisch typisiert, wenn die Variablen mit ihren Datentyp im Quelltext deklariert werden. Dabei kann dieser Variable ausschließlich ein Objekt zugewiesen werden, sofern dieses kompatibel mit dem statisch festgelegten Datentyp ist. Dies passiert in Sprachen wie Scala, Haskell, Java, C, C# und C++, wohingegen Perl, PHP und Phyton Beispiele für dynamische Typisierung sind.[7] [6] Bei ihnen sind die Variablen nicht an Objekte gebunden. Deshalb kann zu verschiedenen Zeitpunkten ein und dieselbe Variable unterschiedliche Objekte referenzieren. Bei dieser Aufteilung darf beispielsweise statische Typisierung nicht mit statischer Typprüfung verwechselt werden. Letzteres bezieht sich darauf, wann die Typüberprüfung stattfindet. Bei der statsichen Prüfung findet diese während der Übersetzungszeit statt, bei der Dynamischen hingegen erst zur Laufzeit. 1.2.3 Explizite und Implizite Typisierung Explizit typisiert ist eine Bezeichnung für Programmiersprachen, bei denen der Programmierer Elementen Typangaben zuordnet. Als Beispiel ist Java zu nennen, da der Programmierer hier meistens gezwungen ist, den Datentyp explizit anzugeben. In Sprachen wie beispielsweise Haskell ist es jedoch möglich Typen per Typinferenz zu bestimmen. Dies wird meist durch Prüfen der Typsignatur, die ein Typsystem zur Verfügung stellt, ermöglicht. 2 2.1 Hauptteil: Verbindung von Typ- und Laufzeitsystem Typisierte Sprachen in der Praxis Treten in einem Programm spontan Fehlersituationen auf, so regeln in den meisten Programmiersprachen Ausnahmebehandlungen den Umgang mit diesen Fehlern. Dabei gibt es Fehler wie ungültige Eingaben oder nicht gefundene Dateien, die geplante Ausnahmen genannt werden, und es gibt beispielsweise Speichermangel oder die Division durch Null, die als ungeplanten Ausnahmen bezeichnet werden. Verursacht ein C-Programm beispielsweise einen Divisondurch-Null-Fehler, dann wird dieser durch eine Interrupt Routine während der Laufzeit behandelt. Dabei ist eine sofortige Beendigung des Prozesses die Folge. In Java wird die Behandlung einiger Fehler dem Programmierer überlassen. Eine Division durch Null würde hier eine Exception werfen, welche abgefangen 5 werden kann. Dies bietet dem Programmierer die Chance, auf dieses Szenario zu reagieren.[5] Warum die Überprüfung erst zur Laufzeit stattfinden lässt sich leicht beantworten. Durch die späte Prüfung wird ein mächtigeres Verhalten zugelassen, wie z.B die richtige Verarbeitung von Erweiterungen zur Laufzeit und Typen, die normalerweise inkompatibel zueinander sind. Um einige Beispiele solcher Fehlersituationen zu zeigen, sollte folgende Tabelle einen Überblick über die Kategorisierung spezieller Programmiersprachen geben: [5],[8] Tabelle 1: Einordnung der Sprachen Statische Typisierung Dynamische Typisierung • C • PHP • C ++ • Perl Schwach • Delphi Stark 2.1.1 • Java • Python • C# • Smalltalk • Haskell • Lisp • ML • Scheme Java Java ist eine statische, explizite und starke Sprache. Als starke Sprache schützt Java seine Abstraktion, indem bei folgendem Code in Abhängigkeit von a eine Exception geworfen wird: Integer doSth (Object a, int b) { int add = (int)a + b; } Der Methode doSth werden zwei Parameter übergeben, eine Variable a vom Typ Object und eine Variable b vom Typ int. Der Datentyp von a ist kein Basistyp und es wird eine ClassCastException, oder sogar eine NullPointerException gemeldet, falls a ganz undefiniert ist. Beide Exceptions werden während der Laufzeit entdeckt, da das Laufzeitsystem erst während der Laufzeit testen 6 kann, welchen Typ die Variable a besitzt, die zur Kompilierzeit vom Typ Object ist. Folgendes Beispiel führt ebenfalls zu einer ClassCastException: public class Liste{ public static void main(String argv[]) { List myList = new ArrayList(); myList.add(new String(“Hel”)); myList.add(new String(“lo”)); myList.add(new Integer(42)); Iterator iterates = myList.iterator(); while(iterates.hasNext()) { String point = (String) iterates.next(); System.out.println(point); } } } Das Java Programm befüllt eine Liste mit Strings und einem Integer. Anschließend wird auf der Konsole ausgegeben. Dabei kann List als Klasse jedes Objekt aufnehmen, dass von der Klasse Object stammt. Diese bildet in Java das obere Ende der Klassenhierarchie. Dadurch wird List zu einem generischen Container zur Speicherung von Objekten. Diese Flexibilität wird durch das Typsystem zur Verfügung gestellt, jedoch wird zur Kompilierzeit nicht erkannt, dass der Typ der gespeicherten Listenelemente zum Abbruch des Programms führen kann. Ausgelöst wird dies durch den letzten Befehl, in dem ein Objekt vom Typ Integer angehängt wird. Allerdings erwartet die Schleife, welche die Liste ausgeben soll, ausschließlich gespeicherte Objekte vom Typ String. Zur Übersetzungszeit wird der Code fehlerfrei übersetzt, doch zur Laufzeit wird ein Typkonflikt erkannt. Das Programm bricht in der dritten Iteration der Schleife ab und es wird eine ClassCastException ausgegeben. Auch in diesem Fall wird ein Fehler während der Laufzeit entdeckt: int[] numbers = new int[10]; for(int i = 0; i ≤ numbers.length; i++) { numbers[i] = 0; } Hier wird ein Array erstellt, das 10 Elemente vom Typ int aufnehmen soll. Die Schleife, die durch diese Arrayelemente iteriert, löst letztendlich eine ArrayIndexOutOfBounds Exception aus, da sie im letzten Durchgang auf ein Element hinter dem letzten Arrayelement zugreifen will. Ein weiteres wichtiges Konzept ist die Typzerstörung oder type erasure. In Java werden generische Anwendungen vom Compiler übersetzt. Ein Code der ehemals folgendermaßen aussah public class Pocket<T>{ 7 private T value; public void set( T value ){ this.value = value; } public T get() { return value;} } wird nach dem Kompilieren so aussehen und an das Laufzeitsystem übergeben: public class Pocket { private Object value; public void set( Object value ){ this.value = value; } public Object get() { return value; } } Da die Laufzeitumgebung von Java keine Generics im Typsystem hat, löscht der Compiler alle Typinformationen. Diese bestehen somit zur Laufzeit nicht mehr. Die Folge davon ist allerdings, dass Folgendes nicht mehr möglich ist: class Pocket <T >{ T newPocketContent() { return new T(); } } Nach dem Kompilieren würde “new T()” nicht mehr funktionieren, da dieses durch “new Object()” ersetzt wurde und nun ein neues Objekt erstellt wird, obwohl man ein Objekt vom Typ T erwartet. 2.1.2 C In der statisch und schwach typisierten Sprache C treten häufig Fehler auf, die sich auf eine falsche Verwendung des Typsystems zurückführen lassen. Nachfolgend ist ein Beispiel dargestellt: static int mL{ // Angabe in Millilitern int mass = 50; return mass; } int main(int argc, char *argv[]) { // Angabe in Litern int water; water = mL(); 8 printf(“Water mass = %d Liter”, water); return 0; } Hier wird die Methode mL aufgerufen, die eine Variable vom Typ int zurückgibt. Diese Funktion wird in der main Methode aufgerufen und der Rückgabewert wird in der Variable water gespeichert. Anschließend wird der Wert von water auf der Konsole ausgegeben. Betrachtet man die Kommentare im Code, so stößt man auf einen Typkonflikt. Die main-Methode geht von einem Wert in der Einheit Liter aus, wobei die Methode mL von der Einheit Milliliter ausgeht. Durch die zeitgleiche Nutzung des Datentyps int wird der ’Fehler’ während der Kompilierzeit offensichtlich nicht erkannt. C bietet jedoch die Möglichkeit an, den typedef Konstruktor typedef int ml; typedef int liter; zu verwenden und somit die Bezeichnung der Datentypen benutzerdefiniert erzeugen zu lassen. Diese lassen sich genauso wie der Datentyp int verwenden. Modifiziert man die Methoden damit, lässt sich die Typinkonsistenz leichter erkennen und die Qualität durch höhere Transparenz steigern: typedef int ml; typedef int liter; static ml mL{ // Angabe in Millilitern ml mass = 50; return mass; } int main(int argc, char *argv[]) { // Angabe in Litern liter water; water = mL(); printf(“Water mass = %d Liter”, water); return 0; } Trotzdem wird der Typkonflikt noch nicht vom Kompilierer erkannt und das Programm wird weiterhin fehlerfrei übersetzt. Der Grund dafür liegt am typedef -Mechanischmus. Dieser definiert lediglich ein Synonym fà 14 r den bereits existierenden Datentyp. Eine endgültige mögliche Lösung des Problems ließe sich durch eine Kapselung finden, die bewirkt, dass die beiden Datentypen 9 während der Kompilierzeit unterschiedlich behandelt werden. Eine mögliche Umsetzung sieht folgendermaßen aus: typedef struct { int value; } ml; typedef struct { int value; } liter; static ml mL{ // Angabe in Millilitern ml mass = {50}; return mass; } int main(int argc, char *argv[]) { // Angabe in Litern liter water; water = mL(); printf(“Water mass = %d Liter”, water.value); return 0; } 2.1.3 Weitere typisierte Sprachen Die bereits vorgestellten Sprachen Java und C sind beide statisch typisierte Sprachen. Ihre Compiler sind oft größer und umfangreicher als bei dynamisch typisierten Sprachen wie Perl und PHP. Bei ihnen findet die Typüberprüfung erst zur Laufzeit statt. Im Perl Code $x = 3.5; $x = $x + “8-6”; $x = $x + “test”; ist x erst die Zeichenkette “3.5”, dann “11.5”, da nur die 8 addiert wurde und letztendlich bleibt x unverändert, da “test” nicht mit einer Zahl beginnt. An dieser Stelle geht deutlich hervor, dass das Typsystem von Perl solche Fälle nicht abdeckt. Genau wie Perl ist auch PHP eine dynamische, implizite und schwach typisierte Sprache. Sie verhält sich ähnlich wie Perl, was man an folgendem Beispiel erkennt: $x = 5; $x = 13 + “59”; $x = 10 + “test”; Hier wird x erst der Wert 5 zugewiesen. Dann wird die Zeichenkette in 10 eine Zahl umgewandelt und mit 13 addiert. $x hat nun den Wert 72. In der dritten Zeile wird die Zeichenkette “test” zu 0 gewandelt und $x nimmt den Wert 10 an. Die starke Sprache Python geht mit den selben Aufrufen anders um: x x x x = = = = 5; 13 + “59”; 13 + int(“59”); 10 + int(“test”); In der ersten Zeile wird x wieder der Wert 1 zugewiesen. Bei der Addition der Zahl mit der Zeichenkette würde jedoch der Laufzeitfehler TypeError erscheinen. Die Lösung des Problems ist das Umwandeln der Zeichenkette in einen Datentyp int. Dies ist in Python möglich und x nimmt den Wert 72 an. Bei dem Versuch eine Zeichenkette in einen int umzuwandeln, die nicht umwandelbar ist, tritt ein weiterer Laufzeitfehler, ValueError auf. Um noch ein Beispiel zu nennen, das eindeutig schwache von starken Sprachen unterscheidet, betrachte man diese Programmzeile: x = “5” + 6 Die Wertzuweisung von x hängt hier von der Sprache ab. Die schwach typisierte Sprache JavaScript wandelt den Wert 6 in einen String um und konkateniert beide Argumente. Das Ergebnis wäre der String “56” Die ebenfalls schwachen Sprachen PHP und Perl wandeln “5” in eine Zahl um und addieren beide Argument. Die Variable x hätte nun den Wert 11. In C würde der Wert “5” in einen Pointer gewandelt, der auf die Speicheradresse zeigt, in der der String gespeichert ist. Daraufhin würde die Adresse dann den Wert 6 addieren und es entstünde eine andere Speicheradresse. Obwohl C ebenfalls schwach ist, wäre eine Sicherheitsverletzung möglich. Eine stark Typisierte Sprache würde die Typinkonsistenz schon während dem Kompilieren erkennen oder aber eine Fehlermeldung anzeigen. Die Sprache BASIC würde das Programm gar nicht erst laufen lassen, da der Fehler schon vom Compiler erkannt und zurückgewiesen wird. Die starke Sprache Ruby würde während der Ausführung anzeigen, dass die Additionsoperation ill-typed ist. 2.2 Vor- und Nachteile Aufgrund der aufgezeigten Ausnahmebehandlungen stellt sich die Frage was für die jeweiligen Typisierungen spricht, da diese die Qualität des Programms beeinflussen. Die folgende Tabelle soll die Vorteile übersichtlich darstellen. Wie in Tabelle 2 aufgezeigt, profitieren statisch typisierte Sprachen von ihren zwingend vorhandenen Deklarationen, da der Compiler durch diese zu jeder Zeit den Variablentyp kennt. Dadurch werden Typinkonsistenzen durch simple Vergleiche schon während der Übersetzungszeit entdeckt. Darüber hinaus kann 11 Tabelle 2: Vor -und Nachteile der Typisierungen Vorteile Nachteile Statisch: • Geringe Flexibilität • Compiler erkennt Typinkonsistenz sofort • Deklaration der Datentypen kostet Zeit • Funktionen wie automatisch erzeugte Komfortfunktionen möglich Dynamisch: • Inkonsistenzen erst zur Laufzeit erkannt • Hohe Flexibilität • Steigerung der Produktivität • Geringe Typinformation • Geringere Zeitkosten Schwach • Hohe Fehleranfälligkeit • Flexibilität • Optimierungspotenzial Stark: • Erschwerte Datentypänderung • geringe Fehleranfälligkeit • Exceptions verhindern Systemabsturz (Java) es von Vorteil sein, den Datentyp genau zu kennen, wenn man den erzeugten Maschinencode zu optimieren versucht.[5] Zusätzlich können Entwicklungsumgebungen die Typinformationen ausnutzen, z.B für die Einfärbung des Quelltext während der Bearbeitung. Außerdem gehören zum heutigen Stand der Technik unter anderem auch Kontextmenüs, die alle Methoden auflisten, die für die aktuell ausgewählten Variablen zur Verfügung stehen. Dies kann mit nicht statisch festgelegten Datentypen nur eingeschränkt realisiert werden. Die Nachteile bei der statischen Typisierung sind gleichzeitig die Vorteile der dynamischen. So ziehen diese ihren Vorteil aus ihrer deutlich höheren Flexibilität. Die gesteigerte Produktivität, wie sie in Tabelle 2 aufgezählt wird, reicht daher, dass die Deklaration einer Variable nicht vor ihrer Verwendung stattfinden 12 muss. Der Begriff rapid prototyping wird oft mit dynamisch typisierten Sprachen verbunden, da diese sich durch die schnelle Erstellung von Programmen gut für die Erzeugung von Prototypen eignen. Auf der negativen Seite lässt sich aufführen, dass die geringe Typinformation auch das Optimierungspotenzial des Compilers einschränkt.[5] Schwach typisierte Sprachen bieten eine große Interpretationsfähigkeit für die im Speicher abgelegten Daten und ermöglichen dadurch eine hohe Flexibilität und ebenfalls Optimierungspotenzial. Da der Programmierer allerdings die volle Verantwortung für die Typkonsistenz trägt, entsteht eine erhöhte Fehleranfälligkeit.[5] Im Gegensatz dazu stellen stark typisierte Sprachen eingeschränkte oder sogar keine Mechanismen zur Verfügung, um den Variablentyp zu ändern. Dies zeichnet sich vorallem in der Sprache Haskell aus, bei der auf type-casts zur Veränderung der Datentypen verzichtet wird. Java stellt trotz ihrer starken Typisierung dabei eine Ausnahme dar. Laut Sprachdefinition ist es in Java erlaubt, einen Variablentyp zu ändern, was sie aus den stark typisierten Sprachen ausschließen sollte. Jedoch werden Inkonsistenzen hier durch die Laufzeitumgebung erkannt und Verletzungen durch Exceptions behandelt. Dadurch wird das Programm im Fehlerfall unterbrochen und es kommt nicht zum Systemabsturz.[5] 3 Schluss: Fazit Typisierungen einer Sprache helfen vor allem dem Programmierer, sei es durch Einfachheit, Lesbarkeit und Fehlererkennung. Typsysteme bringen Ordnung in das Chaos, das ensteht, wenn eine Vielzahl an Datenworte während der Laufzeit eines Programms verarbeitet werden. Heutzutage setzen fast alle Programmiersprachen das Prinzip der Typisierung ein, denn sie ist eines der mächtigsten Hilfsmittel zur Qualitätssicherung und entlastet den Programmierer, indem es ihm einige Verantwortung für ein korrekt funktionierendes Programm abnimmt. Wie sich in Kapitel 2.1 gezeigt hat, wirken sich Typsysteme oft auf die Laufzeitumgebung aus, denn selbst in stark typisierten Sprachen haben die benutzten Typsysteme Sicherheitslücken. Wie groß diese Lücken sein können wurde im Hauptteil aufgezeigt. Die Nachteile, die in Kapitel 2.2 aufgezeigt wurden, die jede Typisierung mit sich bringt, unterstützen die These. Auch Laufzeitsysteme haben ihr Grenzen. Obwohl die versuchen Fehler zu beheben, sei es durch Exception-Handler oder ähnliches, ist dies nicht in jedem Fall möglich und es kommt zum Systemabsturz. Letztendlich ist festzuhalten, dass es negative Auswirkungen auf das Laufzeitsystem gibt, aber auch positive. Wie Programmierer zukünftig mit diesen Auswirkungen umgehen, wird sich erst noch zeigen. 13 Literatur [1] Gilbert Brands. Das C++ Kompendium. Springer-Verlag, 2005. [2] Danny Goodman. JavaScript Bible. Hungry Minds, 2001. [3] Robert Harper. Practical Foundations for Programming Languages. Cambridge University Press, 2012. [4] Tobias Hauser. JavaScript Kompendium. Markt+Technik Verlag, 2003. [5] Dirk W. Hoffmann. Software-Qualität . Springer Verlag, 2008. [6] Liberty J. Programmieren mit C#. O’Reilly and Associates, 2005. [7] P. Pepper. Funktionales Programmieren in OPAL, ML, HASKELL, GOFER. Springer Verlag, 2003. [8] Benjamin J. Pierce. Types and Programming Languages. The MIT Press, 2002. [9] Zhong Shao. Programming Languages and Systems. Springer-Verlag, 2014. [10] H. Trauboth. Software-Qualitätssicherung. Oldenburg-Verlag, 1996. [11] Peter Wegner. Concepts and paradigms of objectoriented programming. 1990. 14