Suche
Funktionale Programmierung in Java

Effective Java: Im Zeichen der 8

Angelika Langer, Klaus Kreft
14_langer_ssmedia_0
©S&SMedia

Als Java-Entwickler beschreiben wir, wie Objekte aussehen, wenn wir eine Klasse definieren, d. h. wir legen fest, welche Daten den Zustand eines Objekts beschreiben und welche Methoden die Fähigkeiten eines Objekts ausmachen. Wir erzeugen Objekte, wenn wir von den Klassen Instanzen bilden. Wir verändern Objekte beispielsweise, wenn wir die Felder ändern oder Methoden aufrufen, die dies tun. Wir reichen Objekte herum, wenn wir sie z. B. als Argumente an Methoden übergeben. Mit Objekten sind wir als Java-Entwickler bestens vertraut.

In funktionalen Sprachen (wie zum Beispiel Erlang oder Haskell) stehen nicht Objekte, sondern Funktionen im Vordergrund. Funktionen ähneln Methoden; sie repräsentieren ausführbare Funktionalität. Sowohl Methoden als auch Funktionen werden aufgerufen und ausgeführt. Funktionen in funktionalen Sprachen werden darüber hinaus herumgereicht. Man übergibt sie beispielsweise als Argumente an Operationen; diese Operationen können dann die übergebenen Funktionen in einem bestimmten Kontext aufrufen. Funktionen können auch als Return-Wert einer Operation zurückgegeben werden. Das heißt, in funktionalen Sprachen werden Funktionen herumgereicht wie Objekte in objektorientierten Sprachen. Dieses Prinzip des Herumreichens von Funktionen wird auch als „Code-as-Data“ bezeichnet.

Funktionen werden aber nicht nur übergeben und aufgerufen, sondern auch kombiniert und verkettet oder manipuliert und verändert. Es gibt z. B. das sogenannte Currying (benannt nach Haskell Brooks Curry), bei dem aus einer Funktion mit mehreren Argumenten durch Argument-Binding eine Funktion mit einem Argument gemacht wird. In einer reinen funktionalen Sprache (pure functional language) haben die Funktionen nicht einmal Seiteneffekte. Das heißt insbesondere, dass Funktionen keine Daten verändern, sondern bestenfalls neue Daten erzeugen.

Soviel zur Theorie. Was fängt man damit in der Praxis an? Kann man funktionale Prinzipien in Java überhaupt gebrauchen? Zur Illustration wollen wir uns ein Idiom ansehen, bei dem Funktionen eine wesentliche Rolle spielen und das auch in Java recht nützlich sein kann. Es geht um das Execute-Around-Method-Pattern.

Copyright @ 2013-2014 by Angelika Langer & Klaus Kreft. All rights reserved

[ header = Seite 2: Das Execute-Around-Method-Pattern ]

Das Execute-Around-Method-Pattern

Bei dem Execute-Around-Method-Pattern [1], [2] geht es darum, strukturell ähnlichen Code so zu zerlegen, dass die immer wiederkehrende, identische Struktur herausgelöst und in eine Hilfsmethode ausgelagert wird. Der Teil, der in dieser Struktur variiert, wird an die Hilfsmethode übergeben und in dieser Hilfsmethode, eingebettet in die Struktur, an der richtigen Stelle aufgerufen. Beispiele für solche wiederkehrenden Strukturen gibt es viele:

Verwendung von Ressourcen: Wenn eine Ressource verwendet wird, dann ergibt sich oft eine wiederkehrende Struktur, nämlich:

acquire resource

use resource

release resource

Das Anfordern und Freigeben der Ressource ist oft identisch, aber die Benutzung dazwischen variiert. Ein Beispiel für solche Ressourcen sind die expliziten Locks wie zum Beispiel das ReentrantLock. Hier ist die immer gleiche Struktur, die sich bei der Benutzung von expliziten Locks ergibt:

lock.lock();
try {
.. critical region ...
} finally {
  lock.unlock();
}

Das Anfordern und Freigeben des Locks ist immer gleich, nur die Anweisungen dazwischen variieren.

Exception Handling: Wenn Operationen aus einem bestimmten Framework verwendet werden, dann werfen sie oft die gleichen Exceptions, die immer gleich behandelt werden:

try {
  ... invoke operations ...
} catch (ExceptionType_1 e1) { ... }
catch (ExceptionType_2 e2) { ... }
catch (ExceptionType_3 e3) { ... }

Die catch-Klauseln sind immer gleich, aber die im try-Block aufgerufenen Operationen sind unterschiedlich.

Iterierung: Es wird ein Iterator angefordert und aufs jeweils nächste Element in einer Sequenz weitergeschaltet, bis das letzte Element erreicht ist:

Iterator iter = seq.iterator();
while (iter.hasNext()) {
  Object elem = iter.next();
  ... use element ...
}

Die Handhabung des Iterators ist immer gleich; lediglich die Verwendung des jeweiligen Elements variiert.

Beim Execute-Around-Method-Pattern wird der gemeinsame, wiederkehrende Teil in eine Hilfsmethode ausgelagert. Der veränderliche Teil wird als Argument an die Hilfsmethode übergeben. Wir wollen dies am Beispiel der Iteration demonstrieren. Wir definieren eine Hilfsmethode forEach:

public class Utilities {
  public static <E> void forEach(Iterable<E> seq, Consumer<E> block) {
    Iterator<E> iter = seq.iterator();
    while (iter.hasNext()) {
      E elem = iter.next();
      block.accept(elem);
    }
  }
}

Die Hilfsmethode forEach enthält den strukturell wiederkehrenden Teil, nämlich das Anfordern des Iterators, die Abfrage auf das Ende der Sequenz und das Weiterschalten des Iterators auf das jeweils nächste Element. Die Verwendung des Elements ist der sich unterscheidende Teil. Er wird von außen an die Hilfsmethode übergeben und in der Hilfsmethode an entsprechender Stelle aufgerufen. Die Hilfsmethode forEach bekommt deshalb als Argumente die Sequenz der Elemente, auf der man iterieren will, und die Funktionalität, die während der Iterierung auf jedes Element in der Sequenz angewandt werden soll. Für die Beschreibung der Funktionalität, die auf jedes Element angewandt wird, definieren wir ein Interface Consumer:

public interface Consumer<T> {
  void accept(T t);
}


Dieses Interface gibt es tatsächlich in Java 8 im Package java.util.function. Mit dieser Zerlegung in die Hilfsmethode mit dem strukturell identischen Teil und das Interface mit dem variierenden Teil brauchen wir die Iterierung nicht mehr redundant hinschreiben. Hier ist ein Benutzungsbeispiel. Wir wollen alle Elemente aus einer Liste von Zahlen ausgeben. Herkömmlich sieht es so aus:

List<Integer> numbers = new ArrayList<>();
... populate list ...
Iterator iter = numbers.iterator();
while (iter.hasNext()) {
  Integer elem = iter.next();
  System.out.println(elem);
}


Gemäß Execute-Around-Method-Pattern sieht es so aus:

List<Integer> numbers = new ArrayList<>();
... populate list ...
Utilities.forEach(numbers, new Consumer<Integer>() {
  public void accept(Integer elem) {
    System.out.println(elem);
  }
});



Nun mag man sich fragen, was an der Execute-Around-Method-Version besser sein soll als an der guten alten Iterierung per Schleife. Mit klassischen Java-Mitteln, so wie sie uns in Java 7 zur Verfügung stehen, ist nichts gewonnen. Man muss eine Implementierung des Consumer-Interface definieren, um den Consumer an die Hilfsmethode forEach zu übergeben, und das ist selbst unter Verwendung von anonymen inneren Klassen noch recht umständlich. Genau diese syntaktische Umständlichkeit verschwindet in Java 8 mit den Lambda-Ausdrücken [3], [4]. In Java 8 mit einem Lambda-Ausdruck sieht es viel eleganter aus:

List<Integer> numbers = new ArrayList<>();
... populate list ...
Utilities.forEach(numbers, e -> System.out.println(e));



Es geht auch noch eleganter mithilfe von Methodenreferenzen – einem weiteren neuen Sprachmittel in Java 8. So sieht es dann in Java 8 mit einer Methodenreferenz aus:

List<Integer> numbers = new ArrayList<>();
... populate list ...
Utilities.forEach(numbers, System::println);


Auf die Syntax von Lambda-Ausdrücken wie e -> System.out.println(e) und Methodenreferenzen wie System.out::println wollen wir in diesem Beitrag nicht näher eingehen. Das besprechen wir im nächsten Artikel der Serie im Detail. Aber auch ohne große Erläuterung kann man intuitiv verstehen, dass der Lambda-Ausdruck so etwas Ähnliches wie eine Funktion ist. Er nimmt ein Argument mit Namen e, dessen Typ sich der Compiler selbst überlegen kann. Augenscheinlich soll es ein Integer aus der Liste sein. Dieses Argument e wird per System.out.println-Methode ausgegeben. Auf diese Weise wird die forEach-Methode alle Zahlen in der Liste numbers mit println auf System.out ausgeben.

Die Methodenreferenz ist auch selbsterklärend. System.out::println ist die println-Methode von System.out. In der forEach-Methode sollen also alle Elemente mit println nach System.out ausgegeben werden.

Im JDK 8 wird es solche Hilfsmethoden wie forEach geben. Sie sind dann aber nicht in irgendwelchen Utility-Klassen definiert, sondern die Collections selbst sind erweitert worden. In Java 8 hat jede Collection aus dem java.util-Package eine forEach-Methode, und zwar erbt sie diese von ihrem Superinterface Iterable. Das Iterable-Interface ist für Java 8 erweitert worden und sieht in Java 8 so aus:

public interface Iterable<T> {
  Iterator<T> iterator();

  default void forEach(Consumer<? super T> action) {
    for (T t : this) {
      action.accept(t);
    }
  }
} 



Das Iterable-Interface hat zusätzlich zur iterator-Methode, die es schon immer hatte, eine forEach-Methode bekommen. Um die existierenden Interfaces im JDK wie oben gezeigt erweitern zu können, hat man mit Java 8 die so genannten Default-Methoden erfunden. Darauf werden wir in einem der Folgebeiträge genauer eingehen. Hier nur ganz kurz: Normalerweise kann man ein Interface nicht problemlos erweitern. Wenn man Methoden hinzufügt, dann müssen alle abgeleiteten Klassen diese Methode implementieren. Andernfalls gibt es Fehlermeldungen bei der Kompilierung. Die Default-Methoden sind nun Methoden, die eine Implementierung haben. Das heißt, sie sind nicht abstrakt und müssen von den abgeleiteten Klassen auch nicht implementiert werden. Alle Klassen, die keine Implementierung für die neue zusätzliche Methode haben, erben einfach die Default-Implementierung aus dem Interface. Auf diese Weise kann man existierende Interfaces erweitern, ohne die abgeleiteten Klassen ändern zu müssen. Die forEach-Methode im Iterable-Interface ist eine solche Default-Methode. Sie hat eine Implementierung. Sie verwendet in der Implementierung die for-each-Schleife, die es seit Java 5 gibt und die intern einen Iterator verwendet. Die forEach-Methode im Interface Iterable entspricht der forEach-Methode aus unserer Utilities-Klasse, mit dem kleinen Unterschied, dass sie das erste Argument nicht braucht, weil sie als nicht statische Methode der Collection ohnehin über die this-Referenz auf die Collection zugreifen kann. Das Beispiel von oben sieht in Java 8 letztendlich so aus:

List<Integer> numbers = new ArrayList<>();
... populate list ...
numbers.forEach(System.out::println);


Die herkömmliche Art der Iterierung mit einem expliziten Iterator bezeichnet man im Übrigen als externe Iterierung, im Gegensatz zur internen Iterierung in der forEach-Methode einer Collection [5]. Bei der externen Iterierung wird der Iterator an den externen Benutzer einer Collection gegeben und der Benutzer bestimmt, wie er den Iterator verwendet, um alle Elemente der Sequenz zu besuchen. Bei der internen Iterierung bestimmt die Collection selbst, wie sie in ihrer forEach-Methode alle Elemente besucht. Das kann sie mit einem Iterator machen, so wie wir es im Beispiel gesehen haben. Sie kann es aber auch ganz anders machen, zum Beispiel parallel mit vielen Threads statt sequenziell mit nur einem Thread.

Genau die parallele Ausführung von Operationen wie forEach ist der wesentliche Grund dafür, dass man die Sprache um Lambda-Ausdrücke erweitert hat. Eines der Ziele in Java 8 ist die bessere Unterstützung von Parallelverarbeitung. Deshalb wird es neue Abstraktionen im JDK-Collection-Framework geben, nämlich so genannte Streams. Diese Streams haben Operationen wie forEach (oder auch sort, filter etc.) mit interner Iterierung, die wahlweise sequenziell oder parallel ausgeführt werden können. Die Streams und ihr umfangreiches API werden wir uns in einem der nachfolgenden Beiträge im Detail ansehen.

Copyright @ 2013-2014 by Angelika Langer & Klaus Kreft. All rights reserved

[ header = Seite 3: Funktionale Programmierung in Java ]

Funktionale Programmierung in Java

In dem oben geschilderten Beispiel der internen Iterierung bzw. des Execute-Around-Method-Patterns sieht man typische Elemente der funktionalen Programmierung. Beispielsweise sieht man das „Code-as-Data“-Prinzip: die forEach-Methode benötigt als Argument eine Funktion, die auf alle Elemente der Collection angewandt werden soll. Es wird zwar streng genommen ein Objekt als Argument übergeben, aber dieses Objekt repräsentiert Funktionalität. Das einzige, was an dem Objekt interessant ist, ist die eine Methode, die es mitbringt und die auf alle Elemente der Sequenz angewandt werden soll. In diesem Sinne ist das Consumer-Argument der forEach-Methode eine Funktion. In Java 7 muss dafür umständlich eine anonyme innere Klasse definiert werden. In Java 8 mit den Lambda-Ausdrücken und Methodenreferenzen sieht die Funktionalität optisch und syntaktisch so aus, wie man sich eine Funktion vorstellt.

Allerdings gehen die Spracherweiterungen in Java 8 nicht so weit, dass aus Java nun eine funktionale Sprache wird. Viele Dinge, die es in funktionalen Sprachen gibt, wird es in Java nicht geben. Genauer gesagt: Es wird sie zunächst einmal nicht geben. Es ist nicht ausgeschlossen, dass es in Java 9, 10 oder später weitergehende Unterstützung für funktionale Programmierung in Java geben wird. Die Sprachdesigner haben darauf geachtet, dass der Weg für zukünftige Erweiterungen nicht verbaut wird. Aber für Java 8 wurde versucht, die Spracherweiterung erst einmal minimalistisch zu gestalten und nur das hinzuzufügen, was für die Weiterentwicklung des JDK zwingend erforderlich ist.

Beispielsweise hat Java keine Funktionstypen. Die Sprachdesigner haben es bewusst vermieden, das Typsystem von Java umzukrempeln und um eine völlig neue Kategorie von Typ zu erweitern. Stattdessen hat man sich überlegt, wie der Compiler es bewerkstelligen kann, als Typ für Lambda-Ausdrücke und Methodenreferenzen ganz „normale“ Typen (d. h. Klassen oder Interfaces) zu verwenden.

Target Typing und SAM Types: Sehen wir uns die Einbettung von Lambda-Ausdrücken und Methodenreferenzen ins Java-Typsystem anhand unseres Beispiels an. Noch einmal das Beispiel von oben:

List<Integer> numbers = new ArrayList<>();
... populate list ...
numbers.forEach(System.out::println);


Der Compiler geht prinzipiell so vor: Er schaut sich den Kontext an, in dem ein Lambda-Ausdruck oder eine Methodenreferenz steht, überlegt, welcher Typ von Objekt an dieser Stelle benötigt wird, und deduziert daraus den Typ für den Lambda-Ausdruck oder die Methodenreferenz.

In unserem Beispiel findet er die Methodenreferenz System.out::println als Argument im Aufruf der forEach-Methode. Der Compiler schaut sich also den deklarierten Argumenttyp der forEach-Methode an. Weil es die forEach-Methode einer List<Integer> ist, stellt der Compiler fest, dass für den Methodenaufruf ein Objekt vom Typ Consumer<Integer> benötigt wird. Consumer<Integer> ist ein Interface mit einer einzigen abstrakten Methode, nämlich der accept-Methode. Nun prüft der Compiler, ob die Signatur der accept-Methode kompatibel zur Methodenreferenz System.out::println ist. Die accept-Methode von Consumer<Integer> nimmt ein Argument vom Typ Integer, gibt void zurück und wirft keine checked-Exceptions. Das passt zu unserer Methodenreferenz System.out::println. Die println-Methode ist überladen und unter all den vielen println-Varianten gibt es eine, die ein Argument vom Typ Integer nimmt, void zurückgibt und keine checked-Exceptions wirft. Das heißt, die accept-Methode aus dem Consumer<Integer>-Interface hat dieselbe Signatur wie die Methodenreferenz System.out::println. Der Compiler schließt daraus, dass die Referenz System.out::println in diesem Kontext vom Typ Consumer<Integer> ist.

Diesen Prozess der Deduktion des Typs eines Lambda-Ausdrucks oder einer Methodenreferenz wird als Target Typing bezeichnet, weil dabei der Zieltyp (Target Type) für den Ausdruck oder die Referenz aus dem Kontext ermittelt wird. Für Lambda-Ausdrücke funktioniert das Target Typing ganz analog.

Interfaces wie Consumer mit einer einzigen abstrakten Methode heißen übrigens Functional Interface Types (oder auch SAM Types, wobei SAM für Single Abstract Method steht). Die SAM-Typen spielen beim Target Typing eine wesentliche Rolle. Sie sind nämlich die einzigen Typen, die als Zieltypen in Frage kommen.

Über diesen Trick mit den SAM-Typen und der Deduktion eines kontextabhängigen Zieltyps konnte es vermieden werden, gravierend in das Typsystem von Java einzugreifen. Deshalb gibt es in Java – anders als in funktionalen Sprachen – keine spezielle Kategorie von Typen, mit denen man Funktionen oder Funktionssignaturen beschreiben könnte.

Seiteneffekte: Das Fehlen von echten Funktionstypen ist aber nur eine Eigenart, die funktionale Programmierung in Java von funktionalen Sprachen unterscheidet. In reinen funktionalen Sprachen sind die Funktionen stets frei von Seiteneffekten. Insbesondere modifiziert eine reine Funktion keine Daten, sondern produziert ein Ergebnis. Das ist in Java natürlich anders. Es gibt in Java gar keine Möglichkeit, eine Funktion daran zu hindern, Felder oder Variablen zu modifizieren.

In unserem Beispiel haben unsere Lambda-Ausdrücke und Methodenreferenzen zwar nichts modifiziert, aber einen Seiteneffekt, nämlich die Ausgabe auf System.out, haben sie dennoch produziert. Für diese Funktionen macht es einen Unterschied, ob sie mehrfach aufgerufen werden oder in welcher Reihenfolge sie aufgerufen werden, denn es hat Einfluss auf die Ausgabe. Bei einer reinen Funktion, die keinerlei Seiteneffekte hat, wäre es völlig egal, wie oft und in welcher Reihenfolge sie ausgeführt wird. Eine reine Funktion wäre beispielsweise folgender Lambda-Ausdruck:

IntPredicate isEven = (int i) -> { return i%2==0; };

Dabei ist IntPredicate ein SAM-Typ aus dem Package java.util.function mit einer einzigen abstrakten Methode, die so aussieht: boolean test(int value).

Dieser Lambda-Ausdruck (int i) -> { return i%2==0; } nimmt einen int-Wert und liefert true zurück, wenn es eine gerade Zahl ist, andernfalls false. Hier wird überhaupt kein Seiteneffekt ausgelöst. Es wird einfach nur ein Wert genommen und ein boolesches Ergebnis zurückgeliefert. Diese Funktion kann aufgerufen werden, so oft man will und in jeder beliebigen Reihenfolge. Es macht überhaupt keinen Unterschied. Hier zum Kontrast ein Lambda-Ausdruck, der Modifikationen macht:

List<Point> points = new ArrayList<>();
... populate list ...
points.forEach(p -> { p.x = 0; });

Hier werden alle Elemente der Sequenz modifiziert; es sind Point-Objekte, deren x-Koordinate in dem Lambda-Ausdruck geändert wird. Das ist im Sinne der funktionalen Programmierung schlechter Stil, kann aber in Java nicht verhindert werden. Wenn das Argument einer Funktion eine Referenz auf ein veränderliches Objekt ist, dann kann die Funktion Modifikationen machen. Java hat einfach keine Sprachmittel, um solche Modifikationen zu verhindern.

Solche Lambda-Ausdrücke sind u. U. problematisch. Wir werden in nachfolgenden Beiträgen erläutern, warum. Aber bereits hier sollte schon klar sein, dass man mit modifizierenden Lambda-Ausdrücken leicht Fehler machen kann. Hier ist eine solche Fehlersituation:

List<Point> points = new ArrayList<>();
... populate list ...
points.forEach(p -> points.add(new Point(0,p.y)));

In dem Lambda-Ausdruck werden während der Iterierung neue Elemente in die Collection eingefügt. Das scheitert zur Laufzeit mit einer ConcurrentModificationException.

Copyright @ 2013-2014 by Angelika Langer & Klaus Kreft. All rights reserved

[ header = Seite 4: Zusammenfassung und Ausblick ]

Zusammenfassung und Ausblick

Java 8 hat neue Sprachmittel, die in gewissem Umfang funktionale Programmierung in Java unterstützen. Die betreffenden Sprachmittel sind Lambda-Ausdrücke und Methodenreferenzen. Wir haben uns in diesem Beitrag das Execute-Around-Method-Pattern sowie die interne Iterierung als Spezialfälle davon angesehen. Beide sind Idiome, die von den neuen Sprachmitteln profitieren. Mit Lambda-Ausdrücken und Methodenreferenzen sind sie wesentlich einfacher zu benutzen. In Java 8 unterstützen die Collections interne Iterierung. Genau für diese Erweiterungen der Collections im JDK sind die neuen Sprachmittel entwickelt worden. Im nächsten Beitrag sehen wir uns die Lambda-Ausdrücke und Methodenreferenzen genauer an.

Copyright @ 2013-2014 by Angelika Langer & Klaus Kreft. All rights reserved

Geschrieben von
Angelika Langer
Angelika Langer
  Angelika Langer arbeitet selbstständig als Trainer mit einem eigenen Curriculum von Java- und C++-Kursen. Kontakt: http://www.AngelikaLanger.com.
Klaus Kreft
Klaus Kreft
  Klaus Kreft arbeitet selbstständig als Consultant und Trainer. Kontakt: http://www.AngelikaLanger.com.
Kommentare
  1. Simon2014-03-24 19:10:03

    "Code-As-Data" beschreibt nicht wie im Artikel genannt das herumreichen von Funktionen (--> "Higher Order functions") vielmehr kommt "code-as-data" aus dem Lisp Bereich da dort mittels Makros der Code direkt verändert werden kann bzw ein String eben als String oder als ausführbarer Code betrachtet werden kann.

    1. Angelika Langer2014-03-27 19:38:40

      "code as data" war nicht in Anlehnung an Lisp gemeint, sondern so wie es auch die Sprachdesigner bei Oracle verwenden (siehe z.B. http://stuartmarks.files.wordpress.com/2012/10/con5089_marks_jumpstartinglambda-v3.pdf, Seite3). Es heißt einfach nur: Lambdas ermöglichen es uns, Code (oder Funktionalität) herumzureichen - also mit Code das zu tun, was wir in einer objekt-orientierten Sprache wie Java bislang nur mit Daten (oder Objekten) gemacht haben.

  2. Simon2014-03-24 19:10:03

    "Code-As-Data" beschreibt nicht wie im Artikel genannt das herumreichen von Funktionen (--> "Higher Order functions") vielmehr kommt "code-as-data" aus dem Lisp Bereich da dort mittels Makros der Code direkt verändert werden kann bzw ein String eben als String oder als ausführbarer Code betrachtet werden kann.

    1. Angelika Langer2014-03-27 19:38:40

      "code as data" war nicht in Anlehnung an Lisp gemeint, sondern so wie es auch die Sprachdesigner bei Oracle verwenden (siehe z.B. http://stuartmarks.files.wordpress.com/2012/10/con5089_marks_jumpstartinglambda-v3.pdf, Seite3). Es heißt einfach nur: Lambdas ermöglichen es uns, Code (oder Funktionalität) herumzureichen - also mit Code das zu tun, was wir in einer objekt-orientierten Sprache wie Java bislang nur mit Daten (oder Objekten) gemacht haben.

  3. Landei2014-03-25 10:00:45

    Der Code-Highlighter scheint die Generics aufzufressen (zumindest in meinem Chrome sehe ich keine)...

  4. Landei2014-03-25 10:00:45

    Der Code-Highlighter scheint die Generics aufzufressen (zumindest in meinem Chrome sehe ich keine)...

  5. Redaktion JAXenter2014-03-26 12:44:46

    Oh ja tatsächlich - der Code Highlighter hat uns die spitzen Klammern aufgefressen! Jetzt sollten die Listings aber alle korrekt sein!

    Vielen Dank für den Hinweis!

  6. Redaktion JAXenter2014-03-26 12:44:46

    Oh ja tatsächlich - der Code Highlighter hat uns die spitzen Klammern aufgefressen! Jetzt sollten die Listings aber alle korrekt sein!

    Vielen Dank für den Hinweis!

Hinterlasse eine Antwort

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind markiert *

Du kannst folgende HTML-Tags benutzen: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>