Samstag, September 27, 2008

Zuweisung, Verzögerung, Rekursion - alles Lambda, oder was?!

Es mag Spaß machen, einigermaßen komplexe Programme in eine einzige, unleserliche Zeile zu stopfen. In der Programmiersprache Perl sind die "one-liners" ja beinahe Kult.

Von geradezu didaktischem Wert ist die Konstruktion von Einzeilern für Python von Dan Piponi. Bei ihm lernt man, was die Mächtigkeit von Lambda-Ausdrücken ausmacht. Zuweisung, Verzögerung, Rekursion -- das alles kann man leicht mit Lambdas ausdrücken. Mit ihrer Hilfe kann man sich beliebige Einzeiler basteln, siehe "On writing Python one-liners".

Nicht, dass ich Sie zu Einzeilern ermutigen möchte. Aber Lambda-Ausdrücke sollte jeder Programmierer kennen. Sie sind die Wunderwaffe der funktionalen Programmierer!

Dienstag, September 23, 2008

Objekt-orientierte Parser-Kombinatoren in Python

Ich möchte Ihnen eine Technik zur Syntaxanalyse vorstellen, die zwar gar nicht so neu ist, aber immer wieder Aufmerksamkeit erregt. Zum Beispiel in der Smalltalk-Gemeinde durch einen Beitrag von Gilad Bracha zu Newspeak ("Executable Grammars in Newspeak"). In der Haskell-Welt machen Parser-Kombinatoren und die Forschung zu ihnen immer wieder von sich reden, siehe z.B. aktuell "Parser Combinators for Ambiguous Left-Recursive Grammars" von R.A. Frost, R. Hafiz und P. Callaghan, PADL 2008.

Im Folgenden setze ich voraus, dass Sie wissen, was ein Parser ist und Ihnen auch die EBNF zur Darstellung von Grammatiken bekannt ist. Wenn wir weiter unten von einer Parser-Funktion reden, dann meinen wir eine Funktion, die einen String entgegennimmt, diesen parst und ein geeignetes Ergebnis der Parse-Analyse zurückgibt.

Der Begriff des Kombinators kommt aus der Ecke der funktionalen Programmiersprachen. Ein Kombinator ist eine Funktion höherer Ordnung, eine Higher-Order Function (HOF), die selbst eine Funktion zurückgibt. Eine HOF ist eine Funktion, die Funktionen als Argumente entgegennimmt und gegebenenfalls eine Funktion zurückgibt. Von einem Kombinator wird dann gesprochen, wenn die HOF syntaktisch als Infix notiert werden kann. Anschaulich: Statt der bei Funktionen verbreiteten Prefix-Notation combinatorX(functionA,functionB) steht in der Infix-Notation der Kombinator in der Mitte: functionA combinatorX functionB. Damit wird hervorgehoben, dass der Kombinator, ähnlich einem Operator wie etwa "+", zwei Funktionen "kombiniert".

Ein Parser-Kombinator verknüpft Parser-Funktionen und liefert selber eine Parser-Funktion zurück. Nehmen wir an, wir haben eine Parser-Funktion token(), die Token erkennt. Angenommen, der Parser-Kombinator für Sequenzen oder Folgen von Parser-Funktionen kann mit einem Komma "," notiert werden. So lässt sich dann sehr kompakt ein Parser ausdrücken, der zwei Token-Parser als Sequenz kombiniert: token("Hell"),token("o"). Mit dieser Schreibweise kann die Struktur einer EBNF-Beschreibung einer Grammatik direkt als Kombination von Parser-Funktionen ausgedrückt werden. Die Funktionen bilden die Struktur einer EBNF-Beschreibung nach ("function follows form").

Da der Parser-Kombinator selber wieder ein Parser ist, können wir ihn mit einem String aufrufen: (token("Gut"),token("en"))("Guten Tag!")

In vielen Programmiersprachen ist es nicht so ohne weiteres möglich, die Syntax zur Schreibweise von Funktionen zu ändern. Eine Infix-Notation verbietet sich meist. So bleibt einem nur, Parser-Kombinatoren in der nicht ganz so eleganten Prefix-Notation zu nutzen. Ein sehr schönes Beispiel, wie man mit Python Parser-Kombinatoren bauen kann, liefert ein Autor namens "Stefan" (Kürzel "sma") im deutschen Python-Forum in dem Beitrag "Kleine Parser-Kombinator-Bibliothek" (16. Feb. 2008, 16:31). Wenn Sie diesen Beitrag lesen, werden Sie das Folgende besser verstehen.

Will man einen Parser-Kombinator konsequent objekt-orientiert anlegen, dann wird die Idee der Kombination von Parser-Funktionen abgelöst durch eine Kombination von Parser-Objekten, die wieder ein Parser-Objekt statt einer Parser-Funktion liefern. Die Kombination von Objekten, die wieder ein Objekt zurückliefern ist sehr elegant und einfach realisierbar durch Konstruktor-Methoden.

Werden wir konkret. Mit den folgenden zwei Parser-Klassen führen wir einen Token-Parser bzw. einen Parser für reguläre Ausdrücke ein. In Python kann man Objekte aufrufbar (callable) machen, indem man sie mit einer __call__-Methode ausstattet. Ein Objekt wird damit gleichermaßen zu einer "zustandsbehafteten Funktion". Die __repr__-Methode dient zur Überschreibung der standardmäßigen Selbstrepräsentation eines Objekts, vergleichbar mit der "toString"-Methode in anderen Sprachen.

import re, types

class Parser(object): pass

class Token(Parser):
def __init__(self,token,annotation=None):
self.token, self.annotation = token, annotation
assert isinstance(annotation,types.StringType) or annotation == None
def __call__(self,text):
if text.startswith(self.token):
return Node(self,self.token), text[len(self.token):]
return None, text
def __repr__(self):
return "Token('" + str(self.token) + "')"

class RegExp(Parser):
def __init__(self,regexp,annotation=None):
self.regexp, self.annotation = re.compile(regexp), annotation
assert isinstance(annotation,types.StringType) or annotation == None
def __call__(self,text):
match = self.regexp.match(text)
if match: return Node(self,match.group()),text[match.end():]
return None, text
def __repr__(self):
return "RegExp(" + str(self.regexp.pattern) + ")"

Schauen wir uns einmal den Gebrauch der Token-Klasse über die Kommandozeile an:

>>> Token("Hey")
Token('Hey')
>>> Token("Hey")("Hey!")
(Hey, '!')
>>> Token("Hey")("Heu")
(None, 'Heu')
>>>

Der Token-Parser liefert immer ein Tupel zurück. Der erste Teil des Tupels ist None, wenn der Parser nicht erfolgreich ist. Bei Erfolg wird ein Knoten-Objekt zurückgegeben. Der zweite Teil des Tupels ist der String-Anteil, den es noch zu parsen gilt. Bei Erfolg ist es der Rest-String (hier "!"), bei Misserfolg der gesamte übergebene String ("Heu").

Hier, der Vollständigkeit halber, der Code zu Node, den Sie der Parser-Klasse voranstellen müssen:

class Node(object):
def __init__(self,parser,result):
self.parser = parser
self.result = result
def __repr__(self): # reconstruct the parsed input
if self.result == None: return ""
if isinstance(self.result,types.TupleType):
assert all([isinstance(e,Node) for e in self.result])
return "".join([str(element) for element in self.result])
return str(self.result)

Nun wird es interessant. Kommen wir zu den Kombinatoren der Parser-Objekte um Sequenzen bzw. Alternativen abzubilden:

class NaryOperator(Parser):
def __init__(self,annotation="",*parsers):
assert all([isinstance(parser,Parser) for parser in parsers])
assert isinstance(annotation,types.StringType)
self.parsers, self.annotation = parsers, annotation

class Sequence(NaryOperator):
def __init__(self,annotation,*parsers):
NaryOperator.__init__(self,annotation,*parsers)
assert len(self.parsers) >= 1
def __call__(self,text):
results = ()
text_ = text
for parser in self.parsers:
node, text_ = parser(text_)
if not node: return None, text
results += (node,)
assert len(results) == len(self.parsers)
return Node(self,results), text_
def __repr__(self):
return "("+",".join([str(parser) for parser in self.parsers])+")"

class Alternative(NaryOperator):
def __init__(self,annotation,*parsers):
NaryOperator.__init__(self,annotation,*parsers)
assert len(self.parsers) >= 1
def __call__(self,text):
for parser in self.parsers:
node, text_ = parser(text)
if node: return Node(self,node), text_
return None, text
def __repr__(self):
return "("+"|".join([str(parser) for parser in self.parsers])+")"

Sie sehen, dass ich die Annotation von Parser-Objekten erlaube, womit später die Verarbeitung des erzeugten Parsebaums vereinfacht wird. Das soll hier aber nicht unser Thema sein. Schauen wir uns den Gebrauch der Parser-Kombinatoren an:

>>> s = Sequence("",Token("Gut"),Token("en"))
>>> s
(Token('Gut'),Token('en'))
>>> s("Guten Tag!")
(Guten, ' Tag!')
>>> s("Was ein Tag!")
(None, 'Was ein Tag!')
>>> a = Alternative("",Token("Gut"),Token("en"))
>>> a
(Token('Gut')|Token('en'))
>>> a("Guten Tag!")
(Gut, 'en Tag!')
>>> a("Tag!")
(None, 'Tag!')

Parser-Kombinatoren sind also tatsächlich selbst wiederum Parser -- genau so soll es sein!

Lassen Sie uns noch ein paar einwertige Parser-Kombinatoren hinzufügen, damit wir alles zusammen haben, um eine Grammatik vollständig beschreiben zu können:

class UnaryOperator(Parser):
def __init__(self,parser,annotation=None):
assert isinstance(parser,Parser)
assert isinstance(annotation,types.StringType) or annotation == None
self.parser, self.annotation = parser, annotation

class Optional(UnaryOperator):
def __call__(self,text):
node, text = self.parser(text)
if node: return Node(self,node), text
return Node(self,None), text
def __repr__(self):
return str(self.parser) + "?"

class ZeroOrMore(UnaryOperator):
def __call__(self,text):
results = ()
while True:
node, text = self.parser(text)
if not node: break
results += (node,)
return Node(self,results), text
def __repr__(self):
return str(self.parser) + "*"

class OneOrMore(UnaryOperator):
def __call__(self,text):
results = ()
text_ = text
while True:
node, text_ = self.parser(text_)
if not node: break
results += (node,)
if results == (): return None, text
return Node(self,results), text_
def __repr__(self):
return str(self.parser) + "+"

Ein Konsolenbeispiel zu OneOrMore:

>>> OneOrMore(Token('a'))('aaabbbccc')
(aaa, 'bbbccc')
>>> OneOrMore(Token('a'))('bbbccc')
(None, 'bbbccc')

Wir sind fast am Ziel! Wenn Sie eine Grammatik aufschreiben, in der es rekursive Bezüge gibt, werden Sie auf ein Problem stoßen. Probieren Sie mal Folgendes:

group = Sequence("group",Token("{"),doc,Token("}"))
config = Sequence("config",Token("["),doc,Token("]"))
comment = Sequence("comment",RegExp(r'%.*\n'))

doc = ZeroOrMore(Alternative("doc",comment,config,group))

Sie können nicht in "group" auf "doc" verweisen, bevor Sie "doc" eingeführt haben! Würden Sie die Regel von "doc" dem "group" voranstellen, hätten Sie dasselbe Problem, nur anders herum.

Eine Lösung ist, eine Delay-Klasse einzuführen, mit der Sie ein Parser-Objekt anlegen und seine "Logik" per set-Methode nachrüsten. In einer OO-Umgebung ist das eine sehr einfache Lösung, in einer funktionalen Programmierumgebung müssten Sie sich etwas anderes einfallen lassen.

class Delay(Parser):
def set(self,parser,annotation=None):
assert isinstance(parser,Parser)
assert isinstance(annotation,types.StringType) or annotation == None
self.parser = parser
self.annotation = annotation
def __call__(self,text):
return self.parser(text)
def __repr__(self):
if self.parser.annotation:
return "{"+ str(self.parser.annotation) + "}"
return "{" + self.parser.__class__.__name__ + "}"

Nun können wir die aus dem letzten Post vorgestellte einfache Grammatik für LateX ("Eine einfache Grammatik für LaTeX") mit unserem objekt-orientierten Parser-Kombinator beschreiben und mit ihr arbeiten:

doc = Delay()

text = RegExp(r"[^\\\{\}\[\]%]+","text")

group = Sequence("group",Token("{"),doc,Token("}"))
config = Sequence("config",Token("["),doc,Token("]"))
comment = Sequence("comment",RegExp(r'%.*\n'))

commandToken = RegExp(r"\\\\?[^\\\{\}\[\]%\s]*","commandToken")

commandConfig = Sequence("commandConfig",Optional(comment),config)
commandGroup = Sequence("commandGroup" ,Optional(comment),group)

command = Sequence("command",
commandToken,
Optional(commandConfig,"head"),
ZeroOrMore(commandGroup,"tail"))

doc.set(ZeroOrMore(Alternative("doc",command,comment,config,group,text)))

Ein erster Härtetest einer jeden Grammatik ist, ob der geparste Input als Output wieder erzeugt werden kann. Wenn Sie Spaß daran haben, probieren Sie es einmal mit einem LaTeX-File.

(Eine Anmerkung am Rande: Die Konstruktortechnik, hier zur Kombination von Parser-Objekten verwendet, habe ich schon einmal kurz erwähnt in dem Blogbeitrag "Uniform Syntax")

Donnerstag, September 18, 2008

Eine einfache Grammatik für LaTeX

Informatiker schreiben ihre Artikel, Berichte und Arbeiten natürlich mit TeX bzw. mit LaTeX -- sonst gehört man einfach irgendwie nicht dazu ;-) Es gibt unzählige Erweiterungen (im LaTeX-Slang "packages" genannt), die ebenso unzählige Features und Gimmicks nachrüsten für so ziemlich jedes Problem, das man sich vorstellen kann.

Für die Überarbeitung eines Artikels hatte mir der Verlag die Auflage gemacht, alle Änderungen zur vorigen Version hervorzuheben. In Microsoft Word ein Klacks, in LaTeX zugegebenermaßen ein Umstand. Aber mit \usepackage{changes} steht einem glücklicherweise ein Paket zur Verfügung, das an dieser Stelle aushilft. So übersäte ich mein LaTeX-Dokument mit \added{...}, \deleted{...} und \replaced{...}{...}.

Für eine erneute Überarbeitung wollte ich nun die vielen Änderungsauszeichnungen aus dem LaTeX-Dokument entfernen und zwar so, dass die Änderungen selbst im Text zurückbleiben. Natürlich automatisch und nicht per Hand. Das heißt, etwas vereinfacht gesagt: In dem LaTeX-Dokument können die \deleted{...}-Auszeichner samt Inhalt einfach verschwinden, von einem \added{...} muss der Inhalt in der geschweiften Klammerung erhalten bleiben, von einem \replaced{...}{...} nur der Inhalt der ersten geschweiften Klammerung.

Diese kleine Herausforderung ist ein fast klassisches Informatik-Problem. Die Anwendung von regulären Ausdrücken für einfache Ersetzungen funktioniert in LaTeX nicht, da LaTeX-Auszeichner verschachtelt sein können und somit das schließende Ende zu einer geöffneten geschweiften Klammer "{" nicht zuverlässig gefunden werden kann. Also muss man das LaTeX-Dokument parsen. Da TeX seine Grammatik zur Laufzeit ändern kann, ist auch das im Prinzip ein hoffnungsloses Unterfangen -- doch ganz so schlimm ist es in der Realität erfreulicherweise nicht. Für 99.99% aller LaTeX-Dokumente kommt ein sehr regelmäßiges Schema zum Tragen. Nur findet man dazu wenig im Netz.

Ich habe mir einen Parser in Python geschrieben (einen "Parser Combinator"), mit dem ich mit einigen einfachen Grammatiken für LaTeX-Dokumente experimentiert habe. Hier das Ergebnis, das mir für meine Zwecke gereicht hat:

text := RegExp(r"[^\\\{\}\[\]%]+")

group := "{" doc "}"
config := "[" doc "]"
comment := RegExp(r'%.*\n')

commandToken := RegExp(r"\\\\?[^\\\{\}\[\]%\s]*")

commandConfig := comment? config
commandGroup := comment? group

command := commandToken commandConfig? commandGroup*

doc := ( command | comment | config | group | text )*

Ein kleiner Hinweis: Der reguläre Ausdruck für "text" schließt alle die Zeichen aus, die bei "group", "config" und "comment" eine Sonderrolle haben. Auf diese Weise holt sich der Parser mit "text" immer möglichst zusammenhängende Textblöcke rein, ohne jedoch die Trigger "\[]{}%" für die anderen Regeln zu überlaufen.

Wenn Sie also mal in der Verlegenheit sind, eine Nachverarbeitung für LaTeX-Dokumente vornehmen zu müssen, so mag Ihnen diese einfache Grammatik den Einstieg möglicherweise erleichtern.

Sollte ich einmal Zeit dazu haben, dann erkläre ich Ihnen, wie man sich in einer objekt-orientierten Sprache einen Parser Combinator schreibt. Das geht erstaunlich einfach.

Sonntag, September 07, 2008

Add-ons for Chrome - or - A new architecture for browsers

On September 2nd, on Tuesday, Google released Chrome -- their own web browser. Chrome has a simple, unobtrusive user interface, it's based on WebKit, it's powered by a new JavaScript implementation, it runs a process for each new tab, and it promises a new approach to safe browsing. On this one, Chrome failed. Chrome has some severe security holes Google needs to fix. Nonetheless, I like the new browser.

Some hours after Google had released Chrome, I read a lot of comments about the Chrome experience. Most users seemed to be quite enthusiastic, some few were not. But one sort of comments caught my attention: Some folks argued that Firefox users will stay with Firefox. Why? Because of all these nice add-ons you can extend Firefox with.

My spontaneous reaction was: Who needs add-ons?

Before you laugh at me. I run Firefox with extensions like Firebug, Web Developer, ScrapBook and others. I use them extensively and like them a lot. Who wants to miss Firebug, for instance? Nobody, right?! I wouldn't use a browser without my beloved add-ons. And one is for sure. A browser without extension capabilities won't make it in the long run. (Ok, I'm not sooo sure.)

So what made me think "Who needs add-ons"?

I contemplated some time over my spontaneous reaction. Forgive me, I don't know much about how to write extensions for Firefix. I am an extension user, not an extension programmer. But my gut feeling tells me that the approach to extensions in Firefox is wrong. It's the worst Google could do to copy the extension mechanism of Firefox. I should rephrase my first reaction: "Who needs such an add-on architecture?"

Instead of criticizing Firefox's extension mechanism, of which I don't know much about, let me sketch an alternative approach to extensions.

Assume a "naked" browser: no buttons to click on, no url field, no menu, nothing. Assume that all the browser does after startup is run a JavaScript program. Let's call this program the "Controller". The key point is: You can change the Controller. Don't like the default Controller? Change it. The Controller is under your control!

The default Controller is a JavaScript program that creates buttons to click on and implements a history of web pages visited. It provides the url input field and displays suggestions while you type. It lets you have menus and so on.

Got it? The Controller is a program that creates a user interface that mimics Firefox. Or Chrome. Or IE. Or Opera. Or Safari. Whatever you want.

If you type in a url in the url field and hit enter, it's the Controller, which -- so to speak -- creates an IFrame and loads the given web page into that frame. So the web page remains fully under control of the Controller.

It does not require that much imagination to see that the Controller can easily be extended by -- right, by extensions or add-ons. These extensions are just other JavaScript programs, which are plugged into the Controller.

To oversimplify things, I envision a browser, which consists only of an HTML and CSS rendering engine and a virtual machine running JavaScript. The rest is initiated by an ordinary JavaScript program using web technology. In other words: My favorite browser is fully programmable with web technology.

The idea is simple, isn't it? It thrills me.

The development of such a "naked" browser inevitably raises questions like:

  • How do we guarantee that the Controller doesn't loose control?

  • How do we compose add-ons? How do we avoid interference of add-ons?

  • Do we need a component architecture? Or do we need a service-oriented architecture?

  • How do we ensure safe browsing and secure execution?


It's much about architecture thinking and a JavaScript implementation that supports fundamental notions of modularization, safety and security. These are aspects which should IMHO drive the standardization activities of JavaScript. Implementors of browsers should radically rethink the way browsers are built and function. Google did a first, tiny step in that direction with Chrome. But there's more to strive for.

Mittwoch, September 03, 2008

Multi-Stage Programming in Scheme

Vor nicht allzu langer Zeit habe ich mich in einem Beitrag mit "Multi-Stage Programming" (MSP) beschäftigt. Ich warf in dem Blog-Posting die Frage auf, ob MSP im Grunde nicht mit der Makroprogrammierung wie z.B. in Scheme oder Lisp vergleichbar sei.

In der Tat kann das MetaOCaml-Beispiel, die Auflösung der Power-Funktion, sehr leicht mit defmacro, dem Urmakro aus Lisp, in Scheme nachgebildet werden. Hier kommt PLT-Scheme zum Einsatz -- mit Dank an Tim für den Code:

(require (lib "defmacro.ss"))
; POWER MACRO
(define-macro power
(lambda (n x)
(if (= n 0)
'1
`(* ,x (power ,(- n 1) ,x)))))
(power 2 4)

Stellt man dem Code in PLT-Scheme noch ein

(require (lib "../macro-debugger/expand.ss"))

voran und gibt dann interaktiv ein

(expand/step '(power 2 2))

ein, dann öffnet sich ein Fenster zum schrittweisen Makroauflösen. Ich habe ein wenig mit den Check-Boxen experimentiert: Mit "Enable macro hiding?" und "Hide mzscheme syntax" sieht die Auflösung unkryptisch aus.

Nun lese ich die Tage von Oleg Kiselyov etwas über "MetaScheme, or untyped MetaOCaml". In seinem Beitrag implementiert er "the four MetaOCaml special forms -- bracket, escape, cross-stage persistence (aka `lift'), and run -- in R5RS Scheme." Er argumentiert, dass eine 1:1-Übersetzung der MetaOCaml Special Forms in Scheme ("MetaOCaml's bracket is like quasiquote, escape is like unquote, and `run' is eval") nicht zum gewünschten Ergebnis führt. Vor allem übersieht Scheme die Bindungsstrukturen, die MetaOCaml natürlich sauber berücksichtigt.

Kiselyov bietet die MetaOCaml Special Forms als Macros für R5RS an, die nun dasselbe Verhalten zeigen wie die Special Forms in MetaOCaml. Eine beeindruckende Lösung, die -- wie alles bei Kiselyov -- ein tiefes Verständnis für Scheme aufweist. Sie verrät aber auch, wie MSP implementiert werden kann.

Eine Einsicht bleibt dennoch: Macros sind der Fast Track zu MSP in Scheme bzw. Lisp -- ohne Netz und doppelten Boden.

Dienstag, September 02, 2008

Browsing aufpoliert: Google Chrome

Firefox, Opera, Safari, Internet Explorer -- die großen "Player" im Browser-Markt versuchen sich immer wieder an neuen, oft kleinen Features, die das Surfen im Internet angenehmer, komfortabler, sicherer und vielleicht auch ein wenig spaßiger gestalten sollen. Jüngst stellte Microsoft seinen neuen Internet Explorer 8 in der Beta-Version vor.

Seit gestern ist es semi-offiziell. Google möchte heute der Welt einen eigenen und etwas anderen Browser bescheren: Google Chrome ("A fresh take on the browser", 1. Sep. 2008). Man scheint es sehr ernst zu nehmen. Ein unterhaltsames Comic soll den Anwender bzw. die Anwenderin vom Vorteil und Nutzen des aufpolierten Browser-Modells überzeugen: "Google Chrome: Behind the Open Source Browser Project".

Technisch hat man das Browser-Modell auf neue Beine gestellt. Mehrere Webseiten lassen sich, wie gehabt, über Tabs gleichzeitig öffnen und organisieren. Doch nun stellt jeder Tab einen eigenen Prozess dar, was echte Parallelität liefert. So blockiert eine "hängende" Seite in einem Tab nicht die Arbeit des ganzen Browsers und damit der anderen Tab-Seiten. Erstaunlich, dass es zu diesem Mehr-Prozess-Fundament des Engagements von Google bedurfte. Doch scheint dieses Feature zusammen mit der neuen JavaScript-Engine namens V8 in der Techie-Szene bereits Begeisterung hervorzurufen (John Resig: "Google Chrome Process Manager"). Selbstredend ist auch Google Gears in Google Chrome integriert. Und Chrome soll open-source sein!

Ich bin gespannt -- und warte schon auf den Link zum Download! Bis dahin: Haben auch Sie viel Spaß an der Vorfreude mit dem Comic.

[[Update: Zwei Tipps zu Chrome. Der Browser verrät einige Interna durch sogenannte "about"-Seiten, siehe "Google Chrome's about:Pages". Ein paar Bedienhinweise liefert "Google Chrome Tips".]]