Text als PDF-Dokument

Werbung
B. Baltes-Götz
Einführung in das
Programmieren mit
C# 3.0
2010.04.09
Herausgeber:
Leiter:
Autor:
Copyright 
Universitäts-Rechenzentrum Trier
Universitätsring 15
D-54286 Trier
WWW: http://www.urt.uni-trier.de/
E-Mail: [email protected]
Tel.: (0651) 201-3417, Fax.: (0651) 3921
Dr. Peter Leinen
Bernhard Baltes-Götz (E-Mail: [email protected])
2009; URT
Vorwort
Dieses Manuskript entstand als Begleitlektüre zum C# - Einführungskurs, den das UniversitätsRechenzentrum Trier (URT) über zwei Semester (WiSe 2008/2009 - SoSe 2009) angeboten hat,
sollte aber auch für das Selbststudium geeignet sein.
Lerninhalte und -ziele
C# ist eine von der Firma Microsoft für das .NET – Framework entwickelte und von der European
Computer Manufacturers Association (ECMA) standardisierte Programmiersprache, die auf den
Vorbildern Java und C++ aufbaut, aber auch etliche Weiterentwicklungen bietet. Die .NET - Plattform etabliert sich allmählich als Standard bei der Softwareentwicklung unter Windows, und das
Open Source - Projekt Mono hat die Plattform erfolgreich auf andere Betriebssysteme (Linux, MacOS-X, UNIX) portiert. Dementsprechend ist auf dem EDV-Arbeitsmarkt ein großes Interesse an der
.NET-Plattform und an der hier bevorzugten Programmiersprache C# festzustellen.
Das Manuskript behandelt wesentliche Konzepte und Methoden der objektorientierten Softwareentwicklung (z.B. Klassen, Vererbung, Polymorphie, Interfaces), berücksichtigt aber auch viele
Standardthemen der Programmierpraxis (z.B. Dateizugriff, Netzwerk, Multithreading, grafische
Benutzeroberflächen, Grafikausgabe, Datenbankprogrammierung, Druckausgabe etc.).
Voraussetzungen bei den Leser(innen)

EDV-Allgemeinbildung
Dass die Leser(innen) wenigstens durchschnittliche Erfahrungen bei der Anwendung von
Computerprogrammen haben sollten, versteht sich von selbst.

Programmierkenntnisse werden nicht vorausgesetzt.

Motivation
Generell ist mit einem erheblichen Zeitaufwand bei der Lektüre und bei der aktiven Auseinandersetzung mit dem Stoff (z.B. durch das Lösen von Übungsaufgaben) zu rechnen.
Software zum Üben
Für die unverzichtbaren Übungen sollte ein Rechner mit einer aktuellen C# - Entwicklungsumgebung zur Verfügung stehen, z.B. mit der Microsoft Visual Studio 2008 Express Edition oder mit
Sharp Develop 3.x. Das ebenfalls erforderliche .NET – Framework wird von der Visual Studio Express Edition bei Bedarf automatisch installiert, ist aber mittlerweile auf praktisch jedem WindowsRechner als Basis zahlloser Anwendungen und seit Windows Vista als Bestandteil des Betriebssystems vorhanden. Die erwähnte Software ist kostenlos verfügbar. Hinweise zur Installation und zur
Verwendung folgen in Kapitel 2 des Manuskripts.
Dateien zum Manuskript
Die aktuelle Version dieses Manuskripts ist zusammen mit den behandelten Beispielen und Lösungsvorschläge zu vielen Übungsaufgaben auf dem Webserver der Universität Trier von der Startseite (http://www.uni-trier.de/) ausgehend folgendermaßen zu finden:
Rechenzentrum > Studierende > EDV-Dokumentationen >
Programmierung > Einführung in das Programmieren mit C#
Leider blieb zu wenig Zeit für eine sorgfältige Kontrolle des Texts, so dass einige Fehler und Mängel verblieben sein dürften. Entsprechende Hinweise an die E-Mail-Adresse
[email protected]
werden dankbar entgegen genommen.
Trier, im Oktober 2009
Bernhard Baltes-Götz
Inhaltsverzeichnis
V
Inhaltsverzeichnis
VORWORT
III
1
1
EINLEITUNG
1.1
Beispiel für die objektorientierte Softwareentwicklung mit C#
1.1.1
Objektorientierte Analyse und Modellierung
1.1.2
Objektorientierte Programmierung
1.1.3
Algorithmen
1.1.4
Startklasse und Main()-Methode
1.1.5
Ausblick auf Anwendungen mit graphischer Benutzerschnittstelle
1.1.6
Zusammenfassung zu Abschnitt 1.1
1
1
6
8
9
11
11
1.2
Das .NET – Framework
1.2.1
Überblick
1.2.2
Installation
1.2.3
C#-Compiler und MSIL
1.2.4
Assemblies und Metadaten
12
12
14
15
17
1.2.4.1
1.2.4.2
1.2.4.3
1.2.4.4
1.2.4.5
1.2.5
1.2.6
1.2.7
Typ-Metadaten
Das Manifest eines Assemblies
Multidatei-Assemblies
Private und allgemeine Assemblies
Vergleich mit der COM-Technologie
CLR und JIT-Compiler
Namensräume und FCL
Zusammenfassung zu Abschnitt 1.2
1.3
Übungsaufgaben zu Kapitel 1
2
WERKZEUGE ZUM ENTWICKELN VON C# - PROGRAMMEN
18
18
18
19
19
20
21
23
24
27
2.1
C# - Entwicklung mit Texteditor und Kommandozeilen-Compiler
2.1.1
Editieren
2.1.2
Übersetzen in MSIL
2.1.3
Ausführen
2.1.4
Programmfehler beheben
27
27
30
32
32
2.2
Entwicklungsumgebungen und andere Programmierhilfen
2.2.1
Microsoft Visual Studio 2008 Professional
34
34
2.2.1.1
2.2.1.2
2.2.1.3
2.2.2
Microsoft Visual C# 2008 Express Edition
2.2.2.1
2.2.2.2
2.2.2.3
2.2.2.4
2.2.3
2.3
Terminal-Server – Umgebung
Konfiguration beim ersten Start
Eine erste GUI-Anwendung
Installation
Ein erstes Konsolen-Projekt
Document Explorer
Referenzen und Ausgabetyp setzen
SharpDevelop
Übungsaufgaben zu Kapitel 2
34
35
37
41
41
44
47
48
51
56
Inhaltsverzeichnis
VI
3
ELEMENTARE SPRACHELEMENTE
3.1
Einstieg
3.1.1
Aufbau von einfachen C# - Programmen
3.1.2
Syntaxdiagramme
3.1.2.1
3.1.2.2
3.1.2.3
3.1.3
3.1.4
3.1.5
3.1.6
Klassendefinition
Methodendefinition
Eigenschaftsdefinition
Hinweise zur Gestaltung des Quellcodes
Kommentare
Namen
Übungsaufgaben zu Abschnitt 3.1
57
57
57
58
58
60
60
61
62
63
64
3.2
Ausgabe bei Konsolenanwendungen
3.2.1
Ausgabe einer (zusammengesetzten) Zeichenfolge
3.2.2
Formatierte Ausgabe
3.2.3
Übungsaufgaben zu Abschnitt 3.2
65
65
66
67
3.3
Variablen und Datentypen
3.3.1
Wert- und Referenztypen
3.3.2
Klassifikation der Variablen nach Zuordnung
3.3.3
Elementare Datentypen
3.3.4
Vertiefung: Darstellung rationaler Zahlen im Arbeitsspeicher des Computers
67
69
71
71
73
3.3.4.1
3.3.4.2
3.3.5
3.3.6
3.3.7
3.3.8
Variablendeklaration, Initialisierung und Wertzuweisung
Blöcke und Deklarationsbereiche für lokale Variablen
Konstanten
Literale
3.3.8.1
3.3.8.2
3.3.8.3
3.3.8.4
3.3.8.5
3.3.9
Binäre Gleitkommadarstellung
Dezimale Gleitkommadarstellung
73
75
77
78
80
81
Ganzzahlliterale
Gleitkommaliterale
bool-Literale
char-Literale
Zeichenkettenliterale
81
82
83
84
84
Übungsaufgaben zu Abschnitt 3.3
85
3.4
Einfache Techniken für Benutzereingaben
3.4.1
Via Konsole
3.4.2
Via InputBox
86
86
88
3.5
Operatoren und Ausdrücke
3.5.1
Arithmetische Operatoren
3.5.2
Methodenaufrufe
3.5.3
Vergleichsoperatoren
3.5.4
Vertiefung: Gleitkommawerte vergleichen
3.5.5
Logische Operatoren
3.5.6
Vertiefung: Bitorientierte Operatoren
3.5.7
Typumwandlung (Casting) bei elementaren Datentypen
3.5.8
Zuweisungsoperatoren
3.5.9
Konditionaloperator
3.5.10
Auswertungsreihenfolge
3.5.11
Übungsaufgaben zu Abschnitt 3.5
90
91
93
94
95
96
98
99
101
103
104
105
3.6
Über- und Unterlauf bei numerischen Variablen
3.6.1
Überlauf bei Ganzzahltypen
3.6.2
Unendliche und undefinierte Werte bei den Typen float und double
3.6.3
Überlauf beim Typ decimal
3.6.4
Unterlauf bei den Gleitkommatypen
107
107
109
111
111
Inhaltsverzeichnis
VII
3.7
Anweisungen (zur Ablaufsteuerung)
3.7.1
Überblick
3.7.2
Bedingte Anweisung und Verzweigung
112
112
113
3.7.2.1
3.7.2.2
3.7.2.3
3.7.3
Wiederholungsanweisungen
3.7.3.1
3.7.3.2
3.7.3.3
3.7.3.4
3.7.3.5
3.7.4
4
if-Anweisung
if-else - Anweisung
switch-Anweisung
Zählergesteuerte Schleife (for)
Iterieren über die Elemente einer Kollektion (foreach)
Bedingungsabhängige Schleifen
Endlosschleifen
Schleifen(durchgänge) vorzeitig beenden
122
123
124
125
127
127
Übungsaufgaben zu Abschnitt 3.7
129
KLASSEN UND OBJEKTE
133
4.1
Überblick, historische Wurzeln, Beispiel
4.1.1
Einige Kernideen und Vorzüge der OOP
4.1.1.1
4.1.1.2
4.1.1.3
4.1.2
4.1.3
113
114
117
Datenkapselung und Modularisierung
Vererbung
Realitätsnahe Modellierung
Strukturierte Programmierung und OOP
Auf-Bruch zu echter Klasse
133
133
133
135
136
136
137
4.2
Instanzvariablen
4.2.1
Deklaration mit Wahl der Schutzstufe
4.2.2
Konstante und schreibgeschützte Instanzvariablen
4.2.3
Sichtbarkeitsbereich, Existenz und Ablage im Hauptspeicher
4.2.4
Initialisierung
4.2.5
Zugriff in klasseneigenen und fremden Methoden
140
141
142
143
144
145
4.3
Instanzmethoden
4.3.1
Methodendefinition
145
146
4.3.1.1
4.3.1.2
4.3.1.3
4.3.1.4
4.3.2
4.3.3
Modifikatoren
Rückgabewerte und return-Anweisung
Formalparameter
Methodenrumpf
Methodenaufruf und Aktualparameter
Methoden überladen
4.4
Objekte
4.4.1
Referenzvariablen deklarieren
4.4.2
Objekte erzeugen
4.4.3
Objekte initialisieren über Konstruktoren
4.4.4
Abräumen überflüssiger Objekte durch den Garbage Collector
4.4.5
Objektreferenzen verwenden
4.4.5.1
4.4.5.2
4.4.5.3
4.5
Objektreferenzen als Wertparameter
Rückgabewerte mit Referenztyp
this als Referenz auf das aktuelle Objekt
Eigenschaften
148
148
149
152
153
154
155
155
156
157
160
161
161
162
162
163
Inhaltsverzeichnis
VIII
4.6
Statische Member und Klassen
4.6.1
Statische Felder und Eigenschaften
4.6.2
Wiederholung zur Kategorisierung von Variablen
4.6.3
Statische Methoden
4.6.4
Statische Konstruktoren
4.6.5
Statische Klassen
165
165
166
167
168
169
4.7
Vertiefungen zum Thema Methoden
4.7.1
Rekursive Methoden
4.7.2
Operatoren überladen
169
169
171
4.8
Aggregieren und innere Klassen
4.8.1
Aggregation
4.8.2
Innere Klassen
172
172
175
4.9
176
Verfügbarkeit von Klassen und Klassenbestandteilen
4.10
Bruchrechnungsprogramm mit WinForms-Benutzerschnittstelle
4.10.1
Projekt anlegen mit Vorlage Windows Forms - Anwendung
4.10.2
Steuerelemente aus der Toolbox übernehmen
4.10.3
Steuerelemente gestalten
4.10.4
Eigenschaften der Steuerelemente ändern
4.10.5
Der Quellcode-Generator
4.10.6
Assembly mit der Bruch-Klasse einbinden
4.10.7
Ereignisbehandlungsmethoden anlegen
177
177
178
179
181
182
183
184
4.11
Übungsaufgaben zu Kapitel 4
185
5
WEITERE .NETTE TYPEN
191
5.1
Strukturen
5.1.1
Vergleich von Klassen und Strukturen
5.1.2
Zur Eignung von Strukturen im Bruchrechnungs-Projekt
5.1.3
Strukturen im Common Type System der .NET – Plattform
191
192
195
197
5.2
198
Boxing und Unboxing
5.3
Arrays
5.3.1
Array-Referenzvariablen deklarieren
5.3.2
Array-Objekte erzeugen
5.3.3
Arrays benutzen
5.3.4
Beispiel: Beurteilung des .NET - Pseudozufallszahlengenerators
5.3.5
Initialisierungslisten
5.3.6
Objekte als Array-Elemente
5.3.7
Mehrdimensionale Arrays
5.3.7.1
5.3.7.2
5.3.8
Rechteckige Arrays
Mehrdimensionale Arrays mit unterschiedlich großen Elementen
Die Kollektionsklasse ArrayList
5.4
Klassen für Zeichenketten
5.4.1
Die Klasse String für konstante Zeichenketten
5.4.1.1
5.4.1.2
5.4.1.3
5.4.2
String als WORM - Klasse
Interner String-Pool
Methoden für String-Objekte
Die Klasse StringBuilder für veränderliche Zeichenketten
200
201
202
203
204
206
207
207
207
208
209
211
211
211
212
214
217
5.5
Enumerationen
218
5.6
Indexer
220
Inhaltsverzeichnis
IX
5.7
Übungsaufgaben zu Kapitel 5
223
6
VERERBUNG UND POLYMORPHIE
6.1
Das Common Type System (CTS) des .NET – Frameworks
228
6.2
Definition einer abgeleiteten Klasse
229
6.3
base-Konstruktoren und Initialisierungs-Sequenzen
230
6.4
Der Zugriffsmodifikator protected
232
227
6.5
Erbstücke durch spezialisierte Varianten verdecken
6.5.1
Geerbte Methoden verdecken
6.5.2
Geerbte Felder verdecken
233
233
235
6.6
Verwaltung von Objekten über Basisklassenreferenzen
236
6.7
Polymorphie (Methoden überschreiben)
238
6.8
Abstrakte Methoden und Klassen
240
6.9
Versiegelte Methoden und Klassen
242
6.10
Übungsaufgaben zu Kapitel 6
242
7
GENERISCHE TYPEN UND METHODEN
7.1
Motive für die Einführung generischer Klassen in .NET 2.0
245
245
7.2
Generische Klassen
7.2.1
Definition
7.2.2
Restringierte Typformalparameter
7.2.3
Generische Klassen und Vererbung
246
247
248
250
7.3
Nullable<T> als Beispiel für generische Strukturen
251
7.4
Generische Methoden
253
7.5
Übungsaufgaben zu Kapitel 7
254
8
INTERFACES
8.1
Interfaces definieren
257
8.2
Interfaces implementieren
258
8.3
Interfaces als Referenzdatentypen
263
8.4
Explizite Interface-Implementationen
264
8.5
Übungsaufgaben zu Kapitel 8
265
255
Inhaltsverzeichnis
X
9
EINSTIEG IN DIE WINFORMS - PROGRAMMIERUNG
9.1
Ein Projektrahmen zum Üben
267
269
9.2
Elementare Klassen im Windows Forms - Framework
9.2.1
Anwendungsfenster und die Klasse Form
9.2.2
Windows-Nachrichten und die Klasse Application
270
271
274
9.3
Delegaten und Ereignisse
9.3.1
Delegaten
276
276
9.3.1.1
9.3.1.2
9.3.1.3
9.3.1.4
9.3.1.5
9.3.2
Delegatentypen definieren
Delegatenobjekte erzeugen und aufrufen
Delegatenobjekte kombinieren
Anonyme Methoden
Generische Delegaten
Ereignisse
9.3.2.1
9.3.2.2
Behandlungsmethoden registrieren
Ereignisse anbieten
9.4
Steuerelemente
9.4.1
Einsatz von Steuerelementen am Beispiel des Labels
9.4.1.1
9.4.1.2
9.4.1.3
9.4.2
Befehlsschalter
9.4.2.1
9.4.2.2
9.4.2.3
9.4.3
9.4.4
Beispiel
Eingabefokus und Standardschaltfläche
Bitmaps auf Schaltflächen
Textfelder
Spezielle Layout-Techniken
9.4.4.1
9.4.4.2
9.4.4.3
9.4.4.4
9.5
Steuerelemente erzeugen und konfigurieren
Ab in den Container
Ereignisbehandlungsmethoden
Z-Anordnung
Verankern
Andocken
TableLayoutPanel
Ereignisse und On-Methoden
277
278
279
280
281
282
283
286
288
289
290
291
292
293
293
295
297
298
300
300
301
303
304
306
9.6
WinForms - RAD
9.6.1
Projekt anlegen mit Vorlage Windows Forms - Anwendung
9.6.2
Steuerelemente aus der Toolbox übernehmen
9.6.3
Eigenschaften der Steuerelemente ändern
9.6.4
Der Quellcode-Generator
9.6.5
Ereignisbehandlungsmethoden anlegen
9.6.6
Quellcode-Umgestaltung (Refactoring)
308
308
309
310
311
313
315
9.7
Zusammenfassung zu Abschnitt 9
317
9.8
Übungsaufgaben zu Kapitel 9
318
Inhaltsverzeichnis
10
AUSNAHMEBEHANDLUNG
10.1
Unbehandelte Ausnahmen
10.2
Ausnahmen abfangen
10.2.1
Die try-catch-finally - Anweisung
10.2.1.1 Ausnahmebehandlung per catch-Block
10.2.1.2 finally
10.2.2
Programmablauf bei der Ausnahmebehandlung
XI
319
319
321
321
322
324
326
10.2.2.1 Beispiel
10.2.2.2 Komplexe Fälle
326
328
10.3
Ausnahmeobjekte im Vergleich mit der traditionellen Fehlerbehandlung
328
10.4
Ausnahme-Klassen im .NET - Framework
330
10.5
Ausnahmen werfen (throw)
332
10.6
Ausnahmen definieren
333
10.7
Übungsaufgaben zu Kapitel 10
336
11
ATTRIBUTE
11.1
Attribute vergeben
339
11.2
Attribute per Reflexion auswerten
341
11.3
Attribute definieren
344
11.4
Attribute für Assemblies und Module
344
339
11.5
Eine Auswahl nützlicher FCL-Attribute
11.5.1
Bitfelder per FlagsAttribute
11.5.2
Unions per StructLayoutAttribute und FieldOffsetAttribute
346
346
347
11.6
Übungsaufgaben zu Kapitel 11
348
12
EIN-/AUSGABE ÜBER DATENSTRÖME
349
12.1
Datenströme aus Bytes
12.1.1
Das Grundprinzip
12.1.2
Wichtige Methoden und Eigenschaften der Basisklasse Stream
12.1.3
Schließen von Datenströmen
12.1.4
Ausnahmen abfangen
12.1.5
FileStream
349
349
350
351
353
353
12.1.5.1 Öffnungsmodus
12.1.5.2 Zugriffsmöglichkeiten für den eigenen Prozess
12.1.5.3 Optionen für gemeinsamen Zugriff
354
354
355
12.2
Verarbeitung von Daten mit höherem Typ
12.2.1
Schreiben und Lesen im Binärformat
12.2.2
Schreiben und Lesen im Textformat
12.2.3
Serialisieren von Objekten
355
356
358
362
Inhaltsverzeichnis
XII
12.3
Verwaltung von Dateien und Verzeichnissen
12.3.1
Dateiverwaltung
12.3.2
Ordnerverwaltung
12.3.3
Überwachung von Ordnern
367
367
369
370
12.4
Übungsaufgaben zu Kapitel 12
371
13
THREADS
13.1
Threads erzeugen
373
374
13.2
Threads synchronisieren
13.2.1
Die lock-Anweisung
13.2.2
Die Klasse Monitor
13.2.3
Koordination per Wait() und Pulse()
378
379
380
381
13.3
383
Threads stoppen
13.4
Thread-Lebensläufe
13.4.1
Scheduling und Prioritäten
13.4.2
Zustände von Threads
384
384
385
13.5
386
Deadlock
13.6
Treadpool und APM
13.6.1
Die ThreadPool-Methode QueueUserWorkItem()
13.6.2
Die Delegatenmethoden BeginInvoke() und EndInvoke()
387
387
388
13.7
Timer
391
13.8
Übungsaufgaben zu Kapitel 13
395
14
NETZWERKPROGRAMMIERUNG
397
14.1
Wichtige Konzepte der Netzwerktechnologie
14.1.1
Das OSI-Modell
14.1.2
Zur Funktionsweise von Protokollstapeln
14.1.3
Optionen zur Netzwerkprogrammierung in C#
397
398
401
402
14.2
Internet - Ressourcen per Request/Response – Modell nutzen
14.2.1
Statische Webinhalte anfordern
14.2.2
Datei-Download per HTTP - Protokoll
14.2.3
Dynamische erstellte Webseiten per GET oder POST anfordern
402
402
405
407
14.3
14.2.3.1 Überblick
14.2.3.2 Arbeitsablauf
14.2.3.3 GET
14.2.3.4 POST
407
407
409
411
IP-Adressen bzw. Host-Namen ermitteln
412
14.4
Socket-Programmierung
14.4.1
TCP-Server
14.4.2
TCP-Klient
14.4.3
Simultane Bedienung mehrerer Klienten
413
414
416
417
14.5
419
Übungsaufgaben zu Kapitel 14
Inhaltsverzeichnis
XIII
15
GRAFIKAUSGABE MIT DEM GDI+
423
15.1
Die Klasse Graphics
423
15.2
GDI-Ressourcen schonen
425
15.3
Fensterrenovierungsmethoden
15.3.1
PaintEventHandler
15.3.2
OnPaint() überschreiben
15.3.3
Fensteraktualisierung per Programm anfordern
15.3.4
ResizeRedraw
425
426
427
428
429
15.4
Positionen und Größen
15.4.1
Standardkoordinatensystem
15.4.2
Point und Size
15.4.3
Rectangle
430
430
431
433
15.5
Farben und Zeichenwerkzeuge
15.5.1
Farben
15.5.2
Pen
15.5.3
Brush
434
434
436
438
15.6
Linien, Kurven und Flächen
15.6.1
Einfache Linien und Figuren
15.6.2
Splines
15.6.3
Flächen
440
440
443
445
15.7
Text malen
15.7.1
Schriftarten und -familien
15.7.2
Standarddialog zur Schriftauswahl
15.7.3
Zeichen setzen
15.7.4
Maß halten
447
448
451
453
456
15.7.4.1 Schrifthöhe und Zeilenabstand
15.7.4.2 Textbreite
456
458
15.8
Transformationen
15.8.1
Auflösungserscheinungen
15.8.2
Metrische Maßeinheiten (Seitentransformation)
15.8.3
Welttransformationen
460
461
462
464
15.9
465
Rastergrafik
15.10 Pfade, Zeichen- und Anzeigebereiche
15.10.1
Grafikpfade
15.10.2
Zeichenbereiche
15.10.3
Anzeigebereiche
469
469
473
475
15.11
Übungsaufgaben zu Kapitel 15
476
16
WEITERE STANDARDKOMPONENTEN FÜR WINFORMS-ANWENDUNGEN 479
16.1
Kontrollkästchen und Optionsfelder
479
16.2
Listen- und Kombinationsfelder
482
16.3
UpDown-Regler
485
16.4
Rollbalken
488
16.5
Ein RTF-Editor auf RichTextBox-Basis
490
Inhaltsverzeichnis
XIV
16.6
Timer-Komponente
491
17
MENÜS
17.1
Wichtige Klassen und Begriffe
493
17.2
Menüitems erzeugen und konfigurieren
495
17.3
Symbole für Menüitems
498
17.4
Verfügbarkeit von Menüitems dynamisch anpassen
500
17.5
Kontextmenü
501
17.6
Unterstützung durch die Entwicklungsumgebungen
502
493
17.7
Die Menütechnik aus .NET 1
17.7.1
Wichtige Klasen und Begriffe
17.7.2
Menüitems erzeugen
17.7.3
Kontextmenü
506
507
508
511
17.8
Übungsaufgaben zu Kapitel 17
512
18
DIALOGFENSTER
513
18.1
Modale Dialogfenster
18.1.1
Konfiguration
18.1.2
Datenaustausch mit dem aufrufenden Formular
18.1.3
Auftritt und Abgang
18.1.4
Übernehmen Sie keinen Aberglauben
513
514
516
517
518
18.2
Nicht-modale Dialogfenster
18.2.1
Konfiguration
18.2.2
Auftritt und Abgang
18.2.3
Kommunikation mit dem Anwendungsfenster
520
520
522
523
18.3
Standarddialoge
18.3.1
FontDialog
18.3.2
ColorDialog
18.3.3
SaveFileDialog
18.3.4
OpenFileDialog
525
525
527
528
530
18.4
Übungsaufgaben zu Kapitel 18
532
19
ANWENDUNGS- UND BENUTZEREINSTELLUNGEN
533
19.1
Konfigurationsdateien im XML-Format
19.1.1
Aufbau einer .NET - Anwendungskonfigurationsdatei
19.1.2
Ablage im Dateisystem
19.1.3
Die Klasse ApplicationSettingsBase
19.1.4
Lesen und Speichen von Einstellungen
19.1.5
Bindung von Einstellungen an Eigenschaften von Komponenten
19.1.6
Validierung von Einstellungen
19.1.7
Unterstützung durch die Entwicklungsumgebungen
19.1.8
Weitere Optionen der .NET - Konfigurationsdateien
534
534
536
538
540
542
542
543
548
19.2
Windows-Registrierungsdatenbank
548
19.3
Übungsaufgaben zu Kapitel 19
552
Inhaltsverzeichnis
XV
20
553
SYMBOL- UND STATUSLEISTE
20.1
Symbolleisten
20.1.1
Do-It-Yourself - Erstellung einer Symbolleiste
20.1.2
Bewegliche Leisten und ToolStripContainer
20.1.3
Unterstützung durch die Entwicklungsumgebungen
553
554
557
558
20.2
Statusleiste
561
20.3
Übungsaufgaben zu Kapitel 20
565
21
DRUCKEN
21.1
Wichtige Datentypen
21.1.1
Die Klasse PrinterSettings zur Verwaltung von Druckereinstellungen
21.1.2
Die Klasse PageSettings zur Verwaltung von Seiteneigenschaften
21.1.3
Die Klasse PrintDocument zur Verwaltung von Druckaufträgen
21.1.3.1 Eigenschaften und Methoden
21.1.3.2 Ereignisse
21.1.3.3 Beispiel
21.1.4
Der Standarddialog Drucken
21.2
Druckfunktionalität für den Editor auf RichTextBox-Basis
21.2.1
Beteiligte Objekte
21.2.2
BeginPrint - Ereignisbehandlung
21.2.3
PrintPage - Ereignisbehandlung
21.2.3.1 PrintPageEventArgs-Eigenschaften
21.2.3.2 Ausgaberechteck ermitteln
21.2.3.3 Textausgabe mit DrawString()
567
567
567
569
570
571
573
574
576
578
579
581
581
581
582
585
21.3
Weitere Standarddialoge zur Unterstützung der Druckausgabe
21.3.1
Seitenansicht
21.3.2
Seiteneinrichtung
587
587
589
21.4
Übungsaufgaben zu Kapitel 21
591
22
ZWISCHENABLAGE, ZIEHEN UND ABLEGEN
22.1
Verbesserte RTF-Unterstützung im Editorprojekt
593
22.2
Windows-Zwischenablagenformate
594
22.3
Ein Zwischenablagen-Inspektor
595
593
22.4
Daten in die Zwischenablage schreiben
22.4.1
Bequeme Clipboard-Methoden für Routineaufgaben
22.4.2
Die Clipboard-Methode SetDataObject() für spezielle Aufgaben
597
597
600
22.5
Daten aus der Zwischenablage abrufen
22.5.1
Bequeme Clipboard-Methoden für Routineaufgaben
22.5.2
Die Clipboard-Methode GetDataObject()
602
602
604
22.6
Ziehen und Ablegen
22.6.1
Die Rolle der Zielanwendung
22.6.2
Die Rolle der Quellanwendung
22.6.3
Drag & Drop - Funktionalität für unseren Editor
605
606
609
611
22.7
612
Übungsaufgaben zu Kapitel 22
Inhaltsverzeichnis
XVI
23
RESSOURCEN
613
23.1
Ressourcendateien und -generatoren
23.1.1
RESX-Datei
23.1.2
TXT-Datei für Zeichenfolge-Ressourcen
23.1.3
RESOURCES-Datei
613
613
615
615
23.2
Ressourcen verwenden
616
23.3
Ressourcen-Verwaltung mit der Visual C# 2008 Express Edition
617
23.4
Übungsaufgaben zu Kapitel 23
620
24
INTEROPERABILITÄT MIT SOFTWARE-ALTLASTEN
621
24.1
Funktionen in nativen DLLs nutzen
24.1.1
DLLs importieren
24.1.2
Parameter-Marshaling
24.1.3
Windows-Fehlercode ermitteln
621
621
622
624
24.2
Kooperation mit der COM-Technologie
24.2.1
Einige Begriffe und Ideen aus der COM-Technologie
624
624
24.2.1.1 COM-Server
24.2.1.2 COM-Schnittstellen und -Typbibliotheken
24.2.1.3 Aufruf einer COM-Methode
24.2.1.4 Frühe und späte Bindung
24.2.1.5 Registrierung
24.2.2
25
COM-Server in .NET - Anwendungen nutzen
625
625
626
627
627
628
24.2.2.1 Proxy-Assembly mit Runtime Callable Wrapper
24.2.2.2 COM-Server mit C++ erstellen
24.2.2.3 COM-Server in C# verwenden
628
629
636
VARIABLE ANSICHTEN
643
25.1
Aufteilungssteuerelemente
25.1.1
Splitter
25.1.2
SplitContainer
643
643
646
25.2
Baumansicht
25.2.1
Konstruktion
25.2.2
Datenerfassung
25.2.3
Verzeichnisbaum
25.2.4
Bilder für die Knoten
25.2.5
Ereignisse
25.2.6
Explorer Light
648
648
650
651
652
654
655
25.3
Listen und Tabellen
25.3.1
Konstruktion
25.3.2
Datenerfassung
25.3.3
Ereignisse
25.3.4
Dateisystem-Verzeichniseinträge auflisten
25.3.5
Bilder für die Listenitems
25.3.6
Spezielle Sortierung
25.3.7
Explorer Midrange
657
658
660
661
661
664
665
666
Inhaltsverzeichnis
26
DATENBANKPROGRAMMIERUNG MIT ADO.NET
XVII
669
26.1
Relationale Datenbanken
26.1.1
Tabellen
26.1.2
Beziehungen zwischen Tabellen
671
671
672
26.2
SQL
26.2.1
26.2.2
26.2.3
26.2.4
26.2.5
26.2.6
26.2.7
673
673
674
675
675
676
676
676
Überblick
Spalten abrufen
Fälle auswählen über die WHERE-Klausel
Daten aus mehreren Tabellen zusammenführen
Abfrageergebnis sortieren
Auswertungsfunktionen
Daten aggregieren
26.3
Microsoft SQL Server 2008 Express Edition
26.3.1
Eigenschaften
26.3.2
Installation
26.3.2.1 SQL Server 2008 Express with Tools
26.3.2.2 Beispieldatenbank Northwind
26.3.3
Konfiguration
26.3.3.1 Rechteverwaltung
26.3.3.2 Netzwerkzugriff via TCP/IP erlauben
26.4
ADO.NET
26.4.1
Überblick
26.4.1.1 Verbindungsloses versus verbindungsorientiertes Arbeiten
26.4.1.2 Provider
26.4.1.3 ADO.NET - Namensräume
26.4.2
Die Connection-Klassen
26.4.2.1 Verbindungszeichenfolge
26.4.2.2 Eigenschaften, Ereignisse und Methoden
26.4.3
Die Command-Klassen
26.4.3.1 Eigenschaften und Methoden
26.4.3.2 Parametrisierte Abfragen
26.4.4
26.4.5
Die DataAdapter-Klassen
DataSet und andere Provider-unabhängige Klassen
676
676
677
677
684
685
685
688
690
690
690
691
692
692
692
696
697
697
698
699
701
26.4.5.1
Abfrageergebnisse und Schema-Informationen aus einer Datenbank übernehmen
701
26.4.5.2 DataTable-Objekte modifizieren
703
26.4.5.3 Beziehungen zwischen Tabellen vereinbaren
708
26.4.6
26.4.7
26.4.8
Datenbank-Update
Zusammenspiel der ADO.NET - Klassen beim verbindungslosen Arbeiten
Einsatz des DataReaders
26.5
Datenbankunterstützung durch die Entwicklungsumgebungen
26.5.1
Datenquelle
26.5.2
Anwendungsbeispiel
26.5.3
Datenbanken mit der Entwicklungsumgebung bearbeiten
26.5.3.1 Tabellen anzeigen und bearbeiten
26.5.3.2 Datenbankdiagramm erstellen
26.5.3.3 Beziehungen definieren
709
711
713
713
713
715
723
724
725
726
Inhaltsverzeichnis
XVIII
27
LINQ
729
27.1
Erweiterungen der Programmiersprache C#
27.1.1
Implizite Typzuweisung bei lokalen Variablen
27.1.2
Instanz- und Listeninitialisierer
27.1.3
Anonyme Klassen
27.1.4
Lambda-Ausdrücke
27.1.5
Erweiterungsmethoden
729
729
729
730
730
731
27.2
LINQ to Objects
27.2.1
Die from-Klausel zur Definition einer Datenquelle
27.2.2
Die Projektion des Elementtyps der Quelle auf den Elementtyp der Abfrage
27.2.3
Formulierung eines Auswahlkriteriums
27.2.4
Weitere LINQ-Operationen
732
732
733
734
734
27.3
LINQ to SQL
27.3.1
Elementare LINQ-to-SQL - Klassen und O/R - Designer
27.3.2
Datenbankbearbeitung via LINQ to SQL
734
735
738
27.3.2.1 Werte ändern
27.3.2.2 Neue Entitäten (Tabellenzeilen) anlegen
27.3.2.3 Entitäten (Tabellenzeilen) löschen
738
739
739
28
ANHANG
741
28.1
Operatorentabelle
28.2
Lösungsvorschläge zu den Übungsaufgaben
Kapitel 1 (Einleitung)
Kapitel 2 (Werkzeuge zum Entwickeln von C# - Programmen)
Kapitel 3 (Elementare Sprachelemente)
Abschnitt 3.1 (Einstieg)
Abschnitt 3.2 (Ausgabe bei Konsolenanwendungen)
Abschnitt 3.3 (Variablen und Datentypen)
Abschnitt 3.5 (Operatoren und Ausdrücke)
Abschnitt 3.7 (Anweisungen)
Kapitel 4 (Klassen und Objekte)
Kapitel 5 (Weitere .NETte Typen)
Kapitel 6 (Vererbung und Polymorphie)
Kapitel 7 (Generische Typen und Methoden)
Kapitel 8 (Schnittstellen)
Kapitel 9 (Einstieg in die WinForms - Programmierung)
Kapitel 10 (Ausnahmebehandlung)
Kapitel 11 (Attribute)
Kapitel 12 (Ein-/Ausgabe über Datenströme)
Kapitel 13 (Threads)
Kapitel 14 (Netzwerkprogrammierung)
Kapitel 15 (Grafikausgabe mit dem GDI+)
Kapitel 17 (Menüs)
Kapitel 18 (Dialogfenster)
Kapitel 19 (Anwendungs- und Benutzereinstellungen)
Kapitel 20 (Symbol- und Statusleiste)
Kapitel 21 (Drucken)
Kapitel 22 (Zwischenablage, Ziehen und Ablegen)
Kapitel 23 (Ressourcen)
741
742
742
744
745
745
745
745
746
747
749
750
751
752
753
753
754
754
754
755
755
755
756
757
757
757
757
757
757
Inhaltsverzeichnis
XIX
LITERATUR
759
STICHWORTREGISTER
761
1 Einleitung
Im ersten Kapitel geht zunächst es um die Denk- und Arbeitsweise (leicht übertrieben: die Weltanschauung) der objektorientierten Programmierung. Danach werden die .NET - Plattform und ihre
wohl wichtigste Programmiersprache C# als Software-Technologie vorgestellt.
1.1 Beispiel für die objektorientierte Softwareentwicklung mit C#
In diesem Abschnitt soll eine Vorstellung davon vermittelt werden, was ein Computerprogramm (in
C#) ist, und wie man es erstellt. Dabei kommen einige Grundbegriffe der Informatik zur Sprache,
wobei wir uns aber nicht unnötig lange von der Praxis fernhalten wollen.
Ein Computerprogramm besteht im Wesentlichen (von Bildern, Klängen und anderen Ressourcen
einmal abgesehen) aus einer Menge von wohlgeformten und wohlgeordneten Definitionen und Anweisungen zur Bewältigung einer bestimmten Aufgabe. Ein Programm muss ...


den betroffenen Gegenstandsbereich modellieren
Beispiel: In einem Programm zur Verwaltung einer Spedition sind z.B. Fahrer, Fahrzeuge,
Servicestationen, Zielpunkte etc. und die kommunikativen Prozesse zu repräsentieren.
Algorithmen realisieren, die in endlich vielen Schritten und unter Verwendung von endlich
vielen Betriebsmitteln (z.B. Speicher) bestimmte Ausgangszustände in akzeptable Zielzustände überführen.
Beispiel: Im Speditionsprogramm muss u.a. für jede Fahrt zu den meist mehreren (Ent-)ladestationen eine optimale Route ermittelt werden (hinsichtlich Entfernung, Fahrtzeit, Mautkosten etc.).
Wir wollen präzisere und komplettere Definitionen zum komplexen Begriff eines Computerprogramms den Lehrbüchern überlassen (siehe z.B. Goll et al. 2000) und stattdessen ein Beispiel im
Detail betrachten, um einen Einstieg in die Materie zu finden.
Bei der Suche nach einem geeigneten C# - Einstiegsbeispiel tritt allerdings ein Dilemma auf:


Einfache Beispiele sind für das Programmieren mit C# nicht besonders repräsentativ, z.B. ist
von der Objektorientierung außer einem gewissen Formalismus eigentlich nichts vorhanden.
Repräsentative C# - Programme eignen sich in der Regel wegen ihrer Länge und Komplexität (aus der Sicht des Anfängers) nicht für eine Detailanalyse. Insbesondere können wir das
eben erfolgreich zur Illustration einer realen Aufgabenstellung verwendete, aber potentiell
sehr aufwändige, Speditionsverwaltungsprogramm jetzt nicht im Detail vorstellen.
Wir analysieren stattdessen ein Beispielprogramm, das trotz angestrebter Einfachheit nicht auf objektorientiertes Programmieren (OOP) verzichtet. Seine Aufgabe besteht darin, elementare Operationen mit Brüchen auszuführen (Kürzen, Addieren), womit es etwa einem Schüler beim Anfertigen
der Hausaufgaben (zur Kontrolle der eigenen Lösungen) nützlich sein kann.
1.1.1 Objektorientierte Analyse und Modellierung
Einer objektorientierten Programmentwicklung geht die objektorientierte Analyse der Aufgabenstellung voran mit dem Ziel einer Modellierung durch Klassen und deren Kommunikation untereinander. Man identifiziert per Abstraktion die beteiligten Objektsorten und definiert für sie jeweils
eine Klasse. Eine solche Klasse ist gekennzeichnet durch:

Merkmale (Instanz- bzw. Klassenvariablen, Felder)
Viele Merkmal gehören zu den Objekten bzw. Instanzen der Klasse (z.B. Zähler und Nenner
eines Bruchs), manche gehören zur Klasse selbst (z.B. Anzahl der bereits erzeugten Brüche).
Im letztlich entstehenden Programm landet jedes Merkmal in einer so genannten Variablen.
Dies ist ein benannter Speicherplatz, der Werte eines bestimmten Typs (z.B. Zahlen, Zei-
Kapitel 1: Einleitung
2

chen) aufnehmen kann. Variablen zur Repräsentation der Merkmale von Objekten oder
Klassen werden oft als Felder bezeichnet.
Handlungskompetenzen (Methoden)
Analog zu den Merkmalen sind auch die Handlungskompetenzen entweder individuellen
Objekten bzw. Instanzen zugeordnet (z.B. das Kürzen bei Brüchen) oder der Klasse selbst
(z.B. das Erstellen neuer Objekte). Im letztlich entstehenden Programm sind die Handlungskompetenzen durch so genannte Methoden repräsentiert. Diese ausführbaren Programmbestandteile enthalten die oben angesprochenen Algorithmen. Die Kommunikation zwischen
Klassen bzw. Objekten besteht darin, ein anderes Objekt oder eine andere Klasse aufzufordern, eine bestimmte Methode auszuführen.
Eine Klasse …


beinhaltet meist einen Bauplan für konkrete Objekte, die im Programmablauf nach Bedarf
erzeugt und mit der Ausführung bestimmter Methoden beauftragt werden,
kann aber andererseits auch Akteur sein (Methoden ausführen und aufrufen).
Weil der Begriff Klasse gegenüber dem Begriff Objekt dominiert, hätte man eigentlich die Bezeichnung klassenorientierte Programmierung wählen sollen. Allerdings gibt es nun keinen ernsthaften
Grund, die eingeführte Bezeichnung objektorientierte Programmierung zu ändern.
Dass jedes Objekt gleich in eine Klasse („Schublade“) gesteckt wird, mögen die Anhänger einer
ausgeprägt individualistischen Weltanschauung bedauern. Auf einem geeigneten Abstraktionsniveau betrachtet lassen sich jedoch die meisten Objekte der realen Welt ohne großen Informationsverlust in Klassen einteilen. Bei einer definitiv nur einfach zu besetzenden Rolle kann eine Klasse
zum Einsatz kommen, die ausnahmsweise nicht zum Instantiieren (Erzeugen von Objekten) gedacht
ist sondern als Akteur.
In unserem Bruchrechnungsbeispiel kann man sich bei der objektorientierten Analyse vorläufig
wohl auf die Klasse der Brüche beschränken. Beim möglichen Ausbau des Programms zu einem
Bruchrechnungstrainer kommen jedoch sicher weitere Klassen hinzu (z.B. Aufgabe, Übungsaufgabe, Testaufgabe).
Dass Zähler und Nenner die zentralen Merkmale eines Bruchs sind, bedarf keiner Begründung. Sie
werden in der Klassendefinition durch ganzzahlige Felder (C# - Datentyp int) repräsentiert:


zaehler
nenner
Auf das oben angedeutete klassenbezogene Merkmal mit der Anzahl bereits erzeugter Brüche wird
(vorläufig) verzichtet.
Im objektorientierten Paradigma ist jede Klasse für die Manipulation ihrer Zustände (Merkmalsausprägungen) selbst verantwortlich. Diese sollen eingekapselt und vor direktem Zugriff durch fremde
Klassen geschützt sein. So kann sichergestellt werden, dass nur sinnvolle Änderungen der Merkmalsausprägungen möglich sind. Außerdem wird aus später zu erläutenden Gründen die Produktivität der Softwareentwicklung durch die Datenkapselung gefördert.
Wie die folgende, an Goll et al. (2000) angelehnte, Abbildung zeigt, bilden die in aufrufbaren Methoden realisierten Handlungskompetenzen einer Klasse demgegenüber ihre öffentlich zugängliche Schnittstelle zur Kommunikation mit anderen Klassen:
3
de
tho
al
rkm
Merkmal
Merkmal
Me
Me
tho
de
Me
rkm
al
Methode
Me
Me
tho
de
Abschnitt 1.1 Beispiel für die objektorientierte Softwareentwicklung mit C#
tho
Me
Methode
de
Me
al
rkm
de
tho
Me
Me
Me
Merkmal
Merkmal
rk m
al
tho
de
Merkmal
KlasseMerkmal
A
Die Objekte (Exemplare, Instanzen) einer Klasse, d.h. die nach diesem Bauplan erzeugten Individuen, sollen in der Lage sein, auf eine Reihe von Nachrichten mit einem bestimmten Verhalten zu
reagieren. In unserem Beispiel sollte die Klasse Bruch z.B. eine Instanzmethode zum Kürzen besitzen. Dann kann einem konkreten Bruch-Objekt durch Aufrufen dieser Methode die Nachricht
zugestellt werden, dass es Zähler und Nenner kürzen soll.
Sich unter einem Bruch ein Objekt vorzustellen, das Nachrichten empfängt und mit einem passenden Verhalten beantwortet, ist etwas gewöhnungsbedürftig. In der realen Welt sind Brüche, die sich
selbst auf ein Signal hin kürzen, nicht unbedingt alltäglich, wenngleich möglich (z.B. als didaktisches Spielzeug). Das objektorientierte Modellieren eines Gegenstandbereiches ist nicht unbedingt
eine direkte Abbildung, sondern eine Rekonstruktion. Einerseits soll der Gegenstandsbereich im
Modell gut repräsentiert sein, andererseits soll eine möglichst stabile, gut erweiterbare und wieder
verwendbare Software entstehen.
Um Objekten aus fremden Klassen trotz Datenkapselung die Veränderung einer Merkmalsausprägung zu erlauben, müssen entsprechende Methoden (mit geeigneten Kontrollmechanismen) angeboten werden. Unsere Bruch-Klasse sollte wohl über Methoden zum Verändern von Zähler und Nenner verfügen. Bei einem geschützten Merkmal ist auch der direkte Lesezugriff ausgeschlossen, so
dass im Bruch-Beispiel auch noch Methoden zum Ermitteln von Zähler und Nenner ratsam sind.
Eine konsequente Umsetzung der Datenkapselung erzwingt also eventuell eine ganze Serie von
Methoden zum Lesen und Setzen von Merkmalsausprägungen.
Mit diesem Aufwand werden aber erhebliche Vorteile realisiert:


Stabilität
Die Merkmalsausprägungen sind vor unsinnigen und gefährlichen Zugriffen geschützt,
wenn Veränderungen nur über die vom Klassendesigner entworfenen Methoden möglich
sind. Treten doch Fehler auf, sind diese leichter zu identifizieren, weil nur wenige Methoden
verantwortlich sein können.
Produktivität
Durch Datenkapselung wird die Modularisierung unterstützt, so dass bei der Entwicklung
großer Softwaresysteme zahlreiche Programmierer reibungslos zusammenarbeiten können.
Der Klassendesigner trägt die Verantwortung dafür, dass die von ihm entworfenen Methoden korrekt arbeiten. Andere Programmierer müssen beim Verwenden einer Klasse lediglich
die Methoden der Schnittstelle kennen. Das Innenleben einer Klasse kann vom Designer
nach Bedarf geändert werden, ohne dass andere Programmbestandteile angepasst werden
Kapitel 1: Einleitung
4
müssen. Bei einer sorgfältig entworfenen Klasse stehen die Chancen gut, dass sie in mehreren Software-Projekten genutzt werden kann (Wiederverwendbarkeit). Besonders günstig
ist die Recycling-Quote bei den Klassen der .NET – Laufzeitbibliothek (siehe Abschnitt
1.2.6), von denen alle C# - Programmierer regen Gebrauch machen. Wir werden unser Beispiel noch um die Klasse BruchAddition erweitern, welche Bruch-Objekte benutzt, um
ein Programm zur Addition von Brüchen zu realisieren.
Im Vergleich zu anderen objektorientierten Programmiersprachen wie Java und C++ bietet C# mit
den so genannten Eigenschaften (engl.: Properties) eine Möglichkeit, den Aufwand mit den Methoden zum Lesen oder Verändern von Merkmalsausprägungen für den Designer und den Nutzer
einer Klasse zu reduzieren. In der Klasse Bruch werden wir z.B. zum Feld zaehler die Eigenschaft Zaehler (großer Anfangsbuchstabe!) definieren, welche dem Nutzer einer Klasse Methoden zum Lesen und Setzen des Merkmals bietet, wobei aber dieselbe Syntax wie beim direkten
Zugriff auf ein Feld verwenden werden darf. Um dieses Argument zu illustrieren, greifen wir der
Beschäftigung mit elementaren C# - Sprachelementen vor. In der folgenden Anweisung wird der
zaehler eines Bruch-Objekts mit dem Namen b1 auf den Wert 4 gesetzt:
b1.Zaehler = 4;
Während der Entwickler Zugriffsmethoden bereitzustellen hat (siehe unten), sieht der Nutzer ein
öffentliches Merkmal der Klasse. Langfristig werden Sie diese Ergänzung des objektorientierten
Sprachumfangs zu schätzen lernen. Momentan ist sie eher eine Belastung, da Sie vielleicht erstmals
mit der Grundarchitektur einer Klasse konfrontiert werden, und die fundamentale Unterscheidung
zwischen Merkmalen und Methoden einer Klasse durch die C# - Eigenschaften unscharf zu werden
scheint. Letztlich erspart eine C# - Eigenschaft wie Zaehler dem Nutzer lediglich die Verwendung von Zugriffsmethoden, z.B.
b1.SetzeZaehler(4);
Damit kann man die C# - Eigenschaften in die Kategorie syntactic sugar (Mössenböck 2003, S. 3)
einordnen, was aber keine Abwertung bedeuten soll. Wegen ihrer intensiven Nutzung in C# - Programmen ist ein Auftritt im ersten Beispiel wohl trotz den angesprochenen didaktischen Probleme
gerechtfertigt.
Insgesamt sollen die Objekte unserer Bruch-Klasse folgende Methoden beherrschen bzw. Eigenschaften besitzen:




Nenner (Eigenschaft zu nenner)
Das Objekt wird beauftragt, seinen nenner – Zustand mitzuteilen bzw. zu verändern. Ein
direkter Zugriff auf das Merkmal soll fremden Klassen nicht erlaubt sein (Datenkapselung).
Bei dieser Vorgehensweise kann ein Bruch-Objekt z.B. verhindern, dass sein nenner auf
Null gesetzt wird.
Zaehler(Eigenschaft zu zaehler)
Das Objekt wird beauftragt, seinen zaehler – Zustand mitzuteilen bzw. zu verändern. Die
Eigenschaft Zaehler bringt im Gegensatz zur Eigenschaft Nenner keinen großen Gewinn an Sicherheit. Sie ist aber der Einheitlichkeit und damit der Einfachheit halber angebracht und hält die Möglichkeit offen, das Merkmal zaehler einmal anders zu realisieren.
Kuerze()
Das Objekt wird beauftragt, zaehler und nenner zu kürzen. Welcher Algorithmus dazu
benutzt wird, bleibt dem Klassendesigner überlassen.
Addiere(Bruch b)
Das Objekt wird beauftragt, den als Parameter (siehe unten) übergebenen Bruch zum eigenen Wert zu addieren.
Abschnitt 1.1 Beispiel für die objektorientierte Softwareentwicklung mit C#


5
Frage()
Das Objekt wird beauftragt, zaehler und nenner beim Anwender via Konsole (Eingabeaufforderung) zu erfragen.
Zeige()
Das Objekt wird beauftragt, zaehler und nenner auf der Konsole anzuzeigen.
Man bezeichnet die in einer Klasse definierten Bestandteile auch als Member. Unsere BruchKlasse enthält folgende Member:



Felder
zaehler, nenner
Eigenschaften (Hier handelt es sich letztlich um Paare von Methoden.)
Zaehler, Nenner
Methoden
Kuerze(), Addiere(), Frage() und Zeige()
Von kommunizierenden Objekten mit Handlungskompetenzen zu sprechen, mag als übertriebener
Anthropomorphismus (als Vermenschlichung) erscheinen. Bei der Ausführung von Methoden sind
Objekte selbstverständlich streng determiniert, während Menschen bei Kommunikation und Handlungsplanung ihren freien Willen einbringen!? Fußball spielende Roboter (als besonders anschauliche Objekte aufgefasst) zeigen allerdings mittlerweile schon recht weitsichtige und auch überraschende Spielzüge. Was sie noch zu lernen haben, sind vielleicht Strafraumschwalben, absichtliches
Handspiel etc. Nach diesen Randbemerkungen kehren wir zum Programmierkurs zurück, um möglichst bald freundliche und kluge Objekte erstellen zu können.
Um die durch objektorientierte Analyse gewonnene Modellierung eines Gegenstandsbereichs standardisiert und übersichtlich zu beschreiben, wurde die Unified Modeling Language (UML) entwickelt. Hier wird eine Klasse durch ein Rechteck mit drei Abschnitten dargestellt:



Oben steht der Name der Klasse.
In der Mitte stehen die Merkmale.
Hinter dem Namen eines Merkmals gibt man seinen Datentyp (siehe unten) an. Bei den Eigenschaften von C# handelt es sich nach obigen Erläuterungen eigentlich um Zugriffsmethoden, die aber syntaktisch wie (öffentlich verfügbare) Merkmale angesprochen werden. Es
hängt vom Adressaten eines Klassendiagramms (z.B. Entwickler-Teamkollege oder Anwender der Klasse) ab, ob man die Felder, die Eigenschaften oder beides angibt.
Unten stehen die Handlungskompetenzen (Methoden).
In Anlehnung an eine in vielen Programmiersprachen (z.B. in C#) übliche und noch ausführlich zu behandelnde Syntax zur Methodendefinition gibt man für die Argumente eines Methodenaufrufs (mit Spezifikationen der gewünschten Ausführungsart bzw. mit Details der
gesendeten Nachricht) den Datentyp an.
Bei der Bruch-Klasse erhält man folgende Darstellung, wenn die Eigenschaften aus der Anwenderperspektive betrachtet werden (als Merkmale und nicht als Methodenpaare):
Kapitel 1: Einleitung
6
Bruch
Zaehler: int
Nenner: int
Kuerze()
Addiere(Bruch b)
Frage()
Zeige()
Sind bei einer Anwendung mehrere Klassen beteiligt, dann sind auch die Beziehungen zwischen
den Klassen wesentliche Bestandteile des UML-Modells.
Nach der sorgfältigen Modellierung per UML muss übrigens die Kodierung eines Softwaresystems
nicht am Punkt Null beginnen, weil professionelle UML-Entwicklerwerkzeuge Teile des Quellcodes automatisch aus dem Modell erzeugen können. 1
Das relativ einfache Einstiegsbeispiel sollte Sie nicht dazu verleiten, den Begriff Objekt auf Gegenstände zu beschränken. Auch Ereignisse wie z.B. die Fehler eines Schülers in einem entsprechend ausgebauten Bruchrechnungsprogramm kommen als Objekte in Frage.
1.1.2 Objektorientierte Programmierung
In unserem Beispielprojekt soll nun die Bruch-Klasse in der Programmiersprache C# kodiert werden, wobei die Felder (Instanzvariablen) zu deklarieren, sowie Eigenschaften und Methoden zu
implementieren sind. Es resultiert der so genannte Quellcode, der am besten in einer Textdatei namens Bruch.cs untergebracht wird.
Zwar sind Ihnen die meisten Details der folgenden Klassendefinition selbstverständlich jetzt noch
fremd, doch sind die Variablendeklarationen sowie die Eigenschafts- und Methodenimplementationen als zentrale Bestandteile leicht zu erkennen:
using System;
public class Bruch {
int zaehler, // wird automatisch mit 0 initialisiert
nenner = 1;
public int Zaehler {
get {
return zaehler;
}
set {
zaehler = value;
}
}
1
Microsofts Entwicklerwerkzeuge haben in den letzten Jahren wenig UML- oder sonstige Modellierungsunterstützung geboten, doch gibt es aktuell (in 2008) deutliche Hinweise auf eine UML-Unterstützung in der kommenden
Version der Premium-Variante Visual Studio Team Systems.
Abschnitt 1.1 Beispiel für die objektorientierte Softwareentwicklung mit C#
7
public int Nenner {
get {
return nenner;
}
set {
if (value != 0)
nenner = value;
}
}
public void Zeige() {
Console.WriteLine("
}
{0}\n
-----\n
{1}\n", zaehler, nenner);
public void Kuerze() {
// größten gemeinsamen Teiler mit dem Euklidischen Algorithmus bestimmen
if (zaehler != 0) {
int ggt = 0;
int az = Math.Abs(zaehler);
int an = Math.Abs(nenner);
do {
if (az == an)
ggt = az;
else
if (az > an)
az = az - an;
else
an = an - az;
} while (ggt == 0);
zaehler /= ggt;
nenner /= ggt;
} else
nenner = 1;
}
public void Addiere(Bruch b) {
zaehler = zaehler*b.nenner + b.zaehler*nenner;
nenner = nenner*b.nenner;
Kuerze();
}
public void Frage() {
Console.Write("Zaehler: ");
Zaehler = Convert.ToInt32(Console.ReadLine());
Console.Write("Nenner : ");
Nenner = Convert.ToInt32(Console.ReadLine());
}
}
Weil für die beiden Felder (zaehler, nenner) die voreingestellte private-Deklaration unverändert gilt, ist im Beispielprogramm das Prinzip der Datenkapselung realisiert. Demgegenüber werden
die Eigenschaften und Methoden über den Modifikator public für die Verwendung in klassenfremden Methoden frei gegeben. Außerdem wird für die Klasse selbst mit dem Modifikator public die
Verwendung in beliebigen .NET – Programmen erlaubt.
Das im kostenlos verfügbaren Windows-SDK der Firma Microsoft (siehe Abschnitt 2.2.3) enthaltene Hilfsprogramm ILDasm liefert die folgende Beschreibung der Klasse Bruch:
Kapitel 1: Einleitung
8
Offenbar werden hier Felder durch eine türkise Raute ( ), Methoden durch ein magentafarbiges
Quadrat ( ) und Eigenschaften durch ein rotes, mit der Spitze nach oben zeigendes Dreieck ( )
dargestellt.
Hier bestätigt sich übrigens die Andeutung von Abschnitt 1.1.1, dass hinter den C# - Eigenschaften
letztlich Methoden für Lese- und Schreibzugriffe stehen (siehe z.B. get_Nenner(),
set_Nenner()).
Wie Sie bei späteren Beispielen erfahren werden, dienen in einem objektorientierten Programm
beileibe nicht alle Klassen zur Modellierung des Aufgabenbereichs. Es sind auch Objekte aus der
Welt des Computers zu repräsentieren (z.B. Fenster der Benutzeroberfläche, Netzwerkverbindungen, Störungen des normalen Programmablaufs).
1.1.3 Algorithmen
Am Anfang von Abschnitt 1.1 wurden mit der Modellierung des Gegenstandbereichs und der Realisierung von Algorithmen zwei wichtige Aufgaben der Softwareentwicklung genannt, von denen
die letztgenannte bisher kaum zur Sprache kam. Auch im weiteren Verlauf des Kurses wird die explizite Diskussion von Algorithmen (z.B. hinsichtlich Voraussetzungen, Korrektheit, Terminierung
und Aufwand) keinen großen Raum einnehmen. Wir werden uns intensiv mit der Programmiersprache C# sowie der .NET – Klassenbibliothek beschäftigen und dabei mit möglichst einfachen Beispielprogrammen (Algorithmen) arbeiten.
Unser Einführungsbeispiel verwendet in der Methode Kuerze() den bekannten und nicht gänzlich trivialen euklidischen Algorithmus, um den größten gemeinsamen Teiler (ggT) von Zähler
und Nenner eines Bruchs zu bestimmen, durch den zum optimalen Kürzen beide Zahlen zu dividieren sind. Beim euklidischen Algorithmus wird die leicht zu beweisende Aussage genutzt, dass für
zwei natürliche Zahlen u und v (u > v > 0) der ggT gleich dem ggT von v und (u - v) ist:
Ist t ein Teiler von u und v, dann gibt es natürliche Zahlen tu und tv mit tu > tv und
u = tut sowie v = tvt
Folglich ist t auch ein Teiler von (u - v), denn:
u - v= (tu- tv)t
Ist andererseits t ein Teiler von u und (u – v), dann gibt es natürliche Zahlen tu und td mit tu > td
und
Abschnitt 1.1 Beispiel für die objektorientierte Softwareentwicklung mit C#
9
u = tut sowie (u – v) = tdt
Folglich ist t auch ein Teiler von v:
u – (u – v) = v = (tu- td)t
Weil die Paare (u, v) und (u, u - v) dieselben Mengen gemeinsamer Teiler besitzen, sind auch die
größten gemeinsamen Teiler identisch. Weil die Zahl Eins als trivialer Teiler zugelassen ist, existiert übrigens zu zwei natürlichen Zahlen immer ein größter gemeinsamer Teiler, der eventuell
gleich Eins ist.
Dieses Ergebnis wird in Kuerze() folgendermaßen ausgenutzt:
Es wird geprüft, ob Zähler und Nenner identisch sind. Trifft dies zu, ist der ggT gefunden (identisch mit Zähler und Nenner). Anderenfalls wird die größere der beiden Zahlen durch deren Differenz ersetzt, und mit diesem verkleinerten Problem startet das Verfahren neu.
Man erhält auf jeden Fall in endlich vielen Schritten zwei identische Zahlen und damit den ggT.
Der beschriebene Algorithmus eignet sich dank seiner Einfachheit gut für das Einführungsbeispiel,
ist aber in Bezug auf den erforderlichen Berechnungsaufwand nicht überzeugend. In einer Übungsaufgabe zu Abschnitt 3.7 werden Sie eine erheblich effizientere Variante implementieren.
1.1.4 Startklasse und Main()-Methode
Bislang wurde im Anwendungsbeispiel aufgrund einer objektorientierten Analyse des Aufgabenbereichs die Klasse Bruch entworfen und in C# realisiert. Wir verwenden nun die Bruch-Klasse in
einer Konsolenanwendung zur Addition von zwei Brüchen. Dabei bringen wir einen Akteur ins
Spiel, der in einem einfachen sequentiellen Handlungsplan Bruch-Objekte erzeugt und ihnen
Nachrichten zustellt, die (zusammen mit dem Verhalten des Anwenders) den Programmablauf voranbringen.
In diesem Zusammenhang ist von Bedeutung, dass es in jedem C# - Programm eine besondere
Klasse geben muss, die eine Methode mit dem Namen Main() in ihren klassenbezogenen Handlungsrepertoire besitzt. Beim Start eines Programms wird seine Startklasse ausfindig gemacht und
aufgefordert, ihre Main()-Methode auszuführen.
Es bietet sich an, die oben angedachte Handlungssequenz des Bruchadditionsprogramms in der obligatorischen Startmethode unterzubringen.
Obwohl prinzipiell möglich, erscheint es nicht sinnvoll, die auf Wiederverwendbarkeit hin konzipierte Bruch-Klasse mit der Startmethode für eine sehr spezielle Anwendung zu belasten. Daher
definieren wir eine zusätzliche Klasse namens BruchAddition, die nicht als Bauplan für Objekte dienen soll und auch kaum Recycling-Chancen hat. Ihr Handlungsrepertoire kann sich auf die
Klassenmethode Main() zur Ablaufsteuerung im Bruchadditionsprogramm beschränken. Indem wir
eine andere Klasse zum Starten verwenden, wird u.a. gleich demonstriert, wie leicht das Hauptergebnis unserer Arbeit (die Bruch-Klasse) für thematisch verwandte Projekte genutzt werden kann.
In der BruchAddition–Methode Main() werden zwei Objekte (Instanzen) aus der Klasse
Bruch erzeugt und mit der Ausführung verschiedener Methoden beauftragt:
Kapitel 1: Einleitung
10
Quellcode in BruchAddition.cs
Ein- und Ausgabe
using System;
1. Bruch
Zaehler: 20
Nenner : 84
5
----21
class BruchAddition {
static void Main() {
Bruch b1 = new Bruch(), b2 = new Bruch();
Console.WriteLine("1. Bruch");
b1.Frage();
b1.Kuerze();
b1.Zeige();
Console.WriteLine("\n2. Bruch");
b2.Frage();
b2.Kuerze();
b2.Zeige();
Console.WriteLine("\nSumme");
b1.Addiere(b2);
b1.Zeige();
}
2. Bruch
Zaehler: 12
Nenner : 36
1
----3
Summe
4
----7
}
Zum Ausprobieren startet man aus dem Ordner (zu finden an der im Vorwort vereinbarten Stelle)
...\BspUeb\Einleitung\Bruch\Konsole\VS\bin\Debug\
das Programm BruchAddition.exe, z.B.:
In der Main()-Methode kommen nicht nur Bruch–Objekte zum Einsatz. Wir nutzen die Kompetenzen der Klasse Console aus der Standardbibliothek und rufen ihre Klassenmethode WriteLine()
auf.
Wir haben zur Lösung der Aufgabe, ein Programm für einfache Operationen mit Brüchen zu erstellen, zwei Klassen mit folgender Rollenverteilung definiert:

Die Klasse Bruch enthält den Bauplan für die wesentlichen Akteure im Aufgabenbereich.
Dort alle Merkmale und Handlungskompetenzen von Brüchen zu konzentrieren, hat folgende Vorteile:
o Die Klasse kann in verschiedenen Programmen eingesetzt werden (Wiederverwendbarkeit). Dies fällt vor allem deshalb so leicht, weil die Objekte Handlungskompetenzen (Methoden) besitzen und alle erforderlichen Instanzvariablen mitbringen.
Wir müssen bei der Definition dieser Klasse ihre allgemeine Verfügbarkeit explizit
mit dem Zugriffsmodifikator public genehmigen. Per Voreinstellung ist eine Klasse
nur intern (in der eigenen Übersetzungseinheit) verfügbar.
Abschnitt 1.1 Beispiel für die objektorientierte Softwareentwicklung mit C#
11
o Beim Umgang mit den Bruch–Objekten sind wenige Probleme zu erwarten, weil
nur klasseneigene Methoden Zugang zu kritischen Merkmalen haben (Datenkapselung). Sollten doch Fehler auftreten, sind die Ursachen in der Regel schnell identifiziert.

Die Klasse BruchAddition dient nicht als Bauplan für Objekte, sondern enthält eine
Klassenmethode Main(), die beim Programmstart automatisch aufgerufen wird und dann für
einen speziellen Einsatz von Bruch-Objekten sorgt. Mit einer Wiederverwendung des
BruchAddition-Quellcodes in anderen Projekten ist kaum zu rechnen.
In der Regel bringt man den Quellcode jeder Klasse in einer eigenen Textdatei unter, die den Namen der Klasse trägt, ergänzt um die Namenserweiterung .cs. Das .NET - Framework erlaubt aber
auch Quellcodedateien mit mehreren Klassen und beliebigem Namen.
1.1.5 Ausblick auf Anwendungen mit graphischer Benutzerschnittstelle
Das obige Beispielprogramm arbeitet der Einfachheit halber mit einer Konsolen-orientierten Einund Ausgabe. Nachdem wir in dieser übersichtlichen Umgebung grundlegende Sprachelemente
kennen gelernt haben, werden wir uns selbstverständlich auch mit der Programmierung von graphischen Benutzerschnittstellen beschäftigen. In folgendem Programm zum Kürzen von Brüchen wird
die oben definierte Klasse Bruch verwendet, wobei an Stelle ihrer Methoden Frage() und Zeige() jedoch grafikorientierte Techniken zum Einsatz kommen:
Mit dem Quellcode zur Gestaltung der graphischen Oberfläche könnten Sie im Moment noch nicht
allzu viel anfangen. Am Ende des Kurses werden Sie derartige Anwendungen aber mit Leichtigkeit
erstellen.
Zum Ausprobieren startet man aus dem Ordner
...\BspUeb\Klassen und Objekte\Bruch\Einleitung\Bruch\GUI\bin\Debug
das Programm BruchKürzenGui.exe.
1.1.6 Zusammenfassung zu Abschnitt 1.1
Im Abschnitt 1.1 sollten Sie einen ersten Eindruck von der Softwareentwicklung mit C# gewinnen.
Alle dabei erwähnten Konzepte der objektorientierter Programmierung und technischen Details der
Realisierung in C# werden bald systematisch behandelt und sollten Ihnen daher im Moment noch
keine Kopfschmerzen bereiten. Trotzdem kann es nicht schaden, an dieser Stelle einige Kernaussagen von Abschnitt 1.1 zu wiederholen:



Vor der Programmentwicklung findet die objektorientierte Analyse der Aufgabenstellung
statt. Dabei werden per Abstraktion die beteiligten Klassen identifiziert.
Ein Programm besteht aus Klassen
Eine Klasse ist charakterisiert durch Merkmale und Methoden.
Kapitel 1: Einleitung
12





Eine Klasse dient als Bauplan für Objekte, kann aber auch selbst aktiv werden (Methoden
ausführen und aufrufen).
Ein Merkmal bzw. eine Methode wird entweder den Objekten einer Klasse oder der Klasse
selbst zugeordnet.
In den Methodendefinitionen werden Algorithmen realisiert, in der Regel unter Verwendung
von zahlreichen vordefinierten Klassen aus diversen Bibliotheken.
Im Programmablauf kommunizieren die Akteure (Objekte und Klassen) durch den Aufruf
von Methoden miteinander, wobei aber in der Regel noch „externe Kommunikationspartner“ (z.B. Benutzer, andere Programme) beteiligt sind.
Beim Programmstart wird die Startklasse vom Laufzeitsystem aufgefordert, die Methode
Main() auszuführen. Ein Hauptzweck dieser Methode besteht oft darin, Objekte zu erzeugen
und somit „Leben auf die objektorientierte Bühne zu bringen“.
1.2 Das .NET – Framework
Eben haben Sie C# als eine Programmiersprache kennen gelernt, die Ausdrucksmittel zur Modellierung von Anwendungsbereichen bzw. zur Formulierung von Algorithmen bereitstellt. Unter einem
Programm wurde dabei der vom Entwickler zu formulierende Quellcode verstanden. Während Sie
derartige Texte bald ohne Mühe lesen und begreifen werden, kann die CPU (Central Processing
Unit) eines Rechners nur einen maschinenspezifischen Satz von Befehlen verstehen, die als Folge
von Nullen und Einsen kodiert werden müssen (Maschinencode). Die ebenfalls CPU-spezifische
Assembler-Sprache stellt eine für Menschen lesbare Form des Maschinencodes dar. Mit dem Assembler- bzw. Maschinenbefehl
mov eax, 4
einer CPU aus der x86-Familie wird z.B. der Wert Vier in das EAX-Register (ein Speicherort im
Prozessor) geschrieben. Die CPU holt sich einen Maschinenbefehl nach dem anderen aus dem
Hauptspeicher und führt ihn aus, heutzutage immerhin mehrere Milliarden Befehle pro Sekunde
(Instructions Per Second, IPS).
Ein Quellcode-Programm muss also erst in Maschinencode übersetzt werden, damit es von einem
Rechner ausgeführt werden kann. Wie dies bei C# und anderen im .NET – Kontext verfügbaren
Programmiersprachen geschieht, sehen wir uns nun näher an. Wir behandeln nicht die komplette
.NET – Softwarearchitektur, sondern beschränken uns auf die für Programmierer wichtigen Hintergrundinformationen.
1.2.1 Überblick
Beim .NET – Framework handelt es sich um eine von der Firma Microsoft entwickelte Plattform
(Sammlung von Technologien), welche die bisherige Windows-Programmierung über das Win32API oder die COM-Technologie (Common Objekt Model) modernisieren und insbesondere vereinfachen soll. Außerdem soll eine Konkurrenz zur Java-Plattform der Firma Sun geschaffen werden,
die bei vielen IT-Firmen und Entwicklern hohes Ansehen geniest.
Als Orientierung für die nächsten Abschnitte kann die folgende, stark vereinfachte Darstellung des
.NET - Frameworks dienen:
13
Abschnitt 1.2 Das .NET – Framework
.NET - Anwendung in einer CLS-Programmiersprache, z.B. C#
(Common Language Specification)
übersetzte .NET - Anwendung in MSIL
(Microsoft Intermediate Language)
Basisklassenbibliothek (u.a. mit ADO.NET, ASP.NET)
(FCL, Framework Class Library)
Laufzeitumgebung
(CLR, Common Language Runtime)
Betriebssystem
(Microsoft-System ab Windows 98, dank Mono aber auch Linux, MacOS-X oder UNIX)
Hardware
Seit der Windows-Version Vista ist das .NET – Framework fester Systembestandteil. Bei älteren
Versionen (ab Windows 98) kann ein bei Microsoft kostenlos verfügbares .NET Framework - Paket
(meist als Redistributable bezeichnet) nachinstalliert werden (zur Beschaffung und Installation
siehe Abschnitt 1.2.2). Dieses Paket darf auch mit eigenen .NET - Anwendungen vertrieben werden. Über Installationspakete aus dem Open Source - Projekt Mono 1 ist das .NET - Framework
auch für Linux, MacOS-X und UNIX (genauer: Solaris) verfügbar.
Vom .NET – Framework sind bisher folgende Versionen erschienen:
Version Erscheinungsjahr
1.0
2002
1.1
2003
2.0
2005
3.0
2006
3.5
2007
4.0 (Beta)
2009
Wir werden im Kurs lange Zeit mit der Version 2.0 auskommen, die derzeit (2009) als Standard
gelten kann, aber z.B. bei der Datenbankprogrammierung auch die neuen Möglichkeiten der Version 3.5 nutzen (Language Integrated Query, LINQ). Die mit Windows Vista ausgelieferte .NET Version 3.0 hat als Neuerung vor allem das Windows Presentations Framework (WPF) gebracht.
Dieses Framework für multimedial ambitionierte Bedienoberflächen wird im Kurs keine Rolle spielen (vgl. Abschnitt 9).
Das .NET-Framework und die Programmiersprache C# besitzen eine eigenständige Versionierung,
wobei die beiden Zeitreihen aber stark korrelieren, wie die folgende Tabelle (mit Prognose der näheren Zukunft) aus Schwichtenberg (2009a) zeigt:
1
Webadresse: http://www.mono-project.com/Main_Page
Kapitel 1: Einleitung
14
.NET - Framework
1.0
1.1
2.0
3.0
3.5
4.0
C#
1.0
1.1
2.0
2.0
3.0
4.0
1.2.2 Installation
Das zum Ausführen von .NET - Programmen erforderliche Framework (mit der CLR, der Klassenbibliothek FCL und den Compilern für C#, VB.NET etc.) ist in Windows Vista (in der Version 3.0)
und in Windows 7 (in der Version 3.5) bereits enthalten und kann für andere Windows-Versionen
nachinstalliert werden. Bei der Installation von Microsofts Visual C# 2008 Express Edition (siehe
Abschnitt 2.2.1) landet das .NET - Framework (in der Version 3.5) automatisch auf der Festplatte.
Wer diese Entwicklungsumgebung verwendet, muss das .NET – Framework also nicht separat installieren. Auch auf den Rechnern Ihrer Kunden ist in der Regel bereits ein .NET – Framework vorhanden. Wenn das Framework (in der benötigten Version) doch fehlt, kann man es kostenlos (z.B.
via Internet bei Microsoft 1) beschaffen und nachinstallieren. Neben der für die meisten Klientenrechner relevanten 32-Bit – Version (Plattform x86) unterstützt Microsoft auch die Plattformen x64
(64-Bit – Windows auf einem Standard-PC mit 64-Bit - CPU) und ia64 (64-Bit – Windows auf einem Itanium-System).
Das Paket zur Framework-Version 2.0 für die x86-Plattform
Microsoft .NET Framework 2.0 Redistributable (x86)
benötigt einen Rechner mit …


Windows 98 (SE), Windows ME, Windows 2000 (mit SP 3), Windows XP (mit SP 2) oder
Windows Server 2003.
ca. 280 MB freiem Festplattenspeicher
Mit Service Pack 1 ist das .NET - Framework 2.0 anspruchsvoller:


Windows 2000 (mit SP 4), Windows XP (mit SP 2) oder Windows Server 2003.
ca. 500 MB freiem Festplattenspeicher
Als Installationsordner dient bei .NET 2.0 (ohne Änderungsmöglichkeit):
%SystemRoot%\Microsoft.NET\Framework\v2.0.50727
Es ist sinnvoll, auch das deutsche Sprachpaket zu installieren:
Microsoft .NET Framework 2.0 Language Pack Deutsch (x86)
Zu der (in Windows 7 bereits enthaltenen) Framework-Version 3.5
Microsoft .NET Framework 3.5
bietet Microsoft einen Online-Installer, der die benötigten Installationsbestandteile während der
Installation ermittelt und von einem Server lädt, sowie einen (deutlich größeren) Offline-Installer,
der kompatible Systeme ohne Internetzugriff versorgen kann. Beide erwarten einen Zielrechner
mit …
1
Eine Quelle zum Herunterladen via Internet ist (z.B. per Suchmaschine) leicht zu finden, so dass auf die Angabe von
länglichen, versionsabhängigen und oft nicht sehr zeitstabilen Internet-Adressen verzichtet wird.
Abschnitt 1.2 Das .NET – Framework


15
Windows XP, Windows Vista, Windows Server 2003, Windows Server 2008
ca. 200 - 600 MB freiem Festplattenspeicher (abhängig von den bereits installierten .NET Versionen)
In der Version 3.5 ist die Bezeichnung Redistributable gestrichen worden. Außerdem ist kein separates Sprachpaket mehr erforderlich. Zudem werden freundlicherweise ältere Framework-Versionen
gleich mit installiert. Dies ist sinnvoll, weil ältere .NET - Programme zur Vermeidung von Versionskonflikten stets von einer CLR auf ihrem eigenen Versionsstand ausgeführt werden. Als Installationsordner dient bei .NET 3.5 (ohne Änderungsmöglichkeit):
%SystemRoot%\Microsoft.NET\Framework\v3.5
Der Ordner zur .NET - Version 3.5 ist deutlich kleiner als der Ordner zu .NET 2.0, weil Microsoft
bei unverändert weiterverwendbaren Dateien einer älteren Version auf ein Kopieren verzichtet.
1.2.3 C#-Compiler und MSIL
Den (z.B. mit einem beliebigen Editor verfassten) C# - Quellcode übersetzt der C# - Compiler im
.NET - Framework in die Microsoft Intermediate Language (MSIL), oft kurz als IL (Intermediate Language) bezeichnet. Wenngleich dieser Zwischencode von den heute üblichen Prozessoren
noch nicht direkt ausgeführt werden kann, hat er doch bereits viele Verarbeitungsschritte auf dem
Weg vom Quell- zum Maschinencode durchlaufen. Weil kompakter als Maschinencode, eignet er
sich gut für die Übertragung über Netzwerke. Die Übersetzung des Zwischencodes in die Maschinensprache einer konkreten CPU geschieht just-in-time bei der Ausführung des Programms durch
die CLR (siehe Abschnitt 1.2.5).
Befinden sich die beiden Quellcodedateien Bruch.cs und BruchAddition.cs im aktuellen Verzeichnis
eines Konsolenfensters, dann kann ihre Übersetzung durch den C# - Compiler csc.exe der .NETVersion 3.5 mit dem folgenden Kommando
%SystemRoot%\Microsoft.NET\Framework\v3.5\csc *.cs
veranlasst werden:
Weil sich der Ordner
%SystemRoot%\Microsoft.NET\Framework\v3.5
mit dem C# - Compiler per Voreinstellung nicht im Suchpfad für ausführbare Programme befindet,
muss er im Aufruf angegeben werden. Der eigentliche Auftrag an den Compiler, sämtliche Dateien
im aktuellen Verzeichnis mit der Namenserweiterung .cs zu übersetzen, ist sehr übersichtlich:
Kapitel 1: Einleitung
16
csc *.cs
Wir werden uns in Abschnitt 2.1.2 noch mit einigen Details des Compiler-Aufrufs beschäftigen.
Im Übersetzungsergebnis BruchAddition.exe
ist u.a. der IL-Code der beiden Klassen enthalten. Besonders kurz sind die implizit zu einer C# Eigenschaft (vgl. Abschnitt 1.1.1) vom Compiler erstellten get- und set-Methoden, z.B.: 1
public int Nenner {
get {
return nenner;
}
set {
if (value != 0)
nenner = value;
}
C# - Compiler

}
Offenbar resultiert aus der Bruch-Eigenschaft Nenner u.a. die Methode get_Nenner(), deren
IL-Code aus sieben Anweisungen besteht.
Die konzeptionelle Verwandtschaft der MSIL mit dem Bytecode der Java-Plattform ist unverkennbar. Von den Unterschieden zwischen beiden Technologien ist vor allem die Sprachunabhängigkeit der Microsoft-Lösung zu erwähnen. Alle .NET – Quellprogramme werden unabhängig von der
verwendeten Programmiersprache in die MSIL übersetzt, die daher auch als CIL (Common Intermediate Language) bezeichnet wird. In gewissen Grenzen (siehe unten) können verschiedene Programmierer bei der Erstellung von Klassen für ein gemeinsames Projekt jeweils die individuell bevorzugte Programmiersprache verwenden. In der Java-Welt arbeiten alle Entwickler hingegen mit
derselben Programmiersprache, was man als Vor- oder Nachteil betrachten kann.
1
Diese Ausgabe liefert das schon in Abschnitt 1.1.2 angesprochene Werkzeug ILDasm nach einem Doppelklick auf
die interessierende Methode (siehe Seite 7).
17
Abschnitt 1.2 Das .NET – Framework
Mittlerweile sind für viele Programmiersprachen MSIL-Compiler verfügbar, etliche werden sogar
mit dem .NET - Framework - Paket ausgeliefert (z.B. csc.exe für C#). Allerdings unterstützen die
.NET-Compiler in der Regel nicht den gesamten MSIL-Sprachumfang, so dass sich mit den Compilern zu verschiedenen .NET-Sprachen durchaus Klassen produzieren lassen, die nicht zusammenarbeiten können (siehe Richter, 2006, S.50). Microsoft (2007a) hat unter dem Namen Common Language Specification (CLS) einen Sprachumfang definiert, den jede .NET-Programmiersprache erfüllen muss. Beschränkt man sich bei der Klassendefinition auf diesen kleinsten gemeinsamen Nenner, ist die Interoperabilität mit anderen CLS-kompatiblen Klassen sichergestellt.
C# kann der großen Konkurrenz als die bevorzugte .NET - Programmiersprache gelten, weil
schließlich das Framework selbst überwiegend in C# entwickelt wurde.
1.2.4 Assemblies und Metadaten
Die von einem .NET - Compiler (z.B. csc.exe bei C#) bei einem Aufruf erzeugten Binärdateien (mit
MSIL-Code) werden als Assemblies bezeichnet und haben die Namenserweiterung:


.exe (bei .NET – Anwendungen) oder
.dll (bei .NET – Bibliotheken)
Man übergibt dem Compiler im Allgemeinen mehrere Quellcodedateien mit jeweils einer Klassendefinition (siehe obiges Beispiel) und erhält als Ergebnis ein Assembly. In der Konsolen-Variante
unseres Beispiels lassen wir vom Compiler ein Exe-Assembly mit dem Zwischencode der Klassen
Bruch und BruchAddition erzeugen.
Die folgende Abbildung (nach Mössenböck 2003, S. 6) fasst wesentliche Informationen über Quellcode, C#-Compiler, MSIL-Code und Assemblies anhand des Bruchadditionsbeispiels zusammen
und zeigt mit den anschließend zu beschriebenen Metadaten weitere wichtige Bestandteile eines
.NET - Assemblies:
Datei Bruch.cs
public class Bruch {
...
}
Datei BruchAddition.cs
class BruchAddition {
...
}
C# - Compiler,
z.B. csc.exe
BruchAddition.exe
Assembly BruchAddition
Manifest mit
Assembly-Metadaten
Modul BruchAddition.exe
Typ-Metadaten der Klassen
Bruch und BruchAddition
MSIL-Code der Klasse
Bruch
MSIL-Code der Klasse
BruchAddition
Datei BruchAddition.exe
Kapitel 1: Einleitung
18
1.2.4.1 Typ-Metadaten
Neben dem IL-Code enthält ein Assembly Typ-Metadaten, die alle enthaltenen Typen (Klassen
und sonstige Datentypen) beschreiben und vom Laufzeitsystem (Common Language Runtime, siehe
unten) für die Verwaltung der Typen genutzt werden. Zu jeder Klasse sind z.B. Informationen über
ihre Methoden und Merkmale vorhanden.
Im Bruchadditions-Assembly sind z.B. über die set_Zaehler - Methode zur ZaehlerEigenschaft der Klasse Bruch folgende Metadaten verfügbar: 1
Neben Definitionstabellen mit Angaben zu den eigenen Klassen enthalten die Metadaten auch Referenztabellen mit Informationen zu den fremden Klassen, die im Assembly benutzt (referenziert)
werden.
1.2.4.2 Das Manifest eines Assemblies
Außerdem gehört zu einem Assembly das so genannte Manifest mit den Assembly-Metadaten.
Dazu gehören:



Name und Version des Assemblies
Durch eine systematische Versionsverwaltung sollen im .NET – Framework Probleme mit
Versionsunverträglichkeiten vermieden werden, die Windows-Anwender und -Entwickler
unter der Bezeichnung DLL-Hölle kennen.
Sicherheitsmerkmale bei signierten Assemblies
Dazu gehört insbesondere der öffentliche Schlüssel des Herausgebers.
Informationen über die Abhängigkeit von anderen Assemblies (z.B. aus der FCL)
Auch hier sorgen exakte Versionsangaben für die Vermeidung von Versionsunverträglichkeiten.
Besteht ein Assembly aus mehreren Dateien (siehe unten), dann ist das Manifest nur in einer Datei
vorhanden und enthält die Namen der weiteren zum Assembly gehörenden Dateien. In diesem Fall
kann das Manifest auch in einer eigenen Datei untergebracht werden.
1.2.4.3 Multidatei-Assemblies
Neben dem bisher beschriebenen Einzeldatei-Assembly, das in einer Binärdatei den Zwischencode
und die Metadaten inklusive Manifest enthält, kennt das .NET – Framework auch das MultidateiAssembly, das Einsteiger zunächst gefahrlos ignorieren dürfen. Es besteht aus mehreren Moduldateien mit Zwischencode und zugehörigen Metadaten. Das Manifest des gesamten Assemblies steckt
1
Die Typ-Metadaten eines Assemblies erhält man in ILDasm über die Tastenkombination Strg+M.
Abschnitt 1.2 Das .NET – Framework
19
entweder in einer Moduldatei oder in einer separaten Datei. Diese Architektur hat z.B. dann Vorteile, wenn ein Klient über eine langsame Netzverbindung auf ein Assembly zugreift, weil sich der
Transport auf die tatsächlich benötigten Module beschränken kann. Ohne die Aufteilung in Module
müsste das gesamte Assembly transportiert werden. Für Moduldateien ohne eigenes Manifest wird
meist die Namenserweiterung .netmodule verwendet.
Wie die obige Abbildung zeigt, ist genau genommen auch beim Einzeldatei-Assembly ein Modul
im Spiel. Dieses Einzelstück trägt denselben Namen wie das Assembly und enthält den MSIL-Code
sowie die Metadaten seiner Typen.
1.2.4.4 Private und allgemeine Assemblies
Ein Assembly benötigt in der Regel weitere Assemblies, deren Typen in den eigenen Methoden
verwendet werden. Dabei kommen private und systemweit verfügbare Assemblies in Frage. Private
Assemblies werden meist im Ordner der Anwendung untergebracht, doch können mit Hilfe der
(später zu behandelnden) Anwendungskonfigurationsdatei auch andere Ordner verwendet werden.
Für systemweit verfügbare Assemblies haben die .NET - Architekten den Global Assembly Cache
(GAC) vorgesehen, der sich unterhalb des Windows-Ordners befindet, z.B.:
Wie das Beispiel msddsp zeigt, können im GAC gleichnamige Assemblies mit unterschiedlichen
Versionsständen existieren. Eine nähere Beschäftigung mit dem GAC ist im ersten C# - Lernjahr
nicht unbedingt erforderlich.
1.2.4.5 Vergleich mit der COM-Technologie
Die Metadaten der .NET -Technologie sorgen dafür, dass die Klassen eines Assemblies unproblematisch von beliebigen .NET - Programmen genutzt werden können. Oben wurde schon erwähnt,
dass mit dem .NET – Framework die ältere COM-Architektur (Component Object Model) abgelöst
werden soll, die in den 90er Jahren des letzten Jahrhunderts geschaffen wurde, um sprachübergreifende Interoperabilität von Software-Komponenten zu ermöglichen, und die auch heute noch von
großer Bedeutung ist. Hier werden ebenfalls Metadaten bereitgestellt, welche alle Typen eines
COM-Servers beschreiben. Diese Metadaten werden in der IDL (Interface Definition Language)
erstellt, befinden sich in separaten Binärdateien (Typbibliotheken) und müssen in die WindowsRegistry eingetragen werden. Es kann leicht zu Problemen kommen, weil die Metadaten zu einem
COM-Server nicht auffindbar oder fehlerhaft sind. Die Metadaten eines .NET – Assemblies sind
demgegenüber stets vorhanden und aktuell. Sie bieten außerdem wichtige Informationen (z.B. Version, Sprache, Abhängigkeiten), die in einer COM-Typbibliothek nur spärlich oder gar nicht vorhanden sind. Außerdem ist kein Registry-Eintrag erforderlich.
Weitere Informationen zu den .NET - Metadaten finden Sie z.B. bei Richter (2006, S. 62ff).
Kapitel 1: Einleitung
20
1.2.5 CLR und JIT-Compiler
Bislang haben Sie erfahren, dass aus dem C# - Quellcode durch einen Compiler (z.B. csc.exe) der
sprach- und maschinenunabhängige MSIL-Code erzeugt wird. Beim Programmstart ist eine weitere
Übersetzung in den Maschinencode der aktuellen CPU erforderlich. Diese Aufgabe wird von der
Laufzeitumgebung für .NET – Anwendungen, der CLR (Common Language Runtime) erledigt. Dazu besitzt sie einen JIT-Compiler (Just In Time), der Leistungseinbußen aufgrund der zweistufigen
Übersetzungsprozedur minimiert (z.B. durch das Speichern von mehrfach benötigtem Maschinencode).
.NET – Programme können auf jedem Windows-Rechner mit passendem Framework auf übliche
Weise gestartet werden, z.B. per Doppelklick auf den Namen der Programmdatei. In der folgenden
Abbildung ist der Weg vom Quellcode bis zum ausführbaren Maschinencode für das Bruchadditionsbeispiel dargestellt, wobei auch noch die Namen wichtiger Dateien des .NET – Frameworks
vermerkt sind:
Quellcode
public class Bruch {
...
}
MSIL
class BruchAddition {
...
}
JIT -
C# Compiler
Maschinencode
Assembly
BruchAddition.exe
(z.B. csc.exe)
Compiler
Maschinencode
in
mscoree.dll
Bibliotheken
(z.B. mscorlib.dll)
Sorgen um mangelnde Performanz aufgrund der indirekten Übersetzung sind übrigens unbegründet.
Eine Untersuchung von Schäpers & Huttary (2003) ergab, dass die Zwischencode-Sprachen Java
und C# bei diversen Benchmarks sehr gut mit den „echten Compiler-Sprachen“ C++ und Delphi
mithalten können.
Das im .NET – Framework enthaltene Hilfsprogramm ngen.exe kann aus einem Assembly Maschinencode erzeugen und abspeichern, so dass bei der späteren Programmausführung kein JITCompiler mehr benötigt wird. Aus verschiedenen Gründen (siehe Richter 2006, S. 43ff) führt dies
aber nicht unbedingt zu einer beschleunigten Programmausführung.
Die CLR hat bei der Verwaltung von .NET – Anwendungen neben der Übersetzungstätigkeit noch
weitere Aufgaben zu erfüllen:



Gewährleisten der Code Access Security (CAS)
Beim Laden eines Assemblies stellt die CLR seine Zugriffsrechte (z.B. auf ein Dateisystem)
aufgrund verschiedener Beweise (z.B. Herkunft, vorhandene digitale Signatur) fest und verhindert ein Überschreiten dieser Rechte. Während ein klassisches Windows-Programm ebenso viele Rechte hat wie der angemeldete Benutzer, orientiert sich die CAS an Merkmalen
des jeweiligen Assemblies und bietet weitaus differenziertere Steuerungsmöglichkeiten.
Verifikation des IL-Codes
Irreguläre Aktionen einer .NET – Anwendung werden vom Verifier der CLR verhindert.
Das macht .NET – Anwendungen sehr stabil.
Unterstützung der .NET – Anwendungen bei der Speicherverwaltung
Überflüssig gewordene Objekte werden vom Garbage Collector (Müllsammler) der CLR automatisch entsorgt. Mit diesem Thema werden wir uns später noch ausführlich beschäftigen.
21
Abschnitt 1.2 Das .NET – Framework
1.2.6 Namensräume und FCL
Damit Programmierer nicht das Rad (und ähnliche Dinge) ständig neu erfinden müssen, bietet das
.NET – Framework eine umfangreiche Bibliothek mit fertigen Klassen für nahezu alle Routineaufgaben. Dass die als Framework Class Library (FCL) bezeichnete Standardbibliothek in allen
.NET – Programmsprachen zur Verfügung steht, ist für C# - Einsteiger noch wenig relevant. Bei der
späteren Teamarbeit kann die sprachunabhängige .NET – Architektur jedoch sehr bedeutsam werden, wenn Anhänger verschiedener Programmiersprachen zusammen treffen.
Die Klassen und sonstigen Datentypen der .NET – Standardbibliothek sind nach funktionaler Verwandtschaft in so genannte Namensräume eingeteilt. Dieses Organisationsprinzip dient in erster
Linie dazu, Namenskollisionen in einem globalen Namensraum zu vermeiden. So dürfen z.B. zwei
Klassen denselben Namen tragen, sofern sie sich in verschiedenen Namensräumen befinden. Ihre
voll qualifizierten Namen sind dann verschieden.
Namensräume sind keinesfalls auf die FCL beschränkt, und die von C# - Entwicklungsumgebungen
angebotenen Vorlagen für neue Projekte (siehe unten) definieren meist einen eigenen Namensraum
(namespace) für jedes Projekt, z.B.:
using System;
using System.Collections.Generic;
using System.Text;
namespace BruchAddition
{
class Program
{
static void Main(string[] args)
{
}
}
}
Bei kleinen Beispielprogrammen sind Namensräume jedoch überflüssig, und wir werden meist der
Einfachheit halber darauf verzichten.
Eine .NET – Klasse muss grundsätzlich mit ihrem voll qualifizierten Namen angesprochen werden,
z.B.:
System.Console.WriteLine("Hallo");
Namensraum
Klasse
Methode
Parameter
Um in einem Programm die Klassen eines Namensraums vereinfacht (ohne Namensraum-Präfix)
ansprechen zu können, muss der Namensraum am Anfang des Quelltexts per using-Direktive importiert werden, z.B.:
using System;
. . .
Console.WriteLine("Hallo");
Durch diese using-Direktive wird dafür gesorgt, dass der Compiler dem Namen jeder Klasse, die
nicht im projekteigenen Quellcode definiert ist, das Präfix System voranstellt und dann die referenzierten Assemblies (siehe unten) nach dem vervollständigten Namen durchsucht.
Bei Namenskollisionen gewinnt generell der lokalste Bezeichner, was zu unerwünschten Ergebnissen führen kann. Im folgenden Beispiel werden der Namensraumbezeichner System und der Klassenname Console (im Namensraum System) durch lokale Bezeichner verdeckt:
Kapitel 1: Einleitung
22
using System;
class Prog {
static int Console = 13;
static int System = 1;
static void Main() {
Console.WriteLine("Hallo");
}
}
Infolgedessen bewirkt die folgende Zeile
Console.WriteLine("Hallo");
keinen Methodenaufruf, weil der Compiler Console als int-Variablennamen betrachtet und meldet, dass der Datentyp int keine Definition für den Bezeichner WriteLine enthalte. In der folgenden Zeile
System.Console.WriteLine("Hallo");
betrachtet der Compiler System als int-Variablennamen und bemängelt, dass der Datentyp int
keine Definition für den Bezeichner Console enthalte. Mit dem Schlüsselwort global und dem ::Operator kann man eine im globalen Namensraum beginnende Namensauflösung anordnen, und
die folgende Zeile führt trotz der Verdeckungen zum erwünschten Methodenaufruf:
global::System.Console.WriteLine("Hallo");
Sie werden in eigenen Programmen das Verdecken von wichtigen Bezeichnern aus der FCL sicher
vermeiden. Wenn komplexe Softwaresysteme unter Beteiligung vieler Programmierer entstehen,
sind Namenskollisionen aber nicht auszuschließen. Der von Entwicklungsumgebungen automatisch
erstellte Quellcode (siehe unten) enthält daher häufig den ::-Operator in Verbindung mit dem
Schlüsselwort global.
Namensräume und Assemblies sind zwei voneinander unabhängige Organisationsstrukturen:


Klassen, die zum selben Namensraum gehören, können in verschiedenen Assemblies implementiert sein.
In einem Assembly können Klassen aus verschiedenen Namensräumen implementiert werden.
Der geschickte Umgang mit Namensräumen zur Erleichterung der Softwareentwicklung ist in erster
Linie Sache der .NET - Compiler. Die CLR entnimmt den Assemblies die vollständigen Typbezeichner und zerlegt diese nicht in ein Namensraumpräfix und den „eigentlichen“ Typnamen.
Namensräume können hierarchisch untergliedert werden, was speziell bei großen Bibliotheken für
Ordnung und entsprechend lange voll qualifizierte Namen mit Punkten zwischen den UnterraumBezeichnungen sorgt. Die .NET – Standardbibliothek (FCL) verwendet System als Wurzelnamensraum und enthält z.B. im Namensraum
System.Drawing.Drawing2D
Klassen und andere Datentypen zur Unterstützung der zweidimensionalen Grafikausgabe.
Einen ersten Eindruck vom Leistungsvermögen der FCL vermittelt die folgende Auswahl ihrer Namensräume. Diese Auflistung ist für Programmiereinsteiger allerdings von begrenztem Wert und
sollte bei diesem Leserkreis keine Verunsicherung durch die große Anzahl fremder Begriffe auslösen:
Abschnitt 1.2 Das .NET – Framework
Namensraum
System
System.Collections
System.Data
System.Drawing
System.IO
System.Net
System.Reflection
System.Seccurity
System.Threading
System.Web
System.Windows.Forms
System.XML
23
Inhalt
... enthält grundlegende Basisklassen sowie Klassen für Dienstleitungen wie mathematische Berechnungen oder Konvertierungen.
U.a. befindet sich hier die Klasse Console, die wir im Einführungsbeispiel für den Zugriff auf Bildschirm und Tastatur verwendet haben.
... enthält Container zum Verwalten von Listen, Warteschlangen,
Bitarrays, Hashtabellen etc.
... enthält zusammen mit diversen untergeordneten Namensräumen
(z.B. System.Data.SqlClient) die Klassen zur Datenbankbearbeitung.
... enthält Klassen für die Grafikausgabe mit GDI+ (erweitertes
Windows Graphics Device Interface).
... enthält Klassen für die Ein-/Ausgabebehandlung im DatenstromParadigma.
... enthält Klassen für die Netzwerk-Programmierung.
... ermöglichst es u.a., zur Laufzeit Informationen über Klassen abzufragen oder neue Methoden zu erzeugen. Dabei werden die Metadaten in den .NET – Assemblies genutzt.
... enthält Klassen, die sich z.B. mit Berechtigungen und Verschlüsselungs-Techniken beschäftigen.
... unterstützt parallele Ausführungsfäden.
... unterstützt die Entwicklung von Internet-Anwendungen (inkl.
ASP.NET).
... enthält Klassen für die Steuerelemente einer WindowsAnwendung (z.B. Befehlschalter, Textfelder, Menüs).
... enthält Klassen für den Umgang mit XML-Dokumenten.
An dieser Stelle sollte vor allem geklärt werden, dass beim Einstieg in die .NET – Programmierung
mit C# ...
 einerseits eine Programmiersprache mit bestimmter Syntax und Semantik zu erlernen
 und andererseits eine umfangreiche Klassenbibliothek zu studieren ist, die im Sinne der in
Abschnitt 1.1.2 geschilderten Vorteile der objektorientierten Programmierung wesentlich an
der Funktionalität eines Programms beteiligt ist.
Microsoft stellt kostenlos eine umfangreiche FCL-Dokumentation und mit dem Document Explorer
zudem ein komfortables Werkzeug zur Nutzung der Informationsfülle zur Verfügung (siehe Abschnitt 2).
1.2.7 Zusammenfassung zu Abschnitt 1.2
Als Vorteile der .NET – Technologie für die Softwareentwicklung sind u.a. zu nennen:



Sprachintegration
Mit C# erstellte Klassen können z.B. auch von VB.NET – Programmierern genutzt werden.
Betriebssystemunabhängigkeit
Es ist möglich, die .NET-Plattform auf andere Betriebssysteme zu portieren. Das von der
Firma Novell unterstützte und von der Firma Microsoft mit Wohlwollen begleitete Open
Source - Projekt Mono ist auf diesem Weg schon weit voran gekommen.
Schutz vor schädlicher und fehlerhafter Software
Die CLR verwaltet die fein granulierten CAS-Rechte (Code Access Security) und verhindert
die Ausführung von fehlerhaftem Zwischencode
Kapitel 1: Einleitung
24

Sehr breites Anwendungsspektrum
Es kann Software für praktisch jeden Einsatzzweck zur Verwendung auf einem Arbeitsplatzrechner, auf einem Webserver oder auf einem PDA (Personal Digital Assistent) entstehen.
Wir haben im Abschnitt 1.2 u.a. folgende Begriffe kennen gelernt:






MSIL
.NET – Compiler übersetzen den Quellcode in die Microsoft Intermediate Language.
Assembly
Beim Übersetzen von (im Allgemeinen mehreren Quellen) entsteht ein Assembly. Diese ist
kleinste Einheit von .NET – Software bei der …
o Weitergabe,
o Versionierung,
o Zuweisung von Sicherheitsbeweisen (evidence)
Ein Assembly kann beliebig viele Klassen implementieren. Ist eine Startklasse (mit Methode
Main()) vorhanden, handelt es sich um ein Programm (Namenserweiterung .exe), anderenfalls um eine Bibliothek (Namenserweiterung .dll).
Metadaten
Ein Assembly enthält neben dem MSIL-Code auch Metadaten. Die Typ-Metadaten enthalten eine Beschreibung der implementierten und der referenzierten Typen. Im Manifest sind
die Assembly-Metadaten mit Angaben zur Version, zur Sicherheit und zur Abhängigkeit
von anderen Assemblies enthalten.
CLR mit JIT-Compiler
Die Ausführungsumgebung für MSIL-Code, der auch als managed code bezeichnet wird,
besitzt einen Just-In-Time – Compiler zur Übersetzung von MSIL-Code in nativen Maschinencode. Außerdem kümmert sich die CLR um Sicherheit (CAS), Stabilität (Verifikation)
und Speicherverwaltung (Garbage Collection).
Namensraum
Indem man eine Klasse in einen Namensraum einfügt, ergänzt man ihren Namen um ein
Präfix und vermeidet Namenskollisionen. Man fasst in der Regel funktional verwandte
Klassen in einen gemeinsamen Namensraum zusammen, der nach Bedarf hierarchisch in
Unterräume aufgeteilt werden kann.
FCL (Framework Class Library)
In der voluminösen und universellen Standardbibliothek der .NET – Plattform wird von
Namensräumen reichlich Gebrauch gemacht.
1.3 Übungsaufgaben zu Kapitel 1
1) Warum steigt die Produktivität der Softwareentwicklung durch objektorientiertes Programmieren?
2) Welche von den folgenden Aussagen sind richtig?
1. .NET - Programme sind nur unter Windows einsetzbar.
2. Das .NET - Framework für Windows wurde in C# programmiert.
3. Unter den .NET – Programmiersprachen zeichnet sich C# durch eine besonders leistungsfähige Standardbibliothek aus.
4. Die Klassen in einem mit C# erstellten DLL-Assembly können auch in anderen .NET – Programmiersprachen (z.B. VB.NET) genutzt werden.
3) Welche Aufgaben erfüllt die Common Language Runtime (CLR)?
Abschnitt 1.3 Übungsaufgaben zu Kapitel 1274H1
25
4) Welche Fakten sprechen dafür, dass eine .NET – Anwendung trotz zweistufiger Übersetzung und
JIT-Compiler zur Laufzeit keine nennenswerten Performanzdefizite im Vergleich zu einem nativen
Windows-Programm hat?
5) In welcher Beziehung stehen Assemblies und Namensräumen?
6) Was bedeuten die Abkürzungen MSIL, FCL, CLS, COM?
2 Werkzeuge zum Entwickeln von C# - Programmen
In diesem Abschnitt werden kostenlos verfügbare Werkzeuge zum Entwickeln von .NET - Applikationen in der Programmiersprache C# beschrieben. Zunächst beschränken wir uns puristisch auf
einen simplen Texteditor zum Erstellen des Quellcodes und ein Konsolenfenster für den direkten
Aufruf des (im .NET – Framework enthaltenen) Compilers csc.exe, der durch Parameter des Startkommandos über seine Aufträge informiert wird (z.B. über die Namen der zu übersetzenden Quellcode-Dateien). In dieser sehr übersichtlichen „Entwicklungsumgebung“ werden die grundsätzlichen
Arbeitsschritte und einige Randbedingungen besonders deutlich.
Anschließend arbeiten wir mit einer zeitgemäßen integrierten Entwicklungsumgebung, wobei Sie
zwischen den folgenden drei Produkten wählen können:



Microsoft Visual Studio 2008 Professional
Auf den Pool-PCs werden wir die Professional-Version von Microsofts kommerzieller Entwicklungsumgebung einsetzen. Dieses Produkt hat seit vielen Jahren einen sehr großen
Marktanteil bei der Softwareentwicklung für Windows und wird von Fremdfirmen sehr gut
durch Plugins für diverse Zwecke unterstützt.
Microsoft Visual C# 2008 Express Edition
Dieser kostenlose Visual Studio - Ableger ist vermutlich auf Ihrem Privat-PC eine sehr gute
Wahl. Bei den für uns relevanten Aufgaben gibt es keine wesentlichen Bedienungsunterschiede zwischen der Visual C# 2008 Express Edition und dem Visual Studio 2008 Professional.
SharpDevelop 2.2 oder 3.0
Dies ist eine gute Alternative für ältere Rechner.
Alle drei Produkte sind bei der Projektverwaltung kompatibel und bieten u.a. folgende Leistungen:



Guter Editor (z.B. mit Codevervollständigung, Syntaxhervorhebung)
Graphischer Fenster-Designer
Verschiedene Assistenten, z.B. zur Datenbankanbindung
Mit Ausnahme der Entwicklungsumgebung SharpDevelop stammt die im Kurs verwendete Software von der Firma Microsoft und kann (abgesehen vom Visual Studio 2008 Professional) über
folgende Web-Adresse kostenlos bezogen werden: 1
http://www.microsoft.com/downloads/
2.1 C# - Entwicklung mit Texteditor und Kommandozeilen-Compiler
2.1.1 Editieren
Grundsätzlich kann man zum Erstellen der Quellcode-Datei einen beliebigen Texteditor verwenden,
z.B. das im Windows-Zubehör enthaltene Programm Notepad (alias Editor). Um das Erstellen,
Compilieren und Ausführen von C# - Programmen ohne großen Aufwand üben zu können, erstellen
wir das unvermeidliche Hallo-Programm:
using System;
class Hallo {
static void Main() {
Console.WriteLine("Hallo Allerseits!");
}
}
1
Auf die Angabe von spezifischen Adressen wird verzichtet, weil sie teilweise kurzlebig und außerdem wegen der
zahlreichen Parameter recht unübersichtlich sind.
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
28
In unserem Einleitungsbeispiel (siehe Abschnitt 1.1) wurde einiger Aufwand in Kauf genommen,
um einen halbwegs realistischen Eindruck von objektorientierter Programmierung (OOP) zu vermitteln. Das Hallo-Beispiel ist zwar angenehm einfach aufgebaut, kann aber durchaus als „pseudoobjektorientiert“ kritisiert werden. Es ist eine einzige Klasse (namens Hallo) mit der einzigen Methode namens Main() vorhanden. Beim Programmstart wird die Klasse Hallo von der CLR aufgefordert, ihre Main()-Methode auszuführen. Trotz Klassendefinition haben wir es praktisch mit einer
Prozedur historischer Bauart zu tun, was für den einfachen Zweck des Programms durchaus angemessen ist. In den Abschnitten 2 und 3 werden wir solche pseudo-objektorientierten (POO-) Programme benutzen, um elementare Sprachelemente in möglichst einfacher Umgebung kennen zu
lernen. Aus den letzten Ausführungen ergibt sich, dass C# zwar eine objektorientierte Programmierweise nahe legen und unterstützen, aber nicht erzwingen kann.
Das Hallo-Programm eignet sich aufgrund seiner Kürze zum Erläutern wichtiger Regeln, an die Sie
sich so langsam gewöhnen müssen. Alle Themen werden aber später noch einmal systematischer
und ausführlicher behandelt:





1
2
In der ersten Zeile wird der Namensraum System importiert, damit die dort angesiedelte
Klasse Console im Programm ohne Namensraum-Präfix angesprochen werden kann (vgl.
Abschnitt 1.2.6). Diese für C# - Programme typische Vorgehensweise soll auch im HalloBeispiel vorgeführt werden, obwohl sie hier den Schreibaufwand sogar vergrößert.
Nach dem Schlüsselwort class folgt der frei wählbare Klassenname 1. Hier ist wie bei allen
Bezeichnern zu beachten, dass C# streng zwischen Groß- und Kleinbuchstaben unterscheidet.
Dem Kopf der Klassendefinition folgt der mit geschweiften Klammern eingerahmte Rumpf.
Weil die Hallo-Klasse startfähig sein soll, muss sie eine Methode namens Main() besitzen. Diese wird beim Programmstart ausgeführt und dient bei „echten“ OOP-Programmen
oft dazu, Objekte zu erzeugen.
Die Definition der Methode Main() wird von zwei Schlüsselwörtern eingeleitet, deren Bedeutung für Neugierige hier schon beschrieben wird: 2
Ein paar Restriktionen gibt es schon. Z.B. sind die reservierten Wörter der Programmiersprache C# verboten. Nähere Informationen folgen in Abschnitt 0.
Die folgende Mega-Fußnote sollte nur lesen, wer im Hallo-Beispielprogramm (z.B. aufgrund von Erfahrungen mit
anderen C# - Beschreibungen) den Modifikator public vermisst:
Die Methode Main() wird beim Programmstart von der CLR aufgerufen. Weil es sich bei der CLR aus Sicht des
Programms um einen externen Akteur handelt, liegt es nahe, die Methode Main() explizit über den Modifikator
public für die Öffentlichkeit frei zu geben. Generell ist nämlich in C# eine Methode (oder ein Feld) private und
folglich nur innerhalb der Klasse verfügbar. In der Tat findet man in der Literatur viele Hallo-Beispielprogramme
(z.B. bei Gunnerson 2001, Louis et al. 2002, Troelsen 2002) mit dem Modifikator public im Kopf der Main()Definition, z.B.:
using System;
class Hallo {
public static void Main() {
Console.WriteLine("Hallo Allerseits!");
}
}
Allerdings wird Main() grundsätzlich nur von der CLR aufgerufen, die eben nicht wie eine fremde Klasse eingestuft
wird. Laut C# - Sprachdefinition (ECMA 2006) ist für die Main() – Methode nur der Modifikator static vorgeschrieben, und demgemäß erweist sich der Modifikator public in der Praxis auch als überflüssig.
In den Hallo-Beispielprogrammen einiger Autoren (z.B. Eller 2001) ist nicht nur die Methode Main(), sondern auch
die Klasse als public definiert, z.B.:
Abschnitt 2.1 C# - Entwicklung mit Texteditor und Kommandozeilen-Compiler





29
o static
Mit diesem Modifikator wird Main() als Klassenmethode gekennzeichnet. Im Unterschied zu den Instanzmethoden der Objekte gehören die Klassenmethoden, oft
auch als statische Methoden bezeichnet, zur Klasse und können ohne vorherige Objekt-Kreation ausgeführt werden (vgl. Abschnitt 1.1.1). Die beim Programmstart automatisch auszuführende Main()–Methode der Startklasse muss auf jeden Fall durch
den Modifikator static als Klassenmethode gekennzeichnet werden. In einem objektorientierten Programm hat sie insbesondere die Aufgabe, die ersten Objekte zu
erzeugen (siehe unsere Klasse BruchAddition auf Seite 9).
o void
Im Beispiel erhält die Methode Main() den Typ void, weil sie keinen Rückgabewert
liefert.
In der Parameterliste einer Methode kann die gewünschte Arbeitsweise näher spezifiziert
werden. Hinter dem Methodennamen muss auf jeden Fall eine durch runde Klammern eingerahmte Parameterliste angegeben werden, gegebenenfalls (wie im Hallo-Beispiel) eben
eine leere. Wir werden uns später ausführlich mit Methodenparametern beschäftigen.
Dem Kopf einer Methodendefinition folgt der mit geschweiften Klammern eingerahmte
Rumpf mit Variablendeklarationen und Anweisungen.
In der Main()-Methode unserer Hallo-Klasse wird die (statische) WriteLine()-Methode
der Klasse Console dazu benutzt, einen Text an die Standardausgabe zu senden. Zwischen
dem Klassen- und dem Methodennamen steht ein Punkt.
Während unsere Main()-Methodendefinition ohne Parameterliste auskommt, benötigt der
im Methodenrumpf enthaltene Aufruf der Methode Console.WriteLine() einen Aktualparameter, damit der gewünschte Effekt auftritt. Wir geben eine durch doppelte Anführungszeichen begrenzte Zeichenfolge an.
Bei einem Methodenaufruf handelt sich um eine Anweisung, die in C# mit einem Semikolon abzuschließen ist.
Es dient der Übersichtlichkeit, zusammengehörige Programmteile durch eine gemeinsame Einrücktiefe zu kennzeichnen. Man realisiert die Einrückungen am einfachsten mit der Tabulatortaste, aber
auch Leerzeichen sind erlaubt. Für den Compiler sind die Einrückungen irrelevant.
Speichen Sie Ihr Quellprogramm unter dem Namen Hallo.cs in einem geeigneten Verzeichnis, z.B.
in
U:\Eigene Dateien\C#\Kurs\Hallo
Im Unterschied zur Programmiersprache Java müssen in C# Klassen- und Dateiname nicht übereinstimmen, zwecks Übersichtlichkeit sollten sie es aber in der Regel doch tun.
using System;
public class Hallo {
public static void Main() {
Console.WriteLine("Hallo Allerseits!");
}
}
Dies ist nicht erforderlich, weil in C# eine (nicht eingeschachtelte) Klasse per Voreinstellung die Schutzstufe internal (siehe unten) besitzt und folglich im gesamten Assembly bekannt ist, in das sie vom Compiler einbezogen wird
(siehe unten). Außerdem wird die einzige Klasse der Hallo-Beispielprogramme ausschließlich von der CLR benutzt.
Die von mir (aber z.B. auch von Drayton et al. 2003 und Mössenböck 2003) bevorzugte Variante mit dem kompletten Verzicht auf public-Modifikatoren für das Hallo-Beispiel und vergleichbare Programme kann folgendermaßen
begründet werden:
 Sie hält sich an die ECMA-Sprachbeschreibung (siehe ECMA 2006).
 Es werden keine überflüssigen, unzureichend begründeten Forderungen aufgestellt.
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
30
2.1.2 Übersetzen in MSIL
Öffnen Sie ein Konsolenfenster, und wechseln Sie in das Verzeichnis mit dem neu erstellten Quellprogramm Hallo.cs. Lassen Sie das Programm vom C#-Compiler csc.exe aus dem .NET Framework übersetzen, z.B.:
C:\WINDOWS\Microsoft.NET\Framework\v3.5\csc Hallo.cs
Auf Dauer ist ein derart umständliches Kommando nicht sinnvoll. Wer längerfristig per Texteditor
programmieren möchte, wird den Framework-Ordner in die Definition der Umgebungsvariablen
PATH aufnehmen. Beim Einsatz einer integrierten Entwicklungsumgebung (siehe Abschnitt 2.2)
wird im Hintergrund derselbe Compiler verwendet, dabei jedoch automatisch mit passender Pfadangabe aufgerufen, so dass kein PATH-Eintrag erforderlich ist.
Falls keine Probleme auftreten (siehe Abschnitt 2.1.4), meldet sich der Rechner nach kurzer Tätigkeit mit einer neuen Kommando-Aufforderung zurück, und die Quellcodedatei Hallo.cs erhält Gesellschaft durch die Assembly-Datei Hallo.exe, z.B.:
Sind mehrere Quellcodedateien in ein Assembly zu übersetzen, gibt man sie beim csc-Aufruf hintereinander an, z.B.
csc Bruch.cs BruchAddition.cs
Wie Sie bereits wissen, sind auch Jokerzeichen erlaubt, z.B.:
csc *.cs
Das entstehende Assembly erbt seinen Namen von der Startklasse (mit der Main()-Methode):
Über die Befehlszeilenoption out kann der Assembly-Name aber auch frei gewählt werden, z.B.
csc /out:ba.exe Bruch.cs BruchAddition.cs
Mit der Befehlszeilenoption target kann ein Ausgabetyp gewählt werden:

exe: ausführbares Konsolenprogramm
Dies ist die Voreinstellung und musste daher in obigen Beispielen nicht angegeben werden.
Beispiel:
csc /target:exe Hallo.cs
Abschnitt 2.1 C# - Entwicklung mit Texteditor und Kommandozeilen-Compiler
31

winexe: ausführbares Windowsprogramm
Im Unterschied zum Typ exe wird kein Konsolenfenster angezeigt, was im Hallo-Beispiel
zu einem sinnlosen Programm ohne jeglichen Bildschirmauftritt sorgen würde.
Beispiel:
csc /target:winexe Bruch.cs BruchKürzen.cs

library: DLL-Assembly
Die resultierende Bibliothek kann analog zum Assembly mscorlib.dll der .NET – Standardbibliothek von anderen Assemblies genutzt werden.
Beispiel:
csc /target:library Simput.cs

module: Teil eines Multidatei-Assemblys (vgl. Abschnitt 1.2.3)
Über die Befehlszeilenoption reference werden dem Compiler die Assemblies bekannt gemacht,
welche die im zu übersetzenden Quellcode verwendeten (referenzierten) Klassen implementieren,
z.B.:
csc /reference:microsoft.visualbasic.dll Prog.cs
Zusätzlich wird das Bibliotheks-Assembly mscorlib.dll, das elementare und oft benötigte FCLKlassen implementiert, grundsätzlich durchsucht.
Man kann den Compiler auch per Response-Datei mit Referenzen und sonstigen Befehlszeilenoptionen versorgen. Die Datei
%SystemRoot%\Microsoft.NET\Framework\Version\csc.rsp
ist Teil der Framework-Installation und wird automatisch vom Compiler ausgewertet. Beim .NETFramework 3.5 enthält sie folgende Referenzen auf häufig benötigte Assemblies:
#
#
#
#
This file contains command-line options that the C#
command line compiler (CSC) will process as part
of every compilation, unless the "/noconfig" option
is specified.
# Reference the common Framework libraries
/r:Accessibility.dll
/r:Microsoft.Vsa.dll
/r:System.Configuration.dll
/r:System.Configuration.Install.dll
/r:System.Core.dll
/r:System.Data.dll
/r:System.Data.DataSetExtensions.dll
/r:System.Data.Linq.dll
/r:System.Data.OracleClient.dll
/r:System.Deployment.dll
/r:System.Design.dll
/r:System.DirectoryServices.dll
/r:System.dll
/r:System.Drawing.Design.dll
/r:System.Drawing.dll
/r:System.EnterpriseServices.dll
/r:System.Management.dll
/r:System.Messaging.dll
/r:System.Runtime.Remoting.dll
/r:System.Runtime.Serialization.dll
/r:System.Runtime.Serialization.Formatters.Soap.dll
/r:System.Security.dll
/r:System.ServiceModel.dll
/r:System.ServiceModel.Web.dll
/r:System.ServiceProcess.dll
/r:System.Transactions.dll
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
32
/r:System.Web.dll
/r:System.Web.Extensions.Design.dll
/r:System.Web.Extensions.dll
/r:System.Web.Mobile.dll
/r:System.Web.RegularExpressions.dll
/r:System.Web.Services.dll
/r:System.Windows.Forms.Dll
/r:System.Workflow.Activities.dll
/r:System.Workflow.ComponentModel.dll
/r:System.Workflow.Runtime.dll
/r:System.Xml.dll
/r:System.Xml.Linq.dll
Wie an den csc.rsp – Einträgen zu sehen ist, kann die Befehlszeilenoption reference durch ihren
Anfangsbuchstaben abgekürzt werden.
Die integrierten Entwicklungsumgebungen (siehe Abschnitt 2.2) rufen den Compiler mit der Befehlszeilenoption
/noconfig
und verhindern damit die Auswertung der voreingestellten Antwortdatei. Folglich wird (zeitsparend!) nur noch das zentrale Bibliotheks-Assembly mscorlib.dll automatisch durchsucht. Zur Verwaltung der in einem Projekt zusätzlich benötigten Referenzen bieten die Entwicklungsumgebungen bequeme Bedienelemente.
Über weitere Befehlszeilenoptionen informiert der Compiler beim folgenden Aufruf
csc /?
2.1.3 Ausführen
.NET – Programme können auf jedem Windows-Rechner mit passendem Framework auf übliche
Weise gestartet werden, z.B. per Doppelklick auf den im Windows-Explorer angezeigten Dateinamen. Das Hallo-Programm startet man am besten im Konsolenfenster durch Abschicken seines
Namens, z.B.:
Trotz der strengen Unterscheidung zwischen Groß- und Kleinbuchstaben im C# - Quellcode und
trotz unserer Entscheidung für einen großen Anfangsbuchstaben im Klassennamen Hallo, ist auf
der Ebene des Windows-Dateisystems, also z.B. beim Starten eines C# - Programms, die
Groß/Kleinschreibung irrelevant.
2.1.4 Programmfehler beheben
Die vielfältigen Fehler, die wir mit naturgesetzlicher Unvermeidlichkeit beim Programmieren machen, kann man einteilen in:


Syntaxfehler
Diese verstoßen gegen eine Syntaxregel der verwendeten Programmiersprache, werden vom
Compiler gemeldet und sind daher relativ leicht zu beseitigen.
Semantikfehler
Hier liegt kein Syntaxfehler vor, aber das Programm verhält sich anders als erwartet, wiederholt z.B. ständig eine nutzlose Aktion („Endlosschleife“).
Abschnitt 2.1 C# - Entwicklung mit Texteditor und Kommandozeilen-Compiler
33
Die C# - Designer haben dafür gesorgt, dass möglichst viele Fehler vom Compiler aufgedeckt werden können (z.B. durch strenge Typisierung, Beschränkung der impliziten Typanpassung).
Wir wollen am Beispiel eines provozierten Syntaxfehlers überprüfen, ob der Framework-Compiler
hilfreiche Fehlermeldungen produziert. Wenn im Hallo-Programm der Bezeichner Console
fälschlicherweise mit kleinem Anfangsbuchstaben geschrieben wird,
using System;
class Hallo {
static void Main() {
console.WriteLine("Hallo Allerseits!");
}
}
meldet der Compiler:
Hallo.cs(4,9): error CS0103: Der Name console ist im aktuellen
Kontext nicht
vorhanden.
Der Compiler hat die fehlerhafte Stelle sehr gut lokalisiert: Datei Hallo.cs, Zeile 4, Spalte 9 (vor
dem kleinen c stehen acht Leerzeichen). Auch die Fehlerbeschreibung fällt ziemlich eindeutig aus.
Wer Erfahrungen mit Programmiersprachen wie Visual Basic oder Delphi hat, muss sich eventuell
noch daran gewöhnen, dass in C# die Groß-/Kleinschreibung signifikant ist.
Im äußerst simplen Hallo-Beispiel einen semantischen Fehler unterzubringen, den der Compiler
nicht bemerkt, ist sehr schwer, vielleicht sogar unmöglich. Im Bruchadditionsbeispiel aus Abschnitt
1.1 stehen unsere Chancen weit besser, Fehler am Compiler vorbei zu schmuggeln. Wird z.B. in der
Nenner-Eigenschafts-Implementierung bei der Absicherung gegen Nullwerte der UngleichOperator (!=) durch sein Gegenteil (==) ersetzt, ist keine C# - Syntaxregel verletzt:
public int Nenner {
get {
return nenner;
}
set {
if (value == 0)
// semantischer Fehler!
nenner = value;
}
}
1
) zeigt das Programm aber ein unerwünschtes Verhal0
ten: Weil die Methode Kuerze() in eine Endlosschleife gerät, „hängt“ das Programm und verbraucht dabei reichlich Rechenzeit, wie der Windows-Taskmanager (auf einem Rechner mit SingleCore - Hyper-Threading - CPU) belegt:
Bei Eingabe kritischer „Brüche“ (wie z.B.
34
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
Ein derart außer Kontrolle geratenes Konsolenprogramm beendet man z.B. mit der Tastenkombination Strg+C:
2.2 Entwicklungsumgebungen und andere Programmierhilfen
Auf die Dauer ist das Hantieren mit Notepad und Kommandozeile beim Entwickeln von .NET Software keine ernsthafte Option. Anschließend werden drei integrierte Entwicklungsumgebungen
für die bequeme und rationelle Softwareentwicklung vorgestellt.
2.2.1 Microsoft Visual Studio 2008 Professional
2.2.1.1 Terminal-Server – Umgebung
Das Visual Studio 2008 Professional steht an der Universität Trier auf einem Terminalserver zur
Verfügung, wobei die Verbindung zum Server auf den Pool-PCs folgendermaßen hergestellt wird:
Start > Alle Programme > Programmentwicklung >
Microsoft Visual Studio > Visual Studio 2008 Professional
Melden Sie sich beim Terminalserver mit Ihrem normalen URT-Konto an.
Im voreingestellten Vollbildmodus der Terminalserververbindung befindet man sich in einem neuen, abgeschlossenen Windows-Dialog, so dass sich z.B. die Tastenkombination:
Alt+Tabulator
zum Wechseln zwischen aktiven Programmen sich auf den Terminalserver bezieht.
Auf dem Terminalserver startet das Visual Studio automatisch. Beim Verlassen der Entwicklungsumgebung wird die Verbindung zum Terminalserver automatisch beendet.
Abschnitt 2.2 Entwicklungsumgebungen und andere Programmierhilfen
35
Wer ohne Beendigung der Terminalserversitzung zwischendurch mit Programmen des lokalen
Windows-Systems arbeiten möchte, kann folgendermaßen vorgehen:


Am oberen Bildschirmrand befindet sich ein gelbes Bedienelement. Eventuell müssen Sie
den Bildschirmrand mit der Maus berühren, damit das Bedienelement erscheint.
Hier finden sich die gewohnten Symbole, um den Vollbildmodus zu verlassen oder die Terminal-Sitzung die Taskleiste (der lokalen Windows-Sitzung) zu schicken.
Der Terminalserver arbeitet mit der englischen Version des Betriebsystems Windows Server 2008,
das zur selben Generation gehört wie das Desktop-System Windows Vista. Auf folgende Weise
können Sie das GUI-Design auf dem Terminalserver dem Vista-Standard annähern (englische Anzeigesprache vorausgesetzt):
Control Panel > Personalize > Theme >Theme = Windows Vista
Gehen Sie folgendermaßen vor, um die voreingestellte Anzeigesprache Englisch durch Deutsch zu
ersetzen:
Control Panel > Clock, Language, and Region > Change Display language >
Choose a display language = Deutsch
2.2.1.2 Konfiguration beim ersten Start
Beim ersten Start der Entwicklungsumgebung sind einige Einstellungen vorzunehmen. Wählen Sie
zunächst in der folgenden Dialogbox die Visual C# - Entwicklungseinstellungen:
Nach einem Mausklick auf Visual Studio starten legt das Visual Studio etliche Ordner und Registrierungsdatenbank-Schlüssel an:
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
36
Auf der Startseite präsentiert das Visual Studio Neuigkeiten für Entwickler, falls Sie im obigen
Konfigurationsdialog das Herunterladen und Anzeigen von RSS-Onlineinhalten zugelassen haben:
Das Startverhalten des Visual Studios lässt sich jederzeit nach
Extras > Optionen > Umgebung
im folgenden Dialog beeinflussen:
Abschnitt 2.2 Entwicklungsumgebungen und andere Programmierhilfen
37
2.2.1.3 Eine erste GUI-Anwendung
Dieser Abschnitt bietet einen Vorausblick auf die spätere Praxis der rationellen Erstellung von Programmen mit graphischer Benutzeroberfläche (Graphical User Interface, GUI). Dabei werden größere Quellcode-Passagen von Assistenten der Entwicklungsumgebung erstellt, also von Programmen, die Programme schreiben. Dieser Quellcode ist zwar leicht zu erstellen, aber durch seine (im
Einzelfall oft überflüssige) Komplexität schwer zu verstehen. Sobald wir das notwendige Grundwissen mit Hilfe von einfachen, komplett selbst verfassten Programmen erworben haben, spricht
nichts mehr dagegen, Assistentenhilfe beim Programmieren in Anspruch zu nehmen.
Nun machen wir uns an die Erstellung des folgenden Hallo-Programms mit GUI:
Wir fordern über
Datei > Neu > Projekt
den folgenden Dialog an:
Hier akzeptieren wir die voreingestellte Projektvorlage Windows-Anwendung, wählen den Projektnamen HalloForms 1 sowie einen geeigneten Speicherort für den Projektordner. Allerdings
wird kaum eine zusammengehörige Familie von Projekten entstehen, die in einem gemeinsamen
Ordner versammelt werden sollten. Entfernen Sie daher nötigenfalls die Markierung beim Kontroll-
1
Das Motiv für den Projektnamen wird Ihnen bald nach der Bekanntschaft mit den Klassen im Namensraum System.Windows.Forms plausibel erscheinen.
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
38
kästchen Projektmappenverzeichnis erstellen. Wir wählen damit eine flachere Projektdateiverwaltung, die für praktisch alle im Kurs entstehende Projekte angemessen ist.
Nach einem Mausklick auf OK präsentiert das Visual Studio im Projektmappen-Explorer (am
rechten Fensterrand) eine Baumansicht zur Projektverwaltung und im Formulardesigner einen Rohling für das Hauptfenster der Anwendung:
Öffnen Sie das Toolbox-Fenster mit dem Menübefehl
Ansicht > Toolbox
oder durch kurzes Verharren des Mauszeigers über der Toolbox-Schaltfläche am linken Fensterrand, und erweitern Sie die Liste der Allgemeinen Steuerelemente:
Ziehen Sie ein Label-Objekt (die Bezeichnung Objekt ist durchaus im Sinn von Abschnitt 1.1 gemeint) und ein Button-Objekt auf das Formular, indem Sie einen linken Mausklick auf das jeweilige Objekt setzen, die Maus dann mit gedrückter Taste zum Ziel bewegen und dort die Taste wieder
loslassen. Das Ergebnis sollte ungefähr so aussehen:
Abschnitt 2.2 Entwicklungsumgebungen und andere Programmierhilfen
39
Wenn Sie einen Doppelklick auf das Button-Objekt setzen, öffnet das Visual Studio den Quellcode-Editor und fügt dort eine Methode namens button1_Click() ein, die im fertigen Programm
nach jedem Mausklick auf den Schalter ausgeführt wird.
Sobald Sie damit beginnen, im Methodenrumpf eine Nachricht an das Label-Objekt mit der Bitte
um Text-Änderung zu verfassen, erahnt das Visual Studio Ihre Absicht und bietet mögliche Fortsetzungen Ihrer Anweisung an:
Akzeptieren Sie den Vorschlag der sogenannten IntelliSense-Technik per Tabulatortaste, und setzen
Sie einen Punkt hinter den Objektnamen. Nun erscheint eine Liste mit den Methoden und Eigenschaften des Objekts. Das Label-Objekt soll aufgefordert werden, seine Text-Eigenschaft auf den
Wert Hallo zu setzen. Wählen Sie diese Eigenschaft aus der Liste (z.B. per Doppelklick), und vervollständigen Sie die Anweisung:
private void button1_Click(object sender, EventArgs e)
{
label1.Text = "Hallo";
}
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
40
Der Quellcode-Editor unserer Entwicklungsumgebung bietet außerdem …



farbliche Unterscheidung verschiedener Sprachestandteile
automatische Quellcode-Formatierung (z.B. bei Einrückungen)
automatische Syntaxprüfung, z.B.:
Über den Schalter , die Funktionstaste F5 oder den Menübefehl
Debuggen > Debugging starten
veranlasst man das Übersetzen und die Ausführung der Anwendung:
vor dem Mausklick auf den Schalter
nach dem Mausklick auf den Schalter
Microsofts Entwicklungsumgebungen unterscheiden beim Übersetzen (bzw. Erstellen) die Konfigurationen Debug und Release (und ggf. noch weiteren benutzerdefinierten Konfigurationen), wobei die Auswahl per Standard-Symbolleiste erfolgt:
Eine Konfiguration ist ein Paket von Einstellungen für die Erstellung und legt auch den Ausgabeordner für die Übersetzung fest (…\bin\Debug bzw. …\bin\Release). Bei der voreingestellten Debug-Konfiguration werden zusätzliche Ausgaben erzeugt, die eine Quellcode-basierte Fehlersuche
erlauben. Weil auch in unserem Beispiel die Debug-Konfiguration eingestellt ist, landet das Assembly HalloForms.exe im Projekt-Unterordner …\bin\Debug:
Soll die Anwendung nur übersetzt (aber nicht ausgeführt) werden, wählt man den Schalter
der Symbolleiste Erstellen), die Funktionstaste F6 oder den Menübefehl
Erstellen > Projektmappe erstellen
(auf
Abschnitt 2.2 Entwicklungsumgebungen und andere Programmierhilfen
41
Trotz der guten Erfahrungen mit der GUI-Programmierung werden wir uns die Grundbegriffe der
Programmierung im Rahmen von möglichst einfachen Konsolenprojekten erarbeiten und auf die
Assistenten im Visual Studio vorläufig verzichten. Im Manuskript werden ab jetzt die meisten Projekte mit der Visual C# 2008 Express Edition erstellt, deren Installation Thema des nächsten Abschnitts ist.
2.2.2 Microsoft Visual C# 2008 Express Edition
Die Firma Microsoft stellt mit der Visual C# 2008 Express Edition eine kostenlose Einstiegsversion
seiner Entwicklungsumgebung Visual Studio 2008 zur Verfügung, die für unsere Kurszwecke sehr
gut geeignet ist 1. Bei Bedienung und Projektverwaltung gibt es für C# -Entwickler wenig Unterschiede zu den kommerziellen Versionen, so dass beim eventuellen Umstieg keine Schwierigkeiten
zu erwarten sind.
Während die kostenpflichtigen Visual Studio - Varianten mehrsprachig sind, gibt es für C#,
VB.NET, C++ und die Webentwicklung jeweils eine eigene Express-Edition. Microsoft hat gegen
die Installation von mehreren Express-Editionen auf einem Rechner nichts einzuwenden, doch wir
benötigen im Kurs lediglich die C# -Variante.
Weil im Programmieralltag unbedingt eine Dokumentation der .NET - Standardbibliothek (FCL)
benötigt wird, sollte nach Möglichkeit zusätzlich zur Visual C# 2008 Express Edition auch die eben
falls frei verfügbare MSDN 2008 Express Edition installiert werden. Ein weiteres kostenloses Angebot von Microsoft ist die MSDN Library für Visual Studio 2008. Dieses Paket dokumentiert neben dem .NET - Framework auch die Win32- und die COM-Programmierung und geht über den
üblichen Informationsbedarf eines C# - Programmierers hinaus. Ist eine flotte Internetverbindung
vorhanden, kann man bei geringer Komforteinschränkung auf eine lokale MSDN-Installation verzichten und die Online-Variante nutzen (http://msdn.microsoft.com/de-de/library/default.aspx). Die
Abkürzung MSDN steht übrigens für das Microsoft Developer Network. Unter diesem Titel bietet
die Firma Microsoft zahlreiche kostenlose oder auch kommerzielle Informationspakete für Software-Entwickler.
2.2.2.1 Installation
Voraussetzungen:




Windows XP ab SP 2, Windows Vista, Windows Server 2003 SP 2, Windows Server 2008,
Windows 7
Prozessor mit 1 GHz Taktfrequenz (1,6 GHz empfohlen)
192 MB RAM (384 empfohlen)
Die folgenden Angaben zum Platzbedarf auf der Festplatte gelten für einen PC mit Windows XP/Vista (32-Bit) und bereits installiertem .NET - Framework 2.0:
Produkt
Platzbedarf
ca. 800 MB
Visual Studio 2008 Express Edition
(ohne Option SQL-Server 2008 Express Edition)
ca. 800 MB
MSDN 2008 Express Edition
Die Installation der Visual C# 2008 Express Edition verläuft einfach und zuverlässig:
1

Installationsprogramm setup.exe starten

Entscheiden Sie selbst, ob Ihre Installationserfahrungen an Microsoft übermittelt werden
sollen:
Auf der Webseite werden http://www.microsoft.com/germany/Express/download/ .
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
42

Stimmen Sie der Lizenzvereinbarung zu:
Außerdem ist festzulegen, ob das Visual Studio Neuigkeiten anzeigen darf (z.B. auf der
Startseite).

Wählen Sie die gewünschten Zusatzoptionen:
Bei Silverlight handelt es sich um Microsofts Alternative zu Flash.
Abschnitt 2.2 Entwicklungsumgebungen und andere Programmierhilfen
43
Die (relativ voluminöse) SQL-Server 2008 Express Edition benötigt man nur bei der
Entwicklung von größeren Datenbankprojekten (z.B. zur Versorgung von mehreren, simultan zugreifenden Benutzern). Mit der Visual C# 2008 Express Edition wird generell
der für kleinere Datenbankaufgaben durchaus geeignete SQL Server Compact 3.5 installiert. Wir werden bei der Behandlung der Datenbankprogrammierung beide SQL - Server verwenden.
Wird die SQL Server 2008 Express Edition (durch Wahl in obigem Dialog) gemeinsam
mit der Visual C# 2008 Express Edition installiert, fehlt anschließend die nützliche graphische Bedienoberfläche SQL Server 2008 Management Studio Express. Bei einer separaten Installation des (ebenfalls kostenlos bei Microsoft verfügbaren) Pakets SQL Server 2008 Express with Tools landet hingegen auch das Management Studio auf der Platte.
Somit wird insgesamt empfohlen, die SQL Server 2008 Express Edition nicht gemeinsam im der Visual C# 2008 Express Edition zu installieren, sondern bei Bedarf später
das Paket SQL Server 2008 Express with Tools zu installieren. Diese Empfehlung wird
hinfällig, sobald das SQL Server 2008 Management Studio Express separat verfügbar ist.
Mit der Visual C# 2008 Express Edition werden generell auch das .NET -Framework 3.5
sowie das Windows-SDK 6.0a installiert.

Eventuell wollen Sie den Zielordner ändern:

Nach einem Mausklick auf Installieren ist etwas Geduld gefragt:
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
44

Fertig:
Anschließend sollten Sie noch die MSDN 2008 Express Edition installieren:

Starten Sie das Installationsprogramm msdnixp.exe und quittieren Sie den WillkommenDialog mit Weiter.

Stimmen Sie der Lizenzvereinbarung zu.

Nach einiger Zeit sollte die Erfolgsmeldung erscheinen:
2.2.2.2 Ein erstes Konsolen-Projekt
Beim ersten Start benötigt Visual C# einige Zeit zum Aufbau seiner Arbeitsumgebung
Von der Startseite ausgehend, die bei allen Visual Studio - Versionen praktisch identisch ist, öffnen
wir mit dem Schalter
oder dem Menübefehl
Abschnitt 2.2 Entwicklungsumgebungen und andere Programmierhilfen
45
Datei > Neu > Projekt
den Dialog für neue Projekte:
Wählen Sie die Vorlage Leeres Projekt und einen Projektnamen.
Öffnen Sie im Projektmappen-Explorer (am rechten Fensterrand) per Maus-Rechtsklick das Kontextmenü zum Projekt (nicht zur Projektmappe), und fügen Sie ein neues Element hinzu:
Entscheiden Sie sich für eine Codedatei mit geeignetem Namen:
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
46
Nach dem Hinzufügen präsentiert die Entwicklungsumgebung ein Projekt ohne eine einzige Zeile
Assistenten-Quellcode.
Wir übernehmen den Quellcode vom Hallo-Beispielprogramm aus Abschnitt 2.1.1 und ergänzen am
Ende der Main()-Methodendefinition noch einen ReadLine()-Methodenaufruf:
Er wartet auf die Enter-Taste und verhindert so, dass die im Rahmen der Entwicklungsumgebung
mit dem Schalter oder der Funktionstaste F5 gestartete Konsolenanwendung nach ihrer Bildschirmausgabe sofort verschwindet. Außerdem passen wir den Namen der Startklasse an den Namen der Quellcodedatei an.
Fordern Sie über den Schalter
oder den Menübefehl
Datei > Alle Speichern
den folgenden Dialog zum Speichern des Projekts an:
Abschnitt 2.2 Entwicklungsumgebungen und andere Programmierhilfen
47
Sicher wird aus dem Miniaturprojekt nie eine Familie von Projekten entstehen, die in einem gemeinsamen Ordner versammelt werden sollten. Entfernen Sie daher nötigenfalls die Markierung
beim Kontrollkästchen Projektmappenverzeichnis erstellen. Wir wählen damit eine flachere
Projektdateiverwaltung, die für praktisch alle im Kurs entstehende Projekte angemessen ist.
Über den Schalter oder die Funktionstaste F5 veranlasst man das Übersetzen und das anschließende Starten der Anwendung:
Sobald Sie die Enter-Taste drücken, kehrt der ReadLine()-Methodenaufruf zurück, sodass die
Anwendung endet und das Konsolenfenster verschwindet.
2.2.2.3 Document Explorer
Zur Anzeige der FCL-Dokumentation und sonstiger MSDN-Informationen verwenden wir bevorzugt den zusammen mit dem Visual Studio Professional oder mit Visual C# installierten Document
Explorer. Er wird über das Hilfemenü der Entwicklungsumgebungen oder auch (beim Visual Studio
Professional) direkt über die Gruppe Microsoft Developer Network im Programm-Menü gestartet und bietet neben einer detaillierten Referenz zur .NET - Standardbibliothek auch zahlreiche Einführungstexte (Tutorials) und Beispiele.
Beim ersten Start benötigt der Document Explorer einige Zeit zum Aufbau seiner Arbeitsumgebung
Auf dem Inhalts-Registerblatt der Navigationszone im linken Teil des Fensters wählt man die Referenzinformationen zur .NET – Standardbibliothek über:
Microsoft MSDN Express Library 2008 > Dokumentation zu .NET Framework SDK >
.NET Framework-Klassenbibliothek
Wenn Sie über die Baumansicht einen erwarteten Inhalt (z.B. die FCL-Dokumentation) nicht finden
können, ist vermutlich der Filter zu eng eingestellt (z.B. auf Visual C# Express Edition). Wählen Sie einen alternativen Filter oder die Option (ungefiltert).
Wer sich z.B. über die in unseren Beispielen oft benutzte Methode WriteLine() der Klasse Console
aus dem Namensraum System informieren möchte, bewegt sich ab dem Ausgangspunkt .NET
Framework-Klassenbibliothek weiter mit den Stationen:
System-Namespace > Console-Klasse > Console Methoden > WriteLine-Methode
Nun ist noch zwischen vielen Spezialisierungen (Überladungen, siehe unten) der Methode
WriteLine() zu wählen, damit im Inhaltsbereich passende Informationen samt Beispiel angezeigt
werden:
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
48
Zur Suche nach speziellen Informationen eignen sich das Index-Registerblatt der Navigationszone
und die Suchfunktion, z.B.:
Wer über eine Suchanfrage und/oder eine Sequenz von Mausklicks auf Verknüpfungen eine interessante Seite in das Inhaltsfenster des Document Explorers befördert hat und deren Standort im Inhaltsverzeichnis erfahren möchte, kann Inhalts- und Navigationsfenster mit dem Schalter
synchronisieren.
Zu einem markierten (die Einfügemarke enthaltenden) C# - Schlüsselwort oder FCL-Bezeichner im
Quellcode-Editor der MS-Entwicklungsumgebungen erreicht man die zugehörige Dokumentation
besonders bequem (aber nicht immer erfolgreich) über die Funktionstaste F1.
2.2.2.4 Referenzen und Ausgabetyp setzen
In Abschnitt 2.1.2 zur Übersetzung von Quellcode in die Microsoft Intermediate Language (MSIL)
wurden wichtige Optionen des Compiler-Aufrufs behandelt, u.a.:


Ausgabetyp des resultierenden Assemblies
Referenzen auf Assemblies, die der Compiler nach Klassen durchsuchen soll
Abschnitt 2.2 Entwicklungsumgebungen und andere Programmierhilfen
49
Beim Einsatz einer integrierten .NET – Entwicklungsumgebung werden die Compileraufrufe automatisch erstellt und im Hintergrund abgesetzt. Die gewünschten Compiler-Optionen wählt man in
einem bequemen Dialogfenster mit Projekteinstellungen.
Um dies üben zu können, erstellen wir nun mit der Visual C# 2008 Express Edition ein HalloProjekt gemäß Abschnitt 2.2.2.2, ersetzen aber die Textausgabe durch eine Windows-Messagebox.
Während die generelle GUI-Programmierung (Graphical User Interface) relativ anspruchsvoll ist
und aus didaktischen Gründen vorläufig vermieden wird, gelingt die Präsentation einer Messagebox
mit Leichtigkeit. Es ist lediglich ein Aufruf der statischen Methode Show() der Klasse MessageBox
an Stelle des Console.WriteLine() – Aufrufs erforderlich, z.B.:
MessageBox.Show("Hallo Allerseits!");
Ohne weitere Ergänzungen des Quellcodes kann die Übersetzung allerdings nicht gelingen, wie die
Entwicklungsumgebung vorausblickend mit genauer Fehlerdiagnose mitteilt:
Mit Hilfe der FCL-Dokumentation, die als wesentlicher Bestandteil der MSDN 2008 Express Edition installiert wird, ermittelt man leicht, welcher Namensraum anzugeben ist (als Präfix zum Klassennamen oder in einer using-Direktive), um dem C# - Compiler die Klasse MessageBox bekannt
zu machen. Von Visual C# aus erreicht man auf dem folgenden Weg
Hilfe > Suchen=MessageBox > MessageBox-Klasse (System.Windows.Forms)
diese Ausgabe:
Mit dem folgenden funktionstüchtigen (!) Quellcode wird das Problem scheinbar nicht gelöst, sondern nur verlagert:
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
50
Visual C# gibt aber mit der Frage nach einem eventuell fehlenden Assembly-Verweis einen guten
Hinweis auf das Restproblem: Weil sich das die Klasse System.Windows.Forms.dll implementierende Assembly System.Windows.Forms.dll nicht in der gültigen Referenzliste des Compilers
befindet, wird die Klasse nicht gefunden.
Das Assembly System.Windows.Forms.dll ist übrigens in der voreingestellten Antwortdatei
csc.rsp
%SystemRoot%\Microsoft.NET\Framework\Version\csc.rsp
(vgl. Abschnitt 2.1.2) enthalten, so dass der obige Quellcode bei einem Compiler-Aufruf im Konsolenfenster ohne reference – Option erfolgreich übersetzt werden kann. Die integrierten Entwicklungsumgebungen rufen den Compiler jedoch mit der Option
/noconfig
auf, welche die Auswertung der voreingestellten Antwortdatei verhindert, so dass nur noch das
zentrale Bibliotheks-Assembly mscorlib.dll automatisch durchsucht wird. Folglich benötigt unser
Projekt eine Referenz auf das Assembly System.Windows.Forms.dll 1. Wählen Sie dazu im Projektmappen-Explorer aus dem Kontextmenü zum Eintrag Verweise die Option Verweis hinzufügen:
Nun kann in folgender Dialogbox auf der Registerkarte .NET das gesuchte Assembly lokalisiert
und im markierten Zustand mit OK in die Verweisliste aufgenommen werden:
Anschließend taucht der Verweis im Projektmappen-Explorer auf:
1
Dies geschieht bei vielen Projektvorlagen der Entwicklungsumgebungen automatisch, nicht jedoch bei der Vorlage
Leeres Projekt, die wir aus Gründen der Einfachheit und Transparenz derzeit bevorzugen.
Abschnitt 2.2 Entwicklungsumgebungen und andere Programmierhilfen
51
und die Reklamation im Quellcode-Editor verschwindet. Wenn Sie das Programm mit dem Schalter
oder der Funktionstaste F5 übersetzen und starten, erscheint die gewünschte Messagebox:
Außerdem erscheint aber auch ein leeres Konsolenfenster. Um seinen Auftritt zu verhindern, ersetzt
man per Compiler-Option den voreingestellten Ausgabetyp exe durch die Alternative winexe (vgl.
Abschnitt 2.1.2). Bei einer integrierten Entwicklungsumgebung ändert man dazu eine Projektoption,
z.B. bei der Visual C# 2008 Express Edition nach:
Projekt > Einstellungen > Anwendung
in folgendem Dialogfenster:
Bei einem Programm mit dem Ausgabetyp winexe (Windows-Anwendung) gehen alle Konsolenausgaben verloren, so dass man diesen Ausgabetyp mit Bedacht wählen sollte. Weitere Unterschiede zwischen den Ausgabetypen exe und winexe bestehen nicht.
2.2.3 SharpDevelop
Das schlanke, unproblematisch zu bedienende und recht flink agierende Programm ist selbst in C#
entwickelt worden. Hier ist die Version 3.0 (Beta 2) zu sehen:
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
52
Über die folgenden Webseite:
http://www.icsharpcode.net/OpenSource/SD/
ist nicht nur ein bequemes Installationsprogramm zu beziehen, sondern auch der Quellcode des Open Source – Projekts.
Mit dem .NET Framework 2.0 und SharpDevelop 2.2 lässt sich eine besonders ressourcenschonende C# - Entwicklungsumgebung realisieren. Wer etwas mehr Festplattenspeicher zur Verfügung hat und SharpDevelop gegenüber den Microsoft-Entwicklungsumgebungen bevorzugt, sollte die Version 3.0 folgendermaßen installieren:



1
.NET Framework 3.5 (siehe Abschnitt 1.2.2)
Windows-SDK 6.1 for Windows Server 2008 and .NET Framework 3.5 1
Das Windows-SDK ist nicht obligatorisch für den Einsatz von SharpDevelop 3.0, allerdings sehr nützlich, weil es die komplette FCL-Dokumentation (in englischer Sprache)
und diverse Hilfsprogramme und enthält (z.B. das schon in Abschnitt 1.1.2 vorgestellte
Programm ILDasm.exe zur Analyse von Assemblies).
Systemvoraussetzungen für das Windows-SDK 6.1:
- Windows XP ab SP 2, Windows Vista, Windows Server 2003 SP 2, Windows
Server 2008
- .NET Framework 3.5
- ca. 2,5 GB (!) Festplattenspeicher
Das MSDN-Online-Angebot ist möglicherweise eine platzsparende Alternative
bei der Beschaffung von FCL-Referenzinformationen (siehe unten).
Bei der Installation einer Microsoft-Entwicklungsumgebung landet übrigens das Windows SDK (in der etwas älteren Version 6.0a) generell auf der Festplatte.
SharpDevelop 3.0
Das Setup-Programm zur Version 3.0 präsentiert nach der Begrüßung
Bezugsquelle: http://www.microsoft.com/downloads/details.aspx?FamilyId=F26B1AA4-741A-433A-9BE5FA919850BDBF&displaylang=en
Abschnitt 2.2 Entwicklungsumgebungen und andere Programmierhilfen
und der Lizenzvereinbarung
folgenden Konfigurationsdialog:
Die Installation
53
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
54
geht flott über die Bühne:
und begnügt sich mit ca. 50 MB Festplattenspeicher.
Leider lässt sich die MSDN 2008 Express Edition (siehe Abschnitt 2.2.2.1) nur installieren, wenn
Visual C# oder ein anderes Produkt der Express - Serie bereits vorhanden ist. Sie ist damit keine
Alternative, wenn die Windows-SDK - Installation wegen des großen Festplattenspeicherbedarfs
vermieden werden soll. Mit einer flotten Internetverbindung kann man aber bei geringer Komforteinschränkung die Online-Variante der MSDN-Bibliothek nutzen (http://msdn.microsoft.com/dede/library/default.aspx). Wählt man in SharpDevelop 3.0 den Menübefehl
Hilfe > Kontext-Hilfe
greift die Entwicklungsumgebung sogar spontan auf das MSDN-Online-Angebot zurück, z.B.:
Abschnitt 2.2 Entwicklungsumgebungen und andere Programmierhilfen
Damit SharpDevelop 3.0 eine lokal installierte FCL-Dokumentation nutzt, müssen Sie eventuell
nach
Extras > Optionen > Tools > Help-2.0-System
die gewünschte Hilfesammlung auswählen, z.B.:
Anschließend bietet das über
Hilfe > Inhalt
erreichbare Inhalts-Fenster die FCL-Dokumentation an:
55
Kapitel 2: Werkzeuge zum Entwickeln von C# - Programmen
56
Weil sich die Projektverwaltung von SharpDevelop 3.0 stark am Visual Studio 2008 bzw. an der
Visual C# 2008 Express Edition orientiert, sollte ein Wechsel zwischen den drei Entwicklungsumgebungen keine Schwierigkeiten bereiten.
2.3 Übungsaufgaben zu Kapitel 2
1) Installieren Sie nach Möglichkeit auf Ihrem privaten PC entweder die Visual C# 2008 Express
Edition (gemäß Abschnitt 2.2.2.1) oder SharpDevelop 3.0 (gemäß Abschnitt 2.2.3).
2) Experimentieren Sie mit dem Hallo-Beispielprogramm in Abschnitt 2.1.1:


Ergänzen Sie weitere Ausgabeanweisungen.
Erstellen Sie eine Variante ohne using-Direktive (vgl. Abschnitt 1.2.6).
3) Beseitigen Sie die Fehler in folgender Variante des Hallo-Programms:
Using System;
class Hallo {
static void Main() {
Console.WriteLn("Hallo Allerseits!);
}
3 Elementare Sprachelemente
In Abschnitt 1 wurde anhand eines halbwegs realistischen Beispiels versucht, einen ersten Eindruck
von der objektorientierten Softwareentwicklung mit C# zu vermitteln. Nun erarbeiten wir uns die
Details der Programmiersprache C# und beginnen dabei mit elementaren Sprachelementen. Diese
dienen zur Realisation von Algorithmen innerhalb von Methoden und sehen bei C# nicht wesentlich
anders als bei älteren, nicht objektorientierten Sprachen (z.B. C) aus.
3.1 Einstieg
3.1.1 Aufbau von einfachen C# - Programmen
Sie haben schon einiges über den Aufbau von einfachen, aus einem einzigen Assembly bestehenden, C# - Programmen erfahren:

Ein C# - Programm besteht aus Klassen. Unser Bruchadditionsbeispiel in Abschnitt 1.1 besteht aus den beiden Klassen Bruch und BruchAddition.

In einer Klassendefinition werden Felder deklariert sowie Eigenschaften und Methoden
definiert. Meist verwendet man für den Quellcode einer Klasse jeweils eine eigene Datei mit
demselben Namen wie die Klasse und .cs als Namenserweiterung.

Die zu einem Programm gehörigen Quellcodedateien werden gemeinsam vom Compiler in
die Microsoft Intermediate Language (MSIL) übersetzt, z.B.:
csc Bruch.cs BruchAddition.cs
Das resultierende exe-Assembly enthält neben dem MSIL-Code aber auch Typ- und Assembly-Metadaten.

Von den Klassen eines Programms muss eine startfähig sein. Dazu benötigt sie eine Methode mit dem Namen Main() und folgenden Besonderheiten:
o Modifikator static
o Rückgabetyp int oder void
Diese Methode wird beim Programmstart vom Laufzeitsystem aufgerufen und von der Klasse selbst ausgeführt (Modifikator static). Wenn die Main() – Methode ihrem Aufrufer (also
der CLR) eine ganze Zahl als Information über den (Miss)erfolg ihrer Tätigkeit liefert (z.B.
0: alles gut gegangen, 1: Fehler), ist in der Methodendefinition der Rückgabetyp int anzugeben. Fehlt eine solche Rückgabe, ist der Pseudorückgabetyp void anzugeben. Beim
Bruchadditions-Beispiel in Abschnitt 1.1 ist die Klasse BruchAddition startfähig.

Die ersten Zeilen einer C# - Quellcodedatei enthalten meist using-Direktiven zum Import
von Namensräumen, damit die dortigen Klassen später ohne Namensraum-Präfix vor den
Klassennamen angesprochen werden können.

Die Definition einer Klasse, Eigenschaft oder Methode besteht aus:
o Kopf
Bei einer Klassendefinition folgt auf das Schlüsselwort class der relativ frei wählbare Klassenname.
o Rumpf
Im Rumpf einer Klassendefinition werden Felder deklariert sowie Eigenschaften und
Methoden definiert. Im Rumpf einer Methode finden sich die Anweisungen zur Bewältigung des Auftrags.
Details folgen gleich in Abschnitt 3.1.2.

Eine Anweisung ist die kleinste ausführbare Einheit eines Programms. In C# sind bis auf
wenige Ausnahmen alle Anweisungen mit einem Semikolon abzuschließen.
Kapitel 3: Elementare Sprachelemente
58
Während der Beschäftigung mit elementaren C# - Sprachelementen werden wir mit einer extrem
einfachen und nicht sonderlich objektorientierten Programmstruktur arbeiten, die Sie schon aus dem
Hallo-Beispiel kennen. Es wird nur eine Klasse definiert, und diese erhält nur eine einzige Methodendefinition. Weil die Klasse startfähig sein muss, liegt Main() als Name der Methode fest, und
wir erhalten die folgende Programmstruktur:
using System;
class Prog {
static void Main() {
// Platz zum Üben elementarer Sprachelemente
}
}
Damit die wenig objektorientierten Beispiele Ihrem Programmierstil nicht prägen, wurde zu Beginn
des Kurses (in Abschnitt 1.1) eine Anwendung vorgestellt, die bereits etliche OOP-Prinzipien realisiert.
3.1.2 Syntaxdiagramme
Um für C# - Sprachbestandteile (z.B. Definitionen oder Anweisungen) die Bildungsvorschriften
kompakt und genau zu beschreiben, werden wir im Kurs u.a. so genannte Syntaxdiagramme einsetzen, für die folgende Vereinbarungen gelten:

Man bewegt sich vorwärts in Pfeilrichtung durch das Pfaddiagramm und gelangt dabei zu
Rechtecken, welche die an der jeweiligen Stelle zulässigen Sprachbestandteile angeben.
z.B.:
class
Modifikator



Bei Abzweigungen kann man sich für eine Richtung entscheiden, wenn nicht durch Pfeilspitzen eine Bewegungsrichtung vorgeschrieben ist.
Für konstante (terminale) Sprachbestandteile, die aus einem Rechteck exakt in der angegebenen Form in konkreten Quellcode zu übernehmen sind, wird fette Schrift verwendet.
Platzhalter sind durch kursive Schrift gekennzeichnet.
Als Beispiele betrachten wir die Syntaxdiagramme zur Definition von Klassen, Methoden- und Eigenschaften. Aus didaktischen Gründen zeigen die Diagramme nur solche Sprachbestandteile, die
bisher in einem Beispiel verwendet oder im Text beschrieben wurden, so dass sie langfristig keinesfalls als Referenz taugen. Trotz der Vereinfachung sind die Syntaxdiagramme aber für die meisten
Leser vermutlich nicht voll verständlich, weil etliche Bestandteile noch nicht systematisch beschrieben wurden (z.B. Modifikator, Feld- und Parameterdeklaration).
In diesem Abschnitt geht es nicht nur darum, Syntaxdiagramme als metasprachliche Hilfsmittel
einzuführen, sondern die vorgestellten Beispiele tragen hoffentlich trotz der gerade angesprochenen
Kompromisse auch zur allmählichen Festigung der wichtigen Begriffe Klasse, Methode und Eigenschaft bei.
3.1.2.1 Klassendefinition
Wir arbeiten vorerst mit dem folgenden, leicht vereinfachten Klassenbegriff:
59
Abschnitt 3.1 Einstieg
Klassendefinition
class
Name
{
}
Felddeklaration
Modifikator
Methodendefinition
Eigenschaftsdefinition
Solange man sich auf zulässigen Pfaden bewegt (immer in Pfeilrichtung, eventuell auch in Schleifen), an den Stationen (Rechtecken) entweder den konstanten Sprachbestandteil exakt übernimmt
oder den Platzhalter auf zulässige (eventuell an anderer Stelle erläuterte) Weise ersetzt, sollte eine
syntaktisch korrekte Klassendefinition entstehen.
Als Beispiel betrachten wir die im Abschnitt 1.1 vorgestellte Klasse Bruch:
Modifikator
Name
public class Bruch {
int zaehler,
nenner = 1;
Felddeklarationen
public int Zaehler {
get {
return zaehler;
}
set {
zaehler = value;
}
}
Eigenschaftsdefinitionen
public int Nenner {
. . .
}
public void Zeige() {
Console.WriteLine("
{0}\n -----\n
zaehler, nenner);
}
public void Kuerze() {
. . .
}
Methodendefinitionen
public void Addiere(Bruch b) {
. . .
}
public void Frage() {
. . .
}
}
{1}\n",
Kapitel 3: Elementare Sprachelemente
60
3.1.2.2 Methodendefinition
Weil ein Syntaxdiagramm für die komplette Methodendefinition etwas unübersichtlich wäre, betrachten wir separate Diagramme für die Begriffe Methodenkopf und Methodenrumpf:
Methodendefinition
Methodenkopf
Methodenrumpf
Methodenkopf
Rückgabetyp
Name
(
Parameterdeklaration
)
Modifikator
,
Methodenrumpf
{
}
Anweisung
Weil wir bald u.a. von einer Variablendeklarationsanweisung sprechen werden, benötigt das Syntaxdiagramm zum Methodenrumpf (im Unterschied zum Klassendefinitionsdiagramm) kein separates Rechteck für die Variablendeklaration.
Als Beispiel betrachten wir die Definition der Bruch-Methode Addiere():
Modifikator
Anweisungen
RückgabeName
Typ
Parameter
public void Addiere(Bruch b) {
zaehler = zaehler*b.nenner + b.zaehler*nenner;
nenner = nenner*b.nenner;
Kuerze();
}
3.1.2.3 Eigenschaftsdefinition
Auch beim Syntaxdiagramm für den Eigenschaftsbegriff gehen wir schrittweise vor:
Eigenschaftsdefinition
Eigenschaftskopf
Eigenschaftsrumpf
61
Abschnitt 3.1 Einstieg
Eigenschaftskopf
Typ
Name
Modifikator
Eigenschaftsrumpf
{
get
{
}
set
{
Anweisung
}
}
Anweisung
Als Beispiel betrachten wir die Bruch-Eigenschaft Nenner:
Modifikator
Anweisung
Anweisung
Typ
Name
public int Nenner {
get {
return nenner;
}
set {
if (value != 0)
nenner = value;
}
}
3.1.3 Hinweise zur Gestaltung des Quellcodes
Zur Formatierung von C# - Programmen haben sich Konventionen entwickelt, die wir bei passender
Gelegenheit besprechen werden. Der Compiler ist hinsichtlich der Formatierung sehr tolerant und
beschränkt sich auf folgende Regeln:



Die einzelnen Bestandteile einer Definition oder Anweisung müssen in der richtigen Reihenfolge stehen.
Zwischen zwei Sprachbestandteilen muss im Prinzip ein Trennzeichen stehen, wobei das
Leerzeichen, das Tabulatorzeichen und der Zeilenumbruch erlaubt sind. Diese Trennzeichen
dürfen sogar in beliebigen Anzahlen und Kombinationen auftreten. Innerhalb eines Sprachbestandteils (z.B. Namens) sind Trennzeichen (z.B. Zeilenumbruch) natürlich sehr unerwünscht.
Zeichen mit festgelegter Bedeutung wie z.B. ";", "(", "+", ">" sind selbstbegrenzend, d.h. vor
und nach ihnen sind keine Trennzeichen nötig (aber erlaubt).
Wer dieses Manuskript am Bildschirm liest oder an einen Farbdrucker geschickt hat, profitiert hoffentlich von der farblichen Gestaltung der Code-Beispiele. Es handelt sich um die Syntaxhervorhebungen der Visual C# 2008 Express Edition, die via Zwischenablage in den Text übernommen
wurden.
Ob man beim Rumpf einer Klassen- oder Methodendefinition die öffnende geschweifte Klammer
an das Ende der Kopfzeile setzt oder an den Anfang der Folgezeile, ist Geschmacksache, z.B.:
Kapitel 3: Elementare Sprachelemente
62
class Hallo {
static void Main() {
System.Console.WriteLine("Hallo");
}
}
class Hallo
{
static void Main()
{
System.Console.WriteLine("Hallo");
}
}
Die Visual C# 2008 Express Edition bevorzugt die rechte Variante, kann aber nach
Extras > Optionen
in der folgenden Dialogbox umgestimmt werden:
Weitere Hinweise zur übersichtlichen Gestaltung des C# - Quellcodes finden sich z.B. bei Krüger
(2002).
3.1.4 Kommentare
C# bietet folgende Möglichkeiten, den Quelltext zu kommentieren:

Zeilenrestkommentar
Alle Zeichen von // bis zum Ende der Zeile gelten als Kommentar, wobei kein KommentarTerminierungszeichen erforderlich ist, z.B.:
private int zaehler; // wird automatisch mit 0 initialisiert
Hier wird eine Variablendeklarationsanweisung in derselben Zeile kommentiert.

Mehrzeilenkommentar
Zwischen einer Einleitung durch /* und einer Terminierung durch */ kann sich ein ausführlicher Kommentar auch über mehrere Zeilen erstrecken, z.B.:
/*
Ein Bruch-Objekt verhindert, dass sein Nenner auf 0
gesetzt wird, und hat daher stets einen definierten Wert.
*/
public int Nenner {
. . .
}
Ein mehrzeiliger Kommentar eignet sich u.a. auch dazu, einen Programmteil (vorübergehend)
zu desaktivieren, ohne ihn löschen zu müssen.
Abschnitt 3.1 Einstieg
63
Weil der Mehrzeilenkommentar ohne farbliche Hervorhebung der auskommentierten Passage
unübersichtlich ist, wird er selten verwendet. Wenn man z.B. im Editor unserer Entwicklungsumgebung das Auskommentieren eines markierten Blocks mit dem Menübefehl
Bearbeiten > Erweitert >Auswahl kommentieren
bzw. mit der Tastenkombination Strg+E+C veranlasst, werden doppelte Schrägstriche vor jede
Zeile gesetzt. Wendet man den Menübefehl
Bearbeiten > Erweitert >Auskommentierung der Auswahl aufheben
bzw. die Tastenkombination Strg+E+C auf einen zuvor mit Doppelschrägstrichen auskommentierten Block an, werden die Kommentar-Schrägstriche entfernt.

Dokumentationskommentar
Neben den Kommentaren, welche ausschließlich das Lesen des Quelltexts unterstützen sollen,
kennt C# noch den Dokumentationskommentar in XML-Syntax. Er wird vom Compiler bei einem Aufruf mit der Option doc in eine separate XML-Dokumentationsdatei umgesetzt, z.B.:
csc *.cs /doc:Bruch.xml
Daraus kann über meist kostenlos verfügbare Hilfsprogramme (z.B. NDoc oder Sandcastle) eine
HTML-Dokumentation erstellt werden kann. In Microsofts Entwicklungsumgebungen fehlt leider eine entsprechende Funktionalität.
Ein Dokumentationskommentar darf vor einem benutzerdefinierten Typ (z.B. einer Klasse) oder
vor einem Klassen-Member (z.B. Feld, Eigenschaft, Methode) stehen und wird in jeder Zeile
durch drei Schrägstriche eingeleitet, z.B.:
/// <summary>
/// Ein Bruch-Objekt verhindert, dass sein Nenner auf Null
/// gesetzt wird und hat daher stets einen sinnvollen Wert.
/// </summary>
public int Nenner {
. . .
}
Durch /** eingeleitete und durch */ terminierte mehrzeilige Dokumentationskommentare erfordern die Beachtung spezieller Regeln und sind wegen der damit verbundenen Fehlerwahrscheinlichkeit nicht empfehlenswert.
3.1.5 Namen
Für Klassen, Eigenschaften, Methoden, Felder, Parameter und sonstige Elemente eines C# - Programms benötigen wir Namen, wobei folgende Regeln zu beachten sind:





Die Länge eines Namens ist nicht begrenzt.
Das erste Zeichen muss ein Buchstabe oder ein Unterstrich sein, danach dürfen außerdem
auch Ziffern auftreten.
C# - Programme werden intern im Unicode-Zeichensatz dargestellt. Daher erlaubt C# im
Unterschied zu vielen anderen Programmiersprachen in Namen auch Umlaute oder sonstige
nationale Sonderzeichen, die als Buchstaben gelten.
Die Groß-/Kleinschreibung ist signifikant. Für den C# - Compiler sind also z.B.
Anz
anz
ANZ
grundverschiedene Namen.
Die folgenden reservierten Wörter dürfen nicht als Namen verwendet werden:
Kapitel 3: Elementare Sprachelemente
64

abstract
as
base
bool
break
byte
case
catch
char
checked
class
const
continue
decimal
default
delegate
do
double
else
enum
event
explicit
extern
false
finally
fixed
float
for
foreach
goto
if
implicit
in
int
interface
internal
is
lock
long
namespace new
null
object
operator
out
override
params
private
protected
public
readonly
ref
return
sbyte
sealed
short
sizeof
stackalloc
static
string
struct
switch
this
throw
true
try
typeof
uint
ulong
unchecked unsafe
using
virtual
volatile
void
while
ushort
Namen müssen in ihrem Deklarationsbereich (siehe unten) eindeutig sein.
Während Sie obige Regeln einhalten müssen, ist die Beachtung der folgenden Konventionen freiwillig, aber empfehlenswert:

Die Namen von lokalen (methodeninternen) Variablen (siehe Abschnitt 3.3.2), Parametern
und privaten (gekapselten, nur klassenintern ansprechbaren) Feldern werden klein geschrieben, z.B.:
ggt
lokale Variable in der Bruch-Methode Kuerze()
b
Parameter in der Bruch-Methode Addiere()
nenner
privates Feld in der Klasse Bruch
Sonstige Namen (z.B. von Klassen, Methoden oder Eigenschaften) beginnen mit großen Anfangsbuchstaben:
Bruch
Name einer Klasse
Kuerze()
Name einer Methode
Nenner
Name einer Eigenschaft

Bei zusammengesetzten Namen beginnt jedes Wort mit einem Großbuchstaben (Pascal Casing), z.B.:
WriteLine()
Eine Ausnahme stellen die nach obiger Empfehlung mit einem Kleinbuchstaben zu beginnenden Namen dar (Camel Casing), z.B.:
numberOfObjects
Alternativ kann man auch den Unterstrich zur Verbesserung der Lesbarkeit zusammengesetzter Namen verwenden, was der folgende Methodenname demonstriert, den Visual C#
im HalloForms-Projekt erstellt hat (siehe Abschnitt 2.2.1.3):
private void button1_Click(object sender, EventArgs e)
3.1.6 Übungsaufgaben zu Abschnitt 3.1
1) Welche Main()-Varianten sind zum Starten eines Programms geeignet?
static void main() { . . . }
public static void Main() { . . . }
static int Main() { . . . }
static double Main() { . . . }
static void Main() { . . . }
2) Welche von den folgenden Namen sind unzulässig?
4you
maiLink
else
Lösung
b_____
Abschnitt 3.2 Ausgabe bei Konsolenanwendungen
65
3.2 Ausgabe bei Konsolenanwendungen
Wie Sie bereits an einigen Beispielen beobachten konnten, lässt sich eine formatierte Konsolenausgabe in C# recht bequem über die Methode Console.WriteLine() erzeugen, z.B.:
using System;
. . .
Console.WriteLine("
{0}\n -----\n
{1}\n", zaehler, nenner);
Es handelt es sich um eine statische Methode der Klasse Console aus dem Namensraum System,
d.h.:



Der Namensraum System muss am Beginn der Quelle per using-Direktive importiert werden, um die Klasse Console ohne Namensraum-Präfix ansprechen zu können.
Weil es sich um eine statische Methode handelt, richten wir den Methodenaufruf nicht an
ein Console-Objekt, sondern an die Klasse selbst.
Im Methodenaufruf sind Klassen- und Methodenname durch einen Punkt zu trennen.
WriteLine() schließt jede Ausgabe automatisch mit einer Zeilenschaltung ab. Wo dies unerwünscht
ist, setzt man die ansonsten äquivalente Console-Methode Write() ein.
Sie kennen bereits zwei nützliche Spezialisierungen der WriteLine()-Methode (später werden wir
von Überladungen sprechen):


In obigem Beispiel ist die formatierte Ausgabe von zwei Werten zu sehen, wobei ein einleitender Zeichenfolgen-Parameter angibt, wie die Ausgabe der restlichen Parameter erfolgen
soll. Auf diese Technik gehen wir in Abschnitt 3.2.2 näher ein.
Oft reicht die im nächsten Abschnitt behandelte Ausgabe einerzusammengesetzten Zeichenfolge.
3.2.1 Ausgabe einer (zusammengesetzten) Zeichenfolge
Im Hallo-Beispiel haben wir der WriteLine()-Methode als einzigen Parameter eine Zeichenkette
zur Ausgabe auf dem Bildschirm übergeben:
Console.WriteLine("Hallo Allerseits!");
Übergebene Argumente anderen Typs werden vor der Ausgabe automatisch in eine Zeichenfolge
konvertiert, z.B. der Wert einer ganzzahligen Variablen (siehe unten):
int i = 4711;
Console.WriteLine(i);
Besonders angenehm ist die Möglichkeit, mehrere Teilausgaben mit dem „+“-Operator zu verketten, z.B.:
int i = 4711;
Console.WriteLine("i hat den Wert: " + i);
Der Wert der ganzzahligen Variablen i wird in eine Zeichenfolge gewandelt, die anschließend an
die Zeichenfolge "i hat den Wert: " angehängt und dann mit ihr zusammen ausgegeben
wird:
Durch die bequeme Zeichenfolgenverkettung mit dem „+“ – Operator und die automatische Konvertierung von beliebigen Datentypen in einer Zeichenfolge ist die WriteLine()-Variante mit unformatierter Ausgabe schon recht flexibel. Außerdem erlauben die folgenden Escape-Sequenzen
Kapitel 3: Elementare Sprachelemente
66
(vgl. Abschnitt 3.3.8.4), die wie gewöhnliche Zeichen in die Ausgabezeichenfolge geschrieben
werden, eine Gestaltung der Ausgabe:
\t
Horizontaler Tabulator
\n
Zeilenwechsel (new line)
Noch mehr Gestaltungsmöglichkeiten bietet die formatierte Ausgabe:
3.2.2 Formatierte Ausgabe
Bei der formatierten Ausgabe per WriteLine() wird als erster Parameter eine Zeichenfolge übergeben, die Platzhalter mit optionalen Formatierungsangaben für die restlichen, auf der Konsole auszugebenden Parameter enthält. Für einen Platzhalter ist folgende Syntax vorgeschrieben:
Platzhalter für die formatierte Ausgabe
{
Nummer
,
Breite
:
Format
Präzision
}
Darin bedeuten:
Nummer
Breite
Format
Präzision
Fortlaufende Nummer des auszugebenden Arguments,
bei Null beginnend
Ausgabebreite für das zugehörige Argument
Positive Werte bewirken eine rechtsbündige, negative Werte eine
linksbündige Ausgabe.
Formatspezifikation gemäß anschließender Tabelle
Anzahl der Nachkommastellen oder sonstige Präzisionsangabe (abhängig vom Format), muss der Formatangabe unmittelbar folgen (ohne trennende Leerstellen)
Es werden u.a. folgende Formate unterstützt:
Beispiele
Format Beschreibung
WriteLine-Parameterliste Ausgabe
("{0,7:d}", 4711)
("{0,-7:d}", 4711)
d, D
Dezimal (für ganze Zahlen geeignet)
f, F
("{0,5:f2}", 4.711)
Festkomma
Präzision: Anzahl der Nachkommastellen
e, E
("{0:e}", 47.11)
Gleitkomma
Präzision: Anzahl Stellen in der Mantisse ("{0:e2}", 47.11)
("{0:E}", 47.11)
4711
4711
4,71
4,711000e+001
4,71e+001
4,711000E+001
Bei fehlenden Formatierungsangaben entscheidet der Compiler.
In der Formatierungszeichenfolge sind auch auszugebende gewöhnliche Zeichen und EscapeSequenzen (vgl. Abschnitt 3.3.8.4) erlaubt:
\t
\n
Horizontaler Tabulator
Zeilenwechsel (new line)
67
Abschnitt 3.3 Variablen und Datentypen
Beispiel:
Quellcode-Fragment
Ausgabe
int i = 47, j = 11;
Console.WriteLine("Werte:\t{0}\n\t{1}", i, j);
Werte:
47
11
Auf eine Formatierungszeichenfolge mit k Platzhaltern müssen entsprechend viele Ausdrücke (z.B.
Variablen) mit einem zum jeweiligen Platzhalterformat passenden Datentyp folgen.
Die beschriebenen Formatierungstechniken sind nicht nur bei Konsolenausgaben zu gebrauchen.
Analog erstellte Zeichenfolgen kann man auch in eine Datei schreiben oder im Rahmen einer graphischen Benutzerschnittstelle präsentieren.
3.2.3 Übungsaufgaben zu Abschnitt 3.2
1) Wie ist das fehlerhafte „Rechenergebnis“ in folgendem Programm zu erklären?
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
Console.WriteLine("3,3 + 2 = " + 3.3 + 2);
}
}
3,3 + 2 = 3,32
Sorgen Sie mit einem Paar runder Klammern dafür, dass die folgende Ausgabe erscheint.
3,3 + 2 = 5,3
Verbringen Sie nicht zuviel Zeit mit der Aufgabe, weil wie die genauen technischen Hintergründe
erst in Abschnitt 3.5.10 behandeln.
2) Schreiben Sie ein Programm, das aufgrund der folgenden Variablendeklaration und -initialisierung
int i = 4711, j = 471, k = 47, l = 4;
mit zwei WriteLine()-Aufrufen diese Ausgabe produziert:
Rechtsbündig:
i = 4711
j = 471
k =
47
l =
4
Linksbündig:
4711
471
47
4
(i)
(j)
(k)
(l)
3.3 Variablen und Datentypen
Während ein Programm läuft, müssen zahlreiche Informationen mehr oder weniger lange im Arbeitsspeicher des Rechners aufbewahrt und natürlich auch modifiziert werden, z.B.:


Die Merkmalsausprägungen eines Objekts werden gespeichert, solange das Objekt existiert.
Die zur Ausführung einer Methode benötigten Daten werden bis zum Ende des Methodenaufrufs aufbewahrt.
Kapitel 3: Elementare Sprachelemente
68
Zum Speichern eines Werts (z.B. einer Zahl) wird eine so genannte Variable verwendet, worunter
Sie sich einen benannten Speicherplatz von bestimmtem Datentyp (z.B. Ganzzahl) vorstellen
können.
Eine Variable erlaubt über ihren Namen den lesenden oder schreibenden Zugriff auf den zugeordneten Platz im Arbeitsspeicher, z.B.:
using System;
class Prog {
static void Main() {
int ivar = 4711;
Console.WriteLine(ivar);
}
}
//schreibender Zugriff auf ivar
//lesender Zugriff auf ivar
Um die Details bei der Verwaltung der Variablen im Arbeitsspeicher müssen wir uns nicht kümmern, da wir schließlich mit einer problemorientierten, „höheren“ Programmiersprache arbeiten.
Allerdings verlangt C# beim Umgang mit Variablen im Vergleich zu anderen Programmier- oder
Skriptsprachen einige Sorgfalt, letztlich mit dem Ziel, Fehler zu vermeiden:

Variablen müssen explizit deklariert werden.
In obigem Beispiel wird die Variable ivar vom Typ int (für ganze Zahlen) deklariert.
Wenn Sie versuchen, eine nicht deklarierte Variable zu verwenden, beschwert sich der
Compiler, z.B.:
Prog.cs(4,28): error CS0103: Der Name ivar ist im aktuellen
Kontext nicht vorhanden.
In der Visual C# 2008 Express Edition informiert schon der Quellcode-Editor über das
Problem, z.B.:
Versucht man trotzdem eine Übersetzung, dann erscheint die Compiler-Reklamation in der
Fehlerliste:
Durch den Deklarationszwang werden z.B. Programmfehler wegen falsch geschriebener Variablennamen verhindert. Auch in VB.NET (Visual Basic .NET) besteht per Voreinstellung
Deklarationszwang. Hier lässt er sich jedoch mit der (nicht empfehlenswerten) CompilerOption Option Explicit Off aufheben, um das Verhalten vieler Skriptsprachen demonstrieren zu können, z.B.:
Quellcode
Ausgabe
Option Explicit Off
Module Module1
Sub Main()
ii = 12
' Verwendung ohne Deklaration
ij = ii + 1 ' Tippfehler fällt nicht auf
Console.WriteLine(ii)
End Sub
End Module
12
Abschnitt 3.3 Variablen und Datentypen

69
C# ist streng und statisch typisiert
Für jede Variable ist bei der Deklaration ein fester (später nicht mehr änderbarer) Datentyp
anzugeben. Er legt fest, …
o welche Informationen (z.B. ganze Zahlen, rationale Zahlen, Zeichen) in der Variablen gespeichert werden können,
o welche Operationen auf die Variable angewendet werden dürfen.
Bereits der Compiler überwacht die korrekte Verwendung der Datentypen, so dass Fehler
frühzeitig aufgedeckt werden (Typsicherheit) . Außerdem kann auf (zeitaufwändige) Typprüfungen zur Laufzeit verzichtet werden. In obigem C# - Programm wird die Variable
ivar vom Typ int deklariert, der sich für ganze Zahlen im Bereich von -2147483648 bis
2147483647 eignet. Die Variable erhält auch gleich den Initialisierungswert 4711. Auf
diese oder andere Weise müssen Sie jeder lokalen, d.h. innerhalb einer Methode deklarierten, Variablen einen Wert zuweisen, bevor Sie zum ersten Mal lesend darauf zugreifen (vgl.
Abschnitt 3.3.5).
Als wichtige Eigenschaften einer C# - Variablen halten wir fest:




Name
Es sind beliebige Bezeichner gemäß Abschnitt 3.1.5 erlaubt.
Datentyp
Damit sind festgelegt: Wertebereich, Speicherplatzbedarf und zulässige Operationen.
Aktueller Wert
Ort im Hauptspeicher
Im Unterschied zu anderen Programmiersprachen (z.B. C++) spielt in C# die Verwaltung
von Speicheradressen praktisch keine Rolle. Wir werden jedoch zwei wichtige Speicherregionen unterscheiden (Stack und Heap), weil Performanzgründe die gezielte Auswahl einer
bestimmten Region nahelegen können.
3.3.1 Wert- und Referenztypen
Bei der objektorientierten Programmierung werden neben den traditionellen Variablen zur Aufbewahrung von Zahlen, Zeichen oder Wahrheitswerten auch Variablen benötigt, welche die Adresse
eines Objekts aufnehmen und so die Kommunikation mit dem Objekt unterstützen können:

Werttypen
Die traditionellen Datentypen werden in C# als Werttypen (Value Types) bezeichnet. Variablen mit einem Werttyp spielen auch in C# eine unverzichtbare Rolle, obwohl sie „nur“ zur
Verwaltung bzw. Nutzung ihres Inhalts dienen. In der Bruch-Klassendefinition (siehe Abschnitt 1.1) haben die Felder für Zähler und Nenner eines Objekts den Werttyp int, können
also eine Ganzzahl im Bereich von –2147483648 bis 2147483647 aufnehmen. Sie werden in
der folgenden Anweisung deklariert:
int zaehler, nenner = 1;

Referenztypen
Eine Variable mit Referenztyp dient dazu, die Speicheradresse eines Objekts aus einer bestimmten Klasse aufnehmen. Sobald ein solches Objekt erzeugt und seine Speicheradresse
der Referenzvariablen zugewiesen worden ist, kann das Objekt über die Referenzvariable
angesprochen werden. Von den Variablen mit Werttyp unterscheidet sich eine Referenzvariable also …
 durch ihren speziellen Inhalt (Objektadresse)
 und durch ihre Rolle bei Kommunikation mit Objekten.
Kapitel 3: Elementare Sprachelemente
70
Man kann jede Klasse (aus der FCL übernommen oder selbst definiert) als Datentyp verwenden, also Referenzvariablen dieses Typs deklarieren. In der Main()-Methode der Klasse
BruchAddition werden z.B. die Referenzvariablen b1 und b2 aus der Klasse Bruch
deklariert:
Bruch b1 = new Bruch(), b2 = new Bruch();
Sie erhalten als Initialisierungswert jeweils eine Referenz auf ein neu erzeugtes BruchObjekt. Daraus resultiert im programmeigenen Speicher folgende Situation:
Stack
Heap
Bruch-Objekt
b1
76788700
zaehler
nenner
0
1
b2
76788716
Bruch-Objekt
zaehler
nenner
0
1
Das von b1 referenzierte Bruch-Objekt wurde bei einem konkreten Programmlauf von der
CLR an der Speicheradresse 76788700 (Hexadezimal: 0x0493b3dc) untergebracht. Wir plagen uns nicht mit solchen Adressen, sondern sprechen die dort abgelegten Objekte über Referenzvariablen an, wie z.B. in der folgenden Anweisung aus der Main()-Methode der Klasse BruchAddition:
b1.Frage();
Jedes Bruch-Objekt enthält die Felder (Instanzvariablen) zaehler und nenner vom
Werttyp int.
Zur Beziehung der Begriffe Objekt und Variable halten wir fest:


Ein Objekt enthält im Allgemeinen mehrere Variablen (Felder) von beliebigem Typ.
So enthält z.B. ein Bruch-Objekt die Variablen (Felder) zaehler und nenner vom
Werttyp int (zur Aufnahme einer Ganzzahl). Bei einer späteren Erweiterung der BruchKlassendefinition werden ihre Objekte auch eine Instanzvariable mit Referenztyp erhalten.
Eine Referenzvariable dient zur Aufnahme einer Objektadresse.
So kann z.B. eine Variable vom Datentyp Bruch die Adresse eines Bruch-Objekts aufnehmen und die Kommunikation mit diesem Objekt ermöglichen. Es ist ohne weiteres möglich und oft sinnvoll, dass mehrere Referenzvariablen die Adresse desselben Objekts enthalten. Das Objekt existiert unabhängig vom Schicksal einer konkreten Referenzvariablen, wird
jedoch überflüssig, wenn im gesamten Programm keine einzige Referenz (Kommunikationsmöglichkeit) mehr vorhanden ist.
71
Abschnitt 3.3 Variablen und Datentypen
3.3.2 Klassifikation der Variablen nach Zuordnung
Nach der Zuordnung zu einer Methode, zu einem Objekt oder zu einer Klasse unterscheidet man:

Lokale Variablen
Sie werden innerhalb einer Methode deklariert. Ihre Gültigkeit beschränkt sich auf die Methode bzw. auf einen Block innerhalb der Methode (siehe Abschnitt 3.3.6).
Solange eine Methode ausgeführt wird, befinden sich ihre Variablen in einem Speicherbereich, den man als Stack (dt.: Stapel) bezeichnet. Die obige Abbildung zeigt die lokalen Variablen b1 und b2 aus der Main()-Methode der Klasse BruchAddition, die als Referenzvariablen auf Objekte der Klasse Bruch zeigen.

Instanzvariablen (nicht-statische Felder)
Jedes Objekt (synonym: jede Instanz) einer Klasse verfügt über einen vollständigen Satz der
Instanzvariablen der Klasse. So besitzt z.B. jedes Objekt der Klasse Bruch einen zaehler und einen nenner.
Solange ein Objekt existiert, befinden es sich mit all seine Instanzvariablen in einem Speicherbereich, den man als Heap (dt.: Haufen) bezeichnet.
Klassenvariablen (statische Felder)
Diese Variablen beziehen sich auf eine Klasse, nicht auf einzelne Instanzen. Z.B. hält man
oft in einer Klassenvariablen fest, wie viele Objekte der Klasse bereits erzeugt worden sind.
In unserem Bruchrechnungs-Beispielprojekt haben wir der Einfachheit halber auf statische
Felder verzichtet.
Während jedes Objekt einer Klasse über einen eigenen Satz mit allen Instanzvariablen verfügt, die beim Erzeugen des Objekts auf dem Heap angelegt werden, existieren Klassenvariablen nur einmal. Sie werden beim Laden der Klasse zusammen mit anderen typbezogenen
Informationen (z.B. Methodentabelle) auf dem Heap abgelegt.

Auf Instanz- und Klassenvariablen kann in allen Methoden der eigenen Klasse zugegriffen werden.
Wenn (abweichend vom Prinzip der Datenkapselung) entsprechende Rechte eingeräumt wurden, ist
dies auch in Methoden fremder Klassen möglich.
In Abschnitt 3 werden wir ausschließlich mit lokalen Variablen arbeiten. Im Zusammenhang mit
der systematischen Behandlung der objektorientierten Programmierung werden die Instanz- und
Klassenvariablen ausführlich erläutert.
Im Unterschied zu anderen Programmiersprachen (z.B. C++) ist es in C# nicht möglich, so genannte globale Variablen außerhalb von Klassen zu deklarieren.
3.3.3 Elementare Datentypen
Als elementare Datentypen sollen die in C# vordefinierten Werttypen zur Aufnahme von einzelnen
Zahlen, Zeichen oder Wahrheitswerten bezeichnet werden. Speziell für Zahlen existieren diverse
Datentypen, die sich hinsichtlich Wertebereich und Speicherplatzbedarf unterscheiden. Von der
folgenden Tabelle sollte man sich vor allem merken, wo sie im Bedarfsfall zu finden ist. Eventuell
sind Sie aber auch jetzt schon neugierig auf einige Details:
Typ
Beschreibung
sbyte
short
int
long
Diese Variablentypen speichern ganze
Zahlen mit Vorzeichen.
Beispiel:
int zaehler = -7
Werte
Bits
–128 … 127
8
–32768 … 32767
16
–2147483648 ... 2147483647
32
–9223372036854775808 …
9223372036854775807
64
Kapitel 3: Elementare Sprachelemente
72
Typ
byte
ushort
uint
Beschreibung
Diese Variablentypen speichern ganze
Zahlen ohne Vorzeichen.
Beispiel:
byte alter = 31;
ulong
float
double
Variablen vom Typ float speichern
Gleitkommazahlen nach der Norm IEEE 754 (32 Bit) mit einer Genauigkeit
von mind. 7 signifikanten Dezimalstellen.
Beispiel:
float p = 4.2526f;
float-Literale (s.u.) benötigen den Suffix f (od. F).
Werte
Bits
0 … 255
8
0 … 65535
16
0 ... 4294967295
32
0 … 18446744073709551615
64
Minimum:
–3.40282351038
Maximum:
3.40282351038
Kleinster Betrag:
1.410-45
32
Minimum:
Variablen vom Typ double speichern
Gleitkommazahlen nach der Norm IE–1,797693134862315710308
EE 754 (64 Bit) mit einer Genauigkeit Maximum:
von 15-16 signifikanten Dezimalstellen. 1,797693134862315710308
Kleinster Betrag:
Beispiel:
double p =
4,910-324
1.13445626535898;
1 für das Vorz.,
8 für den Expon.,
23 für die Mantisse
64
1 für das Vorz.,
11 für den Expon.,
52 für die Mantisse
Variablen vom Typ decimal speichern
alle Dezimalzahlen mit bis zu 28 Stellen exakt und eignen sich besonders für
die Finanzmathematik, wo Rundungsfehler zu vermeiden sind.
Beispiel:
decimal p =
2344.2554634m;
decimal-Literale (s.u.) benötigen den
Suffix m (oder M).
Minimum:
-(296-1)
Maximum:
296-1
Kleinster Betrag:
10-28
char
Variablen vom Typ char speichern ein
Unicode-Zeichen. Im Speicher landet
aber nicht die Gestalt eines Zeichens,
sondern seine Nummer im Zeichensatz.
Daher zählt char zu den ganzzahligen
(integralen) Datentypen.
Beispiel:
char zeichen = 'j';
char – Literale (s.u.) sind mit einfachen
Anführungszeichen einzurahmen.
16
Unicode-Zeichen
Tabellen mit allen UnicodeZeichen sind z.B. auf der Webseite
http://www.unicode.org/charts/
des Unicode-Konsortiums verfügbar.
bool
Variablen vom Typ bool speichern
Wahrheitswerte.
Beispiel:
bool cond = false;
true, false
decimal
128
1 für das Vorz.,
5 für den Expon.,
96 für die Mantisse,
restl. Bits ungenutzt
Im Expon. sind
nur die Werte 0
bis 28 erlaubt, die
negativ interpret.
werden.
1
73
Abschnitt 3.3 Variablen und Datentypen
Als Gleitkommazahl (synonym: Gleitpunkt- oder Fließkommazahl, engl.: floating point number
bezeichnet man eine EDV-freundlich notierte rationale Zahl (Dezimalzahl). Während die Mantisse
mit einer festen Anzahl von Ziffern für Genauigkeit sorgt, speichert man zusätzlich per Exponentialfaktor die Position des Dezimalkommas, sodass der Mantissenwert anwendungsgerecht für z.B.
für Lichtjahre oder Nanometer stehen kann. Weil mit dem Speicheraufwand auch der Wertebereich
begrenzt ist, bilden die Gleitkommazahlen natürlich nur eine endliche Teilmenge der rationalen
Zahlen (im mathematischen Sinn). Zur Verarbeitung von Gleitkommazahlen wurde die Gleitkommaarithmetik entwickelt, normiert und zur Verbesserung der Verarbeitungsgeschwindigkeit teilweise sogar in Computer-Hardware realisiert. Nähere Informationen über die Darstellung von rationalen Zahlen im Arbeitsspeicher eines Computers folgen für speziell interessierte Leser in Abschnitt
3.3.4.
3.3.4 Vertiefung: Darstellung rationaler Zahlen im Arbeitsspeicher des Computers
Die als Vertiefung bezeichneten Abschnitte können beim ersten Lesen des Manuskripts gefahrlos
übersprungen werden. Sie enthalten interessante Details, über die man sich irgendwann im Verlauf
der Programmierkarriere informieren sollte. Im Kurskontext dienen sie auch als Zeitvertreib für
Teilnehmer mit Vorkenntnissen, die sich eventuell (z.B. im Abschnitt über elementare Sprachelemente) etwas langweilen.
3.3.4.1 Binäre Gleitkommadarstellung
Bei den binären Gleitkommatypen float und double werden auch „relativ glatte“ Zahlen nicht unbedingt genau gespeichert, wie das folgende Programm zeigt:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
double f130 = 1.3f;
double f125 = 1.25f;
Console.WriteLine("{0,20:f16}", f130);
Console.WriteLine("{0,20:f16}", f125);
float ff130 = 1.3f;
Console.WriteLine("\n{0,20:f16}", ff130);
Console.ReadLine();
}
}
1,2999999523162800
1,2500000000000000
1,3000000000000000
Die Zahl 1,3 kann im float-Format (sieben signifikante Dezimalstellen) nicht exakt gespeichert
werden. Um dies zu demonstrieren, wird ein float-Wert (erzwungen per Literal-Suffix f) in einer
double-Variablen abgelegt und dann mit 16 Dezimalstellen ausgegeben. Demgegenüber wird die
Zahl 1,25 im float-Format fehlerfrei gespeichert. Mit einer float-Variablen lässt sich das Problem
nicht demonstrieren, weil die Ungenauigkeit bei der Ausgabe von der CLR weggerundet wird.
Diese Ergebnisse sind durch das zugrunde liegende IEEE-754 – Format für die Darstellung von
rationalen Zahlen zu erklären. Es handelt sich um ein binäres Gleitkommaformat, wobei jede
Zahl als Produkt aus drei getrennt zu speichernden Faktoren dargestellt wird:
Vorzeichen  Mantisse  2Exponent
Im ersten Bit einer float- und double - Variable wird das Vorzeichen gespeichert (0: positiv, 1: negativ).
Für die Ablage des Exponenten (zur Basis 2) als Ganzzahl stehen 8 (float) bzw. 11 (double) Bits
zur Verfügung. Allerdings sind im Exponenten die Werte 0 und 255 (float) bzw. 0 und 2047 (double) für Spezialfälle (z.B. denormalisierte Darstellung, +/-Unendlich) reserviert (siehe Abschnitt
3.6.2). Um auch die für Zahlen mit einem Betrag kleiner Eins benötigten negativen Exponenten
Kapitel 3: Elementare Sprachelemente
74
darstellen zu können, werden Exponenten mit einer Verschiebung (Bias) um den Wert 127 (float)
bzw. 1023 (double) abgespeichert und interpretiert. Besitzt z.B. eine float-Zahl den Exponenten
Null, landet der Wert
011111112 = 127
im Speicher, und bei negativen Exponenten resultieren dort Werte kleiner als 127.
Abgesehen von betragsmäßig sehr kleinen Zahlen (siehe unten) werden die float- und doubleWerte normalisiert, d.h. auf eine Mantisse im Intervall [1; 2) gebracht, z.B.:
24,48 = 1,53  24
0,2448 = 1,9584  2-3
Zur Speicherung der Mantisse werden 23 (float) bzw. 52 (double) Bits verwendet. Weil die führende Eins der normalisierten Mantisse nicht abgespeichert wird (hidden bit), stehen alle Bits für die
Restmantisse (die Nachkommastellen) zur Verfügung mit dem Effekt einer verbesserten Genauigkeit. Oft wird daher die Anzahl der Mantissen-Bits mit 24 (float) bzw. 53 (double) angegeben. Das
i-te Mantissen-Bit (von links nach rechts mit Eins beginnend nummeriert) hat die Wertigkeit 2-i, so
dass sich ihr dezimaler Gesamtwert folgendermaßen ergibt:
m
23 bzw. 52
b 2
i 1
i
i
, mit bi {0,1}
Eine float- bzw. double-Variable mit dem Vorzeichen v (Null oder Eins), dem Exponenten e und
dem dezimalen Mantissenwert m speichert also bei normalisierter Darstellung den Wert:
(-1)v  2e-127  (1 + m) bzw. (-1)v  2e-1023  (1 + m)
In der folgenden Tabelle finden Sie einige normalisierte float-Werte:
Wert
0,75
1,0
1,25
-2,0
2,75
-3,5
=
=
=
=
=
=
(-1)0  2(126-127)  (1+0,5)
(-1)0  2(127-127)  (1+0,0)
(-1)0  2(127-127)  (1+0,25)
(-1)1  2(128-127)  (1+0,0)
(-1)0  2(128-127)  (1+0,25+0,125)
(-1)1  2(128-127)  (1+0,5+0,25)
Vorz.
0
0
0
1
0
1
float-Darstellung (normalisiert)
Exponent
Mantisse
01111110 10000000000000000000000
01111111 00000000000000000000000
01111111 01000000000000000000000
10000000 00000000000000000000000
10000000 01100000000000000000000
10000000 11000000000000000000000
Nun kommen wir endlich zur Erklärung der eingangs dargestellten Genauigkeitsunterschiede beim
Speichern der Zahlen 1,25 und 1,3. Während die Restmantisse
0,25  0  2 -1  1  2 -2
1
1
 0   1
4
2
perfekt dargestellt werden kann, gelingt dies bei der Restmantisse 0,3 nur approximativ:
0,3  0  2 1  1 2 2  0  2 3  0  2 4  1 2 5  ...
1
1
1
1
1
 0   1   0   0   1  ...
2
4
8
16
32
Sehr aufmerksame Leser werden sich darüber wundern, wieso die Tabelle mit den elementaren Datentypen in Abschnitt 3.3.3 z.B.
1,410-45
als betragsmäßig kleinsten float-Wert nennt, obwohl der minimale Exponent nach obigen Überlegungen -126 beträgt, was zum (gerundeten) dezimalen Exponentialfaktor
75
Abschnitt 3.3 Variablen und Datentypen
1,210-38
führt. Dahinter steckt die denormalisierte Gleitkommadarstellung, die als Ergänzung zur bisher
beschriebenen normalisierten Darstellung eingeführt wurde, um eine bessere Annäherung an die
Zahl Null zu erreichen. Alle Exponenten-Bits sind auf Null gesetzt, und dem Exponentialfaktor
wird der feste Wert 2-126 (float) bzw. 2-1022 (double) zugeordnet. Die Mantissen-Bits haben dieselbe
Wertigkeiten (2-i) wie bei der normalisierten Darstellung (siehe oben). Weil es kein hidden bit gibt,
stellen sie aber nun einen dezimalen Wert im Intervall [0, 1) dar.
Eine float- bzw. double-Variable mit dem Vorzeichen v (Null oder Eins), mit komplett auf Null
gesetzten Exponenten-Bits und dem dezimalen Mantissenwert m speichert also bei denormalisierter
Darstellung die Zahl:
(-1)v  2-126  m bzw. (-1)v  2-1022  m
In der folgenden Tabelle finden Sie einige denormalisierte float-Werte:
Wert
0,0 = (-1)0  2-126  0
-5,87747210-39 = (-1)1  2-126  2-1
1,40129810-45 = (-1)0  2-126  2-23
float-Darstellung (denormalisiert)
Vorz. Exponent
Mantisse
0
00000000 00000000000000000000000
1
00000000 10000000000000000000000
0
00000000 00000000000000000000001
Weil die Mantissen-Bits auch zur Darstellung der Größenordnung verwendet werden, schwindet die
relative Genauigkeit mit der Annäherung an die Null.
Visual C# - Projekte zur Anzeige der Bits einer (de)normalisierten float- bzw. double-Zahl finden
Sie in den Ordnern
…\BspUeb\Elementare Sprachelemente\Bits\FloatBits
…\BspUeb\Elementare Sprachelemente\Bits\DoubleBits
Diese Programme werden im Text nicht beschrieben, weil die erforderlichen Techniken (speziell
die Nachbildung von C++ - Unions in C# mit Hilfe von Attributen, siehe Abschnitt 11) in .NET Anwendungen ansonsten keine Rolle spielen. Eine Beispielausgabe des Programms FloatBits:
3.3.4.2 Dezimale Gleitkommadarstellung
Neben den eben beschriebenen binären Gleitkommatypen (mit der Basis Zwei in Mantisse und Exponent) bietet C# auch den dezimalen Gleitkommatyp decimal, dessen Speicherorganisation die
Basis Zehn verwendet. Dabei werden 102 Bits folgendermaßen eingesetzt: 1



1
1 Bit für das Vorzeichen
96 Bits für die Mantisse
Hier wird eine Ganzzahl im Bereich von 0 bis 296-1 gespeichert.
5 Bits für den Exponenten
Hier wird die Anzahl der Nachkommastellen als Ganzzahl gespeichert, wobei Werte von 0
bis 28 erlaubt sind (Begründung folgt).
Von den insgesamt belegten 128 Bit bleiben also einige ungenutzt.
Kapitel 3: Elementare Sprachelemente
76
Eine decimal-Variable mit dem Vorzeichen v (0 oder 1), der Mantisse m und dem Exponenten e
speichert den Wert:
(-1)v  10-e  m
Durch die 96-Manitissen-Bits einer decimal-Variablen lassen sich alle natürlichen Zahlen mit max.
28 Stellen darstellen:
28 Stellen
29 Stellen
9999999999999999999999999999 < 296-1 < 99999999999999999999999999999
Folglich kann jede Dezimalzahl mit maximal 28 Stellen (vor oder hinter dem Dezimaltrennzeichen)
exakt in einer decimal-Variablen gespeichert werden.
Im folgenden Programm wird der (besonders in der Finanzmathematik relevante) Genauigkeitsvorteil des Datentyps decimal im Vergleich zu den binären Gleitkommatypen demonstriert:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
Console.WriteLine(10.0f - 9.9f);
Console.WriteLine(10.0 - 9.9);
Console.WriteLine(10.0m - 9.9m);
}
}
0,1000004
0,0999999999999996
0,1
Allerdings hat der Typ decimal auch Nachteile im Vergleich zu float bzw. double




Kleiner Wertebereich
Hoher Speicherbedarf
Hoher Zeitaufwand bei arithmetischen Operationen
Keine Unterstützung für Sonderfälle wie +/- Unendlich und NaN (Not a Number) (vgl. Abschnitt 3.6)
Bei der Aufgabe,
10000000
13000000 -
1,3
i 1
zu berechnen, ergeben sich für die Datentypen double und decimal folgende Genauigkeits- und
Laufzeitunterschiede:
double:
Abweichung:
Benöt. Zeit:
0,0017944723367691
109,375 Millisek.
decimal:
Abweichung:
Benöt. Zeit:
0,0
1421,875 Millisek.
77
Abschnitt 3.3 Variablen und Datentypen
3.3.5 Variablendeklaration, Initialisierung und Wertzuweisung
In C#-Programmen muss jede Variable vor ihrer ersten Verwendung deklariert 1 werden. Dabei sind
auf jeden Fall der Name und der Datentyp anzugeben, wie das Syntaxdiagramm zur Variablendeklarationsanweisung zeigt:
Deklaration einer lokalen Variablen
Typbezeichner
Variablenname
=
Ausdruck
;
,
Als Datentypen kommen in Frage (vgl. Abschnitt 3.3.1):

Werttypen, z.B.
int i;

Referenztypen, also Klassen (aus der FCL oder selbst definiert), z.B.
Bruch b1;
Wir betrachten vorläufig nur lokale Variablen, die innerhalb einer Methode existieren. Ihre Deklaration darf im Methodenquellcode an beliebiger Stelle vor der ersten Verwendung erscheinen. Es ist
üblich, ihre Namen mit einem Kleinbuchstaben beginnen zu lassen (vgl. Abschnitt 3.1.5).
Neu deklarierte Variablen kann man optional auch gleich initialisieren, also auf einen gewünschten
Wert setzen, z.B.:
int i = 4711;
Bruch b1 = new Bruch();
Im zweiten Beispiel wird per new-Operator ein Bruch-Objekt erzeugt und dessen Adresse in die
neue Referenzvariable b1 geschrieben. Mit der Objektkreation und auch mit der Konstruktion von
gültigen Ausdrücken, die einen Wert von passendem Datentyp liefern müssen, werden wir uns noch
ausführlich beschäftigen.
Weil lokale Variablen nicht automatisch initialisiert werden, muss man ihnen unbedingt vor dem
ersten lesenden Zugriff einen Wert zuweisen. Auch im Umgang des Compilers mit uninitialisierten
lokalen Variablen zeigt sich das Bemühen der C# - Designer um robuste Programme. Während C++
- Compiler in der Regel nur warnen, produziert der C# - Compiler eine Fehlermeldung und erstellt
keinen Zwischencode. Dieses Verhalten wird durch folgendes Programm demonstriert:
using System;
class Prog {
static void Main() {
int argument;
Console.WriteLine("Argument = {0}", argument);
}
}
Der Compiler meint dazu:
1
Während in C++ die beiden Begriffe Deklaration und Definition verschiedene Bedeutungen haben, werden sie im
Zusammenhang mit den meisten anderen Programmiersprachen (so auch bei C#) synonym verwendet. In diesem
Manuskript wird im Zusammenhang mit Variablen bevorzugt von Deklarationen, im Zusammenhang mit Klassen
und Methoden meist von Definitionen gesprochen.
Kapitel 3: Elementare Sprachelemente
78
Weil Instanz- und Klassenvariablen automatisch mit dem Standardwert ihres Typs initialisiert werden (siehe unten), ist in C# dafür gesorgt, dass alle Variablen beim Lesezugriff stets einen definierten Wert haben.
Um den Wert einer Variablen im weiteren Pogrammablauf zu verändern, verwendet man eine
Wertzuweisung, die zu den einfachsten und am häufigsten benötigten Anweisungen gehört:
Wertzuweisungsanweisung
Variablenname
Beispiel:
=
Ausdruck
;
ggt = az;
Durch diese Wertzuweisungsanweisung aus der Kuerze() - Methode unserer BruchKlasse (siehe Abschnitt 1.1) erhält die int-Variable ggt den Wert der int-Variablen az.
Es wird sich bald herausstellen, dass auch ein Ausdruck stets einen Datentyp hat. Bei der Wertzuweisung muss dieser Typ natürlich kompatibel zum Datentyp der Variablen sein.
U.a. haben Sie mittlerweile zwei Sorten von C# - Anweisungen kennen gelernt:


Variablendeklaration
Wertzuweisung
3.3.6 Blöcke und Deklarationsbereiche für lokale Variablen
Wie Sie bereits wissen, besteht der Rumpf einer Methodendefinition aus einem Block mit beliebig
vielen Anweisungen, der durch geschweifte Klammern begrenzt ist. Innerhalb des Methodenrumpfes können weitere Anweisungsblöcke gebildet werden, wiederum durch geschweifte Klammen
begrenzt:
Block- bzw. Verbundanweisung
{
Anweisung
}
Man spricht hier auch von einer Block- bzw. Verbundanweisung, und diese kann überall stehen,
wo eine einzelne Anweisung erlaubt ist.
Unter den Anweisungen eines Blocks dürfen sich selbstverständlich auch wiederum Blockanweisungen befinden. Einfacher ausgedrückt: Blöcke dürfen geschachtelt werden.
Oft treten Blöcke als Bestandteil von Bedingungen oder Schleifen (siehe Abschnitt 3.7) auf, z.B. in
der Methode Kuerze() der Klasse Bruch (siehe Abschnitt 1.1):
79
Abschnitt 3.3 Variablen und Datentypen
public void Kuerze() {
if (zaehler != 0) {
int ggt = 0;
int az = Math.Abs(zaehler);
int an = Math.Abs(nenner);
. . .
. . .
zaehler /= ggt;
nenner /= ggt;
} else
nenner = 1;
}
Anweisungsblöcke haben einen wichtigen Effekt auf die Gültigkeit der darin deklarierten Variablen: Eine lokale Variable ist verfügbar von der deklarierenden Zeile bis zur schließenden Klammer
des innersten Blockes. Nur in diesem Deklarations- bzw. Sichtbarkeitsbereich kann sie über ihren Namen angesprochen werden, so dass der Compiler das Übersetzen des folgenden (weitgehend
sinnfreien) Beispielprogramms
using System;
class Prog {
static void Main(){
int wert1 = 1;
if (wert1 == 1) {
int wert2 = 2;
Console.WriteLine("Wert2 = " + wert2);
}
Console.WriteLine("Wert2 = " + wert2);
}
}
mit einer Fehlermeldung ablehnt:
Bei hierarchisch geschachtelten Blöcken ist es in C# nicht erlaubt, auf mehreren Stufen Variablen
mit identischem Namen zu deklarieren. Diese kaum sinnvolle Option ist in der Programmiersprache
C++ vorhanden und erlaubt dort Fehler, die schwer aufzuspüren sind. In C# gehören die eingeschachtelten Blöcke zum Deklarationsbereich der umgebenden Blocks.
Zur übersichtlichen Gestaltung von C# - Programmen ist das Einrücken von Anweisungsblöcken
sehr zu empfehlen, wobei Sie die Position der einleitenden Blockklammer und die Einrücktiefe
nach persönlichem Geschmack wählen können, z.B.:
if (wert1 == 1)
{
int wert2 = 2;
Console.WriteLine("Wert2 = "+wert2);
}
if (wert1 == 1) {
int wert2 = 2;
Console.WriteLine("Wert2 = "+wert2);
}
Bei den Quellcode-Editoren unsere Entwicklungsumgebungen kann ein markierter Block aus mehreren Zeilen mit
Tab
komplett nach rechts eingerückt
und mit
Umschalt + Tab
komplett nach links ausgerückt
Kapitel 3: Elementare Sprachelemente
80
werden. Außerdem kann man sich zu einer Blockklammer das Gegenstück anzeigen lassen:
Einfügemarke des Editors vor der Startklammer
hervorgehobene Endklammer
3.3.7 Konstanten
Für die in einem Programm benötigten festen Werte (z.B. Mehrwertsteuersatz) sollte man in der
Regel jeweils eine Konstante definieren, die dann im Quellcode über ihren Namen angesprochen
werden kann, denn:


Bei einer späteren Änderung des Wertes ist nur die Quellcodezeile mit der Konstantendeklaration betroffen.
Der Quellcode ist leichter zu lesen.
Beispiel:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
const double MWST = 1.19;
double netto = 28.10, brutto;
brutto = netto * MWST;
Console.WriteLine("Brutto: {0:f2}", brutto);
}
}
Brutto: 33,44
Im Vergleich zu einer Variablen weist eine Konstante folgende Besonderheiten auf:


Ihre Deklaration beginnt mit dem Modifikator const und muss eine Initialisierung enthalten,
wobei ein konstanter Ausdruck zu verwenden ist, der nur Literale (siehe Abschnitt 3.3.8)
und Konstanten enthält.
Ihr Wert kann im Programm nicht geändert werden.
Programmierer verwenden traditionell im Namen einer Konstanten ausschließlich Großbuchstaben
und verbessern bei einem Mehrwortnamen die Lesbarkeit durch trennende Unterstriche (z.B.:
MAXIMALE_QUOTE). Dies ist aber nicht vorgeschrieben, und in der FCL scheint Microsoft folgende Konvention anzuwenden:


Namen von Konstanten werden komplett groß geschrieben, wenn sie aus maximal zwei Zeichen bestehen, z.B. Math.PI (Kreiszahl , siehe unten).
Bei längeren Namen wird der Pascal-Stil verwendet (Anfangsbuchstaben aller Wörter groß),
z.B. Double.MaxValue (größter Wert des Typs double).
Das Syntaxdiagramm zur Deklaration einer konstanten lokalen Variablen:
81
Abschnitt 3.3 Variablen und Datentypen
Deklaration einer konstanten lokalen Variablen
const
Datentyp
Name
=
konstanter
Ausdruck
;
,
Neben lokalen Variablen können auch Felder einer Klasse als konstant deklariert werden (siehe
Abschnitt 4.2.2).
3.3.8 Literale
Die im Programmcode auftauchenden expliziten Werte bezeichnet man als Literale. Wie Sie aus
dem Abschnitt 3.3.7 wissen, ist es oft sinnvoll, Literale innerhalb von Konstanten-Deklarationen zu
verwenden, z.B.:
const double MWST = 1.16;
Auch die Literale besitzen in C# stets einen Datentyp, wobei einige Regeln zu beachten sind.
In diesem Abschnitt haben manche Passagen Nachschlage-Charakter, so dass man beim ersten Lesen nicht jedes Detail aufnehmen muss bzw. kann.
3.3.8.1 Ganzzahlliterale
Ganzzahlliterale können im dezimalen oder im hexadezimalen Zahlensystem (mit der Basis 16 und
den Ziffern 0, 1, …, 9, A, B, C, D, E, F) geschrieben werden, wobei der hexadezimale Fall durch
das Präfix 0x oder 0X zu kennzeichnen ist. Die Anweisungen:
int i = 11, j = 0x11;
Console.WriteLine("i = " + i + ", j = " + j);
liefern die Ausgabe:
i = 11, j = 17
Für das Ganzzahlliteral 0x11 ergibt sich der dezimale Wert 17 aufgrund der Stellenwertigkeiten im
Hexadezimalsystem folgendermaßen:
11Hex = 1  16 + 1  1 = 17
Der Typ eines Ganzzahlliterals hängt von seinem Wert und einem eventuell vorhandenen Suffix ab:

Ist kein Suffix vorhanden, hat das Ganzzahlliteral den ersten Typ aus folgender Serie, der
seinen Wert aufnehmen kann:
int, uint, long, ulong
Beispiele:
Literale
2147483647, -21
2147483648
-2147483649, 9223372036854775807
9223372036854775808

Typ
int
uint
long
ulong
Ein Ganzzahlliteral mit Suffix u oder U (unsigned, ohne Vorzeichen) hat den ersten Typ aus
folgender Serie, der seinen Wert aufnehmen kann:
uint, ulong
Kapitel 3: Elementare Sprachelemente
82
Beispiele:
Literale
2147483647U
9223372036854775808u

Typ
uint
ulong
Ein Ganzzahlliteral mit Suffix l oder L (Long) hat den ersten Typ aus folgender Serie, der
seinen Wert aufnehmen kann:
long, ulong
Beispiele:
Literale
2147483647L
9223372036854775808L
Typ
long
ulong
Der Kleinbuchstabe l ist leicht mit der Ziffer 1 zu verwechseln und daher als Suffix wenig
geeignet.

Ein Ganzzahlliteral mit Suffix ul, lu, UL, LU, uL, Lu, Ul, oder lU hat den Typ ulong.

Kann ein Wert von keinem Datentyp aufgenommen werden, produziert die Entwicklungsumgebung eine Warnung, z.B.
Wird der Compiler (trotzdem) mit der Übersetzung beauftragt, produziert er eine Fehlermeldung:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
Console.WriteLine(18446744073709551616);
}
}
Prog.cs(4,21): error
CS1021: Die integrale
Konstante ist zu groß
3.3.8.2 Gleitkommaliterale
Zahlen mit Dezimalpunkt oder Exponent sind in C# vom Typ double, wenn nicht per Suffix ein
alternativer Typ erzwungen wird:



Durch das Suffix F oder f wird der Datentyp float erzwungen, z.B.:
9.78f
Durch das Suffix M oder m wird der Datentyp decimal erzwungen, z.B.:
1.3m
Mit dem (kaum jemals erforderlichen) Suffix D oder d wird der Datentyp double „optisch
betont“, z.B.:
1d
Hinsichtlich der Schreibweise von Gleitkommaliteralen bietet C# etliche Möglichkeiten, von denen
die wichtigsten in den folgenden Syntaxdiagrammen dargestellt werden:
83
Abschnitt 3.3 Variablen und Datentypen
f
Literal für
rationale Zahlen
+
m
-
d
Mantisse
Exponent
Mantisse
.
int-Literal
Exponent
e
int-Literal
int-Literal
E
Die in der Mantisse und im Exponenten auftretenden Ganzzahlliterale müssen das dezimale Zahlensystem verwenden und den Datentyp int besitzen, so dass die in Abschnitt 3.3.8.1 beschriebenen
Präfixe (0x, 0X) und Suffixe (L, U) verboten sind. Die Exponenten werden natürlich zur Basis Zehn
verstanden.
Beispiele:
0.45875e-20
9.78f
2279800223423485.45m
Der Compiler achtet bei Wertzuweisungen streng auf die Typkompatibilität. Z.B. führt die folgende
Deklarationsanweisung:
const float P = 1.25;
zu der Fehlermeldung:
Prog.cs(4,19): error CS0664: Literale des Typs "Double" können
nicht implizit in den Typ "float" konvertiert werden. Verwenden
Sie ein F-Suffix, um ein Literal mit diesem Typ zu erstellen.
3.3.8.3 bool-Literale
Als Literale vom Typ bool sind nur die beiden reservierten Wörter true und false erlaubt, z.B.:
bool cond = true;
Die bool-Literale sind mit kleinem Anfangsbuchstaben zu schreiben, obwohl sie in der Konsolenausgabe anders erscheinen, z.B.:
Quellcode
using System;
class Prog {
static void Main() {
bool b = false;
Console.WriteLine(b);
}
}
Ausgabe
Kapitel 3: Elementare Sprachelemente
84
3.3.8.4 char-Literale
char-Literale werden in C# durch einfache Hochkommata begrenzt. Es sind erlaubt:

Einfache Zeichen
Beispiel:
const char a = 'a';
Das einfache Hochkomma kann allerdings auf diese Weise ebenso wenig zum char-Literal
werden wie der Rückwärts-Schrägstrich (\). In diesen Fällen benötigt man eine so genannte
Escape-Sequenz:

Escape-Sequenzen
Hier dürfen einem einleitenden Rückwärts-Schrägstrich u.a. folgen:
o Ein Steuerzeichen, z.B.:
Neue Zeile
\n
Horizontaler Tabulator
\t
Alarmton
\a
o Einfaches oder doppeltes Hochkomma sowie der Rückwärts-Schrägstrich:
\'
\"
\\
Beispiel:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
char rs = '\\';
Console.WriteLine("Inhalt von rs: " + rs);
}
}

Inhalt von rs: \
Unicode-Escape-Sequenzen
Eine Unicode-Escape-Sequenz enthält eine Unicode-Zeichennummer (vorzeichenlose Ganzzahl mit 16 Bits, also im Bereich von 0 bis 216-1 = 65535) in hexadezimaler, vierstelliger
Schreibeweise (ggf. links mit Nullen aufgefüllt) nach der Einleitung durch \u oder \x. So
lassen sich Zeichen ansprechen, die per Tastatur nicht einzugeben sind.
Beispiel:
const char alpha = '\u03b1';
Im Konsolenfenster werden die Unicode-Zeichen oberhalb von \u00ff in der Regel als Fragezeichen dargestellt. In einem GUI-Fenster erscheinen alle Unicode-Zeichen in voller
Pracht (siehe nächsten Abschnitt).
3.3.8.5 Zeichenkettenliterale
Zeichenkettenliterale werden (im Unterschied zu char-Literalen) durch doppelte Hochkommata
begrenzt. Hinsichtlich der erlaubten Zeichen und der Escape-Sequenzen gelten die Regeln für charLiterale analog, wobei das einfache und das doppelte Hochkomma ihre Rollen tauschen, z.B.:
string name = "Otto's Welt";
85
Abschnitt 3.3 Variablen und Datentypen
Zeichenkettenliterale sind vom Datentyp string, und später wird sich herausstellen, dass es sich bei
diesem Typ um eine Klasse aus dem Namensraum System handelt. 1
Während ein char-Literal stets genau ein Zeichen enthält, kann ein Zeichenkettenliteral aus beliebig vielen Zeichen bestehen oder auch leer sein, z.B.:
string name = "";
Das folgende Programm enthält einen Aufruf der statischen Show()-Methode der Klasse
MessageBox aus dem Namensraum System.Windows.Forms (siehe Abschnitt 2.2.2.4) zur Anzeige eines Zeichenkettenliterals, das drei Unicode-Escape-Sequenzen enthält:
using System.Windows.Forms;
class Prog {
static void Main() {
MessageBox.Show("\u03b1, \u03b2, \u03b3", "Unicode-Demo");
}
}
Beim Programmstart erscheint die folgende Dialogbox:
Um die besondere Bedeutung des Rückwärts-Schrägstrichs in einer Zeichenkette abzuschalten und
den Verdopplungsaufwand zu sparen, stellt man das @-Zeichen voran, z.B.:
Console.WriteLine(@"Pfad: C:\Programme\Mapp\bin");
3.3.9 Übungsaufgaben zu Abschnitt 3.3
1) Wieso klagt der Compiler über ein unbekanntes Symbol, obwohl die Variable i deklariert worden ist?
Quellcode
Fehlermeldung
class Prog {
static void Main() {
{
int i = 2;
}
System.Console.WriteLine(i);
}
}
Prog.cs(6,34): error CS0103: Der Name i
ist im aktuellen Kontext nicht vorhanden.
2) Beseitigen Sie bitte alle Fehler in folgendem Programm:
1
Das (klein geschriebene!) Schlüsselwort string steht in C# aus Bequemlichkeitsgründen als Aliasname für die Klassenbezeichnung System.String zur Verfügung.
Kapitel 3: Elementare Sprachelemente
86
class Prog {
static void Main() {
float pi = 3,141593;
double radius = 2,0;
System.Console.WriteLine('Der Flächeninhalt beträgt: {0:f3}',
pi * radius * radius);
}
}
3) Schreiben Sie bitte ein Programm, das folgende Ausgabe produziert:
Dies ist ein Zeichenkettenliteral:
"Hallo"
4) In folgendem Programm erhält eine char-Variable das Zeichen 'c' als Wert. Anschließend wird
dieser Inhalt auf eine int-Variable übertragen, und bei der Konsolenausgabe erscheint schließlich
die Zahl 99:
Quellcode
Ausgabe
class Prog {
static void Main() {
char zeichen = 'c';
int nummer = zeichen;
System.Console.WriteLine("zeichen = " + zeichen +
"\nnummer = " + nummer);
}
}
zeichen = c
nummer = 99
Warum hat der ansonsten sehr pingelige C# - Compiler nichts dagegen, einer int-Variablen den
Wert einer char-Variaben zu übergeben? Wie kann man das Zeichen 'c' über eine Unicode-EscapeSequenz ansprechen?
3.4 Einfache Techniken für Benutzereingaben
3.4.1 Via Konsole
In der Frage()-Methode der Klasse Bruch aus dem Einleitungsbeispiel (siehe Abschnitt 1.1)
wird folgende Anweisung genutzt, um einen int-Wert von der Konsole entgegen zu nehmen:
Zaehler = Convert.ToInt32(Console.ReadLine());
Mit der statischen Methode ReadLine() der Klasse Console wird eine vom Benutzer per EnterTaste abgeschlossene Zeile von der Konsole gelesen. Diese Zeichenfolge dient anschließend als
Argument der statischen Methode ToInt32() aus der Klasse Convert, die wie Console zum Namensraum System gehört. Sofern sich die übergebene Zeichenfolge als ganze Zahl im intWertebereich interpretieren lässt, liefert der ToInt32() – Aufruf diese Zahl zurück, und sie landet
schließlich in der int-Eigenschaft Zaehler.
Hier liegt hier eine Verschachtelung zweier Methodenaufrufe vor, die bei Programmierern der
kompakten Schreibweise wegen sehr beliebt ist. Ein etwas umständliches, aber für Anfänger leichter verständliches Äquivalent zur obigen Anweisung könnte lauten:
string eingabe;
eingabe = Console.ReadLine();
Zaehler = Convert.ToInt32(eingabe);
87
Abschnitt 3.4 Einfache Techniken für Benutzereingaben
Die vorgestellte Datenerfassungstechnik hat ein Problem mit weniger kooperativen Benutzern:
Wird eine nicht konvertierbare Zeichenfolge abgeschickt, endet das Programm mit einem unbehandelten Ausnahmefehler, z.B.:
Quellcode
Ein- und Ausgabe
using System;
class Prog {
static void Main() {
Console.Write("Ihre Lieblingszahl? ");
int zahl = Convert.ToInt32(Console.ReadLine());
Console.WriteLine("Verstanden: " + zahl);
}
}
Ihre Lieblingszahl? drei
Unbehandelte Ausnahme:
System.FormatException: Die
Eingabezeichenfolge hat das falsche
Format.
Wenn Sie das Programm aus Visual C# heraus mit der Taste F5 oder mit dem Schalter (also im
so genannten Debug-Modus) gestartet haben, führt der Ausnahmefehler zu folgender Anzeige:
In dieser Situation lässt sich das havarierte und noch „baumelnde“ Programm mit dem Menübefehl
Debuggen > Debugging beenden
oder mit der Tastenkombination Umschalt+F5 stoppen.
Um derartige Probleme zu verhindern, sind Programmiertechniken erforderlich, mit denen wir uns
momentan noch nicht beschäftigen wollen, z.B. die folgende Ausnahmebehandlung:
Quellcode
Ein- und Ausgabe
using System;
class Prog {
static void Main() {
int zahl;
try {
Console.Write("Ihre Lieblingszahl? ");
zahl = Convert.ToInt32(Console.ReadLine());
Console.WriteLine("Verstanden: " + zahl);
}
catch {
Console.WriteLine("Falsche Eingabe!");
}
}
}
Ihre Lieblingszahl? drei
Falsche Eingabe!
In den Übungs- bzw. Demoprogrammen verwenden wir der Einfachheit halber ungesicherte
ToInt32 – Aufrufe bzw. analoge Varianten für andere Datentypen.
Kapitel 3: Elementare Sprachelemente
88
3.4.2 Via InputBox
Wer schon jetzt Anwendungen mit grafikorientierter Benutzerinteraktion erstellen möchte, kann
statt ReadLine() z.B. die statische Methode InputBox() der Klasse Interaction aus dem Namensraum Microsoft.VisualBasic benutzen. Die Klasse Interaction ist als Migrationshilfe für Visual
Basic 6 - Programmierer gedacht, kann aber natürlich auch in C# - Programmen genutzt werden.
Dazu muss dem Compiler das implementierende und im GAC (Global Assembly Cache) installierte
Assembly microsoft.visualbasic.dll per /reference – Option bekannt gemacht werden, z.B.:
csc /reference:microsoft.visualbasic.dll Prog.cs
Wie Sie aus Abschnitt 2.2.2.4 bereits wissen, kann man unseren Entwicklungsumgebungen sehr
bequem erklären, welche Assembly-Referenzen beim Übersetzen eines Projekts erforderlich sind.
Man wählt im Projektmappen-Explorer aus dem Kontextmenü zum Projektnamen die Option
Verweis hinzufügen und kann dann in folgender Dialogbox das gesuchte Assembly lokalisieren
und im markierten Zustand per OK in die Verweisliste des Projekts aufnehmen:
Anschließend wird die gewählte Referenz im Projektmappen-Explorer angezeigt:
Aus dem Beispielprogramm in Abschnitt 3.4.1 entsteht folgende GUI-Variante:
using System;
using System.Windows.Forms;
using Microsoft.VisualBasic;
class InputBox {
static void Main() {
int zahl = Convert.ToInt32(
Interaction.InputBox("Ihre Lieblingszahl?", "InputBox", "", -1, -1)
);
MessageBox.Show("Verstanden: "+zahl);
}
}
Ein- und Ausgabe werden per Dialogbox erledigt:
Abschnitt 3.4 Einfache Techniken für Benutzereingaben
89
Wie man ein überflüssiges Konsolenfenster unterdrückt, wurde in Abschnitt 2.3 im Rahmen einer
Übungsaufgabe gezeigt.
Über die Parameter (Argumente) und den Rückgabewert des Inputbox()-Methodenaufrufs kann
man sich z.B. über die Hilfefunktion der Visual C# 2008 Express Edition informieren. Eine Suche
nach dem Klassennamen Interaction führt schnell zum Ziel:
Die Angaben zu optionalen Parametern betreffen allerdings nur Visual Basic - Programmierer. In
der CLS (Common Language Specification) und in C# werden optionale Parameter nicht unterstützt.
Zwar sieht die Ein- bzw. Ausgabe per GUI attraktiver aus, jedoch lohnt sich der erhöhte Aufwand
bei Demo- bzw. Übungsprogrammen zu speziellen Sprachelementen kaum, so dass wir in der Regel
darauf verzichten werden. Beim späteren Entwurf eigener Dialogboxen werden wir die Größen der
Bedienelemente sorgfältig entwerfen und den folgenden Schönheitsfehler vermeiden, der offenbar
durch den höheren Platzbedarf der deutschen Beschriftung im Vergleich zur englischen Variante
entstanden ist:
Die Convert-Methode ToInt32 reagiert natürlich auch im optisch aufgewerteten Programm auf
ungeschickte Benutzereingaben mit einer Ausnahme. Später werden wir das Problem mit einer professionellen Ausnahmebehandlung lösen.
Kapitel 3: Elementare Sprachelemente
90
3.5 Operatoren und Ausdrücke
Im Zusammenhang mit der Variablendeklaration und der Wertzuweisung haben wir das Sprachelement Ausdruck ohne Erklärung benutzt, und diese soll nun nachgeliefert werden. Im aktuellen Abschnitt 3.5 werden wir Ausdrücke als wichtige Bestandteile von C# - Anweisungen recht detailliert
betrachten. Dabei lernen Sie elementare Datenverarbeitungs-Möglichkeiten kennen, die von so genannten Operatoren mit ihren Argumenten veranstaltet werden, z.B. von den arithmetischen Operatoren +, -, *, / für die Grundrechenarten. Am Ende des Abschnitts kann immerhin schon das Programmieren eines Währungskonverters als Übungsaufgabe gestellt werden. Allzu große Begeisterung wird wohl trotzdem nicht aufkommen, doch ein sicherer Umgang mit Operatoren und Ausdrücken ist unabdingbare Voraussetzung für das erfolgreiche Implementieren von Methoden und Eigenschaften. Hier werden Algorithmen bzw. Handlungskompetenzen von Klassen oder Objekten
realisiert.
Während die Variablen zur Speicherung von Werten dienen, geht es bei den Operatoren darum,
aus vorhandenen Variableninhalten oder anderen Argumenten neue Werte zu berechnen. Den zur
Berechnung eines Werts geeigneten, aus Operatoren und zugehörigen Argumenten aufgebauten Teil
einer Anweisung, bezeichnet man als Ausdruck, z.B. in folgender Wertzuweisung:
Operator
az = az - an;
Ausdruck
Durch diese Anweisung aus der Kuerze()-Methode unserer Bruch-Klasse (siehe Abschnitt 1.1)
wird der lokalen int-Variablen az der Wert des Ausdrucks az - an zugewiesen. Wie in diesem
Beispiel landen die Werte von Ausdrücken oft in Variablen, wobei Ausdruck und Variable typkompatibel sein müssen.
Man kann einen Ausdruck als eine temporäre Variable mit einem Datentyp und einem Wert auffassen.
Schon bei einem Literal, einer Variablen oder einem Methodenaufruf haben wir es mit einem Ausdruck zu tun. Besteht ein Ausdruck aus einem Methodenaufruf mit dem Pseudorückgabetyp void,
dann liegt allerdings kein Wert vor.
Beispiel: 1.5
Dies ist ein Ausdruck mit dem Typ double und dem Wert 1,5.
Mit Hilfe diverser Operatoren entstehen komplexere Ausdrücke, wobei Typ und Wert von den Argumenten und den Operatoren abhängen.
Beispiele: 2 * 1.5
Hier resultiert der double-Wert 3,0.
2 > 1.5
Hier resultiert der boolean-Wert true.
In der Regel beschränken sich die Operatoren darauf, aus ihren Argumenten (Operanden) einen
Wert zu ermitteln und diesen für die weitere Verarbeitung zur Verfügung zu stellen. Einige Operatoren haben jedoch zusätzlich einen Nebeneffekt auf eine als Argument fungierende Variable.
Beispiel: int i = 12;
int j = i++;
Im Beispiel hat der Ausdruck i++ den Typ int und den Wert 12. Außerdem wird die
Variable i beim Auswerten des Ausdrucks durch den Postinkrementoperator auf den
neuen Wert 13 gesetzt.
91
Abschnitt 3.5 Operatoren und Ausdrücke
Die meisten Operatoren verarbeiten zwei Operanden (Argumente) und heißen daher zweistellig
bzw. binär.
Beispiel: a + b
Der Additionsoperator wird mit einem „+“ bezeichnet und erwartet zwei numerische
Argumente.
Manche Operatoren begnügen sich mit einem Argument und heißen daher einstellig bzw. unär.
Beispiel: !cond
Der Negationsoperator wird mit einem „!“ bezeichnet und erwartet ein Argument mit
dem Typ bool.
Wir werden auch noch einen dreistelligen Operator kennen lernen.
Weil Ausdrücke von passendem Ergebnistyp als Argumente einer Operation erlaubt sind, können
beliebig komplexe Ausdrücke aufgebaut werden. Unübersichtliche Exemplare sollten jedoch als
potentielle Fehlerquellen vermieden werden.
3.5.1 Arithmetische Operatoren
Weil die arithmetischen Operatoren für die vertrauten Grundrechenarten der Schulmathematik zuständig sind, müssen ihre Operanden (Argumente) einen numerischen Typ haben (sbyte, short, int,
long, byte, ushort, uint, ulong, char, float, double oder decimal).
Die resultierenden arithmetischen Ausdrücke übernehmen ihren Ergebnistyp von den Argumenten. Bei Argumenten unterschiedlichen Typs findet nach Möglichkeit eine automatische (implizite)
Typanpassung „nach oben“ statt (vgl. Abschnitt 3.5.7). Bevor z.B. ein int-Argument zu einem
double-Wert addiert werden kann, muss es in den Typ double konvertiert werden. Ist keine automatische Typanpassung möglich, beschwert sich der Compiler, z.B.:
Es hängt von den Datentypen der Argumente ab, ob die Ganzzahl-, oder die Gleitkommaarithmetik zum Einsatz kommt. Besonders auffällig sind die Unterschiede im Verhalten des Divisionsoperators, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
int i = 2, j = 3;
double a = 2.0, b = 3.0;
Console.WriteLine(i / j);
Console.WriteLine(a / b);
}
}
0
0,666666666666667
Bei der Ganzzahldivision werden die Stellen nach dem Dezimaltrennzeichen abgeschnitten, was
gelegentlich durchaus erwünscht ist. Im Zusammenhang mit dem Über- bzw. Unterlauf werden Sie
noch weitere Unterschiede zwischen Ganzzahl- und Gleitkommaarithmetik kennen lernen.
In der folgenden Tabelle mit allen arithmetischen Operatoren stehen Num, Num1 und Num2 für
Ausdrücke mit numerischem Typ, Var vertritt eine numerische Variable:
Kapitel 3: Elementare Sprachelemente
92
Operator
Beispiel
Programmfragment
Bedeutung
-Var
Vorzeichenumkehr
Num1 + Num2
Num1 – Num2
Num1 * Num2
Num1 / Num2
Addition
Subtraktion
Multiplikation
Division
Num1 % Num2 Modulo (Divisionsrest)
Sei GAD der ganzzahlige Anteil aus dem Ergebnis der Division (Num1 / Num2). Dann
ist Num1 % Num2 def. durch
Num1 - GAD  Num2
++Var
Präinkrement bzw.
-dekrement
--Var
Als Argument ist nur eine Variable erlaubt.
++Var liefert Var + 1
erhöht Var um 1
--Var liefert Var - 1
reduziert Var um 1
Var++
Postinkrement bzw.
-dekrement
Var-Als Argument ist nur eine Variable erlaubt.
Var++ liefert Var
erhöht Var um 1
Var-- liefert Var
reduziert Var um 1
int i = 2;
Console.WriteLine(-i);
Console.WriteLine(2 + 3);
Console.WriteLine(2.6 - 1.1);
Console.WriteLine(4 * 5);
Console.WriteLine(8.0 / 5);
Console.WriteLine(8 / 5);
Console.WriteLine(19 % 5);
Console.WriteLine(-19 % 5.4);
Ausgabe
-2
5
1,5
20
1,6
1
4
-2,8
int i = 4;
double a = 1.2;
Console.WriteLine(++i + "\n" +
--a);
5
0,2
int i = 4;
Console.WriteLine(i++ + "\n" +
i);
4
5
Bei den Inkrement- und den Dekrementoperatoren ist zu beachten, dass sie zwei Effekte haben:


Das Argument wird ausgelesen, um den Wert des Ausdrucks zu ermitteln.
Der Wert des Argumentes wird verändert.
Wegen dieses Nebeneffekts sind Prä- und Postinkrement- bzw. -dekrementausdrücke im
Unterschied zu sonstigen arithmetischen Ausdrücken bereits vollständige Anweisungen (vgl.
Abschnitt 3.7.1), wenn man ein Semikolon dahinter setzt:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
int i = 12;
i++;
Console.WriteLine(i);
}
}
13
Ein (De)inkrementoperator bietet keine eigenständige mathematische Funktion, sondern eine vereinfachte Schreibweise. So ist z.B. die folgende Anweisung
j = ++i;
mit den beiden int-Variablen i und j äquivalent zu
93
Abschnitt 3.5 Operatoren und Ausdrücke
i = i + 1;
j = i;
3.5.2 Methodenaufrufe
Obwohl Ihnen eine gründliche Behandlung der Methoden noch bevorsteht, haben Sie doch schon
einige Erfahrung mit diesen Handlungskompetenzen von Klassen bzw. Objekten gewonnen:




Die Arbeitsweise einer Methode kann von Argumenten (Parametern) abhängen.
Viele Methoden liefern ein Ergebnis an den Aufrufer. Die in Abschnitt 3.4.1 vorgestellte
Methode Convert.ToInt32() liefert z.B. einen int-Wert, sofern die als Parameter übergebene Zeichenfolge als ganze Zahl im int-Wertebereich (siehe Tabelle in Abschnitt 3.3.3) interpretierbar ist. Bei der Methodendefinition ist der Datentyp der Rückgabe anzugeben (siehe
Syntaxdiagramm in Abschnitt 3.1.2.2).
Liefert eine Methode dem Aufrufer kein Ergebnis, ist in der Definition der PseudoRückgabetyp void anzugeben.
Neben dem Rückgabewert hat ein Methodenaufruf oft weitere Effekte, z.B. auf die Merkmalsausprägungen des handelnden Objekts oder auf die Konsolenausgabe.
In syntaktischer Hinsicht stellen wir fest, dass ein Methodenaufruf einen Ausdruck darstellt, wobei
seine Rückgabe den Datentyp und den Wert des Ausdrucks bestimmt. Bei passendem Rückgabetyp
darf ein Methodenaufruf auch als Argument für komplexere Ausdrücke oder für Methodenaufrufe
verwendet werden (siehe Abschnitt 4.3.1.2). Bei einer Methode ohne Rückgabewert resultiert ein
Ausdruck vom Typ void, der nicht als Argument für Operatoren oder andere Methoden taugt.
Ein Methodenaufruf mit angehängtem Semikolon stellt eine Anweisung dar (vgl. Abschnitt 3.7),
wie Sie aus den zahlreichen Einsätzen der Methode Console.WriteLine() in unseren Beispielprogrammen bereits wissen.
Mit den arithmetischen Operatoren lassen sich nur elementare mathematische Probleme lösen. Darüber hinaus stellt das .NET - Framework eine große Zahl mathematischer Standardfunktionen (z.B.
Potenzfunktion, Logarithmus, Wurzel, trigonometrische Funktionen) über Methoden der Klasse
Math im Namensraum System zur Verfügung (siehe FCL-Dokumentation). Im folgenden Programm wird die Methode Pow() zur Berechnung der allgemeinen Potenzfunktion ( b e ) genutzt:
Quellcode
Ausgabe
using System;
class Prog{
static void Main(){
Console.WriteLine(Math.Pow(2.0, 3.0));
}
}
8
Alle Math-Methoden sind als static definiert, werden also von der Klasse selbst ausgeführt.
Im Beispielprogramm liefert die Methode Math.Pow() einen Rückgabewert vom Typ double, der
gleich als Argument der Methode Console.WriteLine() Verwendung findet. Solche Verschachtelungen sind bei Programmierern wegen ihrer Kompaktheit ähnlich beliebt wie die Inkrement- bzw.
Dekrementoperatoren. Ein etwas umständliches, aber für Anfänger leichter verständliches Äquivalent zum obigen WriteLine()-Aufruf könnte z.B. lauten:
double d;
d = Math.Pow(2.0, 3.0);
Console.WriteLine(d);
Kapitel 3: Elementare Sprachelemente
94
3.5.3 Vergleichsoperatoren
Durch Anwendung eines Vergleichsoperators auf zwei komparable (miteinander vergleichbare)
Argumentausdrücke entsteht ein Vergleich. Dies ist ein einfacher logischer Ausdruck (vgl. Abschnitt 3.5.5), kann dementsprechend die booleschen Werte true (wahr) und false (falsch) annehmen und eignet sich dazu, eine Bedingung zu formulieren, z.B.:
if (arg > 0)
Console.WriteLine(Math.Log(arg));
In der folgenden Tabelle mit den von C# unterstützten Vergleichsoperatoren stehen


Expr1 und Expr2 für komparable Ausdrücke
Num1 und Num2 für numerische Ausdrücke
Operator
Expr1
Expr1
Num1
Num1
Num1
Num1
= = Expr2
!= Expr2
> Num2
< Num2
>= Num2
<= Num2
Bedeutung
Gleichheit
Ungleichheit
größer
kleiner
größer oder gleich
kleiner oder gleich
Beispiel
Programmfragment
Console.WriteLine(2 == 3);
Console.WriteLine(2 != 3);
Console.WriteLine(3 > 2);
Console.WriteLine(3 < 2);
Console.WriteLine(3 >= 3);
Console.WriteLine(3 <= 2);
Ausgabe
False
True
True
False
True
False
Achten Sie unbedingt darauf, dass der Identitätsoperator durch zwei „=“-Zeichen ausgedrückt
wird. Ein nicht ganz seltener C# - Programmierfehler besteht darin, beim Identitätsoperator das
zweite Gleichheitszeichen zu vergessen. Dabei muss nicht unbedingt ein harmloser Syntaxfehler
entstehen, der nach dem Studium einer Compiler-Meldung leicht zu beseitigen ist, sondern es kann
auch ein mehr oder weniger unangenehmer Semantikfehler resultieren, also ein irreguläres Verhalten des Programms (vgl. Abschnitt 2.1.4 zur Unterscheidung von Syntax- und Semantikfehlern). Im
ersten WriteLine()-Aufruf des folgenden Programms wird das Ergebnis eines Vergleichs auf die
Konsole geschrieben: 1
Quellcode
Ausgabe
using System;
class Prog{
static void Main(){
int i = 1;
Console.WriteLine(i == 2);
Console.WriteLine(i);
}
}
False
1
Durch Weglassen eines Gleichheitszeichens wird aus dem Vergleich jedoch ein Wertzuweisungsausdruck (siehe Abschnitt 3.5.8) mit dem Typ int und dem Wert 2:
Quellcode
Ausgabe
using System;
class Prog{
static void Main(){
int i = 1;
Console.WriteLine(i = 2);
Console.WriteLine(i);
}
}
2
2
1
Wir wissen schon aus Abschnitt 3.2.1, dass WriteLine() einen beliebigen Ausdruck verarbeiten kann, wobei automatisch eine Zeichenfolgen-Repräsentation erstellt wird.
95
Abschnitt 3.5 Operatoren und Ausdrücke
Die versehentlich entstandene Zuweisung sorgt nicht nur für eine unerwartete Ausgabe, sondern
verändert natürlich auch den Wert der Variablen i, was im weiteren Verlauf eines größeren Programms recht unangenehm werden kann.
3.5.4 Vertiefung: Gleitkommawerte vergleichen
Bei den binären Gleitkommatypen (float und double) muss man beim Identitätstest unbedingt
technisch bedingte Abweichungen von der reinen Mathematik berücksichtigen, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
const double USD = 1.0e-14; //Unterschieds-Schwelle Double
double d1 = 10.0 - 9.9;
double d2 = 0.1;
Console.WriteLine(d1 == d2);
Console.WriteLine(10.0m - 9.9m == 0.1m);
Console.WriteLine(Math.Abs((d1 - d2)/d1) < USD);
}
}
False
True
True
Der naive Vergleich
10.0 - 9.9 == 0.1
führt trotz Datentyp double (mit mindestens 15 signifikanten Dezimalstellen) zum Ergebnis false. Wenn man die in Abschnitt 3.3.4.1 beschriebenen Genauigkeitsprobleme bei der Speicherung
von binären Gleitkommazahlen berücksichtigt, ist das Vergleichsergebnis durchaus nicht überraschend.
Für Anwendungen im Bereich der Finanzmathematik wurde schon in Abschnitt 3.3.4 der dezimale
Gleitkommadatentyp decimal vorgeschlagen. Hier sind bei der Speicherung 28 signifikante Dezimalstellen garantiert, und Berechnungen erfolgen mit einer Genauigkeit von bis zu 28 Dezimalstellen. Mit der dezimalen Gleitkommaarithmetik und -speichertechnik resultiert beim Vergleich
10.0m - 9.9m == 0.1m
das korrekte Ergebnis True. Allerdings eignen sich decimal-Variablen wegen ihres relativ kleinen
Wertebereichs nicht für alle Anwendungen.
Sollen double-Werte mit möglichst großer Präzision verglichen werden, kann eine an der Rechenbzw. Speichergenauigkeit orientierte Unterschiedlichkeitsschwelle verwendet werden. Nach diesem Vorschlag werden zwei normalisierte (also insbesondere von Null verschiedene) double-Werte
d1 und d2 dann als numerisch identisch betrachtet, wenn der relative Abweichungsbetrag kleiner als
1,010-14 ist:
d1  d 2
 1,0  10 14
d1
Die Wahl der Bezugsgröße d1 oder d2 für den Nenner ist beliebig. Um das Verfahren vollständig
festzulegen, wird die Verwendung der betragsmäßig größeren Zahl vorgeschlagen.
Ein Begriff der numerischen Identität muss die relative Differenz zugrunde legen, weil die technisch bedingten Mantissen-Fehler bei zwei double-Variablen mit eigentlich identischem Wert in
Abhängigkeit vom Exponenten zu sehr unterschiedlichen Gesamtfehlern führen können. Vom häufig anzutreffenden Vorschlag, d1  d 2 mit einer Schwelle zu vergleichen, ist daher abzuraten. Die-
Kapitel 3: Elementare Sprachelemente
96
ses Verfahren ist (bei geeignet gewählter Schwelle) nur tauglich für Zahlen in einem engen Größenbereich. Bei einer Änderung der Größenordnung muss die Schwelle angepasst werden.
d1  d 2
gelangt man durch Betrachtung von zwei
d1
double-Variablen d1 und d2, die bis auf ihre durch begrenzte Speicher- und Rechengenauigkeit bedingten Mantissenfehler e1 bzw. e2 denselben Wert enthalten:
Zu einer Schwelle für die relative Abweichung
d1 = (t + e1) 10k und d1 = (t + e2) 10k
Für den Betrag des technisch bedingten relativen Fehlers gilt bei normalisierten Werten (mit einer
Mantisse im Intervall [1, 2)) mit der oberen Schranke  für den absoluten Mantissenfehler einer
einzelnen double-Zahl die Abschätzung:
e  e2
d1  d 2
e e
2
 1 2  1

 2   ( wegen t  e1  1)
d1
t  e1
t  e1
t  e1
Bei normalisierten double-Werten (mit 52 Mantissen-Bits) ist aufgrund der begrenzten Speichergenauigkeit mit Fehlern im Bereich des Abstands zwischen zwei benachbarten Mantissenwerten zu
rechnen:
2 52  2,2  10-16
Die vorgeschlagene Schwelle 1,010-14 berücksichtigt über den Speicherfehler hinaus auch eingeflossene Rechnungsungenauigkeiten. Mit welcher Fehlerkumulation bzw. -verstärkung zu rechnen
ist, hängt vom konkreten Algorithmus ab, so dass die Unterschiedlichkeitsschwelle eventuell angehoben werden muss. Immerhin hängt sie (anders als bei einem Kriterium auf Basis der einfachen
Differenz d1  d 2 ) nicht von der Größenordnung der Zahlen ab.
An der vorgeschlagenen Identitätsprüfung mit Hilfe einer Schwelle für den relativen Abweichungsbetrag ist u.a. zu bemängeln, …



dass noch Feinarbeit erforderlich ist, um eine Division durch Null zu verhindern,
dass eine Verallgemeinerung für die mit geringerer Genauigkeit gespeicherten denormalisierte Werte (Betrag kleiner als 2-1022 beim Typ double, siehe Abschnitt 3.3.4.1) benötigt
wird,
dass die definierte Indifferenzrelation nicht transitiv ist.
Die besprochenen Genauigkeitsprobleme sind auch bei den Grenzfällen von einseitigen Vergleichen
(<, <=, >, >=) relevant.
Bei vielen naturwissenschaftlichen oder technischen Problemen ist es generell wenig sinnvoll, zwei
Größen auf exakte Übereinstimmung zu testen, weil z.B. schon aufgrund von Messungenauigkeiten
eine Abweichung von der theoretischen Identität zu erwarten ist. Bei Verwendung einer anwendungslogisch gebotenen Unterschiedschwelle dürften die technischen Beschränkungen der Gleitkommatypen keine große Rolle mehr spielen. Präzisere Aussagen zur Computer-Arithmetik finden
sich z.B. bei Müller (2004) oder Strey (2003).
3.5.5 Logische Operatoren
Durch Anwendung der logischen Operatoren auf bereits vorhandene logische Ausdrücke kann man
neue, komplexere logische Ausdrücke erstellen. Die Wirkungsweise der logischen Operatoren wird
in Wahrheitstafeln beschrieben (La1 und La2 seien logische Ausdrücke):
97
Abschnitt 3.5 Operatoren und Ausdrücke
Argument
Negation
La1
!La1
true
false
false
true
Argument 1
Argument 2
Logisches UND
Logisches ODER Exklusives ODER
La1
La2
La1 && La2
La1 & La2
La1 || La2
La1 | La2
La1 ^ La2
true
true
false
false
true
false
true
false
true
false
false
false
true
true
true
false
false
true
true
false
In der folgenden Tabelle gibt es noch wichtige Erläuterungen und Beispiele:
Operator
!La1
La1 && La2
La1 & La2
La1 || La2
La1 | La2
La1 ^ La2
Bedeutung
Negation
Der Wahrheitswert wird umgekehrt.
Logisches UND (mit bedingter
Auswertung)
La1 && La2 ist genau dann wahr,
wenn beide Argumente wahr sind.
Ist La1 falsch, wird La2 nicht ausgewertet.
Logisches UND (mit unbedingter
Auswertung)
La1 & La2 ist genau dann wahr,
wenn beide Argumente wahr sind.
Es werden auf jeden Fall beide
Ausdrücke ausgewertet.
Logisches ODER (mit bedingter
Auswertung)
La1 || La2 ist genau dann wahr,
wenn mindestens ein Argument
wahr ist. Ist La1 wahr, wird La2
nicht ausgewertet.
Logisches ODER (mit unbedingter Auswertung)
La1 | La2 ist genau dann wahr,
wenn mindestens ein Argument
wahr ist. Es werden auf jeden Fall
beide Ausdrücke ausgewertet.
Exklusives logisches ODER
La1 ^ La2 ist genau dann wahr,
wenn genau ein Argument wahr
ist, wenn also die Argumente verschiedene Wahrheitswerte haben.
Beispiel
Programmfragment
bool erg = true;
Console.WriteLine(!erg);
Ausgabe
False
int i = 3;
False
bool erg = false && i++ > 3;
3
Console.WriteLine(erg + "\n" + i);
erg = true && i++ > 3;
False
Console.WriteLine(erg + "\n" + i); 4
int i = 3;
False
bool erg = false & i++ > 3;
4
Console.WriteLine(erg + "\n" + i);
int i = 3;
True
bool erg = true || i++ == 3;
3
Console.WriteLine(erg + "\n" + i);
erg = false || i++ == 3;
True
Console.WriteLine(erg + "\n" + i); 4
int i = 3;
True
bool erg = true | i++ > 3;
4
Console.WriteLine(erg + "\n" + i);
Console.WriteLine(true ^ true);
False
Der Unterschied zwischen den beiden UND-Operatoren && und & bzw. zwischen den beiden
ODER-Operatoren || und | ist für Einsteiger vielleicht etwas unklar, weil man spontan den nicht
ausgewerteten logischen Ausdrücken keine Bedeutung beimisst. Allerdings ist es in C# nicht ungewöhnlich, „Nebeneffekte“ in einen logischen Ausdruck einzubauen, z.B.:
Kapitel 3: Elementare Sprachelemente
98
a == b & i++ > 3
Hier erhöht der Postinkrementoperator beim Auswerten des rechten UND-Arguments den Wert der
Variablen i. Eine solche Auswertung wird jedoch in der folgenden Variante des Beispiels unterlassen, wenn bereits nach Auswertung des linken UND-Arguments das Gesamtergebnis false feststeht:
a == b && i++ > 3
Mit der Entscheidung, grundsätzlich die unbedingte Operatorvariante zu verwenden, nimmt man
(mehr oder weniger relevante) Leistungseinbußen in Kauf. Eher empfehlenswert ist der Verzicht
auf Nebeneffekt-Konstruktionen im Zusammenhang mit bedingt arbeitenden Operatoren.
Wie der Tabelle auf Seite 105 zu entnehmen ist, unterscheiden sich die beiden UND-Operatoren
&& und & bzw. die beiden ODER-Operatoren || und | auch hinsichtlich der Auswertungspriorität.
Um die Verwirrung noch ein wenig zu steigern, werden die Zeichen & und | auch für bitorientierte
Operatoren verwendet (siehe Abschnitt 3.5.6). Weil diese Operatoren zwei integrale Argumente
(z.B. Datentyp int) erwarten, kann der Compiler allerdings mühelos erkennen, ob ein logischer oder
ein bitorientierter Operator gemeint ist.
3.5.6 Vertiefung: Bitorientierte Operatoren
Über unseren momentanen Bedarf hinausgehend bietet C# einige Operatoren zur bitweisen Manipulation von Variableninhalten. Statt einer systematischen Darstellung der verschiedenen Operatoren
beschränken wir uns auf ein Beispielprogramm, das zudem nützliche Einblicke in die Speicherung
von char-Daten im Computerspeicher vermitteln kann. Allerdings sind Beispiel und zugehörige
Erläuterungen mit einigen technischen Details belastet. Wenn Ihnen der Sinn momentan nicht danach steht, können Sie den aktuellen Abschnitt ohne Sorge um den weiteren Kurserfolg an dieser
Stelle verlassen.
Das Programm CBit liefert die Unicode-Kodierung zu einem vom Benutzer erfragten Zeichen. Dabei kommt die statische Methode ToChar() der Klasse Convert aus dem Namensraum System
zum Einsatz. Außerdem kommt mit der for-Schleife eine Wiederholungsanweisung zum Einsatz,
die erst in Abschnitt 3.7.3.1 offiziell vorgestellt wird. Im Beispiel startet die Indexvariable i mit
dem Wert 15, der am Ende jedes Schleifendurchgangs um Eins dekrementiert wird (i--). Ob es
zum nächsten Schleifendurchgang kommt, hängt von der Fortsetzungsbedingung ab (i >= 0):
Quellcode
Ausgabe
using System;
class CharBits {
static void Main() {
char cbit;
Console.Write("Zeichen: ");
cbit = Convert.ToChar(Console.ReadLine());
Console.Write("Unicode: ");
for (int i = 15; i >= 0; i--) {
if ((1 << i & cbit) != 0)
Console.Write('1');
else
Console.Write('0');
}
}
}
Zeichen: x
Unicode: 0000000001111000
Der Links-Shift-Operator << im Ausdruck:
1 << i
verschiebt die Bits in der binären Repräsentation der Ganzzahl Eins um i Stellen nach links. Von
den 32 Bit, die ein int-Wert insgesamt belegt (siehe Abschnitt 3.3.3), interessieren im Augenblick
nur die rechten 16. Bei der Eins erhalten wir:
Abschnitt 3.5 Operatoren und Ausdrücke
99
0000000000000001
Im 10. Schleifendurchgang (i = 6) geht dieses Muster z.B. über in:
0000000001000000
Nach dem Links-Shift- kommt der bitweise UND-Operator zum Einsatz:
1 << i & cbit
Das Operatorzeichen & wird leider in doppelter Bedeutung verwendet: Wenn beide Argumente
vom Typ bool sind, wird & als logischer Operator interpretiert (siehe Abschnitt 3.5.5). Sind jedoch
(wie im vorliegenden Fall) beide Argumente von integralem Typ, was auch für den Typ char zutrifft, dann wird & als UND-Operator für Bits aufgefasst. Er erzeugt dann ein Bitmuster, das genau
dann an der Stelle i eine Eins enthält, wenn beide Argumentmuster an dieser Stelle eine Eins besitzen. Bei cbit = 'x' ist das Unicode-Bitmuster
0000000001111000
beteiligt, und 1 << i & cbit liefert z.B. bei i = 6 das Muster:
0000000001000000
Das von 1 << i & cbit erzeugte Bitmuster hat den Typ int und kann daher mit der Null verglichen werden:
(1 << i & cbit) != 0
Dieser logische Ausdruck wird im i-ten Schleifendurchgang genau dann wahr, wenn das korrespondierende Bit in der Binärdarstellung des untersuchten Zeichens den Wert Eins hat.
3.5.7 Typumwandlung (Casting) bei elementaren Datentypen
Beim Auswerten des Ausdrucks
2.3/7
trifft der Divisionsoperator auf ein double- und ein int-Argument, so dass sich der Compiler zwischen der Ganzzahl- und der Gleitkommaarithmetik entscheiden muss. Er wählt die zweite Alternative und nimmt für das int-Argument automatisch eine Wandlung in den Datentyp double vor.
In vergleichbaren Situationen kommt es automatisch zu den folgenden erweiternden Konvertierungen:
Der Typ …
sbyte
byte
short
ushort
int
uint
long
char
float
ulong
wird nach Bedarf automatisch konvertiert in:
short, int, long, float, double, decimal
short, ushort, int, uint, long, ulong, float, double, decimal
int, long, float, double, decimal
int, uint, long, ulong, float, double, decimal
long, float, double, decimal
long, ulong, float, double, decimal
float, double, decimal
ushort, int, uint, long, ulong, float, double, decimal
double
float, double, decimal
Bei den Konvertierungen von int, uint oder long in float sowie von long in double kann es zu einem Verlust an Genauigkeit kommen, z.B.:
100
Kapitel 3: Elementare Sprachelemente
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
long i = 9223372036854775313;
double d = i;
Console.WriteLine(i);
Console.WriteLine("{0,20:f2}", d);
}
}
9223372036854775313
9223372036854780000,00
Eine Abweichung von 4687 (z.B. Euro oder Meter) kann durchaus Proteste von Kunden oder
Schlimmeres zur Folge haben.
Weil eine char-Variable die Unicode-Nummer eines Zeichens speichert, macht die Konvertierung
in numerische Typen kein Problem, z.B.:
Quellcode
Ausgabe
class Prog {
static void Main() {
System.Console.WriteLine("x/2 \t= " + 'x' / 2);
System.Console.WriteLine("x*0,27 \t= " + 'x' * 0.27);
}
}
x/2
x*0,27
= 60
= 32,4
Gelegentlich gibt es gute Gründe, über den Casting-Operator eine explizite Typumwandlung zu
erzwingen. Im folgenden Programm wird z.B. mit
(int)'x'
die int-erpretation des (aus Abschnitt 3.5.6 bekannten) Bitmusters zum kleinen „x“ ausgegeben,
damit Sie nachvollziehen können, warum das letzte Programm beim „Halbieren“ dieses Zeichens
auf den Wert 60 kam:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
Console.WriteLine((int)'x');
double a = 3.14159;
Console.WriteLine((int)a);
a = 1.45367e50;
Console.WriteLine((int)a);
}
}
120
3
-2147483648
In der zweiten Ausgabeanweisung des Beispielprogramms wird per Casting-Operation der ganzzahlige Anteil eines double-Wertes ermittelt, was im Programmieralltag gelegentlich sinnvoll ist. Wie
die dritte Ausgabe zeigt, sind bei einer explizit angeforderten einschränkenden Konvertierung
kapitale Programmierfehler möglich, wenn die Wertebereiche der beteiligten Variablen bzw. Datentypen nicht beachtet werden. So soll die Explosion der europäischen Weltraumrakete Ariane-5 am
4. Juni 1996 (Schaden: ca. 500 Millionen Dollar)
101
Abschnitt 3.5 Operatoren und Ausdrücke
durch die Konvertierung eines double-Werts (mögliches Maximum: 1,797693134862315710308) in
einen short-Wert (mögliches Maximum: 215-1 = 32767) verursacht worden sein.
Die C# - Syntax zur expliziten Typumwandlung:
Typumwandlungs-Operator
(
Typ
)
Ausdruck
3.5.8 Zuweisungsoperatoren
Bei den ersten Erläuterungen zur Wertzuweisung (vgl. Abschnitt 3.3.5) blieb aus didaktischen
Gründen unerwähnt, dass eine Wertzuweisung einen Ausdruck darstellt, dass wir es also mit dem
binären (zweistelligen) Operator „=“ zu tun haben, für den folgende Regeln gelten:



Auf der linken Seite muss eine Variable oder eine Eigenschaft stehen.
Auf der rechten Seite muss ein Ausdruck mit kompatiblem Typ stehen.
Der zugewiesene Wert stellt auch den Ergebniswert des Ausdrucks dar.
Wie beim Inkrement- bzw. Dekrementoperators sind auch beim Zuweisungsoperator zwei Effekte
zu unterscheiden:


Die als linkes Argument fungierende Variable oder Eigenschaft erhält einen neuen Wert.
Es wird ein Wert für den Ausdruck produziert.
In folgendem Beispiel fungiert ein Zuweisungsausdruck als Parameter für einen WriteLine()-Methodenaufruf:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
int ivar = 13;
Console.WriteLine(ivar = 4711);
Console.WriteLine(ivar);
}
}
4711
4711
Beim Auswerten des Ausdrucks ivar = 4711 entsteht der an WriteLine() zu übergebende
Wert, und die Variable ivar wird verändert.
Selbstverständlich kann eine Zuweisung auch als Operand in einen übergeordneten Ausdruck integriert werden, z.B.:
Kapitel 3: Elementare Sprachelemente
102
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
int i = 2, j = 4;
i = j = j * i;
Console.WriteLine(i + "\n" + j);
}
}
8
8
Beim mehrfachen Auftreten des Zuweisungsoperators erfolgt eine Abarbeitung von rechts nach
links (vgl. Tabelle in Abschnitt 3.5.10), so dass die Anweisung
i = j = j * i;
folgendermaßen ausgeführt wird:



Weil der Multiplikationsoperator eine höhere Priorität besitzt als der Zuweisungsoperator,
wird zuerst der Ausdruck j * i ausgewertet, was zum Zwischenergebnis 8 (mit Datentyp
int) führt.
Nun wird die rechte Zuweisung ausgeführt. Der folgende Ausdruck mit Wert 8 und Typ int
j = 8
verschafft der Variablen j einen neuen Wert.
In der zweiten Zuweisung (bei Betrachtung von rechts nach links) wird der Wert des Ausdrucks j = 8 an die Variable i übergeben.
Ausdrücke der Art
i = j = k;
stammen übrigens nicht aus einem Kuriositätenkabinett, sondern sind in C# - Programmen oft anzutreffen.
Wie wir seit Abschnitt 3.3.5 wissen, stellt ein Zuweisungsausdruck bereits eine vollständige Anweisung dar, sobald man ein Semikolon dahinter setzt. Dies gilt auch für die die Prä- und Postinkrementausdrücke (vgl. Abschnitt 3.5.1) sowie für Methodenaufrufe, jedoch nicht für die anderen
Ausdrücke, die in Abschnitt 3.5 vorgestellt werden.
Für die häufig benötigten Zuweisungen nach dem Muster
j = j * i;
(eine Variable erhält einen neuen Wert, an dessen Konstruktion sie selbst mitwirkt), bietet C# spezielle Zuweisungsoperatoren für Schreibfaule, die gelegentlich auch als Aktualisierungsoperatoren bezeichnet werden. In der folgenden Tabelle steht Var für eine numerische Variable und Expr
für einen typkompatiblen Ausdruck:
103
Abschnitt 3.5 Operatoren und Ausdrücke
Operator
Beispiel
Programmfragment
Neuer Wert von i
Bedeutung
Var erhält den neuen Wert
Var + Expr.
Var -= Expr Var erhält den neuen Wert
Var - Expr.
Var *= Expr Var erhält den neuen Wert
Var * Expr.
Var /= Expr Var erhält den neuen Wert
Var / Expr.
Var %= Expr Var erhält den neuen Wert
Var % Expr.
Var += Expr
int i = 2;
i += 3;
5
int i = 10, j = 3;
i -= j * j;
1
int i = 2;
i *= 5;
10
int i = 10;
i /= 5;
2
int i = 10;
i %= 5;
0
Während für zwei byte-Variablen
byte b1 = 1, b2 = 2;
die folgende Zuweisung
b1 = b1 + b2;
verboten ist, weil der Ausdruck (b1 + b2) den Typ int besitzt, akzeptiert der Compiler den
äquivalenten Ausdruck mit Aktualisierungsoperator:
b1 += b2;
3.5.9 Konditionaloperator
Der Konditionaloperator erlaubt eine sehr kompakte Schreibweise, wenn beim neuen Wert einer
Zielvariablen bedingungsabhängig zwischen zwei Ausdrücken zu entscheiden ist, z.B.
i  j falls k  0
i
sonst
i  j
In C# ist für diese Zuweisung mit Fallunterscheidung nur eine einzige Zeile erforderlich:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
int i = 2, j = 1, k = 7;
i = (k>0) ? i+j : i-j;
Console.WriteLine(i);
}
}
3
Eine Besonderheit des Konditionaloperators besteht darin, dass er drei Argumente verarbeitet, welche durch die Zeichen ? und : getrennt werden:
Konditionaloperator
Logischer Ausdruck
?
Ausdruck 1
:
Ausdruck 2
Ist der logische Ausdruck wahr, liefert der Konditionaloperator den Wert von Ausdruck 1, anderenfalls den Wert von Ausdruck 2.
Kapitel 3: Elementare Sprachelemente
104
3.5.10 Auswertungsreihenfolge
Bisher haben wir Ausdrücke mit mehreren Operatoren und das damit verbundene Problem der
Auswertungsreihenfolge nach Möglichkeit gemieden. Nun werden die Regeln vorgestellt, nach denen der C# - Compiler komplexe Ausdrücke mit mehreren Operatoren auswertet:
1) Priorität
Zunächst entscheidet die Priorität der Operatoren (siehe Tabelle unten) darüber, in welcher Reihenfolge die Auswertung vorgenommen wird. Z.B. hält sich C# bei arithmetischen Ausdrücken
an die mathematische Regel
Punktrechnung geht vor Strichrechnung
2) Assoziativität (Auswertungsrichtung)
Steht ein Argument zwischen zwei Operatoren mit gleicher Priorität, dann entscheidet die Assoziativität der Operatoren über die Reihenfolge der Auswertung (vgl. ECMA 2006, S. 151):
 Mit Ausnahme der Zuweisungsoperatoren sind alle binären Operatoren links-assoziativ; sie
werden also von links nach rechts ausgewertet. Z.B. wird
x – y – z
ausgewertet als
(x – y) – z
 Die Zuweisungsoperatoren und der Konditionaloperator sind rechts-assoziativ; sie werden
also von rechts nach links ausgewertet. Z.B. wird
x = y = z
ausgewertet als
x = (y = z)
Für manche Operationen gilt das mathematische Assoziativitätsgesetz, so dass die Reihenfolge
der Auswertung irrelevant ist, z.B.:
(3 + 2) + 1 = 6 = 3 + (2 + 1)
Anderen Operationen fehlt diese nette Eigenschaft, z.B.:
(3 – 2) – 1 = 0  3 – (2 – 1) = 2
3) Klammern
Wenn aus obigen Regeln nicht die gewünschte Auswertungsfolge resultiert, greift man mit runden Klammern steuernd ein. Die Auswertung von (eventuell mehrstufig) eingeklammerten Teilausdrücken erfolgt von innen nach außen.
In der folgenden Tabelle sind die bisher behandelten Operatoren in absteigender Priorität aufgelistet. Gruppen von Operatoren mit gleicher Priorität sind durch fette horizontale Linien voneinander
abgegrenzt. In der Operanden-Spalte werden die zulässigen Datentypen der Argumentausdrücke
mit Hilfe der folgenden Platzhalter beschrieben:
N
I
L
B
S
V
Vn
Ausdruck mit numerischem Datentyp
Ausdruck mit ganzzahligem (integralem) Datentyp
logischer Ausdruck
Ausdruck mit beliebigem kompatiblem Datentyp
String (Zeichenfolge)
Variable mit beliebigem kompatiblem Datentyp
Variable mit numerischem Datentyp
105
Abschnitt 3.5 Operatoren und Ausdrücke
Operator
Bedeutung
Operanden
++, --
Postinkrement bzw. -dekrement
Vn
-
Vorzeichenumkehr
N
!
Negation
L
++, --
Präinkrement bzw. -dekrement
Vn
(Typ)
Typumwandlung
B
*, /
Punktrechnung
N, N
%
Modulo (Divisionsrest)
N, N
+, -
Strichrechnung
N, N
+
Stringverkettung
S, B oder B, S
<<, >>
Links- bzw. Rechts-Shift
I, I
>, <, >=, <=
Vergleichsoperatoren
N, N
==, !=
Gleichheit, Ungleichheit
B, B
&
Bitweises UND
I, I
&
Logisches UND (mit unbedingter Auswertung)
L, L
^
Exklusives logisches ODER
L, L
|
Bitweises ODER
I, I
|
Logisches ODER (mit unbedingter Auswertung)
L, L
&&
Logisches UND (mit bedingter Auswertung)
L, L
||
Logisches ODER (mit bedingter Auswertung)
L, L
?:
Konditionaloperator
L, B, B
=
Wertzuweisung
V, B
+=, -=, *=, /=, %= Wertzuweisung mit Aktualisierung
Vn, N
Im Anhang finden Sie eine erweiterte Version dieser Tabelle, die zusätzlich alle Operatoren enthält,
die im weiteren Verlauf des Kurses noch behandelt werden.
3.5.11 Übungsaufgaben zu Abschnitt 3.5
1) Welche Werte und Datentypen besitzen die folgenden Ausdrücke?
6/4*2.0
(int)6/4.0*3
(int)(6/4.0*3)
3*5+8/3%4*5
Kapitel 3: Elementare Sprachelemente
106
2) Welche Werte haben die Variablen erg1 und erg2 am Ende des folgenden Programms?
using System;
class Prog {
static void Main() {
int i = 2, j = 3, erg1, erg2;
erg1 = (i++ == j ? 7 : 8) % 3;
erg2 = (++i == j ? 7 : 8) % 2;
Console.WriteLine("erg1 = {0}\nerg2 = {1}", erg1, erg2);
}
}
3) Welche Wahrheitsweite erhalten in folgendem Programm die booleschen Variablen la1 bis
la3?
using System;
class Prog {
static void Main() {
bool la1, la2, la3;
int i = 3;
char c = 'n';
la1 = (3 > 2) && (2 == 2) ^ (1 == 1);
Console.WriteLine(la1);
la2 = ((2 > 3) && (2 == 2)) ^ (1 == 1);
Console.WriteLine(la2);
la3 = !(i > 0 || c == 'j');
Console.WriteLine(la3);
}
}
Tipp: Die Negation von zusammengesetzten Ausdrücken ist etwas unangenehm. Mit Hilfe der Regeln von DeMorgan kommt man zu äquivalenten Ausdrücken, die leichter zu interpretieren sind:
!(la1 && la2)
!(la1 || la2)
=
=
!la1 || !la2
!la1 && !la2
4) Erstellen Sie ein Programm, das den Exponentialfunktionswert ex zu einer vom Benutzer eingegebenen Zahl x bestimmt und ausgibt, z.B.:
Eingabe: Argument: 1
Ausgabe: exp(1) = 2,71828182845905
Hinweise:


Suchen Sie mit Hilfe der FCL-Dokumentation zur Klasse Math im Namensraum System
eine passende Methode.
Verwenden Sie zum Einlesen des Argumentes eine Variante der in Abschnitt 3.4 beschriebenen Technik, wobei die Convert-Methode ToInt32() zu ersetzen ist durch ToDouble().
5) Erstellen Sie ein Programm, das einen DM-Betrag entgegen nimmt und diesen in Euro konvertiert. In der Ausgabe sollen ganzzahlige, korrekt gerundete Werte für Euro und Cent erscheinen,
z.B.:
Eingabe: DM-Betrag: 321
Ausgabe: 164 Euro und 12 Cent
Umrechnungsfaktor: 1 Euro = 1,95583 DM
Abschnitt 3.6 Über- und Unterlauf bei numerischen Variablen
107
3.6 Über- und Unterlauf bei numerischen Variablen
Wie Sie inzwischen wissen, haben die numerischen Datentypen jeweils einen bestimmten Wertebereich (siehe Tabelle in Abschnitt 3.3.3). Dank strenger Typisierung kann der Compiler verhindern,
dass einer Variablen ein Ausdruck mit „zu großem Typ“ zugewiesen wird. So kann z.B. einer intVariablen kein Wert vom Typ long zugewiesen werden. Bei der Auswertung eines Ausdrucks kann
jedoch „unterwegs“ ein Wertebereichsproblem (z.B. ein Überlauf) auftreten. Im betroffenen Programm ist mit einem mehr oder weniger gravierenden Fehlverhalten zu rechnen, so dass Wertebereichsprobleme unbedingt vermieden bzw. rechtzeitig diagnostiziert werden müssen.
3.6.1 Überlauf bei Ganzzahltypen
Ohne besondere Vorkehrungen stellt ein C# - Programm im Fall eines Ganzzahl-Überlaufs keinesfalls seine Tätigkeit ein (z.B. mit einem Ausnahmefehler), sondern arbeitet munter weiter. Dieses
Verhalten ist beim Programmieren von Pseudozufallszahlgeneratoren willkommen, ansonsten aber
eher bedenklich. Das folgende Programm
using System;
class Prog {
static void Main() {
int i = 2147483647, j = 5, k;
k = i + j;
// Überlauf!
Console.WriteLine(i + " + " + j + " = " + k);
}
}
liefert ohne jede Warnung das fragwürdige Ergebnis:
2147483647 + 5 = -2147483644
Oft kann ein Überlauf durch Wahl eines geeigneten Datentyps verhindert werden. Mit den Deklarationen
long i = 2147483647, j = 5, k;
erhält man das korrekte Ergebnis, weil neben i, j und k nun auch der Ausdruck i+j den Typ long
hat:
2147483647 + 5 = 2147483652
Im Beispiel genügt es nicht, für die Zielvariable k den beschränkten Typ int durch long zu ersetzen,
weil der Überlauf beim Berechnen des Ausdrucks („unterwegs“) auftritt. Mit den Deklarationen
int i = 2147483647, j = 5;
long k;
bleibt das Ergebnis falsch, denn …



In der Anweisung
k = i + j;
wird zunächst der Ausdruck i+j berechnet.
Weil beide Operanden vom Typ int sind, erhält auch der Ausdruck diesen Typ, und die
Summe kann nicht korrekt berechnet bzw. zwischenspeichert werden.
Schließlich wird der long-Variablen k das falsche Ergebnis zugewiesen.
In C# steht im Unterschied zu vielen anderen Programmiersprachen mit dem checked-Operator
eine Möglichkeit bereit, den Überlauf bei Ganzzahlvariablen abzufangen. Eine gesicherte Variante
des ursprünglichen Beispielprogramms
Kapitel 3: Elementare Sprachelemente
108
using System;
class Prog {
static void Main() {
int i = 2147483647, j = 5, k;
k = checked(i + j);
Console.WriteLine(i + " + " + j + " = " + k);
}
}
rechnet nach einem Überlauf nicht mit „Zufallszahlen“ weiter, sondern bricht mit einem Ausnahmefehler ab:
Im Abschnitt über Ausnahmebehandlung werden Sie lernen, solche Situationen programmintern zu
beheben, so dass sie nicht mehr zum Abbruch des Programms führen.
An Stelle des checked-Operators bietet C# noch folgende Möglichkeiten, die Überlaufdiagnose für
Ganzzahltypen einzuschalten:

checked-Anweisung
Man kann die Überwachung für einen kompletten Anweisungsblock einschalten.
Beispiel:
checked {
. . .
k = i + j;
. . .
}

checked-Compileroption
Man kann die Überwachung für einen kompletten Compiler-Lauf einschalten.
Beispiel:
csc /checked Prog.cs
Für ein Projekt der Visual C# 2008 Express Edition vereinbart man diese Compiler-Option
folgendermaßen:
o Menübefehl Projekt > Eigenschaften
o Registerkarte Erstellen
o Schalter Erweitert
o Kontrollkästchen Auf arithmetischen Über-/Unterlauf überprüfen
Abschnitt 3.6 Über- und Unterlauf bei numerischen Variablen
109
Der Vollständigkeit halber soll an dieser Stelle noch der unchecked-Operator erwähnt werden, mit
dem sich die Typüberwachung des Compilers abschalten lässt, was für sehr spezielle Zwecke (vielleicht zum Programmieren von Pseudozufallszahlengeneratoren) durchaus einmal sinnvoll sein
kann. Im folgenden Beispiel ist in der ersten Wertzuweisung an die int-Variable die Überwachung
abgeschaltet, während der Zuweisungsversuch in der nächsten Zeile das normale Verhalten des
Compilers demonstriert.
3.6.2 Unendliche und undefinierte Werte bei den Typen float und double
Auch bei den binären Gleitkommatypen float und double kann ein Überlauf auftreten, obwohl der
unterstützte Wertebereich hier weit größer ist. Dabei kommt es aber weder zu einem sinnlosen Zufallswert noch zu einem Ausnahmefehler, sondern zu den speziellen Gleitkommawerten +/- Unendlich, mit denen anschließend sogar weitergerechnet werden kann. Das folgende Programm:
using System;
class Prog {
static void Main() {
double bigd = Double.MaxValue;
Console.WriteLine("Double.MaxValue =\t" + bigd);
bigd = Double.MaxValue * 10.0;
Console.WriteLine("Double.MaxValue * 10 =\t" + bigd);
Console.WriteLine("Unendl. + 10 =\t\t" + (bigd + 10));
Console.WriteLine("Unendl. * -13 =\t\t" + (bigd * -13));
Console.WriteLine("13.0/0.0 =\t\t" + (13.0 / 0.0) + "");
Console.ReadLine();
}
}
liefert die Ausgabe:
Double.MaxValue =
Double.MaxValue * 10 =
Unendl. + 10 =
Unendl. * -13 =
13.0/0.0 =
1,79769313486232E+308
+unendlich
+unendlich
-unendlich
+unendlich
Mit Hilfe der Unendlich-Werte „gelingt“ offenbar sogar die Division durch Null.
Bei diesen „Berechnungen“
Unendlich  Unendlich
Unendlich
Unendlich
Unendlich  0
0
0
resultiert der spezielle Gleitkommawert NaN (Not a Number), wie das folgende Programm zeigt:
Kapitel 3: Elementare Sprachelemente
110
using System;
class Prog {
static void Main() {
double bigd;
bigd = Double.MaxValue * 10.0;
Console.WriteLine("Unendlich – Unendlich =\t" + (bigd - bigd));
Console.WriteLine("Unendlich / Unendlich =\t" + (bigd / bigd));
Console.WriteLine("Unendlich * 0.0 =\t" + (bigd * 0.0));
Console.WriteLine("0.0 / 0.0 =\t\t" + (0.0 / 0.0));
}
}
Es liefert die Ausgabe:
Unendlich
Unendlich
Unendlich
0.0 / 0.0
- Unendlich = n. def.
/ Unendlich = n. def.
* 0.0 =
n. def.
=
n. def.
Zu den letzten Beispielprogrammen ist noch anzumerken, dass man über das öffentliche Feld
MaxValue der Struktur 1 Double aus dem Namensraum System den größten Wert in Erfahrung
bringt, der in einer double-Variablen gespeichert werden kann.
Über die statischen Double-Methoden



IsPositiveInfinity()
IsNegativeInfinity()
IsNaN()
lässt sich für eine double-Variable prüfen, ob sie einen unendlichen oder undefinierten Wert besitzt,
z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
double d = 0.0 / 0.0;
if (Double.IsNaN(d))
Console.WriteLine("0/0 ist NaNsense");
}
}
0/0 ist NaNsense
Die if-Anweisung (siehe Abschnitt 3.7.2) sorgt dafür, dass die Ausgabe nur unter einer bestimmten
Bedingung (Rückgabewert true von IsNaN()) erfolgt.
Für besonders neugierige Leser sollen abschließend noch die float-Darstellungen der speziellen
Gleitkommawerte angegeben werden (vgl. Abschnitt 3.3.4.1):
float-Darstellung
Vorz. Exponent
Mantisse
+unendlich
0
11111111 00000000000000000000000
-unendlich
1
11111111 00000000000000000000000
NaN
0
11111111 10000000000000000000000
Wert
1
Bei den später noch ausführlich zu behandelnden Strukturen handelt es sich um Werttypen mit starker Verwandtschaft zu den Klassen. Insbesondere wird sich zeigen, dass die elementaren Datentypen (z.B. double) auf Strukturtypen aus dem Namensraum System abgebildet werden (z.B. Double).
111
Abschnitt 3.6 Über- und Unterlauf bei numerischen Variablen
3.6.3 Überlauf beim Typ decimal
Beim Typ decimal wird im Fall eines Überlaufs nicht mit dem speziellen Wert Unendlich weiter
gearbeitet, sondern eine Ausnahme gemeldet, die unbehandelt zur Beendigung des Programms
führt. Im Unterschied zu den Ganzzahltypen muss die Überlaufdiagnose nicht per checked-Operator, -Anweisung oder -Compileroption angeordnet werden. Das Beispielprogramm
using System;
class Prog {
static void Main() {
decimal d = Decimal.MaxValue;
d = d * 10;
Console.WriteLine(d);
}
}
wird mit folgender Meldung abgebrochen:
3.6.4 Unterlauf bei den Gleitkommatypen
Bei den Gleitkommatypen float, double und decimal ist auch ein Unterlauf möglich, wobei eine
Zahl mit sehr kleinem Betrag (bei double: < 4,9406564584124710-324) nicht mehr dargestellt werden kann. In diesem Fall rechnet ein C# - Programm mit dem Wert 0,0 weiter, was in der Regel
akzeptabel ist, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
double smalld = Double.Epsilon;
Console.WriteLine(smalld);
smalld /= 10.0;
Console.WriteLine(smalld);
}
}
4,94065645841247E-324
0
Das öffentliche Feld Double.Epsilon enthält den betragsmäßig kleinsten Wert, der in einer doubleVariablen gespeichert werden kann (vgl. Abschnitt 3.3.4.1 zu denormalisierten Werten bei den binären Gleitkommatypen float und double.
Erfreulicherweise wird ein Unterlauf bei Zwischenergebnissen wirksam verhindert. Das folgende
Programm liefert für den Ausdruck a * 0.1 * b * 10.0 * c ein recht präzises Resultat,
obwohl im Zwischenergebnis a * 0.1 ein irreversibler Unterlauf zu befürchten war:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
double a = 1e-323;
double b = 1e308;
double c = 1e16;
Console.WriteLine(a*b*c);
Console.WriteLine(a * 0.1);
Console.WriteLine(a * 0.1 * b * 10.0 * c);
}
}
9.88131291682493
0
9,88131291682493
Kapitel 3: Elementare Sprachelemente
112
In Java 6.0 tritt der befürchtete Unterlauf beim Zwischenergebnis tatsächlich auf. Dort kann aber
mit Objekten der Klasse BigDecimal an Stelle von double-Werten wirksam Abhilfe geschaffen
werden.
3.7 Anweisungen (zur Ablaufsteuerung)
Wir haben uns in Abschnitt 3 zunächst mit (lokalen) Variablen und elementaren Datentypen vertraut gemacht. Dann haben wir gelernt, aus Variablen, Literalen und Methodenaufrufen mit Hilfe
von Operatoren mehr oder weniger komplexe Ausdrücke zu bilden. Diese wurden meist mit der
Console-Methode WriteLine() auf dem Bildschirm ausgegeben oder in Wertzuweisungen verwendet.
In den meisten Beispielprogrammen traten nur wenige Sorten von Anweisungen auf (Variablendeklarationen, Wertzuweisungen und Methodenaufrufe). Nun werden wir uns systematisch mit dem
allgemeinen Begriff einer C# - Anweisung befassen und vor allem die wichtigen Anweisungen zur
Ablaufsteuerung (Verzweigungen und Schleifen) kennen lernen.
3.7.1 Überblick
Ausführbare Programmteile, die in C# stets als Methoden von Klassen zu realisieren sind, bestehen
aus Anweisungen (engl. statements).
Am Ende von Abschnitt 3.7 werden Sie die folgenden Sorten von Anweisungen kennen:


Variablendeklarationsanweisung
Die Variablendeklarationsanweisung wurde schon in Abschnitt 3.3.5 eingeführt.
Beispiel: int i = 1, k;
Ausdrucksanweisungen
Folgende Ausdrücke werden zu Anweisungen, sobald man ein Semikolon dahinter setzt:
o Wertzuweisung (vgl. Abschnitte 3.3.5 und 3.5.8)
Beispiel: k = i + j;
o Prä- bzw. Postinkrement- oder -dekrementoperation
Beispiel: i++;
Hier ist nur der „Nebeneffekt“ des Ausdrucks i++ von Bedeutung. Sein Wert bleibt
ungenutzt.
o Methodenaufruf
Beispiel: Console.WriteLine(cond);
Besitzt die aufgerufene Methode einen Rückgabewert (siehe unten), wird dieser ignoriert.

Leere Anweisung
Beispiel: ;
Die durch ein einsames (nicht anderweitig eingebundenes) Semikolon ausgedrückte leere
Anweisung hat keinerlei Effekte und kommt gelegentlich zum Einsatz, wenn die Syntax eine Anweisung verlangt, aber nichts geschehen soll.

Blockanweisung
Eine Folge von Anweisungen, die durch geschweifte Klammern zusammengefasst bzw. abgegrenzt werden, bildet eine Verbund- bzw. Blockanweisung. Wir haben uns bereits in
Abschnitt 3.3.6 im Zusammenhang mit dem Deklarationsbereich von lokalen Variablen mit
Anweisungsblöcken beschäftigt. Wie gleich näher erläutert wird, fasst man z.B. dann mehrere Abweisungen zu einem Block zusammen, wenn diese Anweisungen unter einer gemeinsamen Bedingung ausgeführt werden sollen. Es wäre ja sehr unpraktisch, dieselbe Bedingung für jede betroffene Anweisung wiederholen zu müssen.
113
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung)

Anweisungen zur Ablaufsteuerung
Die Methoden der bisherigen Beispielprogramme in Abschnitt 3 bestanden meist aus einer
Sequenz von Anweisungen, die bei jedem Programmlauf komplett und linear durchlaufen
wurde:
Anweisung
Anweisung
Anweisung
Oft möchte man jedoch z.B.
o die Ausführung einer Anweisung (eines Anweisungsblocks) von einer Bedingung
abhängig machen
o oder eine Anweisung (einen Anweisungsblock) wiederholt ausführen lassen.
Für solche Zwecke stellt C# etliche Anweisungen zur Ablaufsteuerung zur Verfügung, die
bald ausführlich behandelt werden (bedingte Anweisung, Fallunterscheidung, Schleifen).
Blockanweisungen sowie Anweisungen zur Ablaufsteuerung enthalten andere Anweisungen und
werden daher auch als zusammengesetzte Anweisungen bezeichnet.
Anweisungen werden durch ein Semikolon abgeschlossen, sofern sie nicht mit einer schließenden
Blockklammer enden.
3.7.2 Bedingte Anweisung und Verzweigung
Oft ist es erforderlich, dass eine Anweisung nur unter einer bestimmten Bedingung ausgeführt wird.
Etwas allgemeiner formuliert geht es darum, dass viele Algorithmen Fallunterscheidungen benötigen, also an bestimmten Stellen in Abhängigkeit vom Wert eines steuernden Ausdrucks in unterschiedliche Pfade verzweigen müssen.
3.7.2.1 if-Anweisung
Nach dem folgenden Programmablaufplan bzw. Flussdiagramm soll eine (Block-)Anweisung
nur dann ausgeführt werden, wenn ein logischer Ausdruck den Wert true besitzt:
Log. Ausdruck
true
false
Anweisung
Zur Realisation verwendet man die if-Anweisung mit der folgenden Syntax:
Kapitel 3: Elementare Sprachelemente
114
if-Anweisung
if
(
Log. Ausdruck
)
Anweisung
Als Anweisung ist allerdings keine Variablendeklaration erlaubt. Im folgenden Beispiel wird eine
Meldung ausgegeben, wenn die Variable anz den Wert Null besitzt:
if (anz == 0)
Console.WriteLine("Die Anzahl muss > 0 sein!");
Der Zeilenumbruch zwischen dem logischen Ausdruck und der (Unter-)Anweisung dient nur der
Übersichtlichkeit und ist für den Compiler irrelevant.
Selbstverständlich kommt als Anweisung auch ein Block in Frage.
3.7.2.2 if-else - Anweisung
Soll auch etwas passieren, wenn der steuernde logische Ausdruck den Wert false besitzt,
Log. Ausdruck
true
false
Anweisung
Anweisung
erweitert man die if-Anweisung um eine else-Klausel.
Zur Beschreibung der if-else - Anweisung wird an Stelle eines Syntaxdiagramms eine alternative
Darstellungsform gewählt, die sich am typischen C# - Quellcode-Layout orientiert:
if (Logischer Ausdruck)
Anweisung 1
else
Anweisung 2
Wie bei den Syntaxdiagrammen gilt auch für diese Form der Syntaxbeschreibung:


Für terminale Sprachbestandteile, die exakt in der angegebenen Form in konkreten Quellcode zu übernehmen sind, wird fette Schrift verwendet.
Platzhalter sind durch kursive Schrift gekennzeichnet.
Während die Syntaxbeschreibung im Quellcode-Layout sehr übersichtlich ist, bietet das Syntaxdiagramm den Vorteil, bei komplizierter, variantenreicher Syntax alle zulässigen Formulierungen
kompakt und präzise als Pfade durch das Diagramm zu beschreiben.
Wie schon bei der einfachen if-Anweisung gilt auch bei der if-else-Anweisung, dass Variablendeklarationen nicht als eingebettete Anweisungen erlaubt sind.
Im folgenden if-else - Beispiel wird der natürliche Logarithmus zu einer Zahl geliefert, falls diese
positiv ist. Anderenfalls erscheint eine Fehlermeldung mit Alarmton (Escape-Sequenz \a). Das Ar-
115
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung)
gument wird vom Benutzer über eine ToDouble()-ReadLine() - Konstruktion erfragt (vgl. Abschnitt 3.4.1).
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
Console.Write("Argument: ");
double arg = Convert.ToDouble(Console.ReadLine());
if (arg > 0)
Console.WriteLine("ln(" + arg + ") = "
+ Math.Log(arg));
else
Console.WriteLine("\aArgument <= 0!");
}
}
Argument: 2
ln(2) = 0,693147180559945
Eine bedingt auszuführende Anweisung darf durchaus wiederum vom if– bzw. if-else – Typ sein, so
dass sich mehrere, hierarchisch geschachtelte Fälle unterscheiden lassen. Den folgenden Programmablauf mit „sukzessiver Restaufspaltung“
Log. Ausdruck
true
false
Anweisung
Log. Ausdruck
true
false
Anweisung
Log. Ausdruck
true
false
Anweisung
Anweisung
realisiert z.B. eine if-else – Konstruktion nach diesem Muster:
Kapitel 3: Elementare Sprachelemente
116
if (Logischer Ausdruck 1)
Anweisung 1
else if (Logischer Ausdruck 2)
Anweisung 2
. . .
. . .
else if (Logischer Ausdruck k)
Anweisung k
else
Default-Anweisung
Wenn alle logischen Ausdrücke den Wert false annehmen, wird die else-Klausel zur letzten ifAnweisung ausgeführt. Die Bezeichnung Default-Anweisung in der Syntaxdarstellung erfolgte im
Hinblick auf die in Abschnitt 3.7.2.3 vorzustellende switch-Anweisung, die bei einer Mehrfallunterscheidung gegenüber einer verschachtelten if-else – Konstruktion zu bevorzugen ist, wenn die
Fallzuordnung über die verschiedenen Werte eines Ausdrucks (z.B. vom Typ int) erfolgen kann.
Beim Schachteln von bedingten Anweisungen kann es zum so genannten dangling-else - Problem 1
kommen, wobei ein Missverständnis zwischen Compiler und Programmierer hinsichtlich der Zuordnung einer else-Klausel besteht. Im folgenden Codefragment
if (i > 0)
if (j > i)
k = j;
else
k = 13;
lassen die Einrücktiefen vermuten, dass der Programmierer die else-Klausel auf die erste ifAnweisung bezogen zu haben glaubt:
i > 0 ?
true
false
k = 13;
j > i ?
true
false
k = j;
Der Compiler ordnet eine else-Klausel jedoch dem in Aufwärtsrichtung nächstgelegenen if zu, das
nicht durch Blockklammern abgeschottet ist und noch keine else-Klausel besitzt. Im Beispiel be1
Deutsche Übersetzung von dangling: baumelnd.
117
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung)
zieht er die else-Klausel also auf die zweite if-Anweisung, so dass de facto folgender Programmablauf resultiert:
i > 0 ?
true
false
j > i ?
true
false
k = j
k = 13;
Mit Hilfe von Blockklammern kann die gewünschte Zuordnung erzwungen werden:
if (i > 0)
{if (j > i)
k = j;}
else
k = 13;
Alternativ kann man auch dem zweiten if eine else-Klausel spendieren und dabei eine leere Anweisung verwenden:
if (i > 0)
if (j > i)
k = j;
else
;
else
k = 13;
3.7.2.3 switch-Anweisung
Wenn eine Fallunterscheidung mit mehr als zwei Alternativen in Abhängigkeit vom Wert eines
Ausdrucks vorgenommen werden soll,
Kapitel 3: Elementare Sprachelemente
118
k = ?
1
Anweisung
2
3
Anweisung
Anweisung
dann ist eine switch-Anweisung weitaus handlicher als eine verschachtelte if-else - Konstruktion. In
Bezug auf den Datentyp des steuernden Ausdrucks ist C# sehr flexibel und erlaubt:



numerische Datentypen
Dazu gehört auch der integrale Datentyp char (vgl. Abschnitt 3.3.3).
Aufzählungstypen (siehe unten)
Zeichenfolgen (Datentyp string)
Der Genauigkeit halber wird die switch-Anweisung mit einem Syntaxdiagramm beschrieben. Wer
die Syntaxbeschreibung im Quellcode-Layout bevorzugt, kann ersatzweise einen Blick auf die
gleich folgenden Beispiele werfen.
switch-Anweisung
switch
case
default
switch-Ausdruck
(
Marke
:
:
)
{
Anweisung
Stopper
;
Anweisung
Stopper
;
}
Weil später noch ein praxisnahes (und damit auch etwas kompliziertes) Beispiel folgt, ist hier ein
ebenso einfaches wie sinnfreies Exemplar zur Erläuterung der Syntax angemessen:
119
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung)
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
int zahl = 2;
switch (zahl) {
case 1:
Console.WriteLine("Fall 1 (mit break-Stopper)");
break;
case 2:
Console.WriteLine("Fall 2\n (mit Durchfall per goto)");
goto case 3;
case 3:
case 4:
Console.WriteLine("Fälle 3 und 4");
break;
default: Console.WriteLine("Restkategorie");
break;
}
}
}
Fall 2
(mit Durchfall per goto)
Fälle 3 und 4
Als case-Marken sind konstante Ausdrücke erlaubt, deren Ergebnis schon der Compiler ermitteln
kann (z.B. Literale, Konstanten oder mit konstanten Argumenten gebildete Ausdrücke).
Stimmt bei der Ausführung einer Methode der Wert des switch-Ausdrucks mit einer case-Marke
überein, dann wird die zugehörige Anweisung ausgeführt, ansonsten (falls vorhanden) die defaultAnweisung.
Soll für mehrere Werte des switch-Ausdrucks dieselbe Anweisung ausgeführt werden, setzt man
die zugehörigen case-Marken hintereinander und lässt die Anweisung auf die letzte Marke folgen.
Leider gibt es keine Möglichkeit, eine Serie von Fällen durch Angabe der Randwerte (z.B. von a
bis z) festzulegen.
Jeder Fall muss mit einem Stopper abgeschlossen werden. Der in anderen Programmiersprachen
(z.B. C, C++, Java) mögliche „Durchfall“ zu den Anweisungen „tieferer“ Fälle ist verboten, womit
einige elegante Formulierungen und viele Programmierfehler vermieden werden. Meist stoppt man
mit der break-Anweisung, wobei die Methode hinter der switch-Anweisung fortgesetzt wird. Mögliche Alternativen sind:



return-Anweisung
Die Methode wird verlassen.
throw-Anweisung
Es wird eine Ausnahme ausgelöst (siehe unten).
goto-Anweisung
Per goto-Anweisung
goto-Anweisung
goto
case
Marke
;
Sprungmarkenname
kann eine case-Marke innerhalb der switch-Anweisung oder eine Sprungmarke
Sprungmarke
Name
:
Kapitel 3: Elementare Sprachelemente
120
an anderer Stelle innerhalb der Methode angesteuert werden. Das gute (böse) alte goto, als
Inbegriff rückständigen Programmierens aus vielen modernen Programmiersprachen verbannt, ist also in C# erlaubt. Es ist in manchen Situationen durchaus brauchbar, z.B. um den
aus anderen Programmiersprachen bekannten switch-Durchfall in C# trotz StopperVorschrift zu realisieren.
Im folgenden Beispielprogramm wird die Persönlichkeit des Benutzers mit Hilfe seiner Farb- und
Zahlpräferenzen analysiert. Während bei einer Vorliebe für Rot oder Schwarz die Diagnose sofort
feststeht, wird bei den restlichen Farben auch die Lieblingszahl berücksichtigt:
using System;
class PerST {
static void Main(string[] args) {
if (args.Length < 2) {
Console.WriteLine("Bitte Lieblingsfarbe und -zahl angeben!");
return;
}
char farbe = args[0][0];
int zahl = Convert.ToInt32(args[1]);
switch (farbe) {
case 'r':
Console.WriteLine("Sie sind ein emotionaler Typ.");
break;
case 'g':
case 'b': {
Console.WriteLine("Sie scheinen ein sachlicher Typ zu sein");
if (zahl % 2 == 0)
Console.WriteLine("Sie haben einen geradlinigen Charakter.");
else
Console.WriteLine("Sie machen wohl gerne krumme Touren.");
}
break;
case 's':
Console.WriteLine("Nehmen Sie nicht Alles so tragisch.");
break;
default:
Console.WriteLine("Offenbar mangelt es Ihnen an Disziplin.");
break;
}
}
}
Das Programm PerST demonstriert nicht nur die switch-Anweisung, sondern auch die Verwendung von Befehlszeilenargumenten. Benutzer des Programms sollen beim Start ihre bevorzugte
Farbe aus einer Palette mit den drei Grundfarben und Schwarz sowie ihre Lieblingszahl angeben,
wobei die Farbe folgendermaßen durch einen Buchstaben zu kodieren ist:
r
g
b
s
für Rot
für Grün
für Blau
für Schwarz
Wer z.B. die Farbe Blau und die Zahl 17 bevorzugt, sollte das Programm also (bis auf die beliebige
Groß-/Kleinschreibung beim Programmnamen) folgendermaßen starten:
PerST b 17
Im Quellcode wird jeweils nur eine Anweisung benötigt, um die (durch Leerzeichen getrennten)
Befehlszeilenargumente auszuwerten und das Ergebnis in eine char- bzw. int-Variable zu befördern. Die zugehörigen Erklärungen werden Sie mit Leichtigkeit verstehen, sobald MethodenParameter sowie Arrays und Zeichenketten behandelt worden sind. An dieser Stelle greifen wir späteren Erläuterungen mal wieder etwas vor (hoffentlich mit motivierendem Effekt):

Bei einem Array handelt es sich um ein Objekt, das eine Serie von Elementen desselben
Typs aufnimmt, auf die man per Index, d.h. durch die mit eckigen Klammern begrenzte
Elementnummer, zugreifen kann.
121
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung)

In unserem Beispiel kommt ein Array mit Elementen vom Datentyp string zum Einsatz,
wobei es sich um Zeichenketten handelt. Literale mit diesem Datentyp sind uns schon öfter
begegnet (z.B. "Hallo").

In der Parameterliste einer Methode kann die gewünschte Arbeitsweise näher spezifiziert
werden.
Besitzt die Main()-Methode einer Startklasse einen (ersten und einzigen) Parameter vom
Datentyp string[] (Array mit string-Elementen), dann übergibt das .NET – Laufzeitsystem
dieser Methode als string-Elemente die Spezifikationen, die der Anwender beim Start hinter
den Programmnamen in die Kommandozeile, jeweils durch Leerzeichen getrennt, geschrieben hat. Der Datentyp des Parameters ist fest vorgegeben, sein Name jedoch frei wählbar
(im Beispiel: args). In der Methode Main() kann man auf den Array args lesend genauso
zugreifen wie auf eine lokale Variable vom selben Typ.


Das erste Befehlszeilenargument landet im ersten Element des Zeichenketten-Arrays args
und wird mit args[0] angesprochen, weil Array-Elemente mit Null beginnend nummeriert sind. Das Array-Element args[0] kann mehrere Zeichen enthalten (z.B. blau), von
denen im Beispiel aber nur das erste interessiert, welches mit args[0][0] (Zeichen
Nummer Null vom Array-Element Nummer Null) angesprochen und der char-Variablen
farbe zugewiesen wird.

Das zweite Element des Zeichenketten-Arrays args (mit der Nummer Eins) enthält das
zweite Befehlszeilenargument. Zumindest bei kooperativen Benutzern des Beispielprogramms kann man aus dieser Zeichenfolge mit der Klassenmethode ToInt32() der Klasse
Convert eine Zahl vom Datentyp int gewinnen und anschließend der Variablen zahl zuweisen.
Man kann sich den string-Array args ungefähr so vorstellen:
Heap
args[0]
b
l
a
u
args[1]
1
7
args[0][0]
Ansonsten ist im Beispielprogramm noch die return-Anweisung von Interesse, welche die Main()Methode und damit das Programm in Abhängigkeit von einer Bedingung sofort beendet:
return;
Sie ist uns oben schon als Stopper in der switch-Anweisung begegnet und wird im Zusammenhang
mit der ausführlichen Behandlung der Methoden noch näher erläutert.
Für den Programmstart in der Visual C# 2008 - Umgebung kann man die Befehlszeilenargumente
folgendermaßen vereinbaren:



Menübefehl Projekt > Eigenschaften
Registerkarte Debuggen
Befehlszeilenargumente eintragen, z.B.:
Kapitel 3: Elementare Sprachelemente
122
3.7.3 Wiederholungsanweisungen
Eine Wiederholungsanweisung (oder schlicht: Schleife) kommt zum Einsatz, wenn eine (Verbund)Anweisung mehrfach ausgeführt werden soll, wobei sich in der Regel schon der Gedanke daran
verbietet, die Anweisung entsprechend oft in den Quelltext zu schreiben.
Im folgenden Flussdiagramm ist ein iterativer Algorithmus zu sehen, der die Summe der quadrierten natürlichen Zahlen von Eins bis Fünf berechnet:
double s = 0.0;
int i = 1;
false
i <= 5 ?
true
s += i*i;
i++;
C# bietet verschiedene Wiederholungsanweisungen, die sich bei der Ablaufsteuerung unterscheiden
und gleich im Detail vorgestellt werden:



Zählergesteuerte Schleife (for)
Die Anzahl der Wiederholungen steht typischerweise schon vor Schleifenbeginn fest. Bei
der Ablaufsteuerung kommt eine Zählvariable zum Einsatz, die vor dem ersten Schleifendurchgang initialisiert und nach jedem Durchlauf aktualisiert (z.B. inkrementiert) wird. Die
zur Schleife gehörige (Verbund-)Anweisung wird ausgeführt, bis die Zählvariable einen
festgelegten Grenzwert erreicht hat (siehe obige Abbildung).
Iterieren über die Elemente einer Kollektion (foreach)
Mit der von Visual Basic übernommenen foreach-Schleife bietet C# die Möglichkeit, eine
Anweisung für jedes Element eines Arrays, einer Zeichenfolge oder einer anderen Kollektion (siehe unten) auszuführen.
Bedingungsabhängige Schleife (while, do)
Bei jedem Schleifendurchgang wird eine Bedingung überprüft, und das Ergebnis entscheidet
über das weitere Vorgehen:
123
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung)
o true: Die zur Schleife gehörige Anweisung wird ein weiteres mal ausgeführt.
o false: Die Schleife wird beendet.
Bei der kopfgesteuerten while-Schleife wird die Bedingung vor Beginn eines Durchgangs
geprüft, bei der fußgesteuerten do-Schleife hingegen am Ende. Weil man z.B. nach dem 3.
Schleifendurchgang in keiner anderen Lage ist wie vor dem 4. Schleifendurchgang, geht es
bei der Entscheidung zwischen Kopf- und Fußsteuerung lediglich darum, ob auf jeden Fall
ein erster Schleifendurchgang stattfinden soll oder nicht.
Die gesamte Konstruktion aus Schleifensteuerung und (Verbund-)anweisung stellt in syntaktischer
Hinsicht eine zusammengesetzte Anweisung dar.
3.7.3.1 Zählergesteuerte Schleife (for)
Die Anweisung einer for-Schleife wird ausgeführt, solange eine Bedingung erfüllt ist, die normalerweise auf eine ganzzahlige Indexvariable Bezug nimmt. Anschließend wird die Methode hinter
der for-Schleife fortgesetzt.
Auf das Schlüsselwort for folgt die von runden Klammern umgebene Schleifensteuerung, wo die
Vorbereitung der Indexvariablen (nötigenfalls samt Deklaration), die Fortsetzungsbedingung und
die Aktualisierungsvorschrift untergebracht werden können. Am Ende steht die wiederholt auszuführende (Block-)Anweisung:
for (Vorbereitung; Bedingung; Aktualisierung)
Anweisung
Zu den drei Bestandteilen der Schleifensteuerung sind einige Erläuterungen erforderlich, wobei
anschließend etliche weniger typische bzw. sinnvolle Möglichkeiten weggelassen werden:

Vorbereitung
In der Regel wird man sich auf eine Indexvariable beschränken und dabei einen GanzzahlTyp wählen. Somit kommen im Vorbereitungsteil der for-Schleifensteuerung in Frage:
o eine Wertzuweisung, z.B.:
i = 1
o eine Variablendeklaration mit Initialisierung, z.B.
int i = 1
Im folgenden Programm findet sich die zweite Variante:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
double s = 0.0;
for (int i = 1; i <= 5; i++)
s += i*i;
Console.WriteLine("Quadratsumme = " + s);
}
}
Quadratsumme = 55
Der Vorbereitungsteil wird vor dem ersten Durchlauf ausgeführt. Eine hier deklarierte Variable ist lokal bzgl. der for-Schleife, steht also nur in deren Anweisung(sblock) zur Verfügung.
Kapitel 3: Elementare Sprachelemente
124

Bedingung
Üblicherweise wird eine Ober- oder Untergrenze für die Indexvariable gesetzt, doch erlaubt
C# beliebige logische Ausdrücke. Die Bedingung wird vor jedem Schleifendurchgang geprüft. Resultiert der Wert true, so findet eine weitere Wiederholung des Anweisungsteils
statt, anderenfalls wird die for-Schleife verlassen. Folglich kann es auch passieren, dass
überhaupt kein Durchlauf zustande kommt.

Aktualisierung
Am Ende jedes Schleifendurchgangs (nach Ausführung der Anweisung) wird der Aktualisierungsteil ausgeführt. Hier wird meist die Indexvariable in- oder dekrementiert.
Im folgenden Flussdiagramm sind die semantischen Regeln zur for-Schleife dargestellt, wobei die
Bestandteile der Schleifensteuerung an der grünen Farbe zu erkennen sind:
Vorbereitung
false
Bedingung
true
Anweisung
Aktualisierung
Zu den (zumindest stilistisch) bedenklichen Konstruktionen, die der Compiler klaglos umsetzt, gehören for-Schleifenköpfe ohne Vorbereitung oder ohne Aktualisierung, wobei die trennenden
Strichpunkte trotzdem zu setzen sind. In solchen Fällen ist die Umlaufzahl einer for-Schleife natürlich nicht mehr aus dem Schleifenkopf abzulesen. Dies gelingt auch dann nicht, wenn eine Indexvariable in der Schleifenanweisung modifiziert wird.
3.7.3.2 Iterieren über die Elemente einer Kollektion (foreach)
Obwohl wir uns bisher nur anhand von Beispielen mit Kollektionen wie Arrays oder Zeichenfolgen
beschäftigt haben, kann die einfach aufgebaute foreach-Schleife doch hier im Kontext mit den übrigen Schleifen behandelt werden. Hinsichtlich der Steuerungslogik handelt es sich um einen einfachen Spezialfall der for-Schleife:



Es ist eine Kollektion mit einer festen Anzahl von gleichartigen Elementen vorhanden, z.B.
eine Zeichenfolge mit acht Zeichen.
Die Anweisung der foreach-Schleife wird nacheinander für jedes Element der Kollektion
ausgeführt.
Im Schleifenkopf wird eine Iterationsvariable vom Datentyp der Kollektionselemente deklariert, über die in der Schleifenanweisung das aktuelle Element angesprochen werden kann.
Die Syntax der foreach-Schleife:
125
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung)
foreach (Elementtyp Iterationsvariable in Kollektion)
Anweisung
Das Beispielprogramm PerST in Abschnitt 3.7.2.3 hat demonstriert, wie man über einen Parameter
der Methode Main() auf die Zeichenfolgen zugreifen kann, welche der Benutzer beim Start eines
Konsolenprogramms hinter den Assembly-Namen geschrieben hat. Im folgenden Programm wird
durch zwei geschachtelte foreach-Schleifen für jedes Element im string-Array args mit den Befehlszeilenargumenten folgendes getan:


In der äußeren foreach-Schleife wird die aktuelle Zeichenfolge komplett ausgegeben.
In der inneren foreach-Schleife wird jedes Zeichen der aktuellen Zeichenfolge separat ausgegeben.
Quellcode
Ausgabe
using System;
class Prog {
static void Main(string[] args) {
foreach (string s in args) {
Console.WriteLine(s);
foreach (char c in s)
Console.WriteLine(" " + c);
Console.WriteLine();
}
}
}
eins
e
i
n
s
zwei
z
w
e
i
Beim Array mit den Befehlszeilenargumenten haben wir es mit einer Kollektion zu tun, die als Elemente wiederum Kollektionen enthält (nämlich Zeichenfolgen).
Bei der foreach-Schleife wird offenbar das Initialisieren und Inkrementieren der Iterationsvariablen
auf nahe liegende Weise automatisiert. Man darf nur lesend auf die Iterationsvariable zugreifen, so
dass z.B. die folgende Konstruktion verboten ist:
foreach (char c in s)
c = '2';
3.7.3.3 Bedingungsabhängige Schleifen
Wie die Erläuterungen zur for-Schleife gezeigt haben, ist die Überschrift dieses Abschnitts nicht
sehr trennscharf, weil bei der for-Schleife ebenfalls eine beliebige Terminierungsbedingung angegeben werden darf. In vielen Fällen ist es eine Frage des persönlichen Geschmacks, welche Wiederholungsanweisung man zur Lösung eines konkreten Iterationsproblems benutzt. Unter der aktuellen Abschnittsüberschrift diskutiert man traditionsgemäß die while- und die do-Schleife.
3.7.3.3.1 while-Schleife
Die while-Anweisung kann als vereinfachte for-Anweisung beschreiben kann: Wer im Kopf einer
for-Schleife auf Vorbereitung und Aktualisierung verzichten möchte, ersetzt besser das Schlüsselwort for durch while und erhält dann folgende Syntax:
while (Bedingung)
Anweisung
Wie bei der for-Anweisung wird die Bedingung vor Beginn eines Schleifendurchgangs geprüft.
Resultiert der Wert true, so wird die Anweisung ausgeführt, anderenfalls wird die while-Schleife
verlassen, eventuell noch vor dem ersten Durchgang:
Kapitel 3: Elementare Sprachelemente
126
false
Bedingung
true
Anweisung
Im obigen Beispielprogramm zur Quadratsummenberechnung (vgl. Abschnitt 3.7.3.1) kann man die
for-Schleife leicht durch eine while-Schleife ersetzen:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
double s = 0.0;
int i = 1;
while (i <= 5) {
s += i * i;
i++;
}
Console.WriteLine("Quadratsumme = " + s);
}
}
Quadratsumme = 55
3.7.3.3.2 do-Schleife
Bei der do-Schleife wird die Fortsetzungsbedingung am Ende der Schleifendurchläufe geprüft, so
dass wenigstens ein Durchlauf stattfindet:
Anweisung
false
Bedingung
true
Das Schlüsselwort while tritt auch in der Syntax zur do-Schleife auf:
do
Anweisung
while (Bedingung);
do-Schleifen werden seltener benötigt als while-Schleifen, sind aber z.B. dann von Vorteil, wenn
man vom Benutzer eine Eingabe mit bestimmten Eigenschaften einfordern möchte. In folgendem
Programm wird wie gewohnt mit der statischen Methode Console.ReadLine() eine per Enter –
Taste quittierte Zeile von der Konsole gelesen. Weil dieser Methodenaufruf ein Ausdruck vom Typ
string ist, kann das erste Zeichen (mit der Nummer Null) per Indexzugriff (mit Hilfe der eckigen
Klammern) angesprochen werden:
127
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung)
Quellcode
Ein-/Ausgabe
using System;
class Prog {
static void Main() {
char antwort;
do {
Console.Write("Beenden? (j/n)? ");
antwort = Console.ReadLine()[0];
} while (antwort != 'j' && antwort != 'n');
}
}
Beenden? (j/n)? r
Beenden? (j/n)? 4
Beenden? (j/n)? j
Bei einer do-Schleife mit Anweisungsblock sollte man die while-Klausel unmittelbar hinter die
schließende Blockklammer setzen (in dieselbe Zeile), um sie optisch von einer selbständigen whileAnweisung abzuheben (siehe Beispiel).
3.7.3.4 Endlosschleifen
Bei einer for-, while- oder do-Schleife kann es in Abhängigkeit von der Fortsetzungsbedingung
passieren, dass der Anweisungsteil unendlich oft ausgeführt wird. Endlosschleifen sind als gravierende Programmierfehler unbedingt zu vermeiden. Befindet sich ein Programm in diesem Zustand
muss es mit Hilfe des Betriebssystems abgebrochen werden, bei unseren Konsolenanwendungen
z.B. über die Tastenkombination Strg+C.
In folgendem Beispiel resultiert eine Endlosschleife aus einer unvorsichtigen Identitätsprüfung bei
double-Werten (vgl. Abschnitt 3.5.3):
using System;
class Prog {
static void Main() {
int i = 0;
double d = 1.0;
// besser: while (Math.Abs(d - 0.0) > 1.0e-14) {
while (d != 0.0) {
i++;
d -= 0.1;
Console.WriteLine("i = {0}, d = {1}", i, d);
}
Console.WriteLine("Fertig!");
}
}
3.7.3.5 Schleifen(durchgänge) vorzeitig beenden
Mit der break-Anweisung, die uns schon als Bestandteil der switch-Anweisung begegnet ist, kann
eine Schleife vorzeitig verlassen werden. Mit der continue-Anweisung veranlasst man C#, den aktuellen Schleifendurchgang zu beenden und sofort mit dem nächsten zu beginnen.
In folgendem Beispielprogramm zur (relativ primitiven) Primzahlendiagnose wird die schon in Abschnitt 3.4.1 erwähnte Ausnahmebehandlung per try-catch - Anweisung eingesetzt, die später in
einem eigenen Kapitel ausführlich behandelt wird. Während wir in der Regel der Einfachheit halber
auf eine Prüfung der Eingabedaten verzichten, findet im aktuellen Programm vor allem deshalb eine
praxisnahe Validierung statt, weil sich dabei ein sinnvoller continue-Einsatz ergibt:
Kapitel 3: Elementare Sprachelemente
128
using System;
class Primitiv {
static void Main() {
bool tg;
ulong i, mtk, zahl;
Console.WriteLine("Einfacher Primzahlendetektor\n");
while (true) {
Console.Write("Zu untersuchende positive Zahl oder 0 zum Beenden: ");
try {
zahl = Convert.ToUInt64(Console.ReadLine());
} catch {
Console.WriteLine("\aKeine Zahl (im zulässigen Bereich)!\n");
continue;
}
if (zahl == 0)
break;
tg = false;
mtk = (ulong) Math.Sqrt(zahl);
for (i = 2; i <= mtk; i++)
if (zahl % i == 0) {
tg = true;
//Maximaler Teiler-Kandidat
break;
}
if (tg)
Console.WriteLine(zahl+" ist keine Primzahl (Teiler: "+i+").\n");
else
Console.WriteLine(zahl+" ist eine Primzahl.\n");
}
Console.WriteLine("\nVielen Dank für den Einsatz dieser Software!");
}
}
Bei einer irregulären Eingabe erscheint eine Fehlermeldung auf dem Bildschirm, und der aktuelle
Durchgang der while-Schleife wird per continue verlassen. Durch Eingabe der Zahl Null kann das
Beispielprogramm beendet werden, wobei die absichtlich konstruierte while - „Endlosschleife“ per
break verlassen wird.
Man hätte die continue- und die break-Anweisung zwar vermeiden können (siehe Übungsaufgabe
in Abschnitt 3.7.4), doch werden bei dem vorgeschlagenen Verfahren lästige Sonderfälle (unzulässige Werte, Null als Terminierungssignal) auf besonders übersichtliche Weise abgehakt, bevor der
Kernalgorithmus startet.
Zum Kernalgorithmus der Primzahlendiagnose sollte vielleicht noch erläutert werden, warum die
Suche nach einem Teiler des Primzahlkandidaten bei seiner Wurzel enden kann (genauer: bei der
größten ganzen Zahl  Wurzel):
Sei d ( 1) ein echter Teiler der positiven, ganzen Zahl z, d.h. es gibt eine Zahl k ( 2) mit
z=kd
Dann ist auch k ein echter Teiler von z, und es gilt:
d
z oder k 
z
Anderenfalls wäre das Produkt k  d größer als z. Wir haben also folgendes Ergebnis: Wenn eine
Zahl z keinen echten Teiler kleiner oder gleich z hat, kann man auch jenseits dieser Grenze keinen finden, und z ist eine Primzahl.
Zur Berechnung der Wurzel verwendet das Beispielprogramm die Methode Sqrt() aus der Klasse
Math, über die man sich bei Bedarf in der FCL-Dokumentation informieren kann.
129
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung)
Mit den bereits im Kurs vorgestellten Techniken und etwas Studium der FCL - Dokumentation ist
es übrigens kein großes Problem, eine GUI - Variante des Primzahlendetektors zu erstellen, z.B.:
Hier kommen die Methoden MessageBox.Show() (vgl. Abschnitt 2.3) und Interaction.InputBox()
(vgl. Abschnitt 3.4.2) zum Einsatz. Für den Schönheitsfehler
(offenbar entstanden durch
den höheren Platzbedarf der deutschen Beschriftung im Vergleich zur englischen Variante) ist die
Methode InputBox() verantwortlich. Das Visual Studio - Projekt mit dem GUI - Primzahlendetektor befindet sich im Verzeichnis:
...\BspUeb\Elementare Sprachelemente\PrimitivGUI
Mit den im weiteren Verlauf des Kurses zu erwerbenden Kenntnissen über die Windows-Programmierung wird man bei der gegebenen Aufgabenstellung allerdings eher mit einer einzelnen Dialogbox und geeigneten Steuerelementen arbeiten.
3.7.4 Übungsaufgaben zu Abschnitt 3.7
1) In einer Lotterie gilt folgender Gewinnplan:
 Durch 13 teilbare Losnummern gewinnen 100 Euro.
 Losnummern, die nicht durch 13 teilbar sind, gewinnen immerhin noch einen Euro, wenn sie
durch 7 teilbar sind.
Wird in folgendem Codesegment für Losnummern in der int-Variablen losNr der richtige Gewinn
ermittelt?
if (losNr % 13 != 0)
if (losNr % 7 == 0)
Console.WriteLine("Das Los gewinnt einen Euro!");
else
Console.WriteLine("Das Los gewinnt 100 Euro!");
2) Warum liefert dieses Programm widersprüchliche Auskünfte über die boolesche Variable b?
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
bool b = false;
if (b = false)
Console.WriteLine("b ist False");
else
Console.WriteLine("b ist True");
Console.WriteLine("Kontr.ausg. von b: " + b);
}
}
b ist True
Kontr.ausg. von b: False
3) Erstellen Sie eine Variante des Primzahlen-Diagnoseprogramms aus Abschnitt 3.7.3.5, die ohne
break bzw. continue auskommt.
Kapitel 3: Elementare Sprachelemente
130
4) Wie oft wird die folgende while-Schleife ausgeführt?
using System;
class Prog {
static void Main() {
int i = 0;
while (i < 100);
{
i++;
Console.WriteLine(i);
}
}
}
5) Verbessern Sie das im Übungsaufgaben-Abschnitt 3.5.11 in Auftrag gegebene Programm zur
DM-Euro - Konvertierung so, dass es nicht für jeden Betrag neu gestartet werden muss. Vereinbaren Sie mit dem Benutzer ein geeignetes Verfahren für den Fall, dass er das Programm doch irgendwann einmal beenden möchte.
6) Bei einem double-Wert sind maximal 16 signifikante Dezimalstellen garantiert (siehe Abschnitt
3.3.3). Folglich kann ein Rechner die double-Werte 1,0 und 1,0  2 i ab einem bestimmten Exponenten i nicht mehr voneinander unterscheiden. Bestimmen Sie mit einem Testprogramm den größten ganzzahligen Index i, für den man noch erhält:
1,0  2 i  1,0
In dem (zur freiwilligen Lektüre empfohlenen) Vertiefungsabschnitt 3.3.4.1 findet sich eine Erklärung für das Ergebnis.
7) In dieser Aufgabe sollen Sie verschiedene Varianten von Euklids Algorithmus zur Bestimmung
des größten gemeinsamen Teilers (ggT) zweier natürlicher Zahlen u und v implementieren und die
Performanzunterschiede messen. Verwenden Sie als ersten Kandidaten den im Einführungsbeispiel
zum Kürzen von Brüchen (Methode Kuerze()) benutzten Algorithmus (siehe Abschnitt 1.1.2).
Sein offensichtliches Problem besteht darin, dass bei stark unterschiedlichen Zahlen u und v sehr
viele Subtraktions-Operationen erforderlich werden.
In der meist benutzten Variante des Euklidischen Verfahrens wird dieses Problem vermieden, indem an Stelle der Subtraktion die Modulo-Operation zum Einsatz kommt, basierend auf dem folgendem Satz der mathematischen Zahlentheorie:
Für zwei natürliche Zahlen u und v (mit u > v) ist der ggT gleich dem ggT von u und u % v (u
modulo v).
Begründung (analog zu Abschnitt 1.1.3): Für natürliche Zahlen u und v mit u > v gilt:
x ist gemeinsamer Teiler von u und v

x ist gemeinsamer Teiler von u und u % v
Der ggT-Algorithmus per Modulo-Operation läuft für zwei natürliche Zahlen u und v (u  v > 0)
folgendermaßen ab:
Es wird geprüft, ob u durch v teilbar ist.
Trifft dies zu, ist v der ggT.
Anderenfalls ersetzt man:
u durch v
v durch u % v
Das Verfahren startet neu mit den kleineren Zahlen.
131
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung)
Die Voraussetzung u  v ist nicht wesentlich, weil beim Start mit u < v der erste Algorithmusschritt
die beiden Zahlen vertauscht.
Um den Zeitaufwand für beide Varianten zu messen, kann man z.B. mit der Struktur DateTime aus
dem Namensraum System arbeiten. Mit ihrer statischen Now-Eigenschaft ermittelt man den aktuellen Zeitpunkt und fragt dann die Ticks-Eigenschaft dieser DateTime-Instanz ab. Der letzte Satz ist
zugegebenermaßen recht kompliziert geraten und enthält etliche noch unzureichend erklärte Details.
Man erhält jedenfalls mit einer recht einfachen Syntax die Anzahl der 100-NanosekundenIntervalle, die seit dem 1. Januar 0001, 00:00:00 vergangen sind, z.B.:
long zeit = DateTime.Now.Ticks;
Für die Beispielwerte u = 999000999 und v = 36 liefern beide Euklid-Varianten sehr verschiedene
Laufzeiten (CPU: Intel Pentium 4 mit 3,0 GHz):
ggT-Bestimmung mit Euklid (Differenz)
ggT-Bestimmung mit Euklid (Modulo)
Erste Zahl:
999000999
Erste Zahl:
999000999
Zweite Zahl:
36
Zweite Zahl:
36
ggT:
9
ggT:
9
Benöt. Zeit:
203,125 Millisek.
Benöt. Zeit:
0 Millisek.
4 Klassen und Objekte
Softwareentwicklung mit C# besteht im Wesentlichen aus der Definition von Klassen, die aufgrund
der vorangegangenen objektorientierten Analyse …


als Baupläne für Objekte
und/oder als Akteure
konzipiert werden können. Wenn ein spezieller Akteur im Programm nur einfach benötigt wird,
kann eine handelnde Klasse diese Rolle übernehmen. Sind hingegen mehrere Individuen einer Gattung erforderlich (z.B. mehrere Brüche im Bruchrechnungsprogramm oder mehrere Fahrzeuge in
der Speditionsverwaltung), dann ist eine Klasse mit Bauplancharakter gefragt.
Für eine Klasse und/oder ihre Objekte werden Merkmale (Felder), Eigenschaften, Handlungskompetenzen (Methoden) und weitere (bisher nicht behandelte) Bestandteile vereinbart bzw. entworfen.
In den Methoden eines Programms werden vordefinierte (z.B. der Standardbibliothek entstammende) oder selbst erstellte Klassen zur Erledigung von Aufgaben verwendet. Meist werden dabei Objekte aus Klassen mit Bauplancharakter erzeugt und mit Aufträgen versorgt. Mit dem „Beauftragen“
eines Objekts oder einer Klasse bzw. mit dem „Zustellen einer Botschaft“ ist nichts anderes gemeint
als ein Methodenaufruf.
In der Hoffnung, dass die bisher präsentierten Eindrücke von der objektorientierten Programmierung (OOP) neugierig gemacht und nicht abgeschreckt haben, kommen wir nun zur systematischen
Behandlung dieser Softwaretechnologie. Für die in Abschnitt 1 speziell für größere Projekte empfohlene objektorientierte Analyse (z.B. mit Hilfe der UML) ist dabei leider keine Zeit (siehe z.B.
Balzert 1999).
4.1 Überblick, historische Wurzeln, Beispiel
4.1.1 Einige Kernideen und Vorzüge der OOP
Lahres & Rayman (2006) nennen in ihrem Praxisbuch Objektorientierung unter Berufung auf Alan
Kay, der den Begriff Objektorientierte Programmierung geprägt und die objektorientierte Programmiersprache Smalltalk entwickelt hat, als unverzichtbare OOP-Grundelemente:



Datenkapselung
Mit diesem Thema haben wir uns bereits beschäftigt. Das vorhandene Wissen soll gleich
vertieft und gefestigt werden.
Vererbung
Dieses OOP-Element wird gleich vorgestellt und später ausführlich behandelt.
Polymorphie
Weil die Vorteile der Polymorphie mit den bisherigen Beispielen nicht plausibel vermittelt
werden können, schieben wir die Behandlung dieses Themas noch etwas auf.
4.1.1.1 Datenkapselung und Modularisierung
In der objektorientierten Programmierung (OOP) wird die traditionelle Trennung von Daten und
Operationen aufgegeben. Hier besteht ein Programm aus Klassen, die durch Felder (also Daten)
und Methoden (also Operationen) sowie weitere Bestandteile definiert sind. Wie Sie bereits aus
dem Einleitungsbeispiel wissen, steht in C# eine Eigenschaft für ein Paar von Methoden zum Lesen bzw. Verändern eines Feldes. Eine Klasse wird in der Regel ihre Felder gegenüber anderen
Klassen verbergen (Datenkapselung, information hiding) und so vor ungeschickten Zugriffen
schützen. Die Methoden und Eigenschaften einer Klasse sind hingegen von Außen ansprechbar und
bilden ihre Schnittstelle. Dies kommt in der folgenden Abbildung zum Ausdruck, die Sie im Wesentlichen schon aus Abschnitt 1 kennen:
Kapitel 4: Klassen und Objekte
od
e
134
e
l
od
od
e
l
e
Eigenschaft
Me
th
ma
od
Me
rk
ma
rk
Me
th
Me
Feld
Merkmal
Me
th
l
ma
en
sch
Ei g
Me
rk
l
Feld
Klasse AFeld
ft
ma
ha
sc
en
rk
Me
Merkmal
Feld
Ei g
aft
Me
th
Methode
Es kann aber auch private Methoden für den ausschließlich internen Gebrauch geben. Öffentliche
Felder einer Klasse gehören zu ihrer Schnittstelle und sollten konstant gesetzt (siehe Abschnitt
3.3.7), also vor Veränderungen geschützt sein (z.B. Math.PI).
Klassen mit Datenkapselung realisieren besser als frühere Software-Technologien (siehe Abschnitt
4.1.2) das Prinzip der Modularisierung, das schon Julius Cäsar (100 v. Chr. - 44 v. Chr.) bei seiner
beruflichen Tätigkeit als römischer Kaiser und Feldherr erfolgreich einsetzte (Divide et impera!). 1
Die Modularisierung ist ein probates, ja unverzichtbares Mittel der Software-Entwickler zur Bewältigung von Projekten mit hoher Komplexität.
Aus der Datenkapselung und der Modularisierung ergeben sich gravierende Vorteile für die Softwareentwicklung:
1

Vermeidung von Fehlern, Erleichterung der Fehlersuche
Direkte Schreibzugriffe auf die Felder einer Klasse bleiben den klasseneigenen Methoden
und Eigenschaften vorbehalten, die vom Designer der Klasse sorgfältig entworfen wurden.
Damit sollten Programmierfehler seltener werden. In unserem Bruch-Beispiel haben wir
dafür gesorgt, dass unter keinen Umständen der Nenner eines Bruches auf Null gesetzt wird.
Anwender unserer Klasse können einen Nenner einzig über die Eigenschaft Nenner()
verändern, die aber den Wert Null nicht akzeptiert. Bei einer anderen Klasse kann es erforderlich sein, dass für eine Gruppe von Feldern bei jeder Änderung gewissen Konsistenzbedingungen eingehalten werden. Treten in einem Programm trotz Datenkapselung Fehler wegen pathologischer Variablenausprägungen auf, sind diese relativ leicht zu lokalisieren, weil
nur wenige Methoden verantwortlich sein können. Per Datenkapselung werden also die
Kosten reduziert, die durch Programmierfehler entstehen.

Produktivität
Selbständig agierende Klassen, die ein Problem ohne überflüssige Anhängigkeiten von anderen Programmbestandteilen lösen, sind potenziell in vielen Projekten zu gebrauchen
(Wiederverwendbarkeit). Wer als Programmierer eine Klasse verwendet, braucht sich um
deren inneren Aufbau nicht zu kümmern, so dass neben dem Fehlerrisiko auch der Einarbeitungsaufwand sinkt. Wir werden z.B. in GUI-Programmen einen recht kompletten Rich-
Deutsche Übersetzung: Teile und herrsche!
135
Abschnitt 4.1 Überblick, historische Wurzeln, Beispiel
Text-Editor über eine Klasse aus der Standardbibliothek integrieren, ohne wissen zu müssen, wie Text und Textauszeichnungen intern verwaltet werden.

Flexibilität, Anpassungsfähigkeit, vereinfachte Wartung
Datenkapselung schafft günstige Voraussetzungen für die Wartung bzw. Verbesserung einer
Klassendefinition. Solange die Methoden und Eigenschaften der Schnittstelle unverändert
bzw. kompatibel bleiben, kann die interne Architektur einer Klasse ohne Nebenwirkungen
auf andere Programmteile beliebig geändert werden.

Erfolgreiche Teamarbeit durch abgeschottete Verantwortungsbereiche
In großen Projekten können mehrere Programmierer nach der gemeinsamen Entwicklung
von Schnittstellen relativ unabhängig an verschiedenen Klassen arbeiten.
4.1.1.2 Vererbung
Zu den Vorzügen der „super-modularen“ Klassenkonzeption gesellt sich in der OOP ein Vererbungsverfahren, das beste Voraussetzungen für die Erweiterung von Softwaresystemen bei rationeller Wiederverwendung der bisherigen Code-Basis schafft: Bei der Definition einer neuen Klasse
können alle Merkmale und Handlungskompetenzen (Methoden, Eigenschaften) einer Basisklasse
übernommen werden. Es ist also leicht, ein Softwaresystem um neue Klassen mit speziellen Leistungen zu erweitern. Durch systematische Anwendung des Vererbungsprinzips entstehen mächtige
Klassenhierarchien, die in zahlreichen Projekten einsetzbar sind. Neben der direkten Nutzung vorhandener Klassen (über statische Methoden oder erzeugte Objekte) bietet die OOP mit der Vererbungstechnik eine weitere Möglichkeit zur Wiederverwendung von Software.
Im .NET – Framework wird das Vererbungsprinzip sogar auf die Spitze getrieben: Alle Klassen
(und auch die Strukturen, siehe unten) stammen von der Urahnklasse Object ab, die an der Spitze
des hierarchischen .NET - Klassensystems steht, das man als seiner Universalität wegen als Common Type System (CTS) bezeichnet. Weil sich im Handlungsrepertoire der Urahnklasse u.a. auch
die Methode GetType() befindet, kann man beliebige .NET - Objekte und Strukturinstanzen (siehe
unten) nach ihrem Datentyp befragen. Im folgenden Programm BruchRechnung wird ein
Bruch-Objekt (vgl. Abschnitt 1.1) nach seinem Datentyp befragt:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
Bruch b = new Bruch();
Console.WriteLine(b.GetType().FullName);
}
}
Bruch
Von GetType() erhält man als Rückgabewert eine Referenz auf ein Objekt der Klasse Type. Über
diese Referenz wird das Type-Objekt gebeten, den Wert seiner Eigenschaft FullName mitzuteilen.
Diese Zeichenfolge mit dem Typnamen (ein Objekt der Klasse String 1) bildet schließlich den Aktualparameter des WriteLine()-Aufrufs und landet auf der Konsole. In unserem Kursstadium ist es
angemessen, die komplexe Anweisung unter Beteiligung von vier Klassen (Console, Bruch, Type,
String), zwei Methoden (WriteLine(), GetType()), einer Eigenschaft (FullName), einer expliziten
Referenzvariablen (b) und einer impliziten Referenz (GetType()-Rückgabewert) genau zu erläutern:
1
Eine Besonderheit der Klasse String ist der aus Bequemlichkeitsgründen vom Compiler unterstützte Aliasname
string (mit kleinem Anfangsbuchstaben).
Kapitel 4: Klassen und Objekte
136
Methodenaufruf,
gerichtet an die
Klasse Console
Referenz auf
ein BruchObjekt
Eigenschaftszugriff, gerichtet an das
von GetType() gelieferte Type-Objekt,
liefert ein String-Objekt
Console.WriteLine(b.GetType().FullName);
Methodenaufruf, gerichtet an das Bruch -Objekt b,
mit Referenz auf Type-Objekt als Rückgabewert
Der FullName-Eigenschaftszugriff ist im Beispiel nicht unbedingt erforderlich, weil die Methode
WriteLine() auch mit Objekten (z.B. aus der Klasse Type) umzugehen weiß und deren garantiert
vorhandene ToString()-Methode nutzt, um sich ein ausgabefähiges String-Objekt zu besorgen.
Durch die technischen Details darf nicht der Blick auf das wesentliche Thema des aktuellen Abschnitts verstellt werden: Die Objekte der Klasse Bruch beherrschen dank Vererbung die Methode
GetType(), obwohl in der Bruch-Klassendefinition nichts davon zu sehen ist.
4.1.1.3 Realitätsnahe Modellierung
Klassen sind nicht nur ideale Bausteine für die rationelle Konstruktion von Softwaresystemen, sondern erlauben auch eine gute Abbildung des Anwendungsbereichs. In der zentralen Projektphase
der objektorientierten Analyse und Modellierung sprechen Softwareentwickler und Auftraggeber
dieselbe Sprache, so dass Kommunikationsprobleme weitgehend vermieden werden.
Neben den Klassen zur Modellierung von Akteuren oder Ereignissen des realen Anwendungsbereichs sind bei einer typischen Anwendung aber auch zahlreiche Klassen beteiligt, die Akteure oder
Ereignisse der virtuellen Welt des Computers repräsentieren (z.B. Bildschirmfenster, Ausnahmefehler).
4.1.2 Strukturierte Programmierung und OOP
In vielen klassischen Programmiersprachen (z.B. C, Fortran oder Pascal) sind zur Strukturierung
von Programmen zwei Techniken verfügbar, die in weiterentwickelter Form auch bei der OOP genutzt werden:

Unterprogrammtechnik
Man zerlegt ein Gesamtproblem in mehrere Teilprobleme, die jeweils in einem eigenen Unterprogramm gelöst werden. Wird die von einem Unterprogramm erbrachte Leistung wiederholt (an verschiedenen Stellen eines Programms) benötigt, muss jeweils nur ein Aufruf
mit dem Namen des Unterprogramms und passenden Parametern eingefügt werden. Durch
diese Strukturierung ergeben sich kompaktere und übersichtlichere Programme, die leichter
erstellt, analysiert, korrigiert und erweitert werden können. Praktisch alle traditionellen Programmiersprachen unterstützen solche Unterprogramme (Subroutinen, Funktionen, Prozeduren), und meist stehen auch umfangreiche Bibliotheken mit fertigen Unterprogrammen für
diverse Standardaufgaben zur Verfügung. Beim Einsatz einer Unterprogrammsammlung
klassischer Art muss der Programmierer passende Daten bereitstellen, auf die dann vorgefertigte Routinen losgelassen werden. Der Programmierer hat also seine Datensammlung und
das Arsenal der verfügbaren Unterprogramme (aus fremder Quelle oder selbst erstellt) zu
verwalten und zu koordinieren.

Problemadäquate Datentypen
Zusammengehörige Daten unter einem Variablennamen ansprechen zu können, vereinfacht
das Programmieren erheblich. Mit dem Datentyp struct der Programmiersprache C oder
Abschnitt 4.1 Überblick, historische Wurzeln, Beispiel
137
dem analogen Datentyp record der Programmiersprache Pascal lassen sich problemadäquate Datentypen mit mehreren Bestandteilen konstruieren, die jeweils einen beliebigen, bereits
bekannten Typ haben dürfen. So eignet sich etwa für ein Programm zur Adressenverwaltung
ein neu definierter Datentyp mit Elementen für Name, Vorname, Telefonnummer etc. Alle
Adressinformationen zu einer Person lassen sich dann in einer Variablen vom selbst definierten Typ speichern. Dies vereinfacht z.B. das Lesen, Kopieren oder Schreiben solcher
Daten.
Die problemadäquate Datentypen der älteren Programmiersprachen werden in C# durch Klassen
ersetzt, wobei diese Datentypen nicht nur durch eine Anzahl von Merkmalen (Feldern beliebigen
Typs) charakterisiert sind, sondern auch Handlungskompetenzen (Methoden, Eigenschaften) besitzen, welche die Aufgaben der Funktionen bzw. Prozeduren der älteren Programmiersprachen übernehmen.
Im Vergleich zur strukturierten Programmierung bietet die OOP u.a. folgende Fortschritte:




Optimierte Modularisierung mit Zugriffsschutz
Die Daten sind sicher in Objekten gekapselt, während sie bei traditionellen Programmiersprachen entweder als globale Variablen allen Missgriffen ausgeliefert sind oder zwischen
Unterprogrammen „wandern“ (Goll et al. 2000, S. 21), was bei Fehlern zu einer aufwändigen Suche entlang der Verarbeitungskette führen kann.
Bessere Abbildung des Anwendungsbereichs
Rationellere (Weiter-)Entwicklung von Software durch die Vererbungstechnik
Mehr Bequemlichkeit für Bibliotheksbenutzer
Jede rationelle Softwareproduktion greift in hohem Maß auf Bibliotheken mit bereits vorhandenen Lösungen zurück. Dabei sind die Klassenbibliotheken der OOP einfacher zu verwenden als klassische Unterprogrammbibliotheken.
4.1.3 Auf-Bruch zu echter Klasse
In den Beispielprogrammen der Abschnitte 2 und 3 wurde mit der Klassendefinition lediglich eine
in C# unausweichliche formale Anforderung an Programme erfüllt. Die in Abschnitt 1.1 vorgestellte Klasse Bruch realisiert hingegen wichtige Prinzipien der objektorientierten Programmierung.
Sie wird nun wieder aufgegriffen und in verschiedenen Varianten bzw. Ausbaustufen als Beispiel
verwendet. Darauf basierende Programme sollen Schüler beim Erlernen der Bruchrechnung unterstützen. Eine objektorientierte Analyse der Problemstellung ergab, dass in einer elementaren Ausbaustufe des Programms lediglich eine Klasse zur Repräsentation von Brüchen benötigt wird. Später sind weitere Klassen (z.B. Aufgabe, Übungsaufgabe, Testaufgabe, Schüler, Lernepisode, Testepisode, Fehler) zu ergänzen.
Wir nehmen nun bei der Bruch-Klassendefinition im Vergleich zur Variante in Abschnitt 1.1 einige Verbesserungen vor:



Als zusätzliches Feld erhält jeder Bruch ein etikett vom Datentyp string. Damit wird eine beschreibende Zeichenfolge verwaltet, die z.B. beim Aufruf der Methode Zeige() zusätzlich auf dem Bildschirm erscheint. Objekte der erweiterten Bruch-Klasse besitzen also
auch eine Instanzvariable mit Referenztyp (neben den int-Feldern zaehler und nenner).
Weil die Bruch-Klasse ihre Merkmale kapselt, also fremden Klassen keine direkten Zugriffe erlaubt, stellt sie auch für das Feld etikett eine Eigenschaft (namens Etikett, mit
großem Anfangsbuchstaben) für das Lesen und das (kontrollierte) Verändern des Feldes zur
Verfügung.
Wir erlauben uns einen erneuten Vorgriff auf die später noch ausführlich zu diskutierende
Ausnahmebehandlung per try-catch - Anweisung, um in der Bruch-Methode Frage()
sinnvoll auf fehlerhafte Benutzereingaben reagieren zu können. Die kritischen Aufrufe der
Kapitel 4: Klassen und Objekte
138

Convert-Methode ToInt32() finden nun innerhalb eines try-Blocks statt. Bei einem Ausnahmefehler aufgrund einer irregulären Eingabe wird daher nicht mehr das Programm beendet, sondern der zugehörige catch-Block ausgeführt. Damit die Methode Frage() den
Aufrufer über eine reibungslose oder verpatzte Ausführung informieren kann, wechselt der
Rückgabetyp von void zu bool. Mit der return-Anweisung wird am Ende einer erfolgreichen Ausführung der Wert true bzw. nach dem Auftreten einer Ausnahme der Wert false
zurückgemeldet.
In der Methode Kuerze() wird die performante Modulo-Variante von Euklids Algorithmus zur Bestimmung des größten gemeinsamen Teilers von zwei ganzen Zahlen verwendet
(vgl. Übungsaufgabe in Abschnitt 3.7.4).
Im folgenden Quellcode der erweiterten Bruch-Klasse sind die unveränderten Methoden bzw. Eigenschaften gekürzt wiedergegeben:
using System;
public class Bruch {
int zaehler,
// zaehler wird automatisch mit 0 initialisiert
nenner = 1;
string etikett = ""; // die Ref.typ-Init. auf null wird ersetzt, siehe Text
public int Zaehler {
. . .
}
public int Nenner {
. . .
}
public string Etikett {
get {
return etikett;
}
set {
if (value.Length <= 40)
etikett = value;
else
etikett = value.Substring(0, 40);
}
}
public void Kuerze() {
// größten gemeinsamen Teiler mit dem Euklids Algorithmus bestimmen
// (performante Variante mit Modulo-Operator)
if (zaehler != 0) {
int rest;
int ggt = Math.Abs(zaehler);
int divisor = Math.Abs(nenner);
do {
rest = ggt % divisor;
ggt = divisor;
divisor = rest;
} while (rest > 0);
zaehler /= ggt;
nenner /= ggt;
} else
nenner = 1;
}
public void Addiere(Bruch b) {
. . .
}
Abschnitt 4.1 Überblick, historische Wurzeln, Beispiel
139
public bool Frage() {
try {
Console.Write("Zaehler: ");
int z = Convert.ToInt32(Console.ReadLine());
Console.Write("Nenner : ");
int n = Convert.ToInt32(Console.ReadLine());
Zaehler = z;
Nenner = n;
return true;
} catch {
return false;
}
}
public void Zeige() {
string luecke = "";
for (int i=1; i <= etikett.Length; i++)
luecke = luecke + " ";
Console.WriteLine(" {0}
{1}\n {2} -----\n {0}
{3}\n",
luecke, zaehler, etikett, nenner);
}
}
Im Unterschied zur Präsentation in Abschnitt 1.1 wird die Bruch-Klassendefinition anschließend
gründlich erläutert. Dabei machen die in Abschnitt 4.2 behandelten Instanzvariablen (Felder) relativ
wenig Mühe, weil wir viele Details schon von den lokalen Variablen her kennen. Bei den Methoden
gibt es mehr Neues zu lernen, so dass wir uns in Abschnitt 4.3 auf elementare Themen beschränken
und später noch wichtige Ergänzungen behandeln.
Jede .NET – Anwendung benötigt eine Startklasse mit statischer Main()-Methode, die von der CLR
(Common Language Runtime) beim Programmstart aufgerufen wird. Für die bei diversen Demonstrationen in den folgenden Abschnitten verwendeten Startklassen (mit jeweils spezieller Implementation) werden wir den Namen BruchRechnung verwenden, z.B.:
using System;
class BruchRechnung {
static void Main() {
Console.WriteLine("Kürzen von Brüchen\n------------------\n");
Bruch b = new Bruch();
b.Frage();
b.Kuerze();
b.Etikett = "Der gekürzte Bruch:";
b.Zeige();
Console.ReadLine();
}
}
In der Main()-Methode dieses Programms zum Kürzen von Brüchen wird ein Objekt aus der Klasse
Bruch erzeugt und mit Aufträgen versorgt.
Zwei Bemerkungen zum Kopf einer Klassendefinition:


Im Beispiel ist die Klasse Bruch ist als public definiert, damit sie uneingeschränkt von anderen Klassen (aus beliebigen Assemblies) genutzt werden kann. Weil bei der startfähigen
Klasse BruchRechnung eine solche Nutzung nicht in Frage kommt, wird hier auf den
(zum Starten durch die CLR nicht erforderlichen) Zugriffsmodifikator public verzichtet.
Klassennamen beginnen einer allgemein akzeptierten C# - Konvention folgend mit einem
Großbuchstaben. Besteht ein Name aus mehreren Wörtern (z.B. BruchRechnung),
schreibt man der besseren Lesbarkeit wegen die Anfangsbuchstaben aller Wörter groß (Pascal-Casing, siehe Abschnitt 3.1.5).
Kapitel 4: Klassen und Objekte
140
Hinsichtlich der Dateiverwaltung wird vorgeschlagen:


Die Klassendefinitionen (z.B. bei Bruch und BruchRechnung) sollten jeweils in einer
eigenen Datei gespeichert werden.
Den Namen dieser Datei sollte man aus dem Klassennamen durch Anhängen der Erweiterung .cs bilden.
Beim gemeinsamen Übersetzen der Quellcode-Dateien Bruch.cs und BruchRechnung.cs entsteht
ein Exe-Assembly, dessen Namen man bei Verwendung der Entwicklungsumgebung Visual C#
2008 Express Edition nach dem Menübefehl
Projekt > Eigenschaften
ändern kann, wenn die vom Assistenten für neue Projekte vergebene Voreinstellung nicht (mehr)
gefällt, z.B.:
Im Beispiel entsteht die Assembly-Datei b0.exe.
4.2 Instanzvariablen
Die Instanzvariablen (bzw. -felder) einer Klasse besitzen viele Gemeinsamkeiten mit den lokalen
Variablen, die wir im Abschnitt 3 über elementare Sprachelemente ausführlich behandelt haben,
doch gibt es auch wichtige Unterschiede, die im Mittelpunkt des aktuellen Abschnitts stehen. Unsere Klasse Bruch besitzt nach der Erweiterung um ein beschreibendes Etikett folgende Instanzvariablen:



zaehler (Datentyp int)
nenner (Datentyp int)
etikett (Datentyp string)
Zu den beiden Feldern zaehler und nenner vom elementaren Datentyp int ist das Feld etikett mit dem Referenzdatentyp string (ein Alias für den Klassennamen String) dazugekommen.
Jedes nach dem Bruch-Bauplan geschaffene Objekt erhält seine eigene, komplette Ausstattung mit
diesen Variablen.
141
Abschnitt 4.2 Instanzvariablen
4.2.1 Deklaration mit Wahl der Schutzstufe
Während lokale Variablen im Anweisungsteil einer Methode deklariert werden, erscheinen die Deklarationen der Instanzvariablen in der Klassendefinition außerhalb jeder Methodendefinition. Man
sollte die Instanzvariablen der Übersichtlichkeit halber am Anfang der Klassendefinition deklarieren, wenngleich der Compiler auch ein späteres Erscheinen akzeptiert.
Bei der Deklaration von Instanzvariablen werden zur Spezifikation der Schutzstufe (und für andere
Zwecke) oft Modifikatoren benötigt (auch mehrere), so dass die Syntax im Vergleich zur Deklaration einer lokalen Variablen entsprechend erweitert werden muss.
Deklaration von Instanzvariablen (-feldern)
Typname
Modifikator
Feldname
=
Ausdruck
;
,
In C# besitzen alle Instanzvariablen per Voreinstellung die Schutzstufe private, so dass sie nur von
klasseneigenen Methoden bzw. Eigenschaften angesprochen werden können. Weil bei den BruchFeldern diese voreingestellte Datenkapselung erwünscht ist, kommen die Felddeklarationen ohne
Modifikatoren aus:
int zaehler,
nenner = 1;
string etikett = "";
Um fremden Klassen trotzdem einen (allerdings kontrollierten!) Zugang zu den Bruch-Instanzvariablen zu ermöglichen, ist jeweils eine zugehörige Eigenschaft vorhanden.
Auf den ersten Blick scheint die Datenkapselung nur beim Nenner eines Bruches relevant zu sein,
doch auch bei den restlichen Instanzvariablen bringt sie (potentiell) Vorteile:

Zugunsten einer übersichtlichen Bildschirmausgabe soll das Etikett auf 40 Zeichen beschränkt bleiben. Mit Hilfe der Eigenschaft Etikett() kann dies gewährleistet werden.

Abgeleitete (erbende) Klassen (siehe unten) werden in die Eigenschaften Zaehler und
Nenner neben der Null-Überwachung für den Nenner eventuell noch weitere WertRestriktionen einbauen (z.B. Beschränkung auf positive Zahlen). Während solche Anpassungen von Zugriffsmethoden problemlos möglich sind, ist beim Ableiten eine Einschränkung von Zugriffsrechten verboten. Für eine als public deklarierte Instanzvariable zaehler wären also auch in abgeleiteten Klassen keine Wertrestriktionen möglich.

Oft kann der Datentyp von gekapselten Instanzvariablen geändert werden, ohne dass die
Schnittstelle inkompatibel zu vorhandenen Programmen wird, welche die Klasse benutzen.
Im Fall der Bruch-Felder zaehler und nenner wird man vielleicht zur Vermeidung
von Überlaufproblemen (vgl. Abschnitt 3.6.1) irgendwann den Datentyp int durch den größeren Typ long ersetzen. Davon würde z.B. die Methode Addiere() profitieren:
public void Addiere(Bruch b) {
zaehler = zaehler*b.nenner + b.zaehler*nenner;
nenner = nenner*b.nenner;
Kuerze();
}
Trotz ihrer überzeugenden Vorteile soll die Datenkapselung nicht zum Dogma erhoben werden. Sie
ist überflüssig, wenn bei einem Feld Lese- und Schreibzugriff erlaubt sind und eine Änderung des
Datentyps nicht in Frage kommt. Um allen Klassen den Direktzugriff auf ein Feld zu erlauben, wird
in seiner Deklaration der Modifikator public angegeben, z.B.:
Kapitel 4: Klassen und Objekte
142
public int Zaehler;
In Abschnitt 4.9 finden Sie eine Tabelle mit allen verfügbaren Schutzstufen und zugehörigen Modifikatoren.
Bei der Benennung von Instanzvariablen haben sich folgende Regeln etabliert:


Für Felder mit den Schutzstufen private, protected oder internal wird das Camel Casing
verwendet.
Für (die seltenen) Felder mit der Schutzstufe public wird das Pascal Casing verwendet.
Um private Instanzvariablen besser von lokalen Variablen und Formalparametern unterscheiden zu
können, verwenden manche Programmierer ein Präfix, z.B.:
int m_nenner; //das m steht für "Member"
int _nenner;
4.2.2 Konstante und schreibgeschützte Instanzvariablen
Neben der Schutzstufenwahl gibt es weitere Anlässe für den Einsatz von Modifikatoren in Felddeklarationen. Mit dem Modifikator const können nicht nur lokale Variablen (siehe Abschnitt 3.3.7)
sondern auch Felder einer Klasse als konstant deklariert werden, wobei der initialisierende Ausdruck zur Übersetzungszeit berechenbar sein muss. Im Zusammenhang mit dem OOP-Prinzip der
Datenkapselung sind konstante und öffentliche Felder oft eine praktische Möglichkeit, um fremden
Klassen ohne Risiko einen syntaktisch einfachen Lesezugriff zu gestatten. In der FCL-Klasse Math
(Namensraum System) ist z.B. das double-Feld PI als public (allgemein verfügbar) und const deklariert:
public const double PI = 3.14159265358979323846;
Konstante Felder einer Klasse sind grundsätzlich statisch, und der Modifikator static kann nicht
zusammen mit const verwendet werden. Bei Referenztyp-Konstanten ist mit Ausnahme der Klasse
String als Initialisierungswert lediglich null erlaubt, so dass bei konstanten Feldern nur die elementaren Datentypen und der Typ String sinnvoll sind.
Soll eine Instanzvariable (von beliebigem Typ) zur Laufzeit in einem so genannten Konstruktor
(siehe Abschnitt 4.4.3) initialisiert und dann fixiert werden, verwendet man den Modifikator
readonly, z.B.:
Quellcode
Ausgabe
using System;
class Klaas {
public readonly int Readomo;
public Klaas(int i) {
Readomo = i;
}
}
class Prog {
static void Main() {
Klaas p = new Klaas(7);
Console.WriteLine(p.Readomo);
}
}
7
Bei den Konstruktoren handelt es sich um spezielle Methoden, die den Namen ihrer Klasse tragen
und keinen Rückgabetyp besitzen (auch nicht void). Im Beispiel verwendet der Konstruktor zur
Initialisierung der schreibgeschützten Instanzvariablen ein Befehlszeilenargument.
143
Abschnitt 4.2 Instanzvariablen
4.2.3 Sichtbarkeitsbereich, Existenz und Ablage im Hauptspeicher
Von den lokalen Variablen einer Methode unterscheiden sich die Instanzvariablen (Felder) einer
Klasse vor allem in Bezug auf den Sichtbarkeitsbereich, die Lebensdauer und die Ablage im Hauptspeicher:
lokale Variable
Eine lokale Variable ist nur in ihrer eigenen Methode sichtbar.
Nach der Deklarationsanweisung
kann sie in den restlichen Anweisungen des lokalsten Blocks angesprochen werden. Eingeschachtelte
Blöcke gehören zum Sichtbarkeitsbereich. In aufgerufenen Methoden ist die lokale Variable nicht
sichtbar.
Instanzvariable
Die Instanzvariablen eines Objekts sind
in allen Methoden sichtbar, die eine
Referenz zum Objekt kennen. Je nach
Schutzstufe ist Methoden fremder Klassen der Zugriff jedoch verwehrt.
Instanzvariablen werden durch gleichnamige lokale Variablen überlagert,
können jedoch über das vorgeschaltete
Schlüsselwort this weiter angesprochen
werden (siehe Abschnitt 4.4.5.3).
Lebensdauer
Sie existiert von der Deklaration
bis zum Verlassen des lokalsten
Blocks.
Ablage im
Speicher
Sie wird auf dem so genannten
Stack (deutsch: Stapel) gespeichert. Innerhalb des programmeigenen Speichers dient dieses Segment zur Verwaltung von Methodenaufrufen.
Für jedes neue Objekt wird ein Satz mit
allen Instanzvariablen seiner Klasse
erzeugt. Die Instanzvariablen existieren
bis zum Ableben des Objekts. Ein Objekt wird zur Entsorgung freigegeben,
sobald keine Referenz auf das Projekt
mehr vorhanden ist.
Die Objekte landen mit ihren Instanzvariablen in einem Bereich des programmeigenen Speichers, der als Heap
(deutsch: Haufen) bezeichnet wird.
Sichtbarkeit
Während die folgende Main()-Methode
class BruchRechnung {
static void Main() {
Bruch b1 = new Bruch(), b2 = new Bruch();
int i = 13, j = 4711;
b1.Etikett = "b1";
b2.Etikett = "b2";
. . .
}
}
ausgeführt wird, befinden sich auf dem Stack die lokalen Variablen b1, b2, i und j. Die beiden
Bruch-Referenzvariablen (b1, b2) zeigen jeweils auf ein Bruch-Objekt auf dem Heap, das einen
kompletten Satz der Bruch-Instanzvariablen besitzt: 1
1
Hier wird aus didaktischen Gründen ein wenig gemogelt: Die beiden Etiketten sind selbst Objekte und liegen „neben“ den Bruch-Objekten auf dem Heap. In jedem Bruch-Objekt befindet sich eine Referenz-Instanzvariable namens etikett, die auf das zugehörige String-Objekt zeigt.
Kapitel 4: Klassen und Objekte
144
Stack
Heap
b1
Bruch-Objekt
76788700
zaehler
nenner
etikett
0
1
"b1"
b2
76788716
Bruch-Objekt
i
zaehler
nenner
etikett
0
1
"b2"
13
j
4711
4.2.4 Initialisierung
Während bei lokalen Variablen der Programmierer für die Initialisierung verantwortlich ist, erhalten
die Instanzvariablen eines neuen Objektes automatisch folgende Startwerte, falls der Programmierer
nicht eingreift:
Datentyp
Initialisierung
sbyte, byte, short, ushort, int, uint, long, ulong
0
float, double, decimal
0,0
char
0 (Unicode-Zeichennummer)
bool
false
Referenztyp
null
Im Bruch-Beispiel wird nur die automatische zaehler-Initialisierung unverändert übernommen:


Beim nenner eines Bruches wäre die Initialisierung auf Null bedenklich, weshalb eine explizite Initialisierung auf den Wert Eins vorgenommen wird.
Wie noch näher zu erläutern sein wird, ist string in C# kein primitiver Datentyp, sondern eine Klasse. Variablen von diesem Typ können einen Verweis auf ein Objekt aus dieser Klasse aufnehmen. Solange kein zugeordnetes Objekt existiert, hat eine string-Instanzvariable
den Wert null, zeigt also auf nichts. Weil der etikett-Wert null z.B. beim Aufruf der
Bruch-Methode Zeige() einen Laufzeitfehler (NullReferenceException) zu Folge hätte,
wird ein string-Objekt mit einer leeren Zeichenfolge erstellt und zur etikettInitialisierung verwendet. Das Erzeugen des string-Objekts erfolgt implizit, indem der
string-Variablen etikett ein Zeichenfolgen-Literal zugewiesen wird.
Abschnitt 4.3 Instanzmethoden
145
4.2.5 Zugriff in klasseneigenen und fremden Methoden
In den Instanzmethoden einer Klasse können die Instanzvariablen des aktuellen (die Methode ausführenden) Objekts direkt über ihren Namen angesprochen werden, was z.B. in der BruchMethode Zeige() zu beobachten ist:
Console.WriteLine(" {0}
{1}\n {2} -----\n {0}
{3}\n",
luecke, zaehler, etikett, nenner);
Im Beispiel zeigt sich syntaktisch kein Unterschied zwischen dem Zugriff auf die Instanzvariablen
(zaehler, nenner, etikett) und dem Zugriff auf die lokale Variable (luecke). Gelegentlich
kann es (z.B. der Klarheit halber) sinnvoll sein, einem Instanzvariablennamen über das Schlüsselwort this (vgl. Abschnitt 4.4.5.3) eine Referenz auf das handelnde Objekt voranzustellen, z.B.:
Console.WriteLine(" {0}
{1}\n {2} -----\n {0}
{3}\n",
luecke, this.zaehler, this.etikett, this.nenner);
Beim Zugriff auf eine Instanzvariable eines anderen Objektes derselben Klasse muss dem Variablennamen eine Referenz auf das Objekt vorangestellt werden, wobei die Bezeichner durch den
Punktoperator zu trennen sind. In der folgenden Zeile aus der Bruch-Methode Addiere()greift das handelnde Objekt lesend auf die Instanzvariablen eines anderen Bruch-Objekts zu,
das über die Referenzvariable b angesprochen wird:
nenner = nenner * b.nenner;
Direkte Zugriffe auf die Instanzvariablen eines Objekts durch Methoden fremder Klassen sind zwar
nicht grundsätzlich verboten, verstoßen aber gegen das Prinzip der Datenkapselung, das in der OOP
von zentraler Bedeutung ist. Würden die Bruch-Instanzvariablen mit dem Modifikator public, also
ohne Datenkapselung deklariert, dann könnte z.B. der Nenner eines Bruches in der Main()Methode der fremden Klasse BruchRechnung direkt angesprochen werden:
Console.WriteLine(b.nenner);
b.nenner = 0;
In der von uns tatsächlich realisierten Bruch-Definition werden solche Zu- bzw. Fehlgriffe jedoch
vom Compiler verhindert, z.B.:
Der Zugriff auf "Bruch.nenner" ist aufgrund der Sicherheitsebene nicht
möglich
In diesem Abschnitt wurden eine Syntaxregel und ein Designprinzip dargestellt:


Instanzvariablen des handelnden Objekts können über den Variablennamen angesprochen
werden. Beim Zugriff auf Instanzvariablen eines anderen Objekts ist eine Referenzvariable
erforderlich.
Direkte Zugriffe auf Instanzvariablen sind in C# per Voreinstellung nur klasseneigenen Methoden erlaubt. Diese Schutzstufe private sollte in der Regel beibehalten werden.
4.3 Instanzmethoden
In einer Bauplan-Klassendefinition werden Objekte entworfen, die eine Anzahl von Verhaltenskompetenzen (Methoden) besitzen, die von anderen Programmbestandteilen (Klassen oder Objekten) per Methodenaufruf genutzt werden können. Objekte sind also Dienstleister, die eine Reihe von
Nachrichten interpretieren und mit passendem Verhalten beantworten können.
Wie es im Objekt drinnen aussieht, geht andere Programmierer nichts an (information hiding). Seine Instanzvariablen sind bei konsequenter Datenkapselung für Objekte (bzw. Methoden) fremder
Klassen unsichtbar. Um anderen Klassen trotzdem (kontrollierte) Zugriffe auf ein Feld zu ermöglichen, definiert man in C# in der Regel eine zugehörige Eigenschaft, die der Compiler letztlich in
Zugriffsmethoden übersetzt (siehe Abschnitt 4.5).
Kapitel 4: Klassen und Objekte
146
Beim Aufruf einer Methode ist oft über so genannte Parameter die gewünschte Verhaltensweise
näher zu spezifizieren, und bei vielen Methoden wird dem Aufrufer ein Rückgabewert geliefert,
z.B. mit der angeforderten Information.
Ziel einer typischen Klassendefinition sind kompetente, einfach und sicher einsetzbare Objekte, die
oft auch noch reale Objekte aus dem Aufgabenbereich der Software gut repräsentieren sollen.
Wenn ein Programmierer z.B. ein Objekt aus unserer Bruch-Klasse verwendet, kann er zur Bildschirmausgabe auf die Methode Zeige() zurückgreifen:
public void Zeige() {
string luecke = "";
for (int i = 1; i <= etikett.Length; i++)
luecke = luecke + " ";
Console.WriteLine(" {0}
{1}\n {2} -----\n {0}
{3}\n",
luecke, zaehler, etikett, nenner);
}
Weil diese Methode auch für fremde Klassen verfügbar sein soll, wird per Modifikator die Schutzstufe public gewählt.
Da es vom Verlauf einer Bildschirmausgabe nichts zu berichten gibt, liefert Zeige() keinen
Rückgabewert. Folglich ist im Kopf der Methodendefinition der Rückgabetyp void anzugeben.
Weil die Zeige()-Methode nur eine einzige Arbeitsweise beherrscht, sind keine Parameter vorhanden, wobei die leere Parameterliste aber weder bei der Definition noch beim Aufruf der Methode fehlen darf. Leere Rückgaben und Parameterlisten sind keinesfalls die Regel, so dass wir uns
bald näher mit den Kommunikationsregeln beim Methodenaufruf beschäftigen müssen.
In der Zeige() - Implementierung gelingt mit einfachen Mitteln eine brauchbar formatierte Konsolenausgabe von etikettierten Brüchen, z.B.:
Erster Bruch:
13
----221
Über und unter dem Etikett steht das string-Objekt luecke, das als leere Zeichenfolge initialisiert
wird. In der for-Schleife werden per Plusoperator Leerzeichen angehängt, bis die Länge des Etiketts
erreicht ist. Diese Länge wird über die Eigenschaft Length des string-Objekts etikett ermittelt.
Während jedes Objekt einer Klasse seine eigenen Instanzvariablen auf dem Heap besitzt, ist der
MSIL-Code der Instanzmethoden jeweils nur einmal im Speicher vorhanden und wird von allen
Objekten verwendet.
4.3.1 Methodendefinition
Die folgende Serie von Syntaxdiagrammen zur Methodendefinition unterscheidet sich von der Variante in Abschnitt 3.1.2.2 durch eine genauere Erklärung der Formalparameterliste:
Methodendefinition
Methodenkopf
Methodenrumpf
147
Abschnitt 4.3 Instanzmethoden
Methodenkopf
Rückgabetyp
Name
(
Formalparameter
)
Modifikator
,
Formalparameter
ref
Datentyp
Name
out
Methodenrumpf
{
}
Anweisung
Anschließend werden die (mehr oder weniger) neuen Bestandteile dieser Syntaxdiagramme erläutert. Dabei werden Methodendefinition und -aufruf keinesfalls so sequentiell und getrennt dargestellt, wie es die Abschnittsüberschriften vermuten lassen. Schließlich ist die Bedeutung mancher
Details der Methodendefinition am besten am Effekt beim Aufruf zu erkennen.
Während sich bei Feldern die Groß-/Kleinschreibung des Anfangsbuchstabens nach einer generell
akzeptierten Konvention an der Schutzstufe orientiert (Camel Casing für Felder mit den Schutzstufen private, protected oder internal, Pascal Casing für öffentliche Felder, vgl. Abschnitt 4.2.1),
sind die Empfehlungen für Methodennamen weniger einheitlich. Auf der MSDN-Webseite
http://msdn.microsoft.com/de-de/library/4df752aw%28en-us,VS.71%29.aspx
empfiehlt Microsoft offenbar unabhängig von der Schutzstufe mit einem Großbuchstaben zu beginnen (Pascal Casing):
.NET Framework General Reference
Method Naming Guidelines
The following rules outline the naming guidelines for methods:
 Use verbs or verb phrases to name methods.
 Use Pascal case.
Die Quellcode-Generatoren (Assistenten) im Visual Studio produzieren bei privaten Methoden jedoch Namen mit kleinen Anfangsbuchstaben, z.B.:
private void button1_Click(object sender, EventArgs e)
Kapitel 4: Klassen und Objekte
148
4.3.1.1 Modifikatoren
Bei einer Methodendefinition kann per Modifikator der voreingestellte Zugriffsschutz verändert
werden. In C# gilt für Methoden gilt wie für Instanzvariablen:


Voreingestellt ist die Schutzstufe private, so dass eine Methode nur in anderen Methoden
(oder Eigenschaften) derselben Klasse aufgerufen werden darf.
Soll eine Methode allen Klassen zur Verfügung stehen, ist in ihrer Definition der Modifikator public anzugeben. Später werden noch weitere Optionen zur Zugriffssteuerung vorgestellt.
Während man bei Instanzvariablen diese Voreinstellung meist belässt, ist sie bei allen Methoden zu
ändern, die zur Schnittstelle einer Klasse gehören sollen. Im Bruch-Beispiel sind alle Methoden
für die Verwendung durch beliebige fremde Klassen frei gegeben.
4.3.1.2 Rückgabewerte und return-Anweisung
Für den Informationstransfer von einer Methode an ihren Aufrufer kann neben Referenz- und Ausgabeparametern (siehe Abschnitt 4.3.1.3) auch ein Rückgabewert genutzt werden. Hier ist man auf
einen einzigen Wert (von beliebigem Typ) beschränkt, doch lässt sich die Übergabe sehr elegant in
den Programmablauf integrieren. Wir haben schon in Abschnitt 3.5.2 gelernt, dass ein Methodenaufruf einen Ausdruck darstellt und als Argument von komplexeren Ausdrücken oder von Methodenaufrufen verwendet werden darf, sofern die Methode einen Wert von passendem Typ abliefert.
Bei der Definition einer Methode muss festgelegt werden, von welchem Datentyp ihr Rückgabewert
ist. Erfolgt keine Rückgabe, ist der Ersatztyp void anzugeben.
Als Beispiel betrachten wir die aktuelle Variante der Bruch-Methode Frage(), die den Aufrufer
durch einen Rückgabewert vom Datentyp bool darüber informiert, ob der Benutzer zwei ganze Zahlen im int-Wertebereich als Eingaben geliefert hat (true) oder nicht (false):
public bool Frage() {
try {
Console.Write("Zaehler: ");
int z = Convert.ToInt32(Console.ReadLine());
Console.Write("Nenner : ");
int n = Convert.ToInt32(Console.ReadLine());
Zaehler = z;
Nenner = n;
return true;
} catch {
return false;
}
}
Ist der Rückgabetyp einer Methode von void verschieden, dann muss im Rumpf dafür gesorgt werden, dass jeder mögliche Ausführungspfad mit einer return-Anweisung endet, die einen Wert passenden Typs übergibt.
return-Anweisung für Methoden mit Rückgabewert
return
Ausdruck
;
Bei Methoden ohne Rückgabewert ist die return-Anweisung nicht unbedingt erforderlich, kann
jedoch (in der Variante ohne Ausdruck) dazu verwendet werden, um die Methode vorzeitig zu beenden (z.B. im Rahmen einer bedingten Anweisung):
149
Abschnitt 4.3 Instanzmethoden
return-Anweisung für Methoden ohne Rückgabewert
return
;
In der Bruch-Methode Frage() wird am Ende eines störungsfrei durchlaufenen try-Blocks der
boolesche Wert true zurück gemeldet. Tritt im try-Block eine Ausnahme auf (z.B. beim Versuch,
irreguläre Benutzereingaben zu konvertieren), dann wird der catch-Block ausgeführt, und die dortige return-Anweisung sorgt für eine Terminierung mit dem Rückgabewert false.
Beim bedenklichen Wunsch des Anwenders, den Nenner auf Null zu setzen, zeigt die Methode
Frage() übrigens keine besondere Reaktion. Ein kritische Bewertung bleibt der Eigenschaft
Nenner überlassen (siehe unten).
4.3.1.3 Formalparameter
Parameter wurden Ihnen bisher vereinfachend als Informationen über die gewünschte Arbeitsweise
einer Methode vorgestellt. Tatsächlich kennt C# verschiedene Parameterarten, um den Informationsaustausch zwischen einem Aufrufer und einer angeforderten Methode in beide Richtungen optimal zu unterstützen.
Im Kopf der Methodendefinition werden über so genannte Formalparameter Daten von bestimmtem Typ spezifiziert, die entweder den Ablauf der Methode steuern, oder durch die Methode verändert werden. Beim späteren Aufruf der Methode sind korrespondierende Aktualparameter anzugeben (siehe Abschnitt 4.3.2), wobei je nach Parameterart Variablen oder Ausdrücke in Frage
kommen.
In den Anweisungen des Methodenrumpfs sind die Formalparameter wie lokale Variablen zu verwenden, die teilweise (je nach Parameterart, siehe unten) mit den beim Aufruf übergebenen Aktualparameterwerten initialisiert wurden.
Für jeden Formalparameter sind folgende Angaben zu machen:




Parameterart
Sie werden gleich Wert-, Referenz- und Ausgabeparameter kennen lernen.
Datentyp
Es sind beliebige Typen erlaubt. Man muss den Datentyp eines Formalparameters auch dann
explizit angeben, wenn er mit dem Typ des Vorgängers (linken Nachbarn) übereinstimmt.
Name
Nach den Empfehlungen aus Abschnitt 3.1.5 ist bei Parameternamen ein kleiner Anfangsbuchstabe zu verwenden. Um Namenskonflikte zu vermeiden, hängen manche Programmierer an Parameternamen ein Suffix an, z.B. par oder einen Unterstrich. Weil Formalparameter im Methodenrumpf wie lokale Variablen zu behandeln sind, …
o können Namenskonflikte mit anderen lokalen Variablen derselben Methode auftreten,
o werden namensgleiche Instanz- bzw. Klassenvariablen überlagert.
Diese bleiben jedoch über ein geeignetes Präfix (z.B. this bei Objekten) weiter ansprechbar.
Position
Die Position eines Formalparameters ist natürlich nicht gesondert anzugeben, sondern liegt
durch die Methodendefinition fest. Sie wird hier als relevante Eigenschaft erwähnt, weil die
beim späteren Aufruf der Methode übergebenen Aktualparameter gemäß ihrer Reihenfolge
den Formalparametern zugeordnet werden.
150
Kapitel 4: Klassen und Objekte
4.3.1.3.1 Wert- bzw. Eingabeparameter
Über einen Wert- bzw. Eingabeparameter werden Informationen in eine Methode kopiert, um diese
mit Daten zu versorgen oder ihre Arbeitsweise zu steuern. Als Beispiel betrachten wir folgende
Variante der Bruch-Methode Addiere() Das beauftragte Objekt soll den via Parameterliste
(zpar, npar) übergebenen Bruch zu seinem eigenen Wert addieren und optional (Parameter autokurz) das Resultat gleich kürzen:
public bool Addiere(int zpar, int npar, bool autokurz) {
if (npar != 0) {
zaehler = zaehler * npar + zpar * nenner;
nenner = nenner * npar;
if (autokurz)
Kuerze();
return true;
} else
return false;
}
Bei der Definition eines formalen Wertparameters ist vor dem Datentyp kein Schlüsselwort anzugeben. Innerhalb der Methode verhält sich ein Wertparameter wie eine lokale Variable, die durch
den im Aufruf übergebenen Wert initialisiert wurde.
Methodeninterne Änderungen dieser lokalen Variablen bleiben ohne Effekt auf eine als Aktualparameter fungierende Variable der rufenden Programmeinheit. Im folgenden Beispiel übersteht die
lokale Variable iM der Methode Main() den Einsatz als Wertaktualparameter beim Aufruf der Methode WertParDemo() ohne Folgen:
Quellcode
Ausgabe
using System;
class Prog {
void WertParDemo(int ipar) {
Console.WriteLine(++ipar);
}
static void Main() {
int iM = 4711;
Prog p = new Prog();
p.WertParDemo(iM);
Console.WriteLine(iM);
}
}
4712
4711
Die Demoklasse Prog ist startfähig, besitzt also eine Methode Main(). Dort wird ein Projekt der
Klasse Prog erzeugt und beauftragt, die Instanzmethode WertParDemo() auszuführen. Mit dieser (auch in den folgenden Abschnitten anzutreffenden) Konstruktion wird es vermieden, im aktuellen Abschnitt 4.3.1 über Details bei der Definition von Instanzmethoden zur Demonstration statische Methoden zu verwenden. Bei den Parametern und beim Rückgabetyp gibt es allerdings keine
Unterschiede zwischen den Instanzmethoden und den Klassenmethoden (siehe Abschnitt 4.6.3).
Als Wertaktualparameter sind nicht nur (initialisierte!) Variablen erlaubt, sondern beliebige Ausdrücke mit einem Typ, der nötigenfalls erweiternd in den Typ des zugehörigen Formalparameters
gewandelt werden kann.
4.3.1.3.2 Referenzparameter
Ein Referenzparameter ermöglicht es der aufgerufenen Methode, eine Variable der aufrufenden
Programmeinheit zu verändern. Die Methode erhält beim Aufruf keine Kopie der betroffenen Vari-
151
Abschnitt 4.3 Instanzmethoden
ablen, sondern die Speicheradresse des Originals. Alle methodenintern über den Formalparameternamen vorgenommenen Modifikationen wirken sich direkt auf das Original aus.
Als Referenzaktualparameter sind nur Variablen erlaubt, die vom selben Typ wie der Formalparameter und außerdem initialisiert sein müssen. Es findet also keine implizite Typanpassung statt. Auf
den garantiert definierten Wert eines Referenzaktualparameters kann die gerufene Methode (vor
einer möglichen Veränderung) auch lesend zugreifen. Im Unterschied zu den Wert- bzw. Eingabeparametern und den gleich vorzustellenden Ausgabeparametern ermöglichen Referenzparameter
also einen Informationsfluss in beide Richtungen.
In der Methodendefinition und beim Methodenaufruf sind Referenzparameter durch das Schlüsselwort ref zu kennzeichnen.
Im folgenden Programm (nach dem aus Abschnitt 4.3.1.3.1 bekannten Strickmuster) tauscht eine
Instanzmethode die Werte zwischen den als Referenzaktualparameter übergebenen Variablen:
Quellcode
Ausgabe
using System;
Vorher: x = 1, y = 2
class Prog {
Nachher: x = 2, y = 1
void Tausche(ref int a, ref int b) {
int temp = a;
a = b;
b = temp;
}
static void Main() {
Prog p = new Prog();
int x = 1, y = 2;
Console.WriteLine("Vorher: x = {0}, y = {1}", x, y);
p.Tausche(ref x, ref y);
Console.WriteLine("Nachher: x = {0}, y = {1}", x, y);
}
}
Abschließend noch ein Satz für Begriffsakrobaten: Wird eine Referenzvariable, die auf ein Objekt
zeigt, als Referenzaktualparameter übergeben, kann man methodenintern nicht nur auf das Objekt
zugreifen (z.B. auf seine Instanzvariablen bei entsprechenden Zugriffsrechten), sondern auch den
Inhalt der Referenzvariablen ändern, so dass sie anschließend auf ein anderes Objekt zeigt. Es ist
nur selten sinnvoll, bei einer Methodendefinition Referenzparameter mit Referenztyp (vom Typ
einer Klasse) zu verwenden. Erlaubt ist diese für Anfänger recht verwirrende Doppelreferenz jedoch. Ein möglicher Einsatzzweck wäre eine methodenintern zu verändernde und zum Aufrufer
zurück zu transportierende Zeichenfolge. Wie sich in Abschnitt 5.4.1.1 zeigen wird, lässt sich ein
String-Objekt nicht ändern, sondern nur durch ein neues String-Objekt ersetzen. Ein ref-Parameter
vom Typ String bietet die Möglichkeit, die Adresse des alten Strings in eine Methode hinein und
die Adresse des neuen Strings zum Aufrufer zurück zu transportieren.
4.3.1.3.3 Ausgabeparameter
Auch über Ausgabeparameter kann man einer Methode die Veränderung von Variablen der rufenden Programmeinheit ermöglichen.
Als Ausgabeaktualparameter sind nur Variablen erlaubt, die vom selben Typ wie der Formalparameter und außerdem initialisiert sein müssen. Es findet also keine implizite Typanpassung statt. Der
Compiler interessiert sich nicht dafür, ob die als Aktualparameter fungierenden Variablen beim
Methodenaufruf initialisiert sind. Stattdessen stellt er sicher, dass jedem Ausgabeparameter vor dem
Verlassen der Methode ein Wert zugewiesen wird.
Kapitel 4: Klassen und Objekte
152
In der Methodendefinition und beim Methodenaufruf sind Ausgabeparameter durch das Schlüsselwort out zu kennzeichnen, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
void Lies(out int z, out int n) {
Console.Write("x = ");
z = Convert.ToInt32(Console.ReadLine());
Console.Write("\ny = ");
n = Convert.ToInt32(Console.ReadLine());
}
static void Main() {
Prog p = new Prog();
int x, y;
p.Lies(out x, out y);
Console.WriteLine("\nx % y = " + (x % y));
}
}
x = 29
y = 5
x % y = 4
4.3.1.3.4 Parameterserien variabler Länge
Vielleicht haben Sie sich schon darüber gewundert, dass man beim Aufruf der Methode
Console.WriteLine() hinter einer geeigneten Formatierungszeichenfolge beliebig viele Ausdrücke
durch jeweils ein Komma getrennt als Aktualparameter übergeben darf, z.B.:
Console.WriteLine("x = {0} ", x);
Console.WriteLine("x = {0}, y = {1} ", x, y);
Diese Variabilität wird durch einen Array-Parameter ermöglicht, der an letzter Stelle deklariert und
durch das Schlüsselwort params gekennzeichnet werden muss. In obigen Syntaxdiagrammen wurde diese Option der Einfachheit halber weggelassen. Zwar haben wir uns bisher kaum mit ArrayDatentypen beschäftigt, doch sollte das folgende Beispiel hinreichend klären, wie Array-Parameter
deklariert und verwendet werden:
Quellcode
Ausgabe
using System;
class Prog {
void PrintSum(params double[] args) {
double summe = 0.0;
foreach (double arg in args)
summe += arg;
Console.WriteLine("Die Summe ist = " + summe);
}
static void Main() {
Prog p = new Prog();
p.PrintSum(1.2, 1.0);
p.PrintSum(1.2, 1.0, 3.6);
}
}
Die Summe ist = 2,2
Die Summe ist = 5,8
4.3.1.4 Methodenrumpf
Über die Verbundanweisung, die den Rumpf einer Methode bildet, haben Sie bereits erfahren:


Hier werden die Formalparameter wie lokale Variablen verwendet.
Wert- und Referenzparameter werden von der aufrufenden Programmeinheit initialisiert, so
dass diese den Ablauf der Methode beeinflussen kann.
153
Abschnitt 4.3 Instanzmethoden


Über Referenz- und Ausgabeparameter können Variablen der aufrufenden Programmeinheit
verändert werden.
Die return-Anweisung dient zur Rückgabe von Werten an den Aufrufer und/oder zum Beenden einer Methodenausführung.
Ansonsten können beliebige Anweisungen unter Verwendung von elementaren und objektorientierten Sprachelementen eingesetzt werden, um den Zweck einer Methode zu realisieren. Definitionen
(z.B. von Klassen oder Methoden) sind jedoch nicht erlaubt. 1
Weil in einer Methode häufig andere Methoden aufgerufen werden, kommt es in der Regel zu
mehrstufig verschachtelten Methodenaufrufen, wobei die Höhe des Stacks (Stapelspeichers) zur
Verwaltung der Methodenaufrufe entsprechend wächst.
4.3.2 Methodenaufruf und Aktualparameter
Beim Aufruf einer Instanzmethode, z.B.:
b1.Zeige();
wird nach objektorientierter Denkweise eine Botschaft an ein Objekt geschickt:
„b1, zeige dich!“.
Als Syntaxregel ist festzuhalten, dass zwischen dem Objektnamen (genauer: dem Namen der Referenzvariablen, die auf das Objekt zeigt) und dem Methodennamen der Punktoperator zu stehen
hat.
Beim Aufruf einer Methode folgt ihrem Namen die in runde Klammern eingeschlossene Liste mit
den Aktualparametern, wobei es sich um eine synchron zur Formalparameterliste geordnete Serie
von Ausdrücken bzw. Variablen passenden Typs handeln muss.
,
Methodenaufruf
Name
Ausdruck
(
)
ref
Variable
out
Es ist grundsätzlich eine Parameterliste anzugeben, ggf. eine leere.
Als Beispiel betrachten wir einen Aufruf der in Abschnitt 4.3.1.1 vorgestellten Variante der
Bruch-Methode Addiere():
b1.Addiere(1, 3, true);
Liefert eine Methode einen Wert zurück, stellt ihr Aufruf einen verwertbaren Ausdruck dar und
kann als Argument in komplexeren Ausdrücken auftreten, z.B.:
1
Im Zusammenhang mit Delegaten und anonymen Methoden werden Sie später doch eine Möglichkeit zum Verschachteln von Methodendefinitionen kennen lernen (siehe Abschnitt 9.3.1.4).
Kapitel 4: Klassen und Objekte
154
do
Console.WriteLine("Welchen Bruch möchten Sie kürzen?");
while (!b1.Frage());
Durch ein angehängtes Semikolon wird jeder Methodenaufruf zur vollständigen Anweisung, wobei
ein Rückgabewert ggf. ignoriert wird, z.B.:
b1.Frage();
Soll in einer Methodenimplementierung vom aktuell handelnden Objekt eine andere Instanzmethode ausgeführt werden, so muss beim Aufruf keine Objektbezeichnung angegeben werden. In beiden
Varianten der Bruch-Methode Addiere() soll das beauftragte Objekt den via Parameterliste
übergebenen Bruch zu seinem eigenen Wert addieren und das Resultat (bei der Variante aus Abschnitt 4.3.1.3.1 paramtergesteuert) gleich kürzen. Zum Kürzen kommt natürlich die entsprechende
Bruch-Methode zum Einsatz. Weil sie vom gerade agierenden Objekt auszuführen ist, wird keine
Objektbezeichnung benötigt, z.B.:
public void Addiere(Bruch b) {
zaehler = zaehler*b.nenner + b.zaehler*nenner;
nenner = nenner*b.nenner;
Kuerze();
}
Wer auch solche Methodenaufrufe nach dem Schema
Empfänger.Botschaft
realisieren möchte, kann mit dem Schlüsselwort this das aktuelle Objekt ansprechen, z.B.:
this.Kuerze();
4.3.3 Methoden überladen
Die in Abschnitt 4.3.1.1 vorgestellte Addiere()-Methode kann problemlos in der BruchKlassendefinition mit der dort bereits vorhandenen Addiere()-Variante koexistieren, weil beide
Methoden unterschiedliche Parameterlisten besitzen. Man spricht hier von einer Methodenüberladung.
Eine Überladung ist erlaubt, wenn sich die Signaturen der beteiligten Methoden unterscheiden.
Zwei Methoden besitzen genau dann dieselbe Signatur, wenn die beiden folgenden Bedingungen
erfüllt sind: 1


Die Namen sind identisch.
Die Parameterlisten sind gleich lang, und die Typen korrespondierender Parameter stimmen
überein.
Für die Signatur ist der Rückgabetyp einer Methode ebenso irrelevant wie die Namen ihrer Formalparameter und das beim letzten Formalparameter erlaubte params-Schlüsselwort (vgl. ECMA
2006, S. 95). Die fehlende Signaturrelevanz des Rückgabetyps resultiert wohl daraus, dass der
Rückgabewert einer Methode in Anweisungen oft keine Rolle spielt (ignoriert wird).
Ist bei einem Methodenaufruf die angeforderte Überladung nicht eindeutig zu bestimmen, meldet
der Compiler einen Fehler.
Von einer Methode unterschiedlich parametrisierte Varianten in eine Klassendefinition aufzunehmen, lohnt sich z.B. in folgenden Situationen:
1
Bei den später zu behandelnden generischen Methoden muss die Liste mit den Kriterien für die Identität von Signaturen erweitert werden.
Abschnitt 4.4 Objekte

155
Für verschiedene Datentypen (z.B. double und int) werden analog arbeitende Methoden benötigt.
So besitzt z.B. die Klasse Math im Namensraum System u.a. folgende Methoden, um den
Betrag einer Zahl zu berechnen:
public static float Abs(decimal value)
public static double Abs(double value)
public static float Abs(float value)
public static int Abs(int value)
public static long Abs(long value)
Seit der .NET – Version 2.0 bieten allerdings generische Methoden (siehe unten) eine elegantere Lösung für die Unterstützung verschiedener Datentypen, z.B.
static void Tausche<T>(ref T a, ref T b) {
T temp = a;
a = b;
b = temp;
}

Für eine Methode sollen unterschiedliche umfangreiche Parameterlisten angeboten werden,
sodass zwischen einer bequem aufrufbaren Standardausführung (z.B. mit leerer Parameterliste) und einer individuell gestalteten Ausführungsvariante gewählt werden kann.
4.4 Objekte
Ein zentrales Ziel der OOP ist die Produktion von Software mit hohem Recycling-Potential. Daher
wurde im Bruchrechnungsprojekt die recht universell verwendbare Bruch-Klasse konzipiert, die
schon in Programmen mit stark unterschiedlichen Benutzerschnittstellen(Konsole versus GUI) zum
Einsatz kam. In Abschnitt 4.4 geht es darum, wie man Objekte solcher Klassen in die Welt setzen
und nutzen kann.
4.4.1 Referenzvariablen deklarieren
Um irgendein Objekt aus der Klasse Bruch ansprechen zu können, benötigen wir eine Referenzvariable mit dem Datentyp Bruch. In der folgenden Anweisung wird eine solche Referenzvariable
definiert und auch gleich initialisiert:
Bruch b = new Bruch();
Um die Wirkungsweise dieser Anweisung Schritt für Schritt zu erklären, beginnen wir mit einer
einfacheren Variante ohne Initialisierung:
Bruch b;
Hier wird die Referenzvariable b mit dem Datentyp Bruch deklariert, die folgende Werte annehmen kann:


die Adresse eines Bruch-Objekts
In der Variablen wird also kein komplettes Bruch-Objekt mit sämtlichen Instanzvariablen
abgelegt, sondern ein Verweis (eine Referenz) auf einen Ort im Heap-Bereich des programmeigenen Speichers, wo sich ein Bruch-Objekt befindet.
null
Dieses Referenzliteral steht für einen leeren Verweis. Eine Referenzvariable mit diesem
Wert ist nicht undefiniert, sondern zeigt explizit auf nichts.
Wir nehmen nunmehr offiziell und endgültig zur Kenntnis, dass Klassen als Datentypen verwendet
werden können und haben damit in C# - Programmen folgende Datentypen zur Verfügung (vgl.
Abschnitt 3.3.1):
Kapitel 4: Klassen und Objekte
156


Elementare Typen (bool, char, byte, double, ...)
Hier handelt es sich um Werttypen.
Klassen (Referenztypen)
Ist eine Variable vom Typ einer Klasse, kann sie die Adresse eines Objekts aus dieser Klasse aufnehmen. Zwar ist der Aufbau einer Objektadresse bei allen Klassen gleich, doch achtet
der Compiler strikt darauf, dass eine Referenzvariable nur auf Objekte aus der deklarierten
Klasse oder aus einer daraus abgeleiteten Klasse (siehe unten) zeigt.
4.4.2 Objekte erzeugen
Damit z.B. der folgendermaßen deklarierten Referenzvariablen b vom Datentyp Bruch
Bruch b;
ein Verweis auf ein Bruch-Objekt als Wert zugewiesen werden kann, muss ein solches Objekt erst
erzeugt werden, was per new-Operator geschieht, z.B. in folgendem Ausdruck:
new Bruch()
Als Operanden erwartet new einen Klassennamen, dem eine Parameterliste zu folgen hat, weil er
hier als Name eines Konstruktors (siehe Abschnitt 4.4.3) aufzufassen ist. Als Wert des Ausdrucks
resultiert eine Referenz (Speicheradresse), die einen Zugriff auf das neue Objekt (seine Methoden,
Eigenschaften, etc.) erlaubt.
In der Main()-Methode der folgenden Startklasse
class BruchRechnung {
static void Main() {
Bruch b = new Bruch();
. . .
}
}
wird die vom new-Operator gelieferte Adresse mit dem Zuweisungsoperator in die lokale Referenzvariable b geschrieben. Es resultiert die folgende Situation im Speicher des Programms:
Stack
Referenzvariable b
Adresse des Bruch-Objekts
Heap
Bruch-Objekt
zaehler
nenner
etikett
0
1
""
Während lokale Variablen bereits beim Aufruf einer Methode (also unabhängig vom konkreten Ablauf) im Stack-Bereich des programmeigenen Speichers angelegt werden, entstehen Objekte (mit
157
Abschnitt 4.4 Objekte
ihren Instanzvariablen) erst bei der Auswertung des new-Operators. Sie erscheinen auch nicht auf
dem Stack, sondern werden im Heap-Bereich des programmeigenen Hauptspeichers angelegt.
In einem Programm können mehrere Referenzvariablen auf dasselbe Objekt zeigen, z.B.:
Quellcode
Ausgabe
using System;
class BruchRechnung {
static void Main() {
Bruch b1 = new Bruch();
b1.Zaehler = 1;
b1.Nenner = 3;
b1.Etikett = "b1 = ";
Bruch b2 = b1;
b2.Etikett = "b2 = ";
b1.Zeige();
}
}
b2 =
1
----3
In der Anweisung
Bruch b2 = b1;
wird die neue Referenzvariable b2 vom Typ Bruch angelegt und mit dem Inhalt von b1 (also mit
der Adresse des bereits vorhandenen Bruch-Objekts) initialisiert. Es resultiert die folgende Situation im Speicher des Programms:
Stack
Heap
b1
76788700
b2
Bruch-Objekt
zaehler
nenner
etikett
1
3
"b1 = "
76788700
Hier sollte nur die Möglichkeit der Mehrfachreferenzierung demonstriert werden. Bei einer ernsthaften Anwendung des Prinzips befinden sich die alternativen Referenzen an verschiedenen Stellen
des Programms, z.B. in Instanzvariablen verschiedener Objekte. In einem Speditionsverwaltungsprogramm kennen z.B. alle Objekte zu einzelnen Fahrzeugen die Adresse des Planerobjekts, dem
sie besondere Ereignisse wie Pannen melden.
4.4.3 Objekte initialisieren über Konstruktoren
In diesem Abschnitt werden spezielle Methoden behandelt, die beim Erzeugen von neuen Objekten
automatisch aufgerufen werden, um deren Instanzvariablen zu initialisieren. Wie Sie bereits wissen,
wird zum Erzeugen von Objekten der new-Operator verwendet. Als Operand ist ein Konstruktor der
gewünschten Klasse anzugeben.
Hat der Programmierer zu einer Klasse keinen Konstruktor definiert, dann kommt ein Standardkonstruktor zum Einsatz. Weil dieser keine Parameter besitzt, ergibt sich sein Aufruf aus dem
Klassennamen durch Anhängen einer leeren Parameterliste, z.B.:
Bruch b = new Bruch();
Kapitel 4: Klassen und Objekte
158
Wie der MSIL-Code zum Standardkonstruktor unserer Bruch-Klasse (mit dem Namen .ctor, Abkürzung für Konstruktor) zeigt, fügt der Compiler automatisch Code für die im Quelltext enthaltenen Initialisierungen von Instanzvariablen ein:
Für eine automatische Null-Initialisierung (vgl. Abschnitt 4.2.4) ist hingegen kein MSIL-Code erforderlich. Zur Anzeige des MSIL-Codes wird hier das im Windows-SDK enthaltene und mit der
Visual C# 2008 Express Edition automatisch installierte Hilfsprogramm ILDasm verwendet 1.
Am Ende (!) des MSIL-Codes zum Standardkonstruktor wird der parameterlose Konstruktor der
Basisklasse aufgerufen, wobei unsere Klasse Bruch direkt von der Urahnklasse Object im Namensraum System abstammt.
Abgesehen von den momentan für uns noch irrelevanten abstrakten Klassen hat der Standardkonstruktor einer Klasse die Schutzstufe public, ist also allgemein verfügbar.
In der Regel ist es beim Klassendesign sinnvoll, mindestens einen Konstruktor explizit zu definieren, um das individuelle Initialisieren der Instanzvariablen von neuen Objekten zu ermöglichen.
Dabei sind folgende Regeln zu beachten:







1
Ein Konstruktor trägt denselben Namen wie die Klasse.
Ein Konstruktor liefert grundsätzlich keinen Rückgabewert, und es wird bei der Definition
kein Typ angegeben, auch nicht der Ersatztyp void, mit dem wir bei gewöhnlichen Methoden den Verzicht auf einen Rückgabewert dokumentieren müssen.
Es darf eine Parameterliste definiert werden, was zum Zweck der Initialisierung ja auch unumgänglich ist.
Sobald man einen eigenen Konstruktor definiert, steht der Standardkonstruktor nicht mehr
zur Verfügung.
Ist weiterhin ein paramameterfreier Konstruktor erwünscht, so muss dieser zusätzlich definiert werden.
Der Compiler fügt bei jedem Konstruktor automatisch MSIL-Code für die im Quellcode der
Klassendefinition enthaltenen Feldinitialisierungen ein (siehe oben), z.B. auch bei einem
Konstruktor mit leerem Anweisungsteil.
Es sind beliebig viele Konstruktoren möglich, die alle denselben Namen und jeweils eine
individuelle Parameterliste haben müssen. Das Überladen von Methoden (vgl. Abschnitt
4.3.3) ist also auch bei Konstruktoren erlaubt.
Bei der Installation der Visual C# 2008 Express Edition landet das Hilfsprogramm im Ordner
%ProgramFiles%\Microsoft SDKs\Windows\v6.0A\bin
159
Abschnitt 4.4 Objekte


Während der Standardkonstruktor die Schutzstufe public besitzt, haben explizite Konstruktoren haben wie gewöhnliche Methoden die voreingestellte Schutzstufe private. Wenn sie
für beliebige Klassen zur Objektkreation verfügbar sein sollen, ist also in der Definition der
Modifikator public anzugeben.
Konstruktoren können nicht direkt aufgerufen, sondern nur als Argument des new-Operators
verwendet werden.
Für die Klasse Bruch eignet sich z.B. der folgende Konstruktor mit Parametern zur Initialisierung
aller Instanzvariablen:
public Bruch(int zpar, int npar, string epar) {
zaehler = zpar;
Nenner = npar;
etikett = epar;
}
Wenn weiterhin auch ein parameterfreier Konstruktor verfügbar sein soll, muss dieser explizit definiert werden, z.B. mit leerem Anweisungsteil:
public Bruch() {}
Im folgenden Programm werden beide Konstruktoren eingesetzt:
Quellcode
using System;
class BruchRechnung {
static void Main() {
Bruch b1 = new Bruch(1, 2, "b1 = ");
Bruch b2 = new Bruch();
b1.Zeige();
b2.Zeige();
}
}
Ausgabe
b1 =
1
----2
0
----1
Von der Regel, dass Konstruktoren nur über den new-Operator genutzt werden können, gibt es eine
Ausnahme: Zwischen Parameterliste und Anweisungsblock eines Konstruktors darf ein anderer
Konstruktor derselben Klasse über das Schlüsselwort this aufgerufen werden, z.B.:
public Bruch() : this(0, 1, "unbekannt") {}
Wie das Windows-SDK - Hilfsprogramm ILDasm für die aktuelle Ausbaustufe des
Bruchrechnungs-Assemblies zeigt, erscheinen die Konstruktoren einer Klasse wie gewöhnliche
Methoden in der Typ-Metadatentabelle:
Öffentliche Felder, die wegen fehlender Datenkapselung nicht in jeder Situation akzeptabel sind,
und öffentliche Eigenschaften (siehe Abschnitt 4.5) können seit C# 3.0 bei der Objektkreation auch
Kapitel 4: Klassen und Objekte
160
ohne spezielle Konstruktordefinition initialisiert werden. Dazu wird hinter den Konstruktoraufruf
eine durch geschweifte Klammern begrenzte Liste von Name-Wert - Paaren angegeben, z.B.:
Quellcode
Ausgabe
using System;
class CT {
public int i;
}
class Prog {
static void Main() {
CT ct = new CT() {i = 13};
Console.WriteLine(ct.i);
Console.ReadLine();
}
}
13
Die neue Option wurde unter der Bezeichnung Objektinitialisierer zur Unterstützung der LINQTechnik (Language Integrated Query) eingeführt (siehe Abschnitt 27.1.2). Bei Verwendung eines
Objektinitialisierers darf eine leere Parameterliste weggelassen werden, z.B.:
CT ct = new CT {i = 13};
4.4.4 Abräumen überflüssiger Objekte durch den Garbage Collector
Wenn keine Referenz mehr auf ein Objekt zeigt, wird es vom Garbage Collector (Müllsammler)
der CLR automatisch entsorgt, und der belegte Speicher wird frei gegeben.
Eine lokale Referenzvariable wird automatisch beim Verlassen ihres Deklarationsbereichs ungültig,
also spätestens beim Beenden der Methode. Man kann eine Referenzvariable aktiv von einem Objekt „entkoppeln“, indem man ihr den Wert null (Verweis auf nichts) oder aber ein alternatives Referenzziel zuweist.
Vermutlich sind Programmiereinsteiger vom Garbage Collector nicht sonderlich beeindruckt.
Schließlich war im Manuskript noch nie die Rede davon, dass man sich um den belegten Speicher
nach Gebrauch kümmern müsse. Der in einer Methode von lokalen Variablen belegte Speicher wird
bei jeder Programmiersprache frei gegeben, sobald die Ausführung der Methode beendet ist. Demgegenüber muss der von Objekten belegte Speicher bei älteren Programmiersprachen (z.B. C++)
nach Gebrauch explizit wieder frei gegeben werden. In Anbracht der Objektmassen, die ein typisches Programm (z.B. ein Grafikeditor) benötigt, ist einiger Aufwand erforderlich, um eine Verschwendung von Speicherplatz zu verhindern. Mit seinem vollautomatischen Garbage Collector
vermeidet C# lästigen Aufwand und zwei kritische Fehlerquellen:


Weil der Programmierer keine Verpflichtung (und Berechtigung) zum Entsorgen von Objekten hat, kann es nicht zu Programmabstürzen durch Zugriff auf voreilig vernichtete Objekte
kommen.
Es entstehen keine Speicherlöcher (memory leaks) durch die vergessene Freigabe des Speichers zu überflüssig gewordenen Objekten.
Sollen die Objekte einer Klasse vor dem Entsorgen noch spezielle Aufräumaktionen durchführen,
z.B. Ressourcen frei geben, die nicht von der CLR verwaltet werden, dann sind spezielle Methoden
(so genannte Destruktoren) zu definieren (siehe unten).
Abschnitt 4.4 Objekte
161
4.4.5 Objektreferenzen verwenden
4.4.5.1 Objektreferenzen als Wertparameter
Wir haben schon festgehalten, dass die formalen Wertparameter einer Methode wie lokale Variablen funktionieren, die beim Methodenaufruf mit den Werten der Aktualparameter initialisiert werden. Bei Wertparametern von elementarem Datentyp wirken sich Änderungen innerhalb einer Methode nicht auf die rufende Programmeinheit aus. Bei einem Wertparameter mit Referenztyp wird
ebenfalls der Wert des Aktualparameters (eine Objektreferenz) beim Methodenaufruf in den Formalparameter kopiert. Es wird jedoch keinesfalls eine Kopie des referenzierten Objekts (auf dem
Heap) erstellt, so dass Formal- und Aktualparameter auf dasselbe Objekt zeigen 1.
Von den beiden Addiere() – Methoden der Klasse Bruch verfügt die ältere Variante über einen
Wertparameter mit Referenztyp:
public void Addiere(Bruch b) {
zaehler = zaehler*b.nenner + b.zaehler*nenner;
nenner = nenner*b.nenner;
Kuerze();
}
Mit dem Aufruf dieser Methode wird ein Objekt beauftragt, den via Parameter spezifizierten Bruch
zum eigenen Wert zu addieren und das Resultat gleich zu kürzen.
Zähler und Nenner des fremden Bruch-Objektes können per Parametername und Punktoperator
trotz Schutzstufe private direkt angesprochen werden, weil der Zugriff in einer Bruch-Methode
stattfindet. Hier liegt kein Verstoß gegen das Prinzip der Datenkapselung vor, weil der Zugriff
durch eine klasseneigene Methode erfolgt, die vom Klassendesigner gut konzipiert sein sollte.
Dass in einer Bruch-Methodendefinition ein Parameter vom Typ Bruch verwendet wird, ist übrigens weder „zirkulär“ noch ungewöhnlich; schließlich sollen Brüche auch mit ihresgleichen interagieren können.
In obiger Addiere() – Methode bleibt das per Parameter ansprechbare Bruch-Objekt unverändert. Sofern entsprechende Zugriffsrechte vorliegen, was bei Parametern vom Typ des agierenden
Objekts stets der Fall ist, kann eine Methode das Parameter-Objekt aber durchaus auch verändern.
Als Beispiel erweitern wir die Bruch-Klasse um die Methode DuplWerte(), die ein Objekt beauftragt, seinen Zähler und Nenner auf ein anderes Bruch-Objekt zu übertragen, das per Referenzparameter bestimmt wird:
public void DuplWerte(Bruch bc) {
bc.zaehler = zaehler;
bc.nenner = nenner;
}
In folgendem Programm wird das Bruch-Objekt b1 beauftragt, die DuplWerte()-Methode auszuführen, wobei als Parameter eine Referenz auf das Objekt b2 übergeben wird:
1
Wertparameter mit Referenztyp arbeiten also analog zu den Referenzparametern (vgl. Abschnitt 4.3.1.3.2), insofern
beide einer Methode Einwirkungen auf die Außenwelt ermöglichen. Die Referenzparameter sind in C# vor allem
deshalb aufgenommen worden, um den Zeit und Speicherplatz sparenden call by reference auch bei Werttypen zu
ermöglichen. Wir werden mit den so genannten Strukturen noch Werttypen kennen lernen, die ähnlich umfangreich
werden können wie Klassentypen.
Kapitel 4: Klassen und Objekte
162
Quellcode
using System;
class BruchRechnung {
static void Main() {
Bruch b1 = new Bruch(1, 2, "b1 = ");
Bruch b2 = new Bruch(5, 6, "b2 = ");
b1.Zeige();
b2.Zeige();
b1.DuplWerte(b2);
Console.WriteLine("Nach DuplWerte():\n");
b2.Zeige();
}
Ausgabe
b1 =
1
----2
b2 =
5
----6
Nach DuplWerte():
b2 =
}
1
----2
4.4.5.2 Rückgabewerte mit Referenztyp
Bisher haben die innerhalb einer Methode erzeugten Objekte das Ende der Methode nicht überlebt,
waren jedenfalls anschließend nicht mehr nutzbar. Weil keine Referenz außerhalb der Methode
existierte, wurden die Objekte dem Garbage Collector überlassen. Soll ein methodenintern erzeugtes Objekt nach Ende der Methodenausführung weiterhin zur Verfügung stehen, muss eine Referenz
außerhalb der Methode geschaffen werden, was z.B. über einen Rückgabewert mit Referenztyp
geschehen kann.
Zur Demonstration des Verfahrens erweitern wir die Bruch-Klasse um die Methode Klone(),
welche ein Objekt beauftragt, einen neuen Bruch anzulegen, mit den Werten der eigenen Instanzvariablen zu initialisieren und die Adresse an den Aufrufer zu übergeben: 1
public Bruch Klone() {
return new Bruch(zaehler, nenner, etikett);
}
Im folgenden Beispiel wird das durch b2 referenzierte Bruch-Objekt in der von b1 ausgeführten
Methode Klone() erstellt:
Quellcode
using System;
class BruchRechnung {
static void Main() {
Bruch b1 = new Bruch(1, 2, "b1 = ");
b1.Zeige();
Bruch b2 = b1.Klone();
b2.Zeige();
}
}
Ausgabe
b1 =
1
----2
b1 =
1
----2
4.4.5.3 this als Referenz auf das aktuelle Objekt
Gelegentlich ist es sinnvoll oder erforderlich, dass ein handelndes Objekt sich selbst ansprechen
bzw. seine eigene Adresse als Methodenaktualparameter verwenden kann. Dies ist mit dem Schlüsselwort this möglich, das innerhalb einer Instanzmethode wie eine Referenzvariable funktioniert. In
1
Bei einer für die breitere Öffentlichkeit gedachten Klasse sollte auch eine die Schnittstelle ICloneable (siehe Kapitel 8) implementierende Vervielfältigungsmethode angeboten werden, obwohl diese Schnittstelle durch semantische
Unklarheit von begrenztem Wert ist, was im Kapitel 8 über Schnittstellen (Interfaces) noch näher erläutert wird.
Abschnitt 4.5 Eigenschaften
163
folgendem Beispiel ermöglicht die this-Referenz die Verwendung von Formalparameternamen, die
mit den Namen von Instanzvariablen übereinstimmen:
public bool Addiere(int zaehler, int nenner, bool autokurz) {
if (nenner != 0) {
this.zaehler = this.zaehler * nenner + zaehler * this.nenner;
this.nenner = this.nenner * nenner;
if (autokurz)
this.Kuerze();
return true;
} else
return false;
}
Außerdem wird beim Kuerze() - Aufruf durch die (nicht erforderliche) this-Referenz verdeutlicht, dass die Methode vom aktuell handelnden Objekt ausgeführt werden soll. Später werden Sie
noch weit relevantere this-Verwendungsmöglichkeiten kennen lernen.
4.5 Eigenschaften
Sollen fremde Klassen Lese- und/oder Schreibzugriff auf ein gekapseltes (also privates) Feld erhalten, sind entsprechende Zugriffsmethoden zu definieren. Im Bruch-Beispiel könnte man z.B. für
das nenner-Feld die folgenden Methoden definieren:
public int GibNenner() {
return nenner;
}
public void SetzeNenner(int value) {
if (value != 0)
nenner = value;
}
Entsprechend sähe der klassenfremde Zugriff auf einen Nenner z.B. so aus:
b1.SetzeNenner(2);
Console.WriteLine(b1.GibNenner());
Mit den Eigenschaften (engl.: properties), die wegen ihrer großen Bedeutung schon in der ersten
Variante des Bruch-Beispiels genutzt wurden, bietet C# die Möglichkeit, das Verwenden einer
Klasse zu vereinfachen. Aus den obigen Methodendefinitionen wird die folgende Eigenschaftsdefinition:
public int Nenner {
get {
return nenner;
}
set {
if (value != 0)
nenner = value;
}
}
Für den Klassendesigner ändert sich nicht allzu viel: Es ist ein get- und ein set-Block mit nahe liegender Syntax zu definieren. Bemerkenswert ist, dass im set-Block der vom Aufrufer übergebene
neue Wert ohne Formalparameterdefinition über das Schlüsselwort value angesprochen wird.
Für den Klassenanwender sind Eigenschaften weit intuitiver zu verwenden als korrespondierende
Methodenaufrufe, z.B.:
164
Kapitel 4: Klassen und Objekte
b1.Nenner = 2;
Console.WriteLine(b1.Nenner);
Sogar die Aktualisierungs-Operatoren werden unterstützt, z.B.:
b1.Nenner += 2;
Zwar handelt es sich bei den Eigenschaften eher um syntactic sugar (Mössenböck 2003, S. 3) als
um einen essentiellen Vorteil gegenüber anderen Programmiersprachen wie Java und C++, doch ist
diese gelungene Kombination aus objektorientierter Datenkapselung und vertrauter MerkmalsSyntax durchaus zu begrüßen.
Wie eine Assembly-Inspektion mit dem Windows-SDK - Hilfsprogramm ILDasm zeigt, erstellt der
Compiler zu den Eigenschaften unserer Klasse Bruch jeweils ein Paar von Zugriffsmethoden:
Diese besitzen neben ihrer speziellen (und sehr begrenzten) Aufgabe eine weiterer Besonderheit
gegenüber den bisher besprochenen Methoden: Der JIT-Compiler der CLR fügt den (meist sehr
kleinen) Maschinencode an jeder Aufrufstelle ein, um bei Eigenschaftszugriffen den Aufwand eines
gewöhnlichen Methodenaufrufs zu vermeiden. Diese auch von anderen Compilern eingesetzte
Technik zur Optimierung von Funktions- bzw. Methodenaufrufen bezeichnet man als Inlining.
Nach Richter (2006, S. 245) ist das Inlining im Debug-Modus zur Erleichterung der Fehlersuche
abgeschaltet.
Um eine Read- bzw. Write-Only Eigenschaft zu realisieren, verzichtet man einfach auf die setbzw. die get-Implementation.
Aus der Bruch-Klassendefinition ist noch die Eigenschaft Etikett von Interesse, die den Referenzdatentyp String besitzt:
public string Etikett {
get {
return etikett;
}
set {
if (value.Length <= 40)
etikett = value;
else
etikett = value.Substring(0, 40);
}
}
Im set-Block wird über das Schlüsselwort value das vom Aufrufer als neuer Wert übergebene
String-Objekt angesprochen. Es informiert in seiner Eigenschaft Length (definiert in der Klasse
String) über die Anzahl der enthaltenen Zeichen. Bei Überlänge wird das value-Objekt mit der
Substring()-Instanzmethode der Klasse String aufgeordert, ein neues, auf die ersten 40 Zeichen
Abschnitt 4.6 Statische Member und Klassen
165
gekürztes String-Objekt zu erzeugen, dessen Adresse schlussendlich den neuen etikett-Wert
bildet.
4.6 Statische Member und Klassen
Neben den objektbezogenen Feldern, Eigenschaften, Methoden und Konstruktoren unterstützt C#
auch klassenbezogene Varianten. Syntaktisch werden diese Member in der Deklaration bzw. Definition durch den Modifikator static gekennzeichnet, und man spricht oft von statischen Feldern, Methoden, Eigenschaften etc. Ansonsten gibt es bei der der Deklaration bzw. Definition kaum Unterschiede zwischen einem Instanz-Member und dem analogen statischen Member.
Abgesehen vom Standardkonstruktor (siehe Abschnitt 4.4.3) gilt auch bei den statischen Membern
für den Zugriffsschutz:


Voreingestellt ist die Schutzstufe private, so dass eine Verwendung nur klasseneigenen Methoden erlaubt ist.
Durch Modifikatoren kann eine alternative Schutzstufe festgelegt werden (z.B. public).
4.6.1 Statische Felder und Eigenschaften
In unserem Bruchrechnungsbeispiel soll ein statisches Feld dazu dienen, die Anzahl der bisher erzeugten Bruch-Objekte aufzunehmen:
using System;
public class Bruch {
int zaehler,
nenner = 1;
string etikett = "";
static int anzahl;
public Bruch(int zpar, int npar, String epar) {
zaehler = zpar;
Nenner = npar;
etikett = epar;
anzahl++;
}
public Bruch() {
anzahl++;
}
. . .
}
Ein statisches Feld kann in klasseneigenen Methoden (objektbezogen oder statisch) direkt angesprochen werden. Im Beispiel wird die (automatisch auf Null initialisierte) Klassenvariable
anzahl in den beiden Instanzkonstruktoren inkrementiert.
Sofern Methoden fremder Klassen (durch den Modifikator public) der direkte Zugriff auf eine
Klassenvariable gewährt wird, müssen diese dem Variablennamen ein Präfix aus Klassennamen und
Punktoperator voranstellen, z.B.:
Console.Writeline("Bisher wurden " + Bruch.anzahl + " Brüche erzeugt");
In unserem Beispiel wird das statische Feld anzahl aber ohne public-Modifikator deklariert, so
dass der direkte Zugriff klasseneigenen Methoden vorbehalten bleibt.
Kapitel 4: Klassen und Objekte
166
Während jedes Objekt einer Klasse über einen eigenen Satz mit allen Instanzvariablen verfügt, existiert eine klassenbezogene Variable nur einmal. Sie wird beim Laden der Klasse angelegt und erhält
per Voreinstellung dieselbe Null-Initialisierung wie eine Instanzvariable (vgl. Abschnitt 4.2.4). Alternative Initialisierungen können in der Variablendeklaration oder im statischen Konstruktor (siehe
Abschnitt 4.6.4) vorgenommen werden.
In der folgenden Tabelle werden wichtige Unterschiede zwischen Klassen- und Instanzvariablen
zusammengestellt:
Instanzvariablen
Klassenvariablen
Deklaration
ohne Modifikator static
mit Modifikator static
Zuordnung
Jedes Objekt besitzt einen eigenen
Satz mit allen Instanzvariablen.
Klassenbezogene Variablen sind nur
einmal vorhanden.
Existenz
Instanzvariablen werden beim Erzeugen des Objektes angelegt und initialisiert.
Sie werden ungültig, wenn das Objekt
nicht mehr referenziert ist.
Klassenvariablen werden beim Laden
der Klasse angelegt und initialisiert.
Damit im erweiterten Bruchrechnungsbeispiel fremde Klassen trotz Datenkapselung die Anzahl der
bisher erzeugten Bruch-Objekte in Erfahrung bringen können, wird noch eine statische Eigenschaft mit public-Zugriff ergänzt:
public static int Anzahl {
get {
return anzahl;
}
}
Weil nur die get-Funktionalität implementiert ist, können fremde Klassen den anzahl-Wert zwar
ermitteln, aber nicht verändern.
4.6.2 Wiederholung zur Kategorisierung von Variablen
Mittlerweile haben wir verschiedene Variablensorten kennen gelernt, wobei die Sortenbezeichnung
unterschiedlich motiviert war. Um einer möglichen Verwirrung vorzubeugen, folgt nun eine Zusammenfassung bzw. Wiederholung. Die folgenden Begriffe sollten Ihnen keine Probleme mehr
bereiten:


Lokale Variablen ...
werden in Methoden deklariert,
landen auf dem Stack,
werden nicht automatisch initialisiert,
sind nur in den Anweisungen des innersten Blocks verwendbar,
existieren, bis der innerste Block endet.
Instanzvariablen ...
werden außerhalb jeder Methode deklariert,
landen (als Bestandteile von Objekten) auf dem Heap,
werden automatisch mit dem typspezifischen Nullwert initialisiert,
sind verwendbar, wo eine Referenz zum Objekt vorliegt und Zugriffsrechte bestehen.
Abschnitt 4.6 Statische Member und Klassen


167
Klassenvariablen ...
werden außerhalb jeder Methode mit dem Modifikator static deklariert,
werden automatisch mit dem typspezifischen Nullwert initialisiert,
sind verwendbar, wo Zugriffsrechte bestehen.
Referenzvariablen ...
zeichnen sich durch ihren speziellen Inhalt aus (Referenz auf ein Objekt). Es kann sich sowohl um lokale Variablen (z.B. b1 in der Main()-Methode von BruchRechnung) als
auch um Instanzvariablen (z.B. etikett in der Bruch-Definition) oder um Klassenvariablen handeln.
Die Variablen in C# kann man einteilen nach ...


Datentyp
Es sind vor allem zu unterscheiden:
o Werttypen (z.B. int, double, bool)
o Referenztypen (mit Objektreferenzen als Inhalt).
Zuordnung
Eine Variable kann zu einem Objekt (Instanzvariable), zu einer Klasse (statische Variable)
oder zu einer Methode (lokale Variable) gehören. Damit sind weitere Eigenschaften wie
Ablageort, Lebensdauer, Sichtbarkeitsbereich und Initialisierung festgelegt (siehe oben).
4.6.3 Statische Methoden
Es ist in vielen Situationen sinnvoll oder sogar unvermeidlich, einer Klasse Handlungskompetenzen
(Methoden) zu verschaffen. So muss z.B. beim Programmstart die Main() - Methode der Startklasse
ausgeführt werden, bevor irgendein Objekt des Programms existiert. Das Erzeugen von Objekten
gehört gerade zu den typischen Aufgaben der statischen Methode Main(), wobei es sich nicht unbedingt um Objekte der eigenen Klasse handeln muss. Sofern Klassenmethoden vorhanden sind,
kann man auch eine Klasse als Akteur auf der objektorientierten Bühne betrachten.
Wie eine statische (und öffentliche) Methode von fremden Klassen genutzt werden kann, ist Ihnen
längst bekannt, weil die statische Methode WriteLine() der Klasse Console bisher in fast jedem
Beispielprogramm zum Einsatz kam, z.B.:
Console.WriteLine("Hallo");
Vor den Namen der gewünschten Methode setzt man (durch den Punktoperator getrennt) den Namen der angesprochenen Klasse, der eventuell durch den Namensraumbezeichner vervollständigt
werden muss, je nach Namensraumzugehörigkeit der Klasse und vorhandenen using-Direktiven am
Anfang des Quellcodes (vgl. Abschnitt 1.2.6).
Trotz Ihrer Erfahrung mit diversen Main()-Methoden soll auch im Kontext unserer Bruch-Klasse
das Definieren einer statischen Methode geübt werden. Zur Vereinfachung von Anweisungsfolgen
nach dem folgenden Muster
Bruch b = new Bruch(0, 1, "Benutzerdefiniert: ");
b.Frage();
b.Kuerze();
definieren wir eine Klassenmethode, die eine Referenz auf ein neues Bruch-Objekt mit benutzerdefinierten und gekürzten Werten liefert:
Kapitel 4: Klassen und Objekte
168
public static Bruch BenDef(string e) {
Bruch b = new Bruch(0, 1, e);
if (b.Frage()) {
b.Kuerze();
return b;
} else
return null;
}
Bei fehlerhaften Benutzereingaben liefert die Methode den Referenzwert null zurück. Mit Hilfe der
neuen Methode kann die obige Sequenz durch eine einzelne Anweisung ersetzt werden:
Quellcode
Eingabe (fett) und Ausgabe
Zaehler: 26
using System;
Nenner : 39
class BruchRechnung {
static void Main() {
Bruch b = Bruch.BenDef("Benutzerdefiniert: "); Benutzerdefiniert:
if (b != null)
b.Zeige();
else
Console.WriteLine("b zeigt auf null");
}
}
2
----3
Wird eine Klassenmethode von anderen Methoden der eigenen Klasse (objekt- oder klassenbezogen) verwendet, muss der Klassenname nicht angegeben werden.
In früheren Abschnitten waren mit Methoden stets objektbezogene Methoden (Instanzmethoden)
gemeint. Dies soll auch weiterhin so gelten.
4.6.4 Statische Konstruktoren
Analog zu den Instanzkonstruktoren (siehe Abschnitt 4.4.3), die beim Erzeugen eines Objekts ausgeführt werden und sich um die Initialisierung von Instanzvariablen kümmern, kann für jede Klasse
ein statischer Konstruktor zur Initialisierung von Klassenvariablen definiert werden. Er wird beim
Laden der Klasse automatisch von der CLR ausgeführt und kann nirgends explizit aufgerufen werden. Naheliegenderweise ist pro Klasse nur ein statistischer Konstruktor erlaubt, und seine Parameterliste muss leer bleiben. In der Definition ist dem Klassennamen der Modifikator static voranzustellen, während andere Modifikatoren verboten sind. Insbesondere dürfen keine Zugriffsmodifikatoren angegeben werden. Diese werden auch nicht benötigt, weil ein statischer Konstruktor ohnehin
nur vom Laufzeitsystem aufgerufen wird. Insgesamt erhalten wir das folgende Syntaxdiagramm:
Statischer Konstruktor
static
Klassenname
()
{
Anweisung
}
Im .NET – Framework ist keine Reihenfolge für die Ausführung der bei einem Programm beteiligten statischen Konstruktoren definiert.
In einer etwas gekünstelten Erweiterung des Bruch-Beispiels soll der parameterfreie Instanzkonstruktor zufallsabhängige, aber pro Programmlauf identische Werte zur Initialisierung der Felder zaehler und nenner verwenden:
public Bruch() {
zaehler = zaehlerVoreinst;
nenner = nennerVoreinst;
anzahl++;
}
169
Abschnitt 4.7 Vertiefungen zum Thema Methoden
Dazu erhält die Bruch-Klasse private statische Felder, die vom statischen Konstruktor beim Laden
der Klasse auf Zufallswerte gesetzt werden sollen:
static readonly int zaehlerVoreinst;
static readonly int nennerVoreinst;
Der Modifikator readonly sorgt dafür, dass die Felder nach der Initialisierung nicht mehr geändert
werden können (vgl. Abschnitt 4.2.1). Im statischen Konstruktor wird ein Objekt der Klasse Random aus dem Namensraum System erzeugt und dann per Next()-Methodenaufruf mit der Produktion von int-Zufallswerten beauftragt:
static Bruch() {
Random zuf = new Random();
zaehlerVoreinst = zuf.Next(1,7);
nennerVoreinst = zuf.Next(zaehlerVoreinst,9);
Console.WriteLine("Klasse Bruch geladen");
}
Außerdem protokolliert der statische Konstruktor noch das Laden der Klasse, z.B.:
Quellcode
Ausgabe
using System;
class BruchRechnung {
static void Main() {
Bruch b1 = new Bruch(), b2 = new Bruch();
b1.Zeige(); b2.Zeige();
}
}
Klasse Bruch geladen
1
----2
1
----2
4.6.5 Statische Klassen
Besitzt eine Klasse ausschließlich statische Methoden, ist das Erzeugen von Objekten nicht sinnvoll. Man kann es mit dem Modifikator static in der Klassendefinition verhindern, z.B.
public static class Service {
. . .
}
Auch die FCL enthält etliche Klassen, die ausschließlich statische Methoden enthalten und damit
nicht zum Erzeugen von Objekten konzipiert sind. Mit der Klasse Math aus dem Namensraum System haben wir ein wichtiges Beispiel bereits kennen gelernt.
4.7 Vertiefungen zum Thema Methoden
4.7.1 Rekursive Methoden
Innerhalb einer Methode darf man selbstverständlich nach Belieben andere Methoden aufrufen. Es
ist aber auch zulässig und in vielen Situationen sinnvoll, dass eine Methode sich selbst aufruft. Solche rekursiven Aufrufe erlauben eine elegante Lösung für ein Problem, das sich sukzessiv auf stets
einfachere Probleme desselben Typs reduzieren lässt, bis man schließlich zu einem direkt lösbaren
Problem gelangt. Zu einem rekursiven Algorithmus (per Selbstaufruf einer Methode) existiert stets
auch ein iterativer Algorithmus (per Wiederholungsanweisung).
Als Beispiel betrachten wir die Ermittlung des größten gemeinsamen Teilers (ggT) zu zwei natürlichen Zahlen, die z.B. in der Bruch-Methode Kuerze() benötigt wird. Sie haben bereits zwei
iterative Realisierungen des Euklidischen Lösungsverfahrens kennen gelernt: In Abschnitt 1.1 wurde ein sehr einfacher Algorithmus benutzt, den Sie später in einer Übungsaufgabe (siehe Abschnitt
Kapitel 4: Klassen und Objekte
170
3.7.4) durch einen effizienteren Algorithmus (unter Verwendung der Modulo-Operation) ersetzt
haben. Im aktuellen Abschnitt betrachten wir noch einmal die effizientere Variante, wobei zur Vereinfachung der Darstellung der ggT-Algorithmus vom restlichen Kürzungsverfahren getrennt und in
eine eigene (private) Methode ausgelagert wird:
int GGTi(int a, int b) {
int rest;
do {
rest = a % b;
a = b;
b = rest;
} while (rest > 0);
return a;
}
public void Kuerze() {
if (zaehler != 0) {
int teiler = GGTi(Math.Abs(zaehler), Math.Abs(nenner));
zaehler /= teiler;
nenner /= teiler;
} else
nenner = 1;
}
Die iterative GGT-Methode GGTi() kann durch folgende rekursive Variante GGTr() ersetzt werden:
int GGTr(int a, int b) {
int rest = a % b;
if (rest == 0)
return b;
else
return GGTr(b, rest);
}
Statt eine Schleife zu benutzen, arbeitet die rekursive Methode nach folgender Logik:

Ist der Parameter a durch den Parameter b restfrei teilbar, dann ist b der ggT, und der Algorithmus ist beendet:
return b;

Anderenfalls wird das Problem, den ggT von a und b zu finden, auf das einfachere Problem
zurückgeführt, den ggT von b und (a % b) zu finden, und die Methode GGTr() ruft sich
selbst mit neuen Aktualparametern auf. Dies geschieht recht elegant im Ausdruck der return-Anweisung:
return GGTr(b, rest);
Im iterativen Algorithmus wird übrigens derselbe Trick zur Reduktion des Problems verwendet,
und den zugrunde liegenden Satz der mathematischen Zahlentheorie kennen Sie schon aus der oben
erwähnten Übungsaufgabe in Abschnitt 3.7.4.
Wird die Methode GGTr() z.B. mit den Argumenten 10 und 6 aufgerufen, kommt es zu folgender
Aufrufverschachtelung:
171
Abschnitt 4.7 Vertiefungen zum Thema Methoden
2
GGTr(10, 6) {
.
.
.
return GGTr(6, 4);
}
GGTr(6, 4) {
.
.
.
return GGTr(4, 2);
}
GGTr(4, 2) {
.
.
return 2;
.
.
}
Generell läuft ein rekursiver Algorithmus nach der im folgenden Struktogramm beschriebenen
Logik ab:
Ist das Problem direkt lösbar?
Ja
Lösung ermitteln
und an den Aufrufer melden
Nein
rekursiver Aufruf mit einem
einfacheren Problem
Lösung des einfacheren
Problems zur Lösung des
Ausgangsproblems verwenden
Im Beispiel ist die Lösung des einfacheren Problems. sogar identisch mit der Lösung des ursprünglichen Problems.
Wird bei einem fehlerhaften Algorithmus der linke Zweig nie oder zu spät erreicht, dann erschöpfen
die geschachtelten Methodenaufrufe die Stack-Kapazität, und es kommt zu einem Ausnahmefehler:
Process is terminated due to StackOverflowException.
Rekursive Algorithmen lassen sich zwar oft eleganter formulieren als die iterativen Alternativen,
benötigen aber durch die hohe Zahl von Methodenaufrufen in der Regel mehr Rechenzeit.
4.7.2 Operatoren überladen
Nicht nur Methoden können in C# überladen werden, sondern auch die Operatoren (+ -* / etc.),
was z.B. in der Klasse String mit dem „+“ - Operator geschehen ist, so dass wir Zeichenfolgen bequem verketten können. Generell geht es beim Überladen von Operatoren darum, dem Anwender
einer Klasse syntaktisch elegante Lösungen für Aufgaben anzubieten, die letztlich einen Methodenaufruf erfordern. Statt die Argumente in einer Aktualparameterliste anzugeben, können sie bei reduziertem Syntaxaufwand um ein Operatorzeichen gruppiert werden. Dieses Zeichen erhält eine neue,
zusätzliche Bedeutung für Argumente aus der betroffenen (mit der Operatorüberladung ausgestatteten) Klasse.
Mit den aktuell in der Klasse Bruch vorhandenen Methoden lässt sich nur umständlich ein neues
Objekt b3 als Summe von zwei vorhandenen Objekten b1 und b2 erzeugen, z.B.:
Bruch b3 = b1.Klone();
b3.Addiere(b2);
Es wäre eleganter, wenn derselbe Zweck mit folgender Anweisung erreicht werden könnte:
Kapitel 4: Klassen und Objekte
172
Bruch b3 = b1 + b2;
Um dies zu ermöglichen, definieren wir eine neue statische Bruch-Methode mit dem merkwürdigen Namen operator+, die ausgeführt werden soll, wenn das Pluszeichen zwischen zwei
Bruch-Objekten auftaucht:
public static Bruch operator+ (Bruch b1, Bruch b2) {
Bruch temp = new Bruch(b1.Zaehler * b2.Nenner + b1.Nenner * b2.Zaehler,
b1.Nenner * b2.Nenner, "");
temp.Kuerze();
return temp;
}
Beim Überladen von Operatoren sind u.a. folgende Regeln zu beachten:


Es ist grundsätzliche eine statische Definition erforderlich.
Als Namen verwendet man das Schlüsselwort operator mit dem jeweiligen Operationszeichen als Suffix.
Nähere Hinweise finden sich z.B. bei Mössenböck (2003, S. 73ff).
Mit dem überladenen „+“ - Operator lassen sich Bruch-Additionen nun sehr übersichtlich formulieren:
Quellcode
using System;
class BruchRechnung {
static void Main() {
Bruch b1 = new Bruch(1, 2, "b1 = ");
b1.Zeige();
Bruch b2 = new Bruch(1, 4, "b2 = ");
b2.Zeige();
Bruch b3 = b1 + b2;
b3.Etikett = "Summe = ";
b3.Zeige();
Ausgabe
b1 =
1
----2
b2 =
1
----4
Summe =
3
----4
}
}
Wie das Windows-SDK - Hilfsprogramm ILDasm zeigt, resultiert aus unserer Operatorenüberladung im Assembly die statische Methode op_Addition():
4.8
Aggregieren und innere Klassen
4.8.1 Aggregation
Bei den Feldern einer Klasse sind beliebige Datentypen zugelassen, auch Klassentypen. Damit ist es
z.B. möglich, vorhandene Klassen als Bestandteile von neuen, komplexeren Klassen zu verwenden.
Neben der später noch ausführlich zu behandelnden Vererbung ist diese Aggregation von Klassen
173
Abschnitt 4.8 Aggregieren und innere Klassen
eine sehr effektive Technik zur Wiederverwendung von Software bzw. zum Aufbau von komplexen
Softwaresystemen. Außerdem ist sie im Sinne einer realitätsnahen Modellierung unverzichtbar,
denn auch ein reales Objekt (z.B. eine Firma) enthält andere Objekte 1 (z.B. Mitarbeiter, Kunden),
die ihrerseits wiederum Objekte enthalten (z.B. ein Gehaltskonto und einen Terminkalender bei den
Mitarbeitern) usw.
Wegen der großen Bedeutung der Aggregation soll ihr ein ausführliches Beispiel gewidmet werden,
obwohl der aktuelle Abschnitt nur einen neuen Begriff für eine längst vertraute Situation bringt.
Wir erweitern das Bruchrechnungsprogramm um eine Klasse namens Aufgabe, die Trainingssitzungen unterstützen soll. In der Aufgabe-Klassendefinition tauchen vier Instanzvariablen vom
Typ Bruch auf:
using System;
public class Aufgabe {
Bruch b1, b2, lsg, antwort;
char op;
public Aufgabe(char op_, int b1Z, int b1N, int b2Z, int b2N) {
op = op_;
b1 = new Bruch(b1Z, b1N, "1. Argument:");
b2 = new Bruch(b2Z, b2N, "2. Argument:");
lsg = new Bruch(b1Z, b1N, "Das korrekte Ergebnis:");
antwort = new Bruch();
Init();
}
private void Init() {
switch (op) {
case '+': lsg.Addiere(b2);
break;
case '*': lsg.Multipliziere(b2);
break;
}
}
public bool Korrekt {
get {
Bruch temp = antwort.Klone();
temp.Kuerze();
if (lsg.Zaehler == temp.Zaehler && lsg.Nenner == temp.Nenner)
return true;
else
return false;
}
}
public void Zeige(int was) {
switch (was) {
case 1: Console.WriteLine("
" + b1.Zaehler +
"
" + b2.Zaehler);
Console.WriteLine(" -----" + op + "
Console.WriteLine("
" + b1.Nenner +
"
" + b2.Nenner);
break;
case 2: lsg.Zeige(); break;
case 3: antwort.Zeige(); break;
}
}
1
Die betroffenen Personen mögen den Fachterminus Objekt nicht persönlich nehmen.
-----");
Kapitel 4: Klassen und Objekte
174
public void Frage() {
Console.WriteLine("\nBerechne bitte:\n");
Zeige(1);
Console.Write("\nWelchen Zähler hat Dein Ergebnis:
");
antwort.Zaehler = Convert.ToInt32(Console.ReadLine());
Console.WriteLine("
------");
Console.Write("Welchen Nenner hat Dein Ergebnis:
");
antwort.Nenner = Convert.ToInt32(Console.ReadLine());
}
public void Pruefe() {
Frage();
if (Korrekt)
Console.WriteLine("\n Gut!");
else {
Console.WriteLine();
Zeige(2);
}
}
public void NeueWerte(char op_,
op = op_;
b1.Zaehler = b1Z; b1.Nenner =
b2.Zaehler = b2Z; b2.Nenner =
lsg.Zaehler = b1Z; lsg.Nenner
Init();
}
int b1Z, int b1N, int b2Z, int b2N) {
b1N;
b2N;
= b1N;
}
Die vier Bruch-Objekte in einer Aufgabe dienen folgenden Zwecken:



b1 und b2 werden dem Anwender (in der Aufgabe-Methode Frage()) im Rahmen einer
Aufgabenstellung vorgelegt, z.B. zum Addieren.
In antwort landet der Lösungsversuch des Anwenders.
In lsg steht das korrekte Ergebnis
In folgendem Programm wird die Klasse Aufgabe für ein Bruchrechnungstraining verwendet:
using System;
class BruchRechnung {
static void Main() {
Aufgabe auf = new Aufgabe('+', 1, 2, 2, 5);
auf.Pruefe();
auf.NeueWerte('*', 3, 4, 2, 3);
auf.Pruefe();
Console.ReadLine();
}
}
Man kann immerhin schon ahnen, wie die praxistaugliche Endversion des Programms einmal arbeiten wird:
Berechne bitte:
1
-----2
+
2
----5
Welchen Zaehler hat Dein Ergebnis:
Welchen Nenner hat Dein Ergebnis:
9
Das korrekte Ergebnis: ----10
3
-----7
175
Abschnitt 4.8 Aggregieren und innere Klassen
Berechne bitte:
3
-----4
*
2
----3
Welchen Zaehler hat Dein Ergebnis:
Welchen Nenner hat Dein Ergebnis:
6
-----12
Gut!
4.8.2 Innere Klassen
Eine Klasse darf neben Feldern, Methoden etc. auch Klassendefinitionen enthalten, wobei innere
Klassen entstehen, die sich vor allem für lediglich lokal benötigte Typen eignen. Im folgenden Beispiel werden innerhalb der Klasse Familie die Klassen Tochter und Sohn definiert:
using System;
class Familie {
string name;
Tochter t;
Sohn s;
public Familie(string name_, string nato, int alto, string naso, int also) {
name = name_;
t = new Tochter(this, nato, alto);
s = new Sohn(this, naso, also);
}
public void Info() {
Console.WriteLine("Die Kinder von Familie {0}:\n", name);
t.Info();
s.Info();
}
static void Main() {
Familie f = new Familie("Müller", "Lea", 7, "Theo", 4);
f.Info();
Console.ReadLine();
}
class Tochter {
Familie f;
string name;
int alter;
public Tochter(Familie f_, string name_, int alt_) {
f = f_;
name = name_;
alter = alt_;
}
public void Info() {
Console.WriteLine(" Ich bin die {0}-jährige Tochter {1} von Familie {2}",
alter, name, f.name);
}
}
Kapitel 4: Klassen und Objekte
176
class Sohn {
Familie f;
string name;
int alter;
public Sohn(Familie f_, string name_, int alt_) {
f = f_;
name = name_;
alter = alt_;
}
public void Info() {
Console.WriteLine(" Ich bin der {0}-jährige Sohn {1} von Familie {2}",
alter, name, f.name);
}
}
}
Die Main()-Methode der umgebenden Klasse Familie sorgt im Beispiel für folgende Ausgabe:
Die Kinder von Familie Müller:
Ich bin die 7-jährige Tochter Lea von Familie Müller.
Ich bin der 4-jährige Sohn Theo von Familie Müller.
Für das Schachteln von Klassendefinitionen gelten u.a. folgende Regeln:

Die Methoden der inneren Klasse dürfen auf die privaten Member der umgebenden Klasse
zugreifen, was z.B. in der folgenden Anweisung des Beispiels geschieht (f.name ist ein
privates Feld in der Klasse Familie):
Console.WriteLine(" Ich bin der {0}-jährige Sohn {1} von Familie {2}.",
alter, name, f.name);


Für die privaten Member einer inneren Klasse hat auch die umgebende Klasse keine
Zugriffsrechte, weshalb im Beispiel die Konstruktoren und die Info()-Methoden der inneren Klassen als public definiert werden.
Innere Klassen besitzen wie die sonstigen Klassen-Member (Felder, Methoden, Eigenschaften etc.) die voreingestellte Schutzstufe private.
4.9 Verfügbarkeit von Klassen und Klassenbestandteilen
Nachdem die Datenkapselung mehrfach als wesentlicher Vorzug bzw. Kernidee der objektorientierten Programmierung herausgestellt wurde und wiederholt Angaben zur Verfügbarkeit von Klassen
bzw. Klassenbestandteilen an verschiedenen Stellen eines .NET - Programms gemacht wurden, sollen die Regeln zum Zugriffsschutz nun zusammengestellt werden, obwohl dabei noch einige kleine
Vorgriffe auf das Thema Vererbung nötig sind.
Bei einer normalen (nicht geschachtelten) Klasse kennt C# folgende Stufen der Verfügbarkeit (vgl.
ECMA 2006, S. 274):
Modifikator
ohne oder internal
public
Die Klasse ist verfügbar …
im eigenen Assembly
überall
ja
nein
ja
ja
Unsere als public definierte Beispielklasse Bruch kann in beliebigen .NET-Programmen mit Zugang zur Assembly-Datei genutzt werden, was gleich in Abschnitt 4.10 demonstriert werden soll.
Für Klassen-Member (Felder, Methoden, Eigenschaften etc.) unterstützt C# folgende Schutzstufen
(siehe ECMA 2006, S. 31):
177
Abschnitt 4.10 Bruchrechnungsprogramm mit WinForms-Benutzerschnittstelle
Der Zugriff ist erlaubt für ...
Modifikator(en)
ohne oder private
internal
protected
protected internal
public
nicht von K
eigene Klasse
abgel. Klassen
(Abk. K) u.
im eigenen
innere Klassen
Assembly
ja
ja
ja
ja
ja
nein
ja
nein
ja
ja
von K abgeleitete Klassen
im eigenen
Assembly
in anderen
Assemblies
nein
ja
geerbte M.
ja
ja
nein
nein
geerbte M.
geerbte M.
ja
sonstige
Klassen
nein
nein
nein
nein
ja
Wir haben die Methoden und Eigenschaften unserer Beispielklasse Bruch als public definiert und
bei den Feldern die voreingestellte Schutzstufe private beibehalten.
Die beschriebenen Zugriffsregeln für Klasen und Member gelten analog auch bei später vorzustellenden Typen (Strukturen, Enumerationen und Delegaten).
4.10 Bruchrechnungsprogramm mit WinForms-Benutzerschnittstelle
Nachdem Sie nun wesentliche Teile der objektorientierten Programmierung mit C# kennen gelernt
haben, ist vielleicht ein weiterer Ausblick auf die nicht mehr sehr ferne Entwicklung von WindowsProgrammen mit graphischer Benutzerschnittstelle als Belohnung und Motivationsquelle angemessen. Schließlich gilt es in diesem Kurs auch die Erfahrung zu vermitteln, dass Programmieren Spaß
machen kann. Wir erstellen nun das schon in Abschnitt 1.1.5 präsentierte Bruchkürzungsprogramm
mit graphischer Benutzerschnittstelle:
4.10.1 Projekt anlegen mit Vorlage Windows Forms - Anwendung
In der folgenden Beschreibung wird die Visual C# 2008 Express Edition verwendet, doch bestehen
keine wesentlichen Bedienungsunterschiede zum Visual Studio 2008 Professional (vgl. Abschnitt
2.2.1.3). Verwenden Sie nach
Datei > Neu > Projekt
für ein neues Projekt mit dem Namen BruchKürzenGui die Vorlage Windows Forms - Anwendung:
178
Kapitel 4: Klassen und Objekte
Nach einem Mausklick auf OK präsentiert die Entwicklungsumgebung im Formulardesigner einen
Rohling für das Hauptfenster der entstehenden Anwendung. Speichern Sie das Projekt über
Datei > Alle speichern
in einem Ordner Ihrer Wahl (festgelegt durch Namen und Basisverzeichnis), z.B.:
Durch den Verzicht auf ein Projektmappenverzeichnis wählen wir eine „flache“ Projektdateiverwaltung ohne Zusammenfassung von mehreren Projekten zu einer Mappe.
4.10.2 Steuerelemente aus der Toolbox übernehmen
Öffnen Sie das Toolbox-Fenster mit dem Menübefehl
Ansicht > Toolbox
oder per Mauszeiger durch kurzes Verharren auf der Toolbox-Schaltfläche am linken Fensterrand:
Abschnitt 4.10 Bruchrechnungsprogramm mit WinForms-Benutzerschnittstelle
179
Erweitern Sie im Toolbox-Fenster die Liste mit den allgemeinen Steuerelementen, und ziehen Sie per Maus zwei TextBox-Objekte, ein Label-Objekt sowie zwei Button-Objekte auf das
Formular, z.B.:
4.10.3 Steuerelemente gestalten
Nun können Sie wie in einem Grafikprogramm die Positionen und Größen der Fensterbestandteile
verändern, um das gewünschte Layout zu erzielen, z.B.:
180
Kapitel 4: Klassen und Objekte
In der Symbolleiste Layout finden sich zahlreiche professionelle Gestaltungswerkzeuge zum Ausrichten, Angleichen etc.:
Statt mehrere Steuerelemente desselben Typs von der Toolbox auf das Formular zu befördern, kann
man bei gedrückter Strg-Taste vorhandene Steuerelemente wie in einem Grafikprogramm kopieren, wobei die Eigenschaften des Originals übernommen werden.
Viele Verhaltensmerkmale des Formulardesigners lassen sich nach
Extras > Optionen > Windows Forms-Designer
in folgender Dialogbox
beeinflussen, z.B. die Positionierungsunterstützung durch Bezugslinien
oder durch ein Gitter:
Abschnitt 4.10 Bruchrechnungsprogramm mit WinForms-Benutzerschnittstelle
181
Einstellungsänderungen wirken sich auf ein in Bearbeitung befindliches Formular nicht aus. Ggf.
müssen Sie also das Fenster des Formulardesigners schließen (siehe Pfeil in der letzten Abbildung)
und dann per Doppelklick auf den Eintrag Form1.cs im Projektmappen-Explorer wieder öffnen.
4.10.4 Eigenschaften der Steuerelemente ändern
Im Eigenschaftsfenster der Entwicklungsumgebung, das bei Bedarf mit dem Menübefehl
Ansicht > Eigenschaftsfenster
zu öffnen ist, lassen sich diverse Eigenschaften (im Sinne von Abschnitt 4.5!) des markierten Formularobjekts durch direkte Werteingabe festlegen, z.B. der Text in der Titelzeile des Formulars:
Ändern Sie analog …




die Text - Eigenschaft der Schaltflächen, so dass sinnvolle Beschriftungen entstehen
die Text - Eigenschaft des Labels, um einen behelfsmäßigen Bruchstrich einzuzeichnen
für beide Textboxen die Eigenschaft TextAlign auf den Wert Center
die AcceptButton-Eigenschaft des Formulars auf den Instanzvariablennamen des Befehlsschalters zum Kürzen (per Voreinstellung: button1), so dass dieser per Enter-Taste angesprochen werden kann
Man kann das aktive Objekt auch über eine Liste im Kopfbereich des Eigenschaftsfensters wählen.
Außerdem lassen sich die Eigenschaften von mehreren gleichzeitig markierten Objekten in einem
Arbeitsgang ändern.
Kapitel 4: Klassen und Objekte
182
4.10.5 Der Quellcode-Generator
Aufgrund Ihrer kreativen Tätigkeit erzeugt die Entwicklungsumgebung im Hintergrund Quellcode
zu einer neuen Klasse namens Form1, die von der Klasse Form im Namensraum System.Windows.Forms abgeleitet wird (siehe unten). Aber auch Sie werden signifikanten Quellcode zu dieser Klasse beisteuern. Damit sich die beiden Autoren nicht in die Quere kommen, wird
der Quellcode der Klasse Form1 auf zwei Dateien verteilt:


Form1.cs
Hier werden die von Ihnen erstellten Methoden landen (siehe unten).
Form1.Designer.cs
Hier landet der automatisch generierte Quellcode, und Sie sollten in dieser Datei keine Änderungen vornehmen, um den Formulardesigner nicht aus dem Tritt zu bringen.
Dem C# - Compiler wird durch das Schlüsselwort partial in der Klassendefinition signalisiert, dass
der Quellcode auf mehrere Dateien verteilt ist, z.B.:
using
using
using
using
using
using
using
using
System;
System.Collections.Generic;
System.ComponentModel;
System.Data;
System.Drawing;
System.Linq;
System.Text;
System.Windows.Forms;
namespace BruchKürzenGui {
public partial class Form1 : Form {
public Form1() {
InitializeComponent();
}
}
}
Der Form1-Quellcode in der Datei Form1.cs enthält u.a. einen Konstruktor, welcher die private
Methode InitializeComponent() aufruft. Diese wird in der Datei Form1.Designer.cs vom Formulardesigner implementiert.
Um eine ungefähre Vorstellung vom Wirken des Code-Generators zu erhalten, öffnen wir die
Quellcodedatei Form1.Designer.cs (ohne Änderungsabsicht) per Doppelklick auf ihren Eintrag im
Projektmappen-Explorer. Hier finden sich u.a. die Deklarationen der Instanzvariablen:
private
private
private
private
private
System.Windows.Forms.TextBox textBox1;
System.Windows.Forms.TextBox textBox2;
System.Windows.Forms.Label label1;
System.Windows.Forms.Button button1;
System.Windows.Forms.Button button2;
Aufgrund unserer Tätigkeit im Formulardesigner enthält die Formularklasse Form1 im Sinne der in
Abschnitt 4.8.1 behandelten Aggregation mehrere Objekte anderer Klassen, die Steuerelemente der
graphischen Benutzeroberfläche repräsentieren. Obwohl die Schutzstufe private für KlassenMember voreingestellt ist, verwendet die Entwicklungsumgebung der Deutlichkeit halber den Modifikator bei jeder Deklaration. Eine weitergehende Analyse des automatisch erstellten Quellcodes
sparen wir uns an dieser Stelle.
Der Quellcode-Generator setzt abweichend von der im Manuskript üblichen Praxis eröffnende geschweifte Klammern (z.B. bei Klassen- oder Methodendefinitionen) in eine eigene Zeile. Das alternative Verhalten lässt nach dem Menübefehl
Extras > Optionen
so einstellen:
Abschnitt 4.10 Bruchrechnungsprogramm mit WinForms-Benutzerschnittstelle
183
4.10.6 Assembly mit der Bruch-Klasse einbinden
Gehen Sie folgendermaßen vor, um Objekte unserer Beispielklasse Bruch im neuen Programm
nutzen zu können:

Kopieren Sie die Assembly-Datei mit der Klasse Bruch, z.B.
…\BspUeb\Klassen und Objekte\Bruch\b7 Operatoren-Überladung\bin\Debug\b7.exe
in den Debugmodus-Ausgabeordner Ordner des neuen Projekts, also z.B. nach
U:\Eigene Dateien\C#\Kurs\BspUeb\Klassen und Objekte\Bruch\BruchKürzenGui\bin\Debug

In der Praxis werden sich allgemein benötigte Assembly-Dateien an einem sinnvollen Ort
befinden (z.B. im Global Assembly Cache, GAC, siehe Abschnitt 1.2.4.4), so dass sie zur
Nutzung in einem konkreten Projekt nicht kopiert werden müssen.
Nehmen Sie per Projektmappen-Explorer die Assembly-Datei Bruch.exe in die Verweisliste
des Projekts auf, z.B.:
Die Klasse Bruch befindet sich im globalen Namensraum, weil wir auf eine namespace-Definition verzichtet haben. Folglich ist beim Zugriff (im Unterschied zu den FCL-Klassen) kein Namensraum anzugeben. Wegen der Gefahr von Namenskollisionen ist dieses Verfahren nicht empfehlenswert bei Klassen(bibliotheken), die in vielen Projekten verwendet werden sollen. Unsere Ent-
184
Kapitel 4: Klassen und Objekte
wicklungsumgebungen definieren grundsätzlich einen Namensraum (abgesehen von der leeren Projektvorlage), so auch im aktuellen Beispiel:
namespace BruchKürzenGui {
. . .
}
4.10.7 Ereignisbehandlungsmethoden anlegen
Nun erstellen wir zu jedem Befehlsschalter eine Methode, die durch das Betätigen des Schalters
(z.B. per Mausklick) ausgelöst werden soll. Setzen Sie im Formulardesigner einen Doppelklick auf
den Befehlsschalter button1 (mit dem Wert Kürzen für die Eigenschaft Text), so dass die Entwicklungsumgebung in Form1.cs die Instanzmethode button1_Click() mit leerem Rumpf
anlegt
private void button1_Click(object sender, EventArgs e) {
}
und die Quellcodedatei in einem Editorfenster öffnet.
Mit Hilfe eines Objekts aus unserer Klasse Bruch ist die benötigte Funktionalität leicht zu implementieren, z.B.:
private void button1_Click(object sender, EventArgs e) {
Bruch b = new Bruch();
try {
b.Zaehler = Convert.ToInt32(textBox1.Text);
b.Nenner = Convert.ToInt32(textBox2.Text);
b.Kuerze();
textBox1.Text = b.Zaehler.ToString();
textBox2.Text = b.Nenner.ToString();
} catch {
MessageBox.Show("Eingabefehler", "Fehler", MessageBoxButtons.OK,
MessageBoxIcon.Error);
}
}
Der Bequemlichkeit halber wird eine lokale Bruch-Referenzvariable verwendet, so dass bei jedem
Methodenaufruf ein neues Objekt entsteht (vgl. Übungsaufgabe in Abschnitt 4.11).
Tritt im try-Block eine Ausnahme auf (z.B. beim Versuch, irreguläre Benutzereingaben zu konvertieren), dann wird der catch-Block ausgeführt, und es erscheint eine Fehlermeldung. Im Aufruf der
MessageBox-Methode Show() sorgen Werte der Enumerationen (siehe unten) MessageBoxButtons bzw. MessageBoxIcon als Aktualparameter für die gewünschte Ausstattung der Meldungsdialogbox. Bei einem gelungenen Ablauf wandern Informationen zwischen den Text-Eigenschaften der beiden TextBox-Objekte (Datentyp string) und den Bruch-Instanzvariablen zaehler und nenner (Datentyp: int).
Erstellen Sie nun per Doppelklick auf den Befehlsschalter button2 (mit dem Wert Beenden für
die Eigenschaft Text) den Rohling für seine Klick-Ereignisbehandlungsmethode, und ergänzen Sie
einen Aufruf der statischen Methode Exit() der Klasse Environment, die zur Beendigung des Programms führt, z.B.:
private void button1_Click(object sender, EventArgs e) {
Environment.Exit(0);
}
Abschnitt 4.11 Übungsaufgaben zu Kapitel 1453H4
185
Veranlassen Sie mit der Funktionstaste F5 das Übersetzen und die Ausführung der fertigen Anwendung. Die Assembly-Datei BruchKürzenGui.exe findet sich im Projektunterordner …\bin\Debug.
Beachten Sie beim Kopieren/Verschieben Ihres Programms, dass sich eine Assembly-Datei mit der
verwendeten Klasse Bruch im selben Ordner befinden muss.
4.11 Übungsaufgaben zu Kapitel 4
1) Welche von den folgenden Aussagen sind richtig?
1.
2.
3.
4.
Alle Instanzvariablen einer Klasse müssen von elementarem Typ sein.
In einer Klasse können mehrere Methoden mit demselben Namen existieren.
Bei der Definition eines Konstruktors ist stets der Rückgabetyp void anzugeben.
Mit der Datenkapselung wird verhindert, dass ein Objekt auf die Instanzvariablen anderer
Objekte derselben Klasse zugreifen kann.
5. Als Wertaktualparameter sind nicht nur Variablen erlaubt, sondern beliebige Ausdrücke mit
kompatiblem (erweiternd konvertierbarem Typ).
6. Ändert man den Rückgabetyp einer Methode, dann ändert sich auch ihre Signatur.
2) Erläutern Sie bitte den Unterschied zwischen einem readonly – deklarierten Feld und einer Eigenschaft ohne set – Implementierung, die man auch als getonly – Eigenschaft bezeichnen könnte.
3) Im folgenden Programm soll die statische Bruch-Eigenschaft Anzahl ausgelesen werden:
using System;
class BruchRechnung {
static void Main() {
Bruch b1 = new Bruch(), b2 = new Bruch();
Console.WriteLine("Jetzt sind wir " + Bruch.Anzahl);
}
}
Es liegt folgende Eigenschaftsdefinition zugrunde:
public static int Anzahl {
get {
return Anzahl;
}
}
Statt der erwarteten Auskunft:
Jetzt sind wir 2
erhält man jedoch (beim Programmstart im Konsolenfenster) die Fehlermeldung:
Process is terminated due to StackOverflowException.
Offenbar hat sich ein Fehler in die Eigenschaftsdefinition eingeschlichen, den der Compiler nicht
bemerkt.
4) Die folgende Aufgabe eignet sich nur für Leser mit Grundkenntnissen in linearer Algebra: Erstellen Sie eine Klasse für Vektoren im IR2, die mindestens über Methoden bzw. Eigenschaften mit
folgenden Leistungen erfügt:

Länge ermitteln
x 
Der Betrag eines Vektors x   1  ist definiert durch:
 x2 
x : x12  x22
Kapitel 4: Klassen und Objekte
186
Verwenden Sie die Klassenmethode Math.Sqrt(), um die Quadratwurzel aus einer double-Zahl zu berechnen.

Vektor auf Länge Eins normieren
Dazu dividiert man beide Komponenten durch die Länge des Vektors, denn mit
x1
x2
~
x : ( ~
x1 , ~
x2 ) sowie ~
x1 :
und ~
x2 :
gilt:
x12  x22
x12  x22

x1
~
x : ~
x12  ~
x22  
2
 x  x2
2
 1

2
 
x2
 
2
  x  x2
2
  1
2

 


x12
x22

1
x12  x22 x12  x22
Vektoren (komponentenweise) addieren
x 
y 
Die Summe der Vektoren x   1  und y   1  ist definiert durch:
 x2 
 y2 
 x1  y1 



x
y
2
 2

Skalarprodukt zweier Vektoren ermitteln
x 
y 
Das Skalarprodukt der Vektoren x   1  und y   1  ist definiert durch:
 x2 
 y2 
x  y : x1 y1  x2 y2

Winkel zwischen zwei Vektoren in Grad ermitteln
Für den Kosinus des Winkels, den zwei Vektoren x und y im mathematischen Sinn (links
herum) einschließen, gilt: 1
x y
cos( x, y ) 
xy
y
(0,1)
x

(1,0)
cos(x,y)
Um aus cos(x, y) den Winkel  in Grad zu ermitteln, können Sie folgendermaßen vorgehen:
o mit der Klassenmethode Math.Acos() den Winkel im Bogenmaß ermitteln
o das Bogenmaß (rad) nach folgender Formel in Grad umrechnen (deg):
1
Dies folgt aus dem Additionstheorem für den Kosinus.
187
Abschnitt 4.11 Übungsaufgaben zu Kapitel 1453H4
deg 

rad
 360
2
Rotation eines Vektors um einen bestimmten Winkelgrad
Mit Hilfe der Rotationsmatrix
 cos()  sin() 

D : 
 sin() cos() 
kann der Vektor x um den Winkel  (im Bogenmaß!) gedreht werden:
 cos()  sin()   x1   cos() x1  sin() x2 

    
x  D x  
 sin() cos()   x2   sin() x1  cos() x2 
Zur Berechnung der trigonometrischen Funktionen stehen die Klassenmethoden
Math.Cos() und Math.Sin() bereit. Winkelgrade (deg) müssen nach folgender Formel in
das von Cos() und Sin() benötigte Bogenmaß (rad) umgerechnet werden:
rad 
deg
 2
360
Erstellen Sie ein Demonstrationsprogramm, das Ihre Vektor-Klasse verwendet und ungefähr den
folgenden Programmablauf ermöglicht (Eingabe fett):
Vektor 1:
Vektor 2:
( 1,00;
( 1,00;
Länge von Vektor 1:
1,00
Länge von Vektor 2:
1,41
Winkel:
0,00)
1,00)
45,00 Grad
Um wie viel Grad soll Vektor 2 gedreht werden: 45
Neuer Vektor 2
( 0,00;
Neuer Vektor 2 normiert ( 0,00;
1,41)
1,00)
Summe der Vektoren
1,00)
( 1,00;
5) Erstellen Sie eine Klasse mit einer statischen Methode zur Berechnung der Fakultät über einen
rekursiven Algorithmus. Erstellen Sie eine Testklasse, welche die rekursive Fakultätsmethode benutzt. Diese Aufgabe dient dazu, an einem einfachen Beispiel mit rekursiven Methodenaufrufen zu
experimentieren. Für die Praxis ist die rekursive Fakultätsberechnung sicher nicht geeignet.
6) Ersetzen Sie beim GUI-Bruchkürzungsprogramm in Abschnitt 4.10 die lokale Bruch-Referenzvariable in der Klick-Ereignisbehandlungsmethode zum Befehlsschalter button1 (mit dem Wert
Kürzen für die Eigenschaft Text) durch eine Instanzvariable der Formularklasse Form1. So wird
vermieden, dass bei jedem Methodenaufruf ein neues Bruch-Objekt entsteht, das nach Beenden
der Methode dem Garbage Collector überlassen wird.
7) Lokalisieren Sie bitte in dieser Abbildung mit einer Kurzform der Klasse Bruch
Kapitel 4: Klassen und Objekte
188
using System;
public class Bruch {
int zaehler, nenner = 1;
string etikett = "";
static int anzahl;
1
2
public Bruch(int zpar, int npar, String epar) {
zaehler = zpar; Nenner = npar;
etikett = epar; anzahl++;
}
public Bruch() {anzahl++;}
3
public int Zaehler { . . . }
public int Nenner {
get {return nenner;}
set {
if (value != 0)
nenner = value;
}
}
public string Etikett { . . . }
4
5
public void Addiere(Bruch b) {
zaehler = zaehler*b.nenner + b.zaehler*nenner;
nenner = nenner*b.nenner;
Kuerze();
}
public Bruch Klone() {
return new Bruch(zaehler, nenner, etikett);
6
7
}
public void Kuerze() { . . . }
public bool Frage() { . . . }
public void Zeige() {
string luecke = "";
for (int i=1; i <= etikett.Length; i++)
luecke = luecke + " ";
Console.WriteLine(" {0}
{1}\n {2} -----\n {0}
{3}\n",
luecke, zaehler, etikett, nenner);
}
public static Bruch operator+ (Bruch b1, Bruch b2) {
Bruch temp = new Bruch(b1.Zaehler * b2.Nenner + b1.Nenner * b2.Zaehler,
b1.Nenner * b2.Nenner, "");
temp.Kuerze();
return temp;
}
public static Bruch BenDef(string e) {
Bruch b = new Bruch(0, 1, e);
if (b.Frage()) {
b.Kuerze();
return b;
} else
return null;
}
public static int Anzahl {
get {return anzahl;}
}
8
9
10
11
12
}
12 Begriffe, und tragen Sie die Positionen in die folgende Tabelle ein
Begriff
Definition einer Instanzmethode
mit Referenzrückgabe
Pos.
Begriff
Konstruktordefinition
Deklaration lokale Variable
Deklaration einer Klassenvariablen
Definition einer Instanzmethode
mit Referenz-Wertparameter
Objekterzeugung
Deklaration von Instanzvariablen
Definition einer Klassenmethode
Methodenaufruf
Definition einer Instanzeigenschaft
Deklaration einer statischen
Eigenschaft
Operatorüberladung
Pos.
Abschnitt 4.11 Übungsaufgaben zu Kapitel 1453H4
Zum Eintragen benötigen Sie nicht unbedingt eine gedruckte Variante des Manuskripts, sondern
können auch das interaktive PDF-Formular
...\BspUeb\Klassen und Objekte\Begriffe lokalisieren.pdf
benutzen. Die Idee zu dieser Übungsaufgabe stammt aus Mössenböck (2003).
189
5 Weitere .NETte Typen
Nachdem wir uns ausführlich mit elementaren Datentypen und Klassen beschäftigt haben, wird in
diesem Abschnitt Ihr Wissen über das Common Type System (CTS) der .NET – Plattform abgerundet. Sie lernen u.a. die folgenden Datentypen kennen:




Strukturen als Klassenalternative mit Wertsemantik
Arrays als Container für eine feste Anzahl von Elementen desselben Datentyps
Klassen zur Verwaltung von Zeichenketten (String, StringBuilder)
Aufzählungstypen (Enumerationen)
5.1 Strukturen
Klassen und Objekte haben ohne Zweifel einen sehr hohen Nutzen, verursachen aber auch Kosten,
z.B. beim Erzeugen von Objekten, bei der Referenzverwaltung und bei der Entsorgung per Garbage
Collector. Daher stellt das .NET – Framework mit den Strukturen auch Werttypen zur Verfügung,
die in manchen Situationen bei geringerem Ressourcen-Verbrauch eine „echte“ Klasse ersetzen und
somit die Performanz der Software steigern können.
Beim Design eines Strukturtyps können dieselben Member eingesetzt werden wie bei einer Klassendefinition (Felder beliebigen Typs, Methoden, Eigenschaften, usw.). Eine Variable vom Typ
einer Struktur enthält jedoch keine Referenz auf ein Heap-Objekt, sondern die Daten ihres Typs.
Individuen nach dem Bauplan einer Struktur werden nicht als Objekte bezeichnet, sondern als Instanzen.
Eine Struktur eignet sich vor allem bei folgender Konstellation:





Der Datentyp ist relativ einfach aufgebaut (wenige Member).
Microsoft empfiehlt auf einer Webseite mit Richtlinien zur Verwendung von Strukturtypen1
eine Instanz-Maximalgröße von 16 Bytes.
Es werden sehr viele Instanzen benötigt.
Werden in einer zeitkritischen Programmsituation sehr viele Instanzen benötigt, kann sich
die Vermeidung von Objektkreationen durch Verwendung eines Strukturtyps lohnen.
Die Instanzen sollen analog zu den elementaren Typen eine Wertsemantik haben. Bei einer
Zuweisung soll also keine Referenz übergeben, sondern der komplette Wert kopiert werden.
Die Instanzen sollen nicht über das Ende der kreierenden Methode hinaus im Hauptspeicher
verbleiben (es sei denn als Member von Objekten).
Es ist keine Vererbung erforderlich.
Bei Strukturen fehlt die Möglichkeit, über die wichtige objektorientierte Technik der Vererbung (siehe Abschnitt 6) eine Hierarchie spezialisierter Typen aufzubauen. Das Implementieren von Schnittstellen (siehe Abschnitt 8) ist aber möglich.
Typische Anwendungsbeispiele für Strukturen:


1
2
Punkte in einem zweidimensionalen Zahlenraum
Komplexe Zahlen 2
URL: http://msdn.microsoft.com/de-de/library/y23b5415(en-us).aspx
Dieser mathematische Begriff meint Paare aus reellen Zahlen, für die spezielle Rechenregeln gelten. Wer nicht mathematisch vorbelastete ist, kann das Beispiel ignorieren.
Kapitel 5: Weitere .NETte Typen
192
5.1.1 Vergleich von Klassen und Strukturen
Um den gravierenden Unterscheid zwischen der Referenzsemantik der Klassen und der Wertsemantik der Strukturen zu demonstrieren, definieren wir sowohl eine Klasse als auch eine Struktur zur
Repräsentation von Punkten der reellen Zahlenebene (IR2). Für die beiden Koordinaten eines Punkts
werden Felder mit dem elementaren Datentyp double verwendet. Wir verzichten auf eine Datenkapselung, erlauben also auch fremden Methoden den direkten Zugriff auf die Felder.
Eine Strukturdefinition unterscheidet sich von der gewohnten Klassendefinition auf den ersten
Blick nur durch das neue Schlüsselwort struct, das an Stelle von class verwendet wird:
using System;
public struct Punkt {
public double X, Y;
public Punkt(double x_, double y_) {
X = x_;
Y = y_;
}
public string Inhalt {
get {
return "("+X+";"+Y+")";
}
}
}
Beim folgenden Einsatz der Punkt-Struktur wird die Variable p1 über den expliziten Konstruktor
mit dem Wert (1, 2) initialisiert. Der Punkt p2 erhält (vom nicht verloren gegangenen) Standardkonstruktor eine Initialisierung auf den Wert (0, 0). Der Punkt p3 erhält ohne KonstruktorBeteiligung eine Kopie von p1, wobei die spätere Änderung von p1 ohne Effekt auf p3 bleibt:
Quellcode
Ausgabe (mit Punkt als Struktur)
using System;
class PunktDemo {
static void Main() {
Punkt p1 = new Punkt(1,
Punkt p2 = new Punkt();
Punkt p3 = p1;
p1.X = 2;
Console.WriteLine("p1 =
"\np2
"\np3
}
}
p1 = (2;2)
p2 = (0;0)
p3 = (1;2)
2);
"+p1.Inhalt+
= "+p2.Inhalt+
= "+p3.Inhalt);
Am Ende der Methode Main() haben wir folgende Situation im Speicher des Programms:
193
Abschnitt 5.1 Strukturen
Stack
Heap
p1
X
Y
2
2
p2
X
Y
0
0
p3
X
Y
1
2
Aus der Punkt–Struktur wird durch wenige Quellcode-Änderungen eine Klasse:
using System;
public class Punkt {
public double X, Y;
public Punkt(double x_, double y_) {
X = x_;
Y = y_;
}
public Punkt() { }
public string Inhalt {
get {
return "("+X+";"+Y+")";
}
}
}
Weil bei Klassen (im Unterschied zu Strukturen) der Standardkonstruktor verloren geht, sobald ein
expliziter Konstruktor vorhanden ist (vgl. Abschnitt 4.4.3), hat die Klasse Punkt einen parameterlosen Konstruktor erhalten.
Das obige Main()-Programm muss beim Wechsel von der Punkt-Struktur zur Punkt-Klasse
nicht geändert werden, zeigt aber ein leicht abweichendes Verhalten (siehe letzte Ausgabezeile):
Quellcode
Ausgabe (mit Punkt als Klasse)
using System;
class PunktDemo {
static void Main() {
Punkt p1 = new Punkt(1,
Punkt p2 = new Punkt();
Punkt p3 = p1;
p1.X = 2;
Console.WriteLine("p1 =
"\np2
"\np3
}
}
p1 = (2;2)
p2 = (0;0)
p3 = (2;2)
2);
"+p1.Inhalt+
= "+p2.Inhalt+
= "+p3.Inhalt);
Kapitel 5: Weitere .NETte Typen
194
p1, p2 und p2 sind nun lokale Referenzvariablen, die auf insgesamt zwei Objekte zeigen:
Stack
Heap
p1
Adresse des 1.
Punkt-Objekts
p3
Punkt-Objekt
X
Y
2
2
Adresse des 1.
Punkt-Objekts
p2
Adresse des 2.
Punkt-Objekts
Punkt-Objekt
X
Y
0
0
Das erste Punkt-Objekt kann über die Referenzvariablen p1 und p3 angesprochen (z.B. verändert) werden.
Während ein Objekt eigenständig auf dem Heap existiert und verfügbar ist, solange irgendwo im
Programm eine Referenz (Kommunikationsmöglichkeit) vorhanden ist, kann eine Strukturinstanz
nur als Objekt-Member das Ende der erzeugenden Methode überstehen (so wie auch die Variablen
mit elementarem Typ). Eine lokale Variable mit Strukturtyp wird auf dem Stack abgelegt und beim
Verlassen der erzeugenden Methode gelöscht. Bei entsprechender Methodendefinition wird dem
Aufrufer via Rückgabewert eine Kopie der Strukturinstanz übergeben.
Neben der eigenständigen Speicherpersistenz fehlt den Strukturen vor allem die Vererbungstechnik,
die wir erst in einem späteren Abschnitt gebührend gründlich behandeln werden. Jede Struktur
stammt implizit von der Klasse ValueType im Namensraum System ab, die wiederum direkt von
der .NET - Urahn-Klasse Object erbt (siehe Abbildung in Abschnitt 5.1.3). Alternative Abstammungen sind bei Strukturen nicht möglich, so dass auch keine Strukturhierarchien entstehen können.
Eine Strukturdefinition unterscheidet sich nur geringfügig von einer Klassendefinition, so dass wir
uns an Stelle von Syntaxdiagrammen auf einige Hinweise beschränken können:





Wie bei Klassen wird die Verfügbarkeit eines Strukturtyps über Modifikatoren geregelt
(Voreinstellung: internal, Alternative: public, vgl. Abschnitt 4.9).
Das Schlüsselwort class wird struct ersetzt.
Bei der Deklaration von Instanzfeldern ist keine explizite Initialisierung erlaubt. Verboten ist
also z.B.:
double delta = 1.0;
Felder von Strukturinstanzen werden jedoch wie die Felder von Klasseninstanzen (Objekten) per Voreinstellung mit der typspezifischen Null initialisiert.
Es sind beliebige viele Konstruktoren erlaubt, wobei der (parameterfreie) Standardkonstruktor nicht verloren geht und auch nicht ersetzt werden darf.
Auch bei Strukturen ist eine Datenkapselung möglich. Bei der Deklaration bzw. Definition
der Member kann der Zugriffsschutz über die Modifikatoren private (Voreinstellung), public und internal reguliert werden. Weil keine Vererbung unterstützt wird, ist der Modifikatoren protected verboten.
195
Abschnitt 5.1 Strukturen


Hinter der Strukturdefinition darf wie bei Klassen ein Semikolon stehen.
Aus dem Vererbungsverbot ergeben sich einige zusätzliche Regeln (siehe ECMA 2006, S.
333).
5.1.2 Zur Eignung von Strukturen im Bruchrechnungs-Projekt
Grundsätzlich lässt sich auch unsere Bruch-Klassendefinition in eine analoge Strukturdefinition
übersetzen:
using System;
public struct Bruch {
int zaehler, nenner;
string etikett;
public Bruch(int zpar, int npar, String epar) {
zaehler = zpar;
nenner = npar;
etikett = epar;
}
. . .
}
Beim Einsatz der Struktur-Brüche entstehen keine Referenzvariablen mit zugehörigen HeapObjekten, sondern lokale Variablen (auf dem Stack) mit einer kompletten Struktur als Wert.
Vom eingesparten Aufwand ist im folgenden Programm, das lediglich zwei Bruch-Instanzen einsetzt, sicher nichts zu spüren:
Quellcode
using System;
class BruchRechnung {
static void Main() {
Bruch b1 = new Bruch(1,2,"b1 =");
Bruch b2 = new Bruch();
b1.Zeige();
b2.Zeige();
}
}
Ausgabe
1
b1 = ----2
0
----0
Man muss man schon eine ziemlich große Anzahl von Bruch-Instanzen bzw. Bruch-Objekten
erzeugen, um nachmessen zu können (z.B. über die Eigenschaft Ticks der DateTime-Struktur),
dass Bruch-Objekte einen ca. zwei bis dreifach höheren Zeitaufwand verursachen. Diese Messung
bei Bruch-Objekten bzw. Instanzen kann allerdings nicht verallgemeinert werden.
Eine Assembly-Inspektion mit dem Windows-SDK - Hilfsprogramm ILDasm zeigt im Vergleich
zur einer identisch ausgestatteten Bruch-Klasse
Kapitel 5: Weitere .NETte Typen
196
bei der Bruch-Struktur nur wenige Abweichungen:
Neben der alternativen Symbolfarbe stellen wir fest:


Bei der Struktur ist als Basisklasse System.ValueType angegeben, was gleich in Abschnitt5.1.3 näher erläutert wird.
In der Member-Liste taucht der parameterfreie Konstruktor nicht auf. Er wird offenbar bei
Strukturen anders realisiert als bei Klassen.
Weil bei der Deklaration von Strukturfeldern keine Initialisierung erlaubt ist, und außerdem der
parameterfreie Konstruktor nicht verändert werden darf, besteht bei parameterfrei konstruierten
Bruch-Strukturinstanzen ein gravierendes Problem: Im Nenner kann der Wert Null nicht verhindert werden (siehe obige Ausgabe). Generell muss eine Struktur so entworfen werden, dass die
Null-Initialisierung durch den Standardkonstruktor zu einer regulären Instanz führt. Bei kritischen
197
Abschnitt 5.1 Strukturen
Typen (wie z.B. Bruch) kann die Regularität trotz der nicht zu verhindernden Null-Initialisierung
über Datenkapselung und geeignetes Design von Methoden und Eigenschaften hergestellt werden.
Statt Aufwand in die Bruch-Struktur zu invertieren, bleiben wir jedoch bei der Realisation durch
eine Klasse. Damit ersparen wir uns Probleme und behalten die früher oder später relevante Vererbungsoption.
5.1.3 Strukturen im Common Type System der .NET – Plattform
Aus den bisherigen Ausführungen zu folgern, dass Strukturen wohl eher exotisch und nur für leistungskritische Anwendungen interessant seien, wäre schon deshalb grundverkehrt, weil es sich bei
allen elementaren Datentypen um Strukturen handelt. Die früher als Typbezeichner eingeführten
reservierten Wörter sind lediglich Aliasnamen für vordefinierte Strukturen aus dem FCLNamensraum System:
Aliasname
sbyte
byte
short
ushort
int
uint
long
ulong
char
float
double
bool
decimal
Struktur
System.SByte
System.Byte
System.Int16
System.UInt16
System.Int32
System.UInt32
System.Int64
System.UInt64
System.Char
System.Single
System.Double
System.Boolean
System.Decimal
Nun wird z.B. klar, warum die Convert-Methode zur Wandlung von Zeichenfolgen in int-Werte
den Namen ToInt32() trägt.
Im Vergleich zu sonstigen Strukturtypen unterstützt C# bei den elementaren Datentypen einige zusätzliche Operationen (vgl. ECMA 2006, S. 110), z.B.:


Erzeugung von Werten über Literale
Deklaration konstanter Variablen über das Schlüsselwort const
Die folgende Abbildung zeigt, in welcher Beziehung die verschiedenen Werttypen der .NET –
Plattform zueinander stehen, und wie sich diese Typen in das streng hierarchisch organisierte
Common Type Systems (CTS) mit der Urahnklasse Object einfügen:
Kapitel 5: Weitere .NETte Typen
198
System.Oject
System.ValueType
Aufzählungstypen
(siehe unten)
Strukturen
Elementare Typen
System.Int32 (alias int)
struct - Typen
z.B. Punkt
Im folgenden Beispiel wird beim Ganzzahlliteral 13 (vom Strukturtyp Int32) über die von
System.Object geerbte Methode GetType() erfolgreich der Datentyp erfragt:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
Console.WriteLine(13.GetType());
}
}
System.Int32
Trotz des obigen Stammbaums ist eine Strukturinstanz kein Objekt, aber aufgrund der gleich zu
beschreibender Raffinesse der .NET - Plattform kann eine Strukturinstanz jederzeit so behandelt
werden, als wäre sie ein Objekt.
5.2 Boxing und Unboxing
Wie bald im Abschnitt über die Vererbung noch näher erläutert wird, kann eine Variable vom Typ
Object Referenzen auf Objekte aus beliebigen Klassen aufnehmen, weil alle Klassen direkt oder
indirekt von Object abstammen. Damit das Common Type System seinem Namen gerecht wird,
muss diese Zuweisungskompatibilität für beliebige Typen, also auch für Werttypen gelten. Wie Sie
wissen, kann eine Referenzvariable vom Typ Object nur die Adresse eines Heap-Objekts aufnehmen. Was soll nun aber geschehen, wenn einer solchen Referenzvariablen z.B. ein Wert vom elementaren Typ int zugewiesen wird?
Eine analoge Situation liegt vor, wenn bei einem Methodenaufruf eine Strukturinstanz auftritt, wo
syntaktisch (gemäß Methodendefinition) und damit auch technisch eine Objektreferenz (die Adresse
eines Heap-Objekts) benötigt wird. Wir haben uns längst daran gewöhnt, dass der Compiler Werte
von beliebigem Typ als Object-Instanzen akzeptiert. Z.B werden im folgenden Programm
Abschnitt 5.2 Boxing und Unboxing
199
using System;
class Prog {
static void Main() {
int i = 7, j = 3;
Console.WriteLine("{0} % {1} = {2}", i, j, i % j);
}
}
der Methode WriteLine() Aktualparameter vom Werttyp int in Positionen mit Formalparametertyp
Object übergeben.
Diese Zuweisungskompatibilität wird möglich durch ein als Boxing bezeichnetes Prinzip: Es sorgt
dafür, dass ein Wert bei Bedarf automatisch in ein Objekt einer passenden Hilfsklasse verpackt
wird. Somit existiert ein Heap-Objekt mit den Handlungskompetenzen der Klasse Object, und der
betroffenen Programmeinheit (z.B. der Console-Methode WriteLine()) kann die benötigte Adresse
übergeben werden. Zur Erläuterung der Boxing-Technik bedienen wir uns in Anlehnung an ECMA
(2006, S. 114f) einer nicht ganz korrekten, aber hilfreichen Vorstellung: Zum Werttyp T nehmen
wir die Existenz der folgenden Boxing-Klasse TBox an:
class TBox {
public T Wert;
public TBox(T t) {
Wert = t;
}
}
Beim automatischen Verpacken eines Werts w vom Typ T wird implizit über den Ausdruck
new TBox(w);
ein TBox-Objekt auf dem Heap erzeugt. Es nimmt eine Kopie des Wertes auf und besitzt alle Object-Handlungskompetenzen, kann z.B. die Methode GetType() ausführen.
Die Erweiterung bzw. Spezialisierung der Klasse Object durch einen Werttyp besteht darin, dass
ein per Boxing entstehendes Objekt über die geerbten Merkmale und Handlungskompetenzen hinaus auch noch einen Wert speichern kann.
Im folgenden Programm mit einer Boxing-Trockenübung wird die int-Variable i einer Referenzvariablen vom Typ object (Aliasname für System.Object) zugewiesen und dabei automatisch in ein
Objekt der zugehörigen Hilfsklasse gesteckt:
using System;
class Prog {
static void Main() {
int i = 4711;
object iBox = i;
int j = (int) iBox;
}
}
Dass die Boxing-Technik nicht nur akademische Trockenübungen ermöglicht, werden Sie z.B. im
Zusammenhang mit Arrays und anderen Containern erfahren. Dort kann man durch Verwendung
des Basistyps Object generische (typunabhängige) Behälter schaffen, die auch Daten mit Werttyp
aufnehmen können. Die erforderlichen Anpassungs- bzw. Verpackungsmaßnahmen laufen automatisch ab.
Wie das letzte Beispiel zeigt, benötigt der Compiler beim Unboxing, also beim Auspacken eines
Wertes, verständlicherweise eine explizite Casting-Operation mit Angabe des Zieltyps:
int j = (int) iBox;
200
Kapitel 5: Weitere .NETte Typen
Neben dem bisher beschriebenen impliziten Boxing, das auch als Autoboxing bezeichnet wird, ist
auch ein explizites Boxing möglich, aber nie erforderlich, z.B.
int i = 4711;
object iBox = (object) i;
Weil das Boxing durch die damit verbundene Objektkreation relativ aufwändig ist, sollte man die
Verwendung dieser Technik auf das notwendige Maß beschränken. Wer sich vergewissern möchte,
dass beim Methodenaufruf
Console.WriteLine("i={0}", 13);
tatsächlich eine Boxing-Operation stattfindet, kann mit dem Windows-SDK - Hilfsprogramm ILDasm einen Blick auf den MSIL-Code werfen. Hier wird die Objektkreation zur Verpackung eines
System.Int32 - Werts mit dem OpCode box veranlasst:
Anschließend wird die Methode Console.WriteLine() mit Aktualparametern vom Typ String bzw.
Object aufgerufen. Auch zur Ausführung des in folgender Anweisung
Console.WriteLine(13.GetType());
enthaltenen GetType()-Aufrufs wird per box-OpCode ein Objekt erzeugt:
5.3 Arrays
Ein Array ist ein Objekt, das als Instanzvariablen eine feste Anzahl von Elementen desselben Datentyps enthält. Man kann den kompletten Array ansprechen (z.B. als Aktualparameter an eine Methode übergeben), oder auf einzelne Elemente über einen Index zugreifen.
Arrays werden in vielen Programmiersprachen auch Felder genannt. In C# bezeichnet man jedoch
recht einheitlich die Instanzvariablen einer Klasse oder einer Struktur als Feld, so dass der Name
hier nicht mehr zur Verfügung steht.
201
Abschnitt 5.3 Arrays
Wir beschäftigen uns erst jetzt mit den zur Grundausstattung praktisch jeder Programmiersprache
gehörenden Arrays, weil diese Datentypen in C# als Klassen realisiert werden und folglich zunächst
entsprechende Grundlagen zu erarbeiten waren. Obwohl wir die wichtige Vererbungsbeziehung
zwischen Klassen noch nicht offiziell behandelt haben, können Sie vermutlich schon den Hinweis
verdauen, dass alle Array-Klassen von der Basisklasse Array im Namensraum System abstammen,
z.B. die Klasse der eindimensionalen Arrays mit Elementen vom Strukturtyp Int32 (alias int):
System.Oject
System.Array
System.Int32[]
Hier ist als konkretes Objekt aus dieser Klasse ein Array namens uni mit fünf int-Elementen zu
sehen:
Heap
1950
1991
1997
2057
2005
uni[0]
uni[1]
uni[2]
uni[3]
uni[4]
Neben den Array-Elementen enthält das Objekt noch Verwaltungsdaten, die wir aber nicht kennen
müssen.
Beim Zugriff auf ein einzelnes Element gibt man nach dem Arraynamen den durch eckige Klammern begrenzten Index an, wobei die Nummerierung bei 0 beginnt und bei n Elementen folglich mit
n - 1 endet.
Im Vergleich zur Verwendung einer entsprechenden Anzahl von Einzelvariablen ergibt sich eine
erhebliche Vereinfachung der Programmierung:



Weil der Index auch durch einen Ausdruck (z.B. durch eine Variable) geliefert werden kann,
sind Arrays im Zusammenhang mit den Wiederholungsanweisungen äußerst praktisch.
Man kann oft die gemeinsame Verarbeitung aller Elemente (z.B. bei der Ausgabe in eine
Datei) per Methodenaufruf mit Array-Aktualparameter veranlassen.
Viele Algorithmen arbeiten mit Vektoren und Matrizen. Zur Modellierung dieser mathematischen Objekte sind Arrays unverzichtbar.
Wir befassen uns zunächst mit eindimensionalen Arrays, behandeln später aber auch den mehrdimensionalen Fall.
5.3.1 Array-Referenzvariablen deklarieren
Eine Array-Variable ist vom Referenztyp und wird folgendermaßen deklariert:
Kapitel 5: Weitere .NETte Typen
202
Arraydeklaration
Typbezeichner
[]
Variablenname
;
,
Modifikator
Im Vergleich zu der bisher bekannten Variablendeklaration (ohne Initialisierung) ist hinter dem
Typbezeichner zusätzlich ein Paar eckiger Klammern anzugeben. In obigem Beispiel kann die Array-Variable uni also z.B. folgendermaßen deklariert werden:
int[] uni;
Bei der Deklaration entsteht nur eine Referenzvariable, jedoch noch kein Array-Objekt. Daher ist
auch keine Array-Größe (Anzahl der Elemente) anzugeben.
Einer Array-Referenzvariablen kann als Wert die Adresse eines Arrays mit Elementen vom vereinbarten Typ oder das Referenzliteral null zugewiesen werden.
5.3.2 Array-Objekte erzeugen
Mit Hilfe des new-Operators erzeugt man ein Array-Objekt mit einem bestimmten Elementtyp und
einer bestimmten Größe auf dem Heap. In der folgenden Anweisung entsteht ein Array mit
(max+1) int-Elementen, und seine Adresse landet in der Referenzvariablen uni:
uni = new int[max+1];
Im new-Operanden muss hinter dem Datentyp zwischen eckigen Klammern die Anzahl der Elemente festgelegt werden, wobei ein beliebiger Ausdruck mit ganzzahligem Wert ( 0) erlaubt ist. Man
kann also die Länge eines Arrays zur Laufzeit festlegen, z.B. in Abhängigkeit von einer Benutzereingabe.
Die Deklaration einer Array-Referenzvariablen und die Erstellung des Array-Objekts kann man
natürlich auch in einer Anweisung erledigen, z.B.:
int[] uni = new int[5];
Mit der Verweisvariablen uni und dem referenzierten Array-Objekt auf dem Heap haben wir insgesamt folgende Situation:
Referenzvariable uni
Adresse des Array-Objekts
Heap
0
0
0
0
0
Array-Objekt mit 5 int-Elementen
Weil es sich bei den Array-Elementen um Instanzvariablen eines Objekts handelt, erfolgt eine automatische Initialisierung nach den Regeln von Abschnitt 4.2.4. Die int-Elemente im Beispiel erhalten folglich den Startwert 0.
203
Abschnitt 5.3 Arrays
Aus der Objekt-Natur eines Arrays folgt unmittelbar, dass er vom Garbage Collector entsorgt wird,
wenn keine Referenz mehr vorliegt (vgl. Abschnitt 4.4.4). Um eine Referenzvariable aktiv von einem Array-Objekt zu „entkoppeln“, kann man ihr z.B. den Wert null (Zeiger auf nichts) oder aber
ein alternatives Referenzziel zuweisen. Es ist ohne weiteres möglich, dass mehrere Referenzvariablen auf dasselbe Array-Objekt zeigen, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
int[] x = new int[3], y;
x[0] = 1; x[1] = 2; x[2] = 3;
y = x; //y zeigt nun auf das selbe Array-Objekt wie x
y[0] = 99;
Console.WriteLine(x[0]);
}
}
99
Seit der .NET - Version 2.0 erlaubt die statische Methode Resize() der Klasse System.Array sogar
eine nachträgliche Längenkorrektur, z.B.:
Array.Resize(ref uni, 2*max+1);
Allerdings muss die Methode ein neues Objekt erzeugen und die Elemente des alten Arrays umkopieren, was einen erheblichen Aufwand bedeuten kann. Solche Aktionen werden auch bei den in
Abschnitt 5.3.8 beschriebenen Kollektionsklassen mit dynamischer Größenanpassung (z.B.
ArrayList) bei Bedarf im Hintergrund automatisch ausgeführt.
5.3.3 Arrays benutzen
Der Zugriff auf die Elemente eines Array-Objektes geschieht über eine zugehörige Referenzvariable, an deren Namen zwischen eckigen Klammern ein passender Index angehängt wird. Als Index ist
ein beliebiger Ausdruck mit ganzzahligem Wert erlaubt, wobei natürlich die Feldgrenzen zu beachten sind. In der folgenden for-Schleife wird pro Durchgang ein zufällig gewähltes Element des intArrays inkrementiert, auf den die Referenzvariable uni gemäß obiger Deklaration und Initialisierung zeigt:
for (i = 1; i <= DRL; i++)
uni[zzg.Next(5)]++;
Den Indexwert liefert die Random-Methode Next() mit Rückgabetyp int (siehe unten).
Wie in vielen anderen Programmiersprachen hat auch in C# das erste von n Array-Elementen die
Nummer 0 und folglich das letzte die Nummer n - 1. Damit existiert z.B. nach
int[] uni = new int[5];
kein Element uni[5]. Ein Zugriffsversuch führt zum Laufzeitfehler vom Typ IndexOutOfRangeException, z.B.:
Unbehandelte Ausnahme: System.IndexOutOfRangeException: Der Index war
außerhalb des Arraybereichs.
at UniRand.Main() in ...\BspUeb\Arrays\UniRand\UniRand.cs:line 7
Wenn das verantwortliche Programm einen solchen Ausnahmefehler nicht behandelt (siehe Abschnitt 10), wird es vom Laufzeitsystem beendet. Man kann sich in C# generell darauf verlassen,
dass jede Überschreitung von Feldgrenzen verhindert wird, so dass es nicht zur Verletzung anderer
Speicherbereiche und den entsprechenden Folgen (Absturz mit Speicherschutzverletzung, unerklärliches Programmverhalten) kommt.
Kapitel 5: Weitere .NETte Typen
204
Die (z.B. durch eine Benutzerentscheidung zur Laufzeit festgelegte) Länge eines Array-Objekts
lässt sich über seine Eigenschaft Length jederzeit feststellen, z.B.:
Quellcode
Eingabe (fett) und Ausgabe
using System;
class Prog {
static void Main() {
Console.Write("Länge des Vektors: ");
int[] wecktor =
new int[Convert.ToInt32(Console.ReadLine())];
Länge des Vektors: 3
Console.WriteLine();
for (int i = 0; i < wecktor.Length; i++) {
Console.Write("Wert von Element " + i + ": ");
wecktor[i] = Convert.ToInt32(Console.ReadLine());
}
Wert von Element 0: 7
Wert von Element 1: 13
Wert von Element 2: 4711
7
13
4711
Console.WriteLine();
for(int i = 0; i < wecktor.Length; i++)
Console.WriteLine(wecktor[i]);
}
}
Auch beim Entwurf von Methoden mit Array-Parametern ist es von Vorteil, dass die Länge eines
übergebenen Arrays ohne entsprechenden Zusatzparameter in der Methode bekannt ist.
5.3.4 Beispiel: Beurteilung des .NET - Pseudozufallszahlengenerators
Oben wurde am Beispiel des 5-elementigen int-Arrays uni demonstriert, dass die Array-Technik
im Vergleich zur Verwendung einzelner Variablen den Aufwand bei der Deklaration und beim
Zugriff deutlich verringert. Insbesondere beim Einsatz in einer Schleifenkonstruktion erweist sich
die Ansprache der einzelnen Elemente über einen Index als überaus praktisch. Die oben zur Demonstration verwendeten Anweisungen lassen sich leicht zu einem Programm erweitern, das die
Verteilungsqualität des .NET - Pseudozufallszahlengenerators überprüft. Dieser Generator produziert Folgen von Zahlen mit einem bestimmten Verteilungsverhalten. Obwohl eine Serie perfekt
von ihrem Startwert abhängt, kann sie in der Regel echte Zufallszahlen ersetzen. Manchmal ist es
sogar von Vorteil, eine Serie über ihren festen Startwert reproduzieren zu können. Meist verwendet
man aber variable Startwerte, z.B. abgeleitet aus einer Zeitangabe. Der Einfachheit halber redet man
oft von Zufallszahlen und lässt den Pseudo-Zusatz weg.
Man kann übrigens mit moderner EDV-Technik unter Verwendung von physikalischen Prozessen
auch echte Zufallszahlen produzieren, doch ist der Zeitaufwand im Vergleich zu Pseudozufallszahlen erheblich höher (siehe z.B. Lau 2009).
Nach der folgenden Anweisung zeigt die Referenzvariable zzg auf ein Objekt der Klasse Random
aus dem Namensraum System, das als Pseudozufallszahlengenerator taugt:
Random zzg = new Random();
Durch Verwendung des parameterfreien Random-Konstruktors entscheidet man sich für einen aus
der Systemzeit abgeleiteten Startwert.
Das angekündigte Programm zieht 10000 Zufallszahlen und überprüft deren Verteilung:
205
Abschnitt 5.3 Arrays
using System;
class UniRand {
static void Main() {
const int DRL = 10000;
int i;
int[] uni = new int[5];
Random zzg = new Random();
for (i = 1; i <= DRL; i++)
uni[zzg.Next(5)]++;
Console.WriteLine("Absolute Häufigkeiten:");
for (i = 0; i < 5; i++)
Console.Write(uni[i] + " ");
Console.WriteLine("\n\nRelative Häufigkeiten:");
for (i = 0; i < 5; i++)
Console.Write((double)uni[i]/DRL + " ");
}
}
Die Random-Methode Next() liefert beim Aufruf mit dem Aktualparameterwert 5 als Rückgabe
eine int-Zufallszahl aus der Menge {0, 1, 2, 3, 4}, wobei die möglichen Werte mit der gleichen
Wahrscheinlichkeit auftreten sollten. Im Programm dient der Rückgabewert als Array-Index dazu,
ein zufällig gewähltes uni-Element zu inkrementieren. Wie das folgende Ergebnis-Beispiel zeigt,
stellt sich die erwartete Gleichverteilung in guter Näherung ein:
Absolute Häufigkeiten:
1986 1983 1995 1995 2041
Relative Häufigkeiten:
0,1986 0,1983 0,1995 0,1995 0,2041
Ein 2-Signifikanztest mit der Gleichverteilung als Nullhypothese bestätigt durch eine Überschreitungswahrscheinlichkeit von 0,893 (weit oberhalb der Grenze 0,05), dass keine Zweifel an der
Gleichverteilung bestehen:
uni
Statistik für Test
Beobachtetes N Erwartete Anzahl
Residuum
uni
1,108a
0
1986
2000,0
-14,0
Chi-Quadrat
1
1983
2000,0
-17,0
df
2
1995
2000,0
-5,0
Asymptotische Signifikanz
3
1995
2000,0
-5,0
a. Bei 0 Zellen (,0%) werden weniger als
4
2041
2000,0
41,0
5 Häufigkeiten erwartet. Die kleinste
Gesamt
10000
4
,893
erwartete Zellenhäufigkeit ist 2000,0.
Über die im Beispielprogramm verwendete Klasse Random und deren Next()-Methode liefert die
als wesentlicher Bestandteil der MSDN 2008 Express Edition (vgl. Abschnitt 2.2.2) installierte
FCL-Dokumentation ausführliche Informationen, die von Visual C# 2008 aus z.B. so zu erreichen
sind:
 Starten Sie den Document-Explorer über die Option Suchen im Hilfe-Menü.
 Suchen Sie nach dem Klassennamen Random.
 Klicken Sie auf den Treffer Random-Member.
 Klicken Sie auf die Methode Next und anschließend auf die Überladung Next(Int32).
 Auf der folgenden Seite werden Syntax und Semantik der Methode erklärt:
206
Kapitel 5: Weitere .NETte Typen
Eine weitere Anleitung zur Nutzung der vom Document-Explorer sehr gut erschlossenen FCL-Dokumentation ist in diesem Manuskript sicher nicht mehr erforderlich.
5.3.5 Initialisierungslisten
Bei Arrays mit wenigen Elementen ist die Möglichkeit von Interesse, beim Deklarieren der Referenzvariablen eine Initialisierungsliste anzugeben und das Array-Objekt dabei implizit (ohne Verwendung des new-Operators) zu erzeugen, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
int[] wecktor = {1, 2, 3};
Console.WriteLine(wecktor[2]);
}
}
3
Die Deklarations- und Initialisierungsanweisung
int[] wecktor = {1, 2, 3};
ist äquivalent zu:
int[] wecktor = new int[3];
wecktor[0] = 1;
wecktor[1] = 2;
wecktor[2] = 3;
Weil in der Deklarations- und Initialisierungsanweisung kein new-Operator auftaucht, spricht man
auch vom impliziten Erzeugen eines Array-Objekts.
Initialisierungslisten sind nicht nur bei der Deklaration erlaubt, sondern auch bei der späteren Objektkreation, z.B.:
int[] wecktor;
wecktor = new int[] {1, 2, 3};
207
Abschnitt 5.3 Arrays
Die eben (bei der Array-Deklaration mit Initialisierung) noch gültige Schreibweise mit impliziter
Objektkreation
wecktor = {1, 2, 3}; // Nicht erlaubt!
akzeptiert der Compiler allerdings jetzt nicht mehr.
5.3.6 Objekte als Array-Elemente
Für die Elemente eines Arrays sind natürlich auch Referenztypen erlaubt. In folgendem Beispiel
wird ein Array mit Bruch-Objekten erzeugt:
Quellcode
using System;
class BruchRechnung {
static void Main() {
Bruch b1 = new Bruch(1, 2, "b1 = ");
Bruch b2 = new Bruch(5, 6, "b2 = ");
Bruch[] bruvek = {b1, b2};
bruvek[1].Zeige();
}
}
Ausgabe
b2 =
5
----6
5.3.7 Mehrdimensionale Arrays
5.3.7.1 Rechteckige Arrays
In der linearen Algebra und in vielen anderen Anwendungsbereichen werden auch mehrdimensionale Arrays benötigt, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
int[,] matrix = new int[4, 3];
int nrow = matrix.GetLength(0);
int ncol = matrix.GetLength(1);
Console.WriteLine("{0} Dimens.,\n{1} Zeilen, {2} Spalten",
matrix.Rank, nrow, ncol);
for (int i = 0; i < nrow; i++) {
for (int j = 0; j < ncol; j++) {
matrix[i, j] = (i + 1) * (j + 1);
Console.Write("{0,3}", matrix[i, j]);
}
Console.WriteLine();
}
}
}
2 Dimens.,
4 Zeilen, 3 Spalten
1 2 3
2 4 6
3 6 9
4 8 12
Im Beispiel wird eine zweidimensionale Matrix mit 4 Zeilen und 3 Spalten erzeugt, auf deren Zellen
man per Doppelindizierung zugreifen kann. Bei der Erzeugung bzw. Verwendung eines mehrdimensionalen rechteckigen Arrays werden die in eckigen Klammern eingeschlossenen Dimensionsangaben bzw. Indexwerte durch Komma getrennt.
Auch im mehrdimensionalen Fall können Initialisierungslisten eingesetzt werden, z.B.:
int[,] matrix = {{1, 4, 2}, {9, 5, 6}, {1, 7, 7}};
Weil alle Arrays von der Basisklasse Array im Namensraum System abstammen, verfügen sie über
entsprechende Methoden und Eigenschaften (siehe FCL-Dokumentation), z.B.:
Kapitel 5: Weitere .NETte Typen
208


Die Eigenschaft Rank enthält die Anzahl der Dimensionen.
Über die Methode GetLength() informiert ein Array-Objekt über die Anzahl der Elemente
in der per Parameter angegebenen Dimension.
Bei den bisher behandelten rechteckigen Matrizen liegen im Speicher alle Elemente unmittelbar
hintereinander, was einen schnellen Indexzugriff ermöglicht:
Referenzvariable matrix
Adresse des Array-Objekts
Array-Objekt mit 4  3 int-Elementen auf dem Heap
1
matrix[0,0]
2
3
2
matrix[1,0]
4
6
3
matrix[2,0]
6
9
4
8
12
matrix[3,0]
5.3.7.2 Mehrdimensionale Arrays mit unterschiedlich großen Elementen
Neben den rechteckigen Arrays unterstützt C# auch „ausgesägte“ Exemplare mit unterschiedlich
großen Elementen (engl.: jagged arrays). So lässt sich etwa eine zweidimensionale Matrix mit unterschiedlich langen Zeilen realisieren:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
int[][] jagmat = new int[3][];
jagmat[0] = new int[1] {1};
jagmat[1] = new int[2] {1, 2};
jagmat[2] = new int[3] {1, 2, 3};
for (int i = 0; i < jagmat.Length; i++) {
for (int j = 0; j < jagmat[i].Length; j++)
Console.Write(jagmat[i][j] + " ");
Console.WriteLine();
}
}
}
1
1 2
1 2 3
Im Unterschied zum int[,] - Objekt matrix aus dem letzten Beispiel, das int-Elemente enthält,
handelt es sich bei den Elementen des int[][] - Objekts jagmat um Referenzen vom Typ int[], die
wiederum auf entsprechende Heap-Objekte (oder null) zeigen können. Während matrix ein zweidimensionaler Array mit int-Elementen ist, handelt es sich bei jagmat um einen eindimensionalen
Array mit int[] – Elementen:
209
Abschnitt 5.3 Arrays
Referenzvariable jagmat
Adresse des Array-Objekts mit int-Array - Elementen
Heap
jagmat[0]
int-Array Adresse
1
jagmat[1]
int-Array Adresse
1
jagmat[2]
int-Array Adresse
1
jagmat[0][0]
jagmat[1][1]
2
2
3
jagmat[2][2]
Beim Erzeugen des jagmat-Objekts darf nur die erste Dimension angegeben werden:
int[][] jagmat = new int[3][];
Anschließend erzeugt man die Zeilenobjekte und legt ihre Adressen in den jagmat-Elementvariablen ab, z.B.:
jagmat[2] = new int[3] {1, 2, 3};
5.3.8 Die Kollektionsklasse ArrayList
Im Namensraum System.Collections bietet die FCL etliche Klassen zur flexiblen Verwaltung von
Datenbeständen variablen Umfangs. Dort findet sich u.a. die Klasse ArrayList, deren Objekte analog zu eindimensionalen Arrays genutzt werden können. Man legt jedoch beim Erzeugen eines ArrayList-Objekts keinen Umfang fest, sondern kann z.B. mit der Methode Add() nach Bedarf neue
Elemente einfügen, z.B.:
Kapitel 5: Weitere .NETte Typen
210
Quellcode
Ausgabe
using System;
using System.Collections;
class ArrayListDemo {
static void Main() {
ArrayList al = new ArrayList();
string s;
Console.WriteLine("Was fällt Ihnen zu C#?\n");
do {
Console.Write(": ");
s = Console.ReadLine();
if (s.Length > 0)
al.Add(s);
else
break;
} while (true);
Was fällt Ihnen zu C#?
: Tolle Sache
: Nicht ganz trivial
: Macht Spaß
:
Ihre Anmerkungen:
Tolle Sache
Nicht ganz trivial
Macht Spaß
Console.WriteLine("\nIhre Anmerkungen:");
for(int i = 0; i < al.Count; i++)
Console.WriteLine(al[i]);
}
}
Das Fassungsvermögen des Containers wird bei Bedarf automatisch erhöht, wobei eine leistungsoptimierende Logik dafür sorgt, dass diese Anpassungsmaßnahme möglichst selten erforderlich ist.
Über die Eigenschaft Capacity kann die momentane Kapazität festgestellt und auch eingestellt
werden. Mit der Methode TrimToSize() reduziert man die Größe auf den momentanen Bedarf, z.B.
nach der Voraussichtlich letzten Neuaufnahme.
Weil die Klasse ArrayList einen Indexer bietet (siehe Abschnitt 5.6), kann man per Indexsyntax
auf die Elemente zugreifen:


Das erste Element hat den Indexwert Null.
Das letzte Element hat den Indexwert (Count – 1), wobei die Count-Eigenschaft die Anzahl
der Elemente angibt.
Als Datentyp für die ArrayList-Elemente dient object (alias System.Object), also die Klasse an
der Spitze des Common Type Systems der .NET – Plattform. Folglich kann ein ArrayList-Container
Daten beliebigen Typs aufnehmen, wobei dank (Un)boxing-Technik (siehe Abschnitt 5.2) auch die
Werttypen erlaubt sind, z.B.:
Quellcode
Ausgabe
using System;
using System.Collections;
class Prog {
static void Main() {
ArrayList al = new ArrayList();
al.Add("Wort");
al.Add(3.14);
al.Add(13);
foreach (object o in al)
Console.WriteLine(o);
}
}
Wort
3,14
13
Ein solcher „Gemischtwarenladen“ (allerdings mit fester Länge) ist durch Wahl des ElementDatentyps object übrigens auch mit einem einfachen Array zu realisieren.
211
Abschnitt 5.4 Klassen für Zeichenketten
5.4 Klassen für Zeichenketten
C# bietet für den Umgang mit Zeichenketten, die grundsätzlich aus Unicode-Zeichen bestehen,
zwei Klassen an:


String (im Namensraum System)
String-Objekte können nach dem Erzeugen nicht mehr geändert werden. Diese Klasse ist
für den lesenden Zugriff auf Zeichenketten optimiert.
StringBuilder (im Namensraum System.Text)
Für variable Zeichenketten sollte unbedingt die Klasse StringBuilder verwendet werden,
weil deren Objekte nach dem Erzeugen noch verändert werden können.
5.4.1 Die Klasse String für konstante Zeichenketten
Weil Objekte der Klasse String aus dem Namensraum System in C# - Programmen sehr oft benötigt werden, hat man diesem Datentyp das reservierte Wort string (mit klein geschriebenen Anfangsbuchstaben) als Aliasnamen spendiert, und das ist nicht die einzige syntaktische Vorzugsbehandlung gegenüber anderen Klassen. In der folgenden Deklarations- und Initialisierungsanweisung
string s1 = "abcde";
wird:



eine String-Referenzvariable namens s1 angelegt,
ein neues String-Objekt mit dem Inhalt „abcd“ auf dem Heap erzeugt,
die Adresse des neuen Heap-Objekts in der Referenzvariablen abgelegt.
Soviel objektorientierten Hintergrund sieht man der angenehm einfachen Anweisung auf den ersten
Blick nicht an. In C# sind jedoch auch Zeichenkettenliterale als String-Objekte realisiert, so dass
z.B.
"abcde"
einen Ausdruck darstellt, der als Wert einen Verweis auf ein String-Objekt auf dem Heap liefert.
Weil in der obigen Deklarations- und Initialisierungsanweisung kein new-Operator auftaucht,
spricht man auch vom impliziten Erzeugen eines String-Objekts. Sie bewirkt im Hauptspeicher
folgende Situation:
Referenzvariable s1
String-Objekt
Adresse des String-Objekts
"abcde"
Heap
5.4.1.1 String als WORM - Klasse
Nachdem ein String-Objekt auf dem Heap erzeugt worden ist, kann es nicht mehr geändert werden.
In der Überschrift zu diesem Abschnitt wird für diesen Sachverhalt eine Abkürzung aus der Elektronik ausgeliehen: WORM (Write Once Read Many). Eventuell werden Sie die Inflexibilität des
String-Inhalts in Zweifel ziehen und ein Gegenbeispiel der folgenden Art vorbringen:
212
Kapitel 5: Weitere .NETte Typen
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
String testr = "abc";
Console.WriteLine("testr = " + testr);
testr = testr + "def";
Console.WriteLine("testr = " + testr);
}
}
testr = abc
testr = abcdef
In der Zeile
testr = testr + "def";
wird aber das per testr ansprechbare String-Objekt (mit dem Text „abc“) nicht geändert, sondern
durch ein neues String-Objekt (mit dem Text „abcdef“) ersetzt. Das alte Objekt ist noch vorhanden,
aber nicht mehr referenziert. Sobald das Laufzeitsystem Langeweile hat oder Speicher benötigt,
wird das alte Objekt vom Garbage Collector eliminiert.
5.4.1.2 Interner String-Pool
Wie oben erläutert, bildet ein Zeichenkettenliteral einen Ausdruck vom Typ String mit einer Objektadresse als Wert. Für wiederholt im Quellcode auftretende Zeichenkettenliterale lässt dieses
Prinzip eine Verschwendung von Speicherplatz befürchten. Um dies zu verhindern, verwaltet die
CLR eine als interner String-Pool bezeichnete Tabelle für die auf Literalen basierenden StringObjekte. Wird zu einem Zeichenkettenliteral eine Objektreferenz benötigt, liefert die CLR nach
Möglichkeit die Adresse eines bereits vorhandenen String-Objekts. Schlägt die Suche fehl, wird ein
neues Objekt erzeugt und im internen Pool registriert. Diese Vorgehensweise ist sinnvoll, weil sich
vorhandene String-Objekte garantiert nicht mehr ändern. Im folgenden Beispiel zeigen eine Instanzvariable und eine lokale Variable vom Typ String auf dasselbe Objekt:
Quellcode
Ausgabe
using System;
class Prog {
string sf = "Dies ist ein Zeichenketten-Literal";
static void Main() {
string ls = "Dies ist ein Zeichenketten-Literal";
Prog p = new Prog();
Console.WriteLine(Object.ReferenceEquals(ls, p.sf));
Console.ReadLine();
}
}
True
Allerdings verhindern die im String-Pool vorhandenen Referenzen eine Entsorgung der zugehörigen Objekte durch den Garbage Collector, so dass der gewünschte Speichereinspareffekt nicht unbedingt erzielt wird. Seit der .NET - Version 2.0 kann daher die Internalisierung der auf Literalen
basierenden String-Objekte durch ein Assembly-Attribut (siehe Abschnitt 11.4) abgeschaltet werden. Weil sich bei zukünftigen .NET - Versionen das Internalisierungs-Verhalten der CLR generell
eventuell ändern, darf die Korrektheit eines Quellcodes keinesfalls von der aktuellen Praxis abhängen (Richter 2006, S. 274ff).
Über die statische String-Methode Intern() kann man den internen String-Pool zusätzlich bevölkern. Die Methode erwartet einen Aktualparameter vom Typ String und liefert einen Rückgabewert
vom selben Typ:
Abschnitt 5.4 Klassen für Zeichenketten

213
Ist ein Objekt im internen String-Pool inhaltsgleich mit dem Parameterstring, wird die Adresse dieses Pool-Objekts geliefert.
Anderenfalls wird der Parameterstring in den internen String-Pool aufgenommen und seine
Adresse als Rückgabe geliefert.

Durch Internalisieren kann Speicherplatz gespart werden, wenn viele Strings mit identischem Inhalt
zu erwarten sind. Außerdem können String-Vergleiche (vgl. Abschnitt 5.4.1.3.2) beschleunigt werden, weil bei Referenzvariablen zu internalisierten Strings aus der Gleichheit der Adressen bereits
die Inhaltsgleichheit folgt. Im folgenden Programm werden ANZ Zufallszeichenfolgen der Länge
LEN jeweils N mal mit einem zufällig gewählten Partner verglichen. Dies geschieht zunächst per
Inhaltsvergleich und dann nach dem zwischenzeitlichen Internieren per Adressvergleich:
using System;
using System.Text;
class StringIntern {
public static void Main() {
const int ANZ = 50000, LEN = 20, N = 50;
StringBuilder sb = new StringBuilder();
Random ran = new Random();
String[] sar = new String[ANZ];
for (int i = 0; i < ANZ; i++) {
for (int j = 0; j < LEN; j++)
sb.Append((char) (65 + ran.Next(26)));
sar[i] = sb.ToString();
sb.Remove(0, LEN);
}
long start = DateTime.Now.Ticks;
int hits = 0;
for (int n = 1; n <= N; n++)
for (int i = 0; i < ANZ; i++)
if (sar[i] == sar[ran.Next(ANZ)])
hits++;
Console.WriteLine((N * ANZ)+" Inhaltsvergleiche ("+hits+
" hits) benoetigen "+((DateTime.Now.Ticks - start)/1.0e4)+
" Millisekunden");
start = DateTime.Now.Ticks;
hits = 0;
for (int j = 1; j < ANZ; j++)
sar[j] = String.Intern(sar[j]);
for (int n = 1; n <= N; n++)
for (int i = 0; i < ANZ; i++)
if ((sar[i] as Object ) == sar[ran.Next(ANZ)])
hits++;
Console.WriteLine((N * ANZ)+" Adressvergleiche ("+hits+
" hits) benoetigen (inkl. Internieren) "+
((DateTime.Now.Ticks - start)/1.0e4)+" Millisekunden");
}
}
Um den Identitätsoperator zu Adressvergleichen zu zwingen, wird ein Vergleichspartner als Instanz
der Klasse Object behandelt:
(sar[i] as Object ) == sar[ran.Next(ANZ)]
Es hängt von den Aufgabenparametern ANZ, LEN und N ab, welche Vergleichsmethode überlegen
ist: 1
1
Die Ergebnisse stammen von einem PC mit Intel - CPU (Pentium 4, 3-GHz).
Kapitel 5: Weitere .NETte Typen
214
ANZ = 50000, LEN = 20, N = 5
ANZ = 50000, LEN = 20, N = 50
Laufzeit in Millisekunden
Inhaltsvergleiche
Internieren u. Adressvergl.
63
109
547
266
Erwartungsgemäß ist das Internieren umso rentabeler, je mehr Vergleiche anschließend mit den
Zeichenfolgen angestellt werden. Bei String-Vergleichen sind sicher noch weitere Verbesserungen
möglich, z.B. durch Ausnutzen der lexikographischen Ordnung.
Auch die String-Methode IsInterned() liefert die Adresse des Parameter-Strings, falls er sich im
internen Pool befindet. Anderenfalls wird jedoch kein Pool-String erzeugt, sondern der Wert null
abgeliefert.
5.4.1.3 Methoden für String-Objekte
Von den zahlreichen Methoden der Klasse der String werden in diesem Abschnitt nur die wichtigsten angesprochen. Für spezielle Anwendungen lohnt sich also ein Blick in die FCL-Dokumentation.
5.4.1.3.1 Verketten von Strings
Weil die Klasse String den „+“- Operator geeignet überladen hat (vgl. Abschnitt 4.7.2), taugt er
zum Verketten von String-Objekten, wobei Operanden beliebige Datentypen bei Bedarf automatisch in String-Objekte konvertiert werden. Wie Sie aus Abschnitt 5.4.1.1 wissen, entsteht beim
Verketten von zwei Zeichenfolgen ein neues String-Objekt. In folgendem Beispiel wird mit Klammern dafür gesorgt, dass der Compiler die „+“ - Operatoren jeweils sinnvoll interpretiert (Verketten
von Strings bzw. Addieren von Zahlen):
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
Console.WriteLine("4 + 3 = " + (4 + 3));
}
}
4 + 3 = 7
5.4.1.3.2 Vergleichen von Strings
Angewandt auf string-Variablen vergleichen die Operatoren „==“ und „!=“ nicht (wie z.B. in Java)
die Adressen, sondern die Inhalte der referenzierten Zeichenfolgenobjekte, z.B.:
Das C#-Programm liefert true
Das Java-Programm liefert false
using System;
class Prog {
static void Main() {
String s1 = "abcde";
String s2 = "de";
String s3 = "abc" + s2;
Console.WriteLine(s1 == s3);
}
}
class Prog {
public static void main(String[] args) {
String s1 = "abcde";
String s2 = "de";
String s3 = "abc" + s2;
System.out.println(s1 == s3);
}
}
Mit der etwas umständlichen s3-Konstruktion wird verhindert, dass s1 und s3 auf dasselbe Objekt
im internen String-Pool zeigen, weil dann Adressen- und Inhaltsvergleich zum selben Ergebnis
kämen. Wie in Abschnitt 5.4.1.2 demonstriert wurde, ist bei einer großen Anzahl von StringVergleichen eventuell durch Internalisierung und Verwendung von Adressenvergleichen eine erhebliche Leistungssteigerung zu erzielen.
215
Abschnitt 5.4 Klassen für Zeichenketten
Zum Testen auf lexikographische Priorität (z.B. beim Sortieren) kann die String-Methode
CompareTo() dienen:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
String a = "Müller, Anja", b = "Müller, Kurt",
c = "Müller", d = ", Anja";
//a und c sollen nicht auf denselben Pool-String zeigen
c = c + d;
Console.WriteLine("< : " + a.CompareTo(b));
Console.WriteLine("= : " + a.CompareTo(c));
Console.WriteLine("> : " + b.CompareTo(a));
}
}
< : -1
= : 0
> : 1
CompareTo() liefert folgende Ergebnisse zurück:
Das befragte String-Objekt ist im Vergleich zum
Aktualparameter lexikographisch …
kleiner
gleich
größer
CompareTo()-Ergebnis
-1
0
1
5.4.1.3.3 Länge einer Zeichenkette
Über die Länge einer Zeichenkette informiert die String-Eigenschaft Length, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
Console.WriteLine("abc".Length);
}
}
3
5.4.1.3.4 Zeichen(folgen) extrahieren, suchen oder ersetzen
Auf einzelne Zeichen eines Strings kann man per Indexsyntax zugreifen, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
string s = "abcd";
Console.WriteLine(s[0]);
Console.WriteLine(s.Substring(0, 2));
Console.WriteLine(s.IndexOf("c"));
Console.WriteLine(s.IndexOf("x"));
Console.WriteLine(s.StartsWith("a"));
Console.WriteLine(s.Replace('c', 'C'));
}
}
a
ab
2
-1
True
abCd
Über die Methode
public string Substring(int start, int anzahl)
erhält man von einem String-Objekt die anzahl Zeichen ab Position start als Kopie.
Kapitel 5: Weitere .NETte Typen
216
Eben wurde eine Technik zur Beschreibung einer vorhandenen Bibliotheksmethode (im Unterschied
zur Beschreibung einer C#-Syntaxregel) erstmals benutzt, die auch in der FCL-Dokumentation in
ähnlicher Form Verwendung findet, z.B.:
Dabei wird die Benutzung einer vorhandenen Methode erläutert durch Angabe von:




Modifikatoren (z.B. für den Zugriffsschutz)
Rückgabetyp
Methodenname
Parameterliste (mit Angabe der Parametertypen)
Analog werden wir auch Eigenschaften von FCL-Klassen oder -Strukturen beschreiben.
Mit der Methode
public int IndexOf(string gesucht)
kann man einen String nach der Existenz einer anderen Zeichenkette befragen. Als Rückgabewert
erhält man ...


nach erfolgreicher Suche: die Startposition der ersten Trefferstelle
nach vergeblicher Suche: -1
Mit der Methode
public bool StartsWith(string start)
lässt sich feststellen, ob ein String mit einer bestimmten Zeichenfolge beginnt.
Mit den Überladungen der Methode
public string Replace(char alt, char neu)
public string Replace(string alt, string neu)
erhält man als Rückgabewert die Adresse eines neuen String-Objekts, das aus dem angesprochenen
Original durch Ersetzen eines alten Zeichens (einer alten Zeichenfolge) durch ein neues Zeichen
(eine neue Zeichenfolge) hervorgeht.
5.4.1.3.5 Groß-/Kleinschreibung normieren
Mit den Methoden
public String ToUpper()
bzw.
public String ToLower()
erhält man einen neuen String, der im Unterschied zum angesprochenen Original auf Groß- bzw.
Kleinschreibung normiert ist, was vor Vergleichen oft sinnvoll ist, z.B.:
217
Abschnitt 5.4 Klassen für Zeichenketten
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
String a = "Otto", b = "otto";
Console.WriteLine(a.ToUpper() == b.ToUpper());
Console.WriteLine(a.ToUpper().IndexOf("T"));
}
}
True
1
In der letzten Anweisung des Beispiels ist der WriteLine()-Parameter etwas komplex geraten, so
dass vielleicht eine kurze Erklärung angemessen ist:

Der linke Punktoperator wird zuerst ausgeführt. Dabei erzeugt der Methodenaufruf
a.ToUpper() ein neues String-Objekt und liefert eine zugehörige Referenz.
Diese Referenz ermöglicht es, dem neuen Objekt Botschaften zu übermitteln, was im Methodenaufruf IndexOf("T") geschieht.

5.4.2 Die Klasse StringBuilder für veränderliche Zeichenketten
Für häufig zu ändernde Zeichenketten sollte man statt der Klasse String unbedingt die Klasse
StringBuilder aus dem Namensraum System.Text verwenden, weil hier beim Ändern einer Zeichenkette die relativ aufwändige Erzeugung eines neuen Objektes entfällt.
Ein StringBuilder–Objekt kann nicht implizit erzeugt werden, jedoch stehen bequeme Konstruktoren zur Verfügung, z.B.:


public StringBuilder()
Beispiel: StringBuilder sb = new StringBuilder();
public StringBuilder(string str)
Beispiel: StringBuilder sb = new StringBuilder("abc");
Im folgenden Programm wird eine Zeichenkette 10000-mal verlängert, zunächst mit Hilfe der
String-Klasse, dann mit Hilfe der StringBuilder-Klasse:
using System;
using System.Text;
class Prog {
static void Main() {
const int N = 10000;
String s = "*";
long vorher = DateTime.Now.Ticks;
for (int i = 0; i < N; i++)
s = s + "*";
long diff = DateTime.Now.Ticks - vorher;
Console.WriteLine("Zeit für String-Manipulation:\t\t"+diff/1.0e4);
StringBuilder t = new StringBuilder("*");
vorher = DateTime.Now.Ticks;
for (int i = 0; i < N; i++)
t.Append("*");
diff = DateTime.Now.Ticks - vorher;
Console.WriteLine("Zeit für StringBuilder-Manipulation:\t"+diff/1.0e4);
}
}
Die (in Millisekunden gemessenen) Laufzeiten unterscheiden sich erheblich: 1
1
Gemessen auf einem Rechner mit Intel-CPU (Pentium 4, Single Core, 3 GHz Taktfrequenz).
Kapitel 5: Weitere .NETte Typen
218
Zeit für String-Manipulation:
Zeit für StringBuilder-Manipulation:
93,75
0
Ein StringBuilder–Objekt kennt u.a. die folgenden Methoden und Eigenschaften (alle public):
StringBuilder-Member
Length
Append()
Insert()
Erläuterung
enthält die Anzahl der Zeichen
Das StringBuilder–Objekt wird um die Stringrepräsentation des Argumentes verlängert, z.B.:
t.Append("*");
Es sind Append()-Überladungen für zahlreiche Datentypen vorhanden.
Die Stringrepräsentation des Argumentes, das von nahezu beliebigem
Typ sein kann, wird vom angesprochenen StringBuilder–Objekt an
einer bestimmten Stelle eingefügt, z.B.:
sb.Insert(4, 3.14);
Remove()
Ab einer Startposition wird eine Anzahl von Zeichen entfernt, z.B.:
sb.Remove(100, 500);
Replace()
Ein Zeichen bzw. eine Zeichenfolge des StringBuilder–Objekts wird
durch anderes Zeichen bzw. eine andere Zeichenfolge ersetzt, z.B.:
sb.Replace("alt", "neu");
ToString()
Es wird ein String-Objekt mit dem Inhalt des StringBuilder–Objekts
erzeugt. Dies ist z.B. erforderlich, um ein StringBuilder- und ein
String-Objekt nach Inhalt vergleichen zu können:
Console.WriteLine(s == sb.ToString());
5.5 Enumerationen
Angenommen, Sie wollen in einer Adressdatenbank auch den Charakter der erfassten Personen
notieren und sich dabei an den vier Temperamentstypen des griechischen Philosophen Hippokrates
(ca. 460 - 370 v. Chr.) orientieren: cholerisch, melancholisch, sanguin, phlegmatisch. Um dieses
Merkmal mit seinen vier möglichen Ausprägungen in einer Instanzvariablen zu speichern, haben
Sie verschiedene Möglichkeiten, z.B.

Eine string-Variable zur Aufnahme der Temperamentsbezeichnung
Dabei wird relativ viel Speicherplatz benötigt, und es drohen Fehler durch inkonsistente
Schreibweisen, z.B.:
if (otto.Temp == "Flegmatisch") ...

Eine int-Variable mit der Kodierungsvorschrift 0 = cholerisch, 1 = melancholisch etc.
Es wird wenig Speicher benötigt, allerdings ist der Quellcode nur für Eingeweihte zu verstehen, und es können leicht fehlerhafte Zuweisungen auftreten, z.B.:
if (otto.Temp == 13) ...
C# bietet mit den Enumerationen (Aufzählungstypen) eine Lösung, die folgende Vorteile bietet:



Gut lesbarer Quellcode durch Klartextnamen für die Merkmalsausprägungen
Falsch geschriebene Klartextnamen werden vom Compiler abgewiesen.
Geringer Speicherbedarf
Eine Enumeration basiert auf einem zugrunde liegenden integralen Typ (meist int) und enthält eine
(meist kleine) Menge von benannten Konstanten dieses Typs, z.B.:
219
Abschnitt 5.5 Enumerationen
Werte vom Typ int
Benannte Konstanten vom Typ Temperament
Temperament.Cholerisch
Temperament.Melancholisch
Temperament.Sanguin
Temperament.Phlegmatisch




0
1
2
3
–2147483648
.
.
.
-1
0
1
2
3
.
.
.
2147483647
Sofern man auf eine explizite Typkonvertierung verzichtet (siehe unten), können einer Variablen
des Enumerationstyps nur die definierten Konstanten zugewiesen werden. Dabei sind nicht die
zugrunde liegenden Werte (per Voreinstellung 0, 1, 2, …) zu verwenden, sondern die vereinbarten
Namen (z.B. Temperament.Sanguin).
Bei der Definition eines Aufzählungstyps folgt auf das Schlüsselwort enum und den Typbezeichner
eine geschweift eingeklammerte Liste mit Namen für die Konstanten:
Enumerationsdefinition
Typbezeichner
Modifikator
{
Wert
}
;
,
Als Beispiel betrachten wir einen Enumerationstyp für die Erfassung des Charakters in der Adressdatenbank:
enum Temperament {Cholerisch, Melancholisch, Sanguin, Phlegmatisch}
Per Voreinstellung verwenden Enumerationen den Basistyp int, belegen also pro Instanz 4 Byte
Speicherplatz, doch kann in der Definition auch ein alternativer Basistyp angegeben werden (siehe
FCL-Dokumentation).
Wie bei Klassen und Strukturen …


wird die Verfügbarkeit eines Enumerationstyps über Modifikatoren geregelt (Voreinstellung: internal, Alternative: public, vgl. Abschnitt 4.9),
kann optional hinter der schließenden Klammer der Enumerationsdefinition ein Semikolon
stehen.
Die Enumerationen sind Werttypen, die folgendermaßen in das CTS (Common Type System) der
.NET – Plattform eingeordnet sind:
Kapitel 5: Weitere .NETte Typen
220
System.Oject
System.ValueType
System.Enum
z.B. Temperament
Alternative Abstammungen sind bei Enumerationstypen nicht möglich; insbesondere kann man eine
Enumeration nicht beerben. Die Basisklasse System.Enum, die übrigens selbst keine Enumeration
ist, darf nicht mit dem zugrunde liegenden Typ (meist int) verwechselt werden.
Weil Enumerationskonstanten stets mit dem Typnamen qualifiziert werden müssen, ist einige Tipparbeit erforderlich, die aber mit einem gut lesbaren Quellcode belohnt wird, z.B.
using System;
enum Temperament {Cholerisch, Melancholisch, Sanguin, Phlegmatisch}
class Person {
public string Vorname;
public string Name;
public int Alter;
public Temperament Temp;
public Person(string vorname, string name, int alter, Temperament temp) {
Vorname = vorname;
Name = name;
Alter = alter;
Temp = temp;
}
static void Main() {
Person otto = new Person("Otto", "Hummer", 35, Temperament.Sanguin);
if (otto.Temp == Temperament.Sanguin)
Console.WriteLine("Lustiger Typ!");
}
}
Einer Variable mit Enumerationstyp können (leider) über eine explizite Typumwandlung neben den
benannten Konstanten auch beliebige andere Werte des zugrunde liegenden Typs zugewiesen werden, z.B.:
otto.Temp = (Temperament) 13;
Daher sollten Enumerations-Instanzvariablen (abweichend von dem obigen schlechten Beispiel) in
der Regel gekapselt werden.
5.6 Indexer
Bei Arrays sowie bei den Klassen String und ArrayList hat sich der Indexzugriff auf die Elemente
eines Objekts als sehr angenehm erwiesen. Um denselben Komfort für eine eigene Klasse oder
Struktur zu realisieren, muss man ihr einen so genannten Indexer spendieren. Analog zur Situation
bei einer Eigenschaft handelt es sich auch bei diesem Member letztlich um ein Paar von Methoden.
221
Abschnitt 5.6 Indexer
Als Beispiel betrachten wir eine „Datenbank“, die mit einer so genannten verketteten Liste von Objekten der Klasse Person arbeitet:
class Person {
public string Vorname;
public string Name;
public Person Next;
public Person(string vorname, string name) {
Vorname = vorname;
Name = name;
}
}
Wir verzichten der Kürze halber bei der Klasse Person auf die hier durchaus empfehlenswerte
Datenkapselung. Jedes Person-Objekt besitzt eine Instanzvariable Next zur Aufnahme der Adresse seines Nachfolgers. Wenn man beim Erzeugen eines neuen Objekts dessen Adresse in das
Next-Feld des bisher letzten Objekts schreibt, entsteht eine (einfach) verkettete Liste. Durch beharrliches Verfolgen der Next-Referenzen lässt sich jedes Serienelement erreichen, sofern eine
Referenz auf das erste Element verfügbar ist. In der folgenden Abbildung ist eine Kette aus drei
Person-Objekten zu sehen:
Heap
Vorname: Otto
Name:
Kolbe
Next:
Adresse
Vorname: Kurt
Name:
Saar
Next:
Adresse
Vorname: Theo
Name:
Müller
Next:
null
Ein Objekt der Klasse PersonDB verwaltet eine verkettete Liste von Person-Objekten:
class PersonDB {
int n;
Person first, last;
public int Count {
get {
return n;
}
}
public void Add(Person neu) {
if (n == 0)
first = last = neu;
else {
last.Next = neu;
last = neu;
}
n++;
}
public Person this[int i] {
get {
if (i >= 0 && i < n) {
Person sel = first;
for (int j = 0; j < i; j++)
sel = sel.Next;
return sel;
} else
return null;
}
Kapitel 5: Weitere .NETte Typen
222
set {
if (i >= 0 && i < n && value != null) {
Person sel = first;
for (int j = 0; j < i - 1; j++)
sel = sel.Next;
value.Next = sel.Next.Next; // Nachfolger des Neulings
sel.Next = value; // Vorgänger des Neulings
}
}
}
}
Für den lesenden oder schreibenden Zugriff auf das i-te Listenelement stellt PersonDB einen Indexer zur Verfügung, der eine Person-Referenz liefert (get) oder das i-te Listenelement durch
eine andere Person-Instanz ersetzt (set).
Einige Regeln für Indexer:





Ihr Name lautet stets this.
Den Indexertyp bestimmt der abgelieferte Wert.
Hinter dem Schlüsselwort this wird eckig eingeklammert der Indexparameter angegeben.
Der set-Methode wird wie bei Eigenschaften ein impliziter Parameter namens value übergeben.
Indexer können wie Methoden überladen werden, z.B. durch Wahl verschiedener Typen für
den Indexparameter.
Vom nicht ganz trivialen PersonDB-Aufbau merkt ein Anwender dieser Klasse nichts, z.B.:
using System;
class PersonDbDemo {
static void Main() {
PersonDB adb = new PersonDB();
adb.Add(new Person("Otto", "Kolbe"));
adb.Add(new Person("Kurt", "Saar"));
adb.Add(new Person("Theo", "Müller"));
for (int i = 0; i < adb.Count; i++)
Console.WriteLine("Nummer {0}: {1} {2}",i,adb[i].Vorname,adb[i].Name);
Console.WriteLine();
adb[1] = new Person("Ilse", "Golter");
for (int i = 0; i < adb.Count; i++)
Console.WriteLine("Nummer {0}: {1} {2}",i,adb[i].Vorname,adb[i].Name);
Console.ReadLine();
}
}
Das Anwendungsprogramm liefert folgende Ausgabe:
Nummer 0: Otto Kolbe
Nummer 1: Kurt Saar
Nummer 2: Theo Müller
Nummer 0: Otto Kolbe
Nummer 1: Ilse Golter
Nummer 2: Theo Müller
Statt eigene Klassen mit Listenkonstruktion und Indexer zu entwerfen, hätten wir eine äquivalente
„Personenverwaltung“ übrigens weit ökonomischer durch Verwendung der in Abschnitt 5.3.8 vorgestellten Kollektionsklasse ArrayList realisieren können. In Abschnitt 7.5 sollen Sie im Rahmen
einer Übungsaufgabe eine Lösung unter Verwendung der generischen Kollektionsklasse
List<Person> entwerfen, die zur Veraltung einer Liste von Elementen desselben Typs gegenüber
der Klasse ArrayList zu bevorzugen ist. Allerdings gehört der Eigenbau einer verketteten Liste zu
einer soliden Programmierer-Grundausbildung, so dass sich der nicht unerhebliche Aufwand des
PersonDB-Beispiels wohl doch lohnt.
223
Abschnitt 5.7 Übungsaufgaben zu Kapitel 1476H5
5.7 Übungsaufgaben zu Kapitel 5
1) Erstellen Sie eine struct-Variante der Klasse für zweidimensionale Vektoren, die Sie im Rahmen
einer früheren Übungsaufgabe erstellt haben (siehe Abschnitt 4.11).
2) Im folgenden Programm wird den beiden object-Variablen o1 und o2 derselbe int-Wert zugewiesen. Wieso haben die beiden Variablen anschließend nicht denselben Inhalt?
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
object o1 = 1;
object o2 = 1;
Console.WriteLine(o1 == o2);
}
}
False
3) Erstellen Sie ein Programm, das 6 Lottozahlen (von 1 bis 49) zieht und sortiert ausgibt. Vermutlich werden Sie für die Lottozahlen einen eindimensionalen int-Array verwenden. Dieser lässt sich
mit der statischen Methode Sort() aus der Klasse Array im Namensraum System bequem sortieren.
4) Erstellen Sie ein Programm zur Primzahlensuche mit dem Sieb des Eratosthenes (ca. 275 - 195
v. Chr.). Dieser Algorithmus reduziert sukzessive eine Menge von Primzahlkandidaten, die initial
alle natürlichen Zahlen bis zu einer Obergrenze K enthält, also {1, 2, 3, ..., K}.

Im ersten Schritt werden alle echten Vielfachen der Basiszahl 2 (also 4, 6, ...) aus der Kandidatenmenge gestrichen, während die Zahl 2 in der Liste verbleibt.

Dann geschieht iterativ folgendes:
o Als neue Basis b wird die kleinste Zahl gewählt, welche die beiden folgenden Bedingungen erfüllt:
b ist größer als die vorherige Basiszahl.
b ist im bisherigen Verlauf nicht gestrichen worden.
o Die echten Vielfachen der neuen Basis (also 2b, 3b, ...) werden aus der Kandidatenmenge gestrichen, während die Zahl b in der Liste verbleibt.

Das Streichverfahren kann enden, wenn für eine neue Basis b gilt:
b> K
Ist eine natürliche Zahl n  K ein Vielfaches von b, dann gibt es eine natürliche Zahl nb
mit
n = nb  b und nb < K
Wir nehmen es ganz genau und unterscheiden zwei Fälle:
o nb war zuvor als Basis dran:
Dann wurde n bereits als Vielfaches von nb gestrichen.
~
~
o nb wurde zuvor als Vielfaches einer Basis b gestrichen ( nb  ub )
~
~
~
Dann wurde n bereits als Vielfaches von b gestrichen: n  nb b  ub b  ubb
Die Kandidatenmenge enthält also nur noch Primzahlen.
Sollen z.B. alle Primzahlen kleiner oder gleich 18 bestimmt werden, so startet man mit folgender
Kandidatenmenge:
Kapitel 5: Weitere .NETte Typen
224
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
14
15
16
17
18
Im ersten Schritt werden die echten Vielfachen der Basis 2 gestrichen:
1
2
3
4
5
6
7
8
9
10
11
12
13
Als neue Basis wird die Zahl 3 gewählt (> 2, nicht gestrichen). Ihre echten Vielfachen werden gestrichen:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Als neue Basis wird die Zahl 5 gewählt (> 3, nicht gestrichen). Allerdings ist 5 größer als 18 (
4,24) und der Algorithmus daher bereits beendet. Als Primzahlen kleiner oder gleich 18 erhalten wir
also:
1, 2, 3, 5, 7, 11, 13 und 17
5) Erstellen Sie eine Klasse für zweidimensionale Matrizen mit Elementen vom Typ float. Implementieren Sie eine Methode zum Transponieren einer Matrix und vielleicht noch andere Methoden
für elementare Aufgaben der Matrixalgebra.
6) Erstellen Sie ein Programm zum Berechnen einer persönlichen Glückszahl (zwischen 1 und 100),
indem Sie:




Vor- und Nachnamen als Kommandozeilenargumente einlesen,
den Anfangsbuchstaben des Vornamens sowie den letzten Buchstaben des Nachnamens ermitteln (beide in Großschreibung),
die Nummern der beiden Buchstaben im Unicode-Zeichensatz bestimmen,
die beiden Buchstabennummern addieren und die Summe als Startwert für den Pseudozufallszahlengenerator aus der Klasse Random verwenden.
Beenden Sie Ihr Programm mit einer Fehlermeldung, wenn weniger als zwei Kommandozeilenparameter übergeben wurden.
Tipps:

Um die durch Leerzeichen getrennten Kommandozeilenargumente im Programm als stringArray verfügbar zu haben, definiert man im Kopf der Main()-Methode einen Parameter vom
Typ string[]:
static void Main(string[] args) {...}

Wie jede andere Methode kann auch Main() per return-Anweisung spontan beendet werden.
7) Erstellen Sie eine Klasse StringUtil mit einer statischen Methode WrapLine(), die einen
String auf die Konsole schreibt und dabei einen korrekten Zeilenumbruch vornimmt. Anwender
Ihrer Methode sollen die gewünschte Zeilenbreite vorgeben können und auch die Trennzeichen festlegen dürfen, aber nicht müssen (Methoden überladen!).
Weitere Anforderungen an die Methode:


Am Anfang einer neuen Ausgabezeile sollen keine Leerzeichen stehen.
Ist ein Wort breiter als die Ausgabezeile, ist ein Umbruch innerhalb des Wortes unvermeidlich.
In folgendem Programm wird die Verwendung der Methode demonstriert:
Abschnitt 5.7 Übungsaufgaben zu Kapitel 1476H5
225
using System;
class StringUtilTest {
static void Main() {
string s = "Dieser Satz passt nicht in eine Schmal-Zeile, "+
"die nur wenige Spalten umfasst.";
StringUtil.WrapLine(s," -", 40);
StringUtil.WrapLine(s, 40);
StringUtil.WrapLine(s);
}
}
Der zweite Methodenaufruf sollte z.B. folgende Ausgabe erzeugen:
Dieser Satz passt nicht in eine
Schmal-Zeile, die nur wenige Spalten
umfasst.
Tipp: Eine wesentliche Hilfe kann die String-Methode Split() sein, die auf Basis einer einstellbaren
Menge von Trennzeichen alle Teilzeichenfolgen der angesprochenen Instanz ermittelt und in einem
String-Array ablegt. In folgendem Programm wird die Arbeitsweise demonstriert:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
String s = "Dies ist der Beispiel-Satz, der zerlegt werden soll.";
string[] tokens = s.Split(new char[] {' ', '-'});
foreach (string t in tokens)
Console.WriteLine(t);
}
}
Dies
ist
der
Beispiel
Satz,
der
zerlegt
werden
soll.
Die Trennzeichen sind nicht in den produzierten Teilzeichenfolgen enthalten, so dass z.B. ein als
Trennzeichen definierter Bindestrich verloren geht. Mit etwas Aufwand lässt sich dieser Verlust
verhindern.
6 Vererbung und Polymorphie
Im Manuskript war schon mehrfach davon die Rede, dass die .NET – Datentypen in eine strenge
Abstammungshierarchie eingeordnet sind. Nun betrachten wir die Vererbungsbeziehung zwischen
Klassen und die damit verbundenen Vorteile für die Softwareentwicklung im Detail.
Modellierung realer Klassenhierarchien
Beim Modellieren eines Gegenstandbereiches durch Klassen, die durch Merkmale (Instanz- und
Klassenvariablen) und Handlungskompetenzen (Instanz- und Klassenmethoden) gekennzeichnet
sind, müssen auch die Spezialisierungs- bzw. Generalisierungsbeziehungen zwischen real existierenden Klassen abgebildet werden. Eine Firma für Transportaufgaben aller Art mag ihre Nutzfahrzeuge folgendermaßen klassifizieren:
Nutzfahrzeug
Personentransporter
Taxi
LKW
Omnibus
Möbelwagen
Kranwagen
Abschleppwagen
Einige Merkmale sind für alle Nutzfahrzeuge relevant (z.B. Anschaffungspreis, momentane Position, maximale Geschwindigkeit), andere betreffen nur spezielle Klassen (z.B. Anzahl der Fahrgäste,
maximale Anhängelast, Hebekraft des Krans). Ebenso sind einige Handlungsmöglichkeiten bei allen Nutzfahrzeugen vorhanden (z.B. eigene Position melden), während andere speziellen Fahrzeugen vorbehalten sind (z.B. Fahrgäste befördern, Klaviere transportieren). Ein Programm zur
Einsatzplanung und Verwaltung des Fuhrparks sollte diese Klassenhierarchie abbilden.
Übungsbeispiel
Bei unseren Beispielprogrammen bewegen wir uns in einem bescheideneren Rahmen und betrachten meist eine einfache Hierarchie mit Klassen für geometrische Figuren:
Figur
Kreis
Rechteck
Die Vererbungstechnik der OOP
In objektorientierten Programmiersprachen ist es weder sinnvoll noch erforderlich, jede Klasse einer Hierarchie komplett neu zu definieren. Es steht eine mächtige und zugleich einfach handhabbare
Vererbungstechnik zur Verfügung: Man geht von der allgemeinsten Klasse aus und leitet durch
Spezialisierung neue Klassen ab, nach Bedarf in beliebig vielen Stufen. Eine abgeleitete Klasse erbt
alle Merkmale und Handlungskompetenzen ihrer Basisklasse und kann nach Bedarf Anpassungen
bzw. Erweiterungen zur Lösung spezieller Aufgaben vornehmen, z.B.:



zusätzliche Felder deklarieren
zusätzliche Methoden oder Eigenschaften definieren
geerbte Methoden ersetzen, d.h. unter Beibehaltung des Namens umgestalten
Die FCL ist das beste Beispiel für den erfolgreichen Einsatz der Vererbungstechnik. Viele von uns
benötigte Klassen haben einen länglichen Stammbaum, z.B. die Klasse Form für die Hauptfenster
von WinForms-Anwendungen:
Kapitel 6: Vererbung und Polymorphie
228
System.Object
System.MarshalByRefObject
System.ComponentModel.Component
System.Windows.Forms.Control
System.Windows.Forms.ScrollableControl
System.Windows.Forms.ContainerControl
System.Windows.Forms.Form
Software-Recycling
Mit ihrem Vererbungsmechanismus bietet die objektorientierte Programmierung ideale Voraussetzungen dafür, vorhandene Software auf rationelle Weise zur Lösung neuer Aufgaben zu verwenden.
Dabei können allmählich umfangreiche und dabei doch robuste und wartungsfreundliche Softwaresysteme entstehen. Spätere Verbesserungen bei einer Basisklasse kommen allen (direkt oder indirekt) abgeleiteten Klassen zu Gute. Die verbreitete Praxis, vorhanden Code per Copy & Paste in
neuen Projekten zu verwenden, hat gegenüber einer sorgfältig geplanten Klassenhierarchie offensichtliche Nachteile. Natürlich kann auch C# nicht garantieren, dass jede Klassenhierarchie exzellent entworfen ist und langfristig von einer stetig wachsenden Programmierergemeinde eingesetzt
wird.
6.1 Das Common Type System (CTS) des .NET – Frameworks
Im .NET – Framework stammen alle Klassen und sonstigen Typen (Strukturen, Enumerationen,
Delegaten) von der Klasse Object aus dem Namensraum System ab. Das gilt sowohl die in der
FCL enthaltenen als auch die von uns selbst definierten Typen. Wird (wie bei unseren bisherigen
Beispielen) in der Definition einer Klasse keine Basisklasse angegeben, dann stammt sie auf direktem Wege von Object ab. Die oben dargestellte Klassenhierarchie zum Figurenübungsbeispiel
muss also folgendermaßen vervollständigt werden:
Object
Figur
Kreis
Rechteck
Auch die Strukturen sind in die globale Hierarchie eingeordnet: Sie stammen implizit von der Klasse ValueType im Namensraum System ab, die wiederum direkt von der Urahn-Klasse Object erbt
(vgl. Abschnitt 5.2). Aus einer Struktur kann aber weder eine andere Struktur noch eine Klasse abgeleitet werden. Analoges gilt für die Aufzählungstypen, die von der Klasse System.Enum abstammen (vgl. Abschnitt 5.5).
Jeder Typ erbt alle Merkmale und Handlungskompetenzen aus der eigenen Abstammungslinie von
der Urahnklasse Object beginnend. Folglich kann z.B. jedes Objekt und jede Strukturinstanz die in
der Urahnklasse definierte Methode GetType() ausführen, die ein Type-Objekt mit den verfügbaren Metadaten liefert. Im folgenden WriteLine()-Aufruf verraten drei Type-Objekte (über die implizit aufgerufene Methode ToString()) die Typbezeichnung samt Namensraum:
229
Abschnitt 6.2 Definition einer abgeleiteten Klasse
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
object o = new object();
string s = "abc";
int i = 13;
Console.WriteLine(o.GetType()+"\n"+
s.GetType()+"\n"+
i.GetType());
}
}
System.Object
System.String
System.Int32
In der FCL-Dokumentation zu einem Datentyp sind die Erbstücke gekennzeichnet, z.B. bei der
Klasse String:
Nach dieser kurzen Beschäftigung mit der ohne eigene Leistungen verfügbaren Standarderbschaft,
machen wir uns daran, eigene Vererbungshierarchien aufzubauen.
6.2 Definition einer abgeleiteten Klasse
Wir definieren im angekündigten Beispiel zunächst die Basisklasse Figur, die Instanzvariablen für
die X- und die Y-Position der linken oberen Ecke einer zweidimensionalen Figur, zwei Konstruktoren sowie eine Methode Wo() zur Positionsmeldung besitzt:
using System;
public class Figur {
double xpos = 100.0, ypos = 100.0;
public Figur(double x, double y) {
xpos = x;
ypos = y;
Console.WriteLine("Figur-Konstruktor");
}
public Figur() { }
public void Wo() {
Console.WriteLine("\nOben Links:\t(" + xpos + ", " + ypos + ") ");
}
}
Wir definieren die Klasse Kreis als Spezialisierung der Klasse Figur, indem wir hinter den
Klassennamen durch Doppelpunkt getrennt den Basisklassennamen setzen:
Kapitel 6: Vererbung und Polymorphie
230
using System;
public class Kreis : Figur {
double radius = 75.0;
public Kreis(double x, double y, double rad) : base(x, y) {
radius = rad;
Console.WriteLine("Kreis-Konstruktor");
}
public Kreis() { }
public double Radius {
get {return radius;}
}
}
Die Kreis-Klasse erbt die beiden Positionsvariablen sowie die Methode Wo() und ergänzt eine
zusätzliche Instanzvariable für den Radius samt Eigenschaft mit Lesezugriff für die Öffentlichkeit.
Es wird ein initialisierender Kreis-Konstruktor definiert, der über das Schlüsselwort base den
initialisierenden (und öffentlich verfügbaren) Konstruktor der Basisklasse aufruft. Weil die FigurInstanzvariablen (noch) als private deklariert sind, wäre dem Kreis-Konstruktor ohnehin kein
direkter Zugriff erlaubt.
Konstruktoren werden generell nicht vererbt, so dass in der Kreis-Klasse ein parameterfreier
Konstruktor neu definiert wird, obwohl auch die Basisklasse einen solchen Konstruktor besitzt (natürlich mit anderem Namen!).
In C# ist keine Mehrfachvererbung möglich: Man kann also in einer Klassendefinition nur eine Basisklasse angeben. Im Sinne einer realitätsnahen Modellierung wäre eine Mehrfachvererbung gelegentlich durchaus wünschenswert. So könnte z.B. die Klasse Receiver von den Klassen Tuner
und Amplifier erben. Man hat man aber die Mehrfachvererbung wegen einiger Risiken bewusst
nicht aus C++ übernommen. Einen gewissen Ersatz bietet die in Abschnitt 8 behandelten Schnittstellen (Interfaces).
6.3 base-Konstruktoren und Initialisierungs-Sequenzen
Zwar werden Konstruktoren nicht vererbt, doch ist bei der Entstehung eines Objekts einer abgeleiteten Klasse ein Konstruktor aus jeder Basisklasse entlang der Ahnenreihe durch impliziten oder expliziten Aufruf beteiligt. Das folgende Programm erzeugt ein Objekt aus der Basisklasse Figur
und ein Objekt aus der abgeleiteten Klasse Kreis, wobei die beteiligten Konstruktoren dieser
Klassen ihre Tätigkeit melden:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
Figur fig = new Figur(50.0, 50.0);
Kreis krs = new Kreis(150.0, 200.0, 50.0);
}
}
Figur-Konstruktor
Figur-Konstruktor
Kreis-Konstruktor
Vom ebenfalls beteiligten Object-Konstruktor ist nichts zu sehen, weil die FCL-Designer natürlich
keine Kontrollausgabe einbebaut haben. Wir werden die Beiträge der einzelnen Konstruktoren bei
der Erstellung eines neuen Kreis-Objekts gleich noch genauer analysieren.
Wie schon in Abschnitt 6.2 zu sehen war, erledigt man den expliziten Aufruf eines Basisklassenkonstruktors im Kopfbereich eines Unterklassenkonstruktors über das Schlüsselwort base, z.B.:
Abschnitt 6.3 base-Konstruktoren und Initialisierungs-Sequenzen
231
public Kreis(double x, double y, double rad) : base(x, y) {
radius = rad;
Console.WriteLine("Kreis-Konstruktor");
}
Dadurch ist es möglich, geerbte Instanzvariablen zu initialisieren, die in der Basisklasse als private
deklariert sind.
In einem Unterklassenkonstruktor ohne base-Klausel ruft der Compiler implizit den parameterlosen
Konstruktor der Basisklasse auf. Fehlt ein solcher, weil der Programmierer einen eigenen, parametrisierten Konstruktor erstellt und nicht durch einen expliziten parameterlosen Konstruktor ergänzt
hat, dann protestiert der Compiler, z.B.:
Kreis.cs(12,12): error CS1501: Keine Überladung für die Methode
Figur erfordert 0-Argumente
Es gibt zwei offensichtliche Möglichkeiten, das Problem zu lösen:


Im Unterklassen-Konstruktor über das Schlüsselwort base einen parametrisierten Basisklassen-Konstruktor aufrufen.
In der Basisklasse einen parameterfreien Konstruktor definieren.
Der parameterlose Basisklassenkonstruktor wird übrigens auch vom Standardkonstruktor der abgeleiteten Klasse aufgerufen.
Es ist klar, dass ein Basisklassenkonstruktor mit passender Signatur nicht nur vorhanden, sondern
auch verfügbar sein muss (z.B. dank public-Deklaration).
Beim Erzeugen eines Unterklassenobjekts laufen folgende Initialisierungs-Maßnahmen ab:



Alle Instanzvariablen (auch die geerbten) werden (auf dem Heap) angelegt und mit den typspezifischen Nullwerten initialisiert.
Der Unterklassenkonstruktor führt nacheinander folgende Aktionen aus:
o Die Instanzvariablen der Klasse erhalten ggf. den in ihrer Deklaration angegebenen
Initialisierungswert. Den zugehörigen MSIL-Code erzeugt der Compiler automatisch.
o Es folgt der Aufruf eines Basisklassenkonstruktors.
o Nach Beendigung des Basisklassenkonstruktors wird der Rumpf des UnterklassenKonstruktors ausgeführt.
Im aufgerufenen Basisklassenkonstruktor läuft dieselbe Sequenz ab (Instanzvariablen der
Klasse initialisieren, Aufruf des Basisklassenkonstruktors, Anweisungsteil). Diese Rekursion endet mit dem Aufruf eines Object-Konstruktors.
Betrachten wir zum Beispiel, was beim Erzeugen eines Kreis-Objektes mit dem KonstruktorAufruf
Kreis(150.0, 200.0, 50.0);
geschieht:

Alle Instanzvariablen (auch die geerbten) werden angelegt und mit den typspezifischen
Nullwerten initialisiert.

Der Kreis-Konstruktor führt die Initialisierung radius = 75.0 aus.

Der explizit über das Schlüsselwort base aufgerufene Figur-Konstruktor mit Positionsparametern startet und führt die Initialisierungen xpos = 100.0 sowie ypos = 100.0
aus.
Der parameterlose Object-Konstruktor startet. Die Instanzvariablen der Klasse Object erhalten den Initialisierungswert laut Deklaration. Derzeit sind mir zwar keine Object-

Kapitel 6: Vererbung und Polymorphie
232

Instanzvariablen bekannt, doch ist die Existenz von gekapselten Exemplaren durchaus möglich.
Der Rumpf des parameterlosen Object-Konstruktors wird ausgeführt.

Der Rumpf des Konstruktoraufrufs Figur(150.0, 200.0) wird ausgeführt, wobei
xpos und ypos die Werte 150 bzw. 200 erhalten.

Der Rumpf des Konstruktoraufrufs Kreis(150.0, 200.0, 50.0) wird ausgeführt,
wobei radius den Wert 50 erhält.
6.4 Der Zugriffsmodifikator protected
Auf private-Member einer Basisklasse haben Methoden einer abgeleiteten Klasse (wie Methoden
beliebiger Klassen) keinen Zugriff. Um abgeleiteten Klassen besondere Rechte einzuräumen, bietet
C# den Zugriffsmodifikator protected, welcher den Zugriff durch die eigene Klasse und durch alle
(direkt oder indirekt) abgeleiteten Klassen erlaubt, z.B.:
using System;
public class Figur {
protected double xpos = 100.0, ypos = 100.0;
public Figur(double x, double y) {
xpos = x;
ypos = y;
Console.WriteLine("Figur-Konstruktor");
}
public Figur() { }
public void Wo() {
Console.WriteLine("\nOben Links:\t(" + xpos + ", " + ypos + ") ");
}
}
Weil die Basisklasse Figur ihre Instanzvariablen xpos und ypos nun als protected deklariert,
können sie in der Kreis-Methode SetzePos() verändert werden:
using System;
public class Kreis : Figur {
double radius = 75;
public Kreis(double x, double y, double rad) : base(x, y) {
radius = rad;
Console.WriteLine("Kreis-Konstruktor");
}
public Kreis() {}
public double Radius {
get {return radius;}
}
public void SetzePos(double x, double y) {
xpos = x;
ypos = y;
}
}
Es ist zu beachten, dass hier geerbte Instanzvariablen von Kreis-Objekten verändert werden. Bei
entsprechender Methodenausstattung kann ein Kreis-Objekt selbstverständlich auch das xposFeld eines anderen Kreis-Objekts verändern. Auf das xpos-Feld eines Figur-Objekts haben die
Methoden der Kreis-Klasse jedoch keinen Zugriff.
Für Methoden fremder Klassen sind protected-deklarierte Member ebenso gesperrt wie private,
z.B.:
Abschnitt 6.5 Erbstücke durch spezialisierte Varianten verdecken
233
using System;
class Prog {
static void Main() {
Kreis krs = new Kreis(10.0, 10.0, 5.0);
// krs.xpos = 77.7;
verboten
krs.SetzePos(77.7, 99.4); // erlaubt
}
}
Der Modifikator protected ist natürlich nicht nur bei Feldern erlaubt, sondern auch bei (instanzoder klassenbezogenen) Methoden, z.B.:
static protected void ProSt() {
Console.WriteLine("Protected und statisch!");
}
6.5 Erbstücke durch spezialisierte Varianten verdecken
6.5.1 Geerbte Methoden verdecken
Eine geerbte Basisklassenmethode kann in einer Unterklasse durch eine Methode mit gleicher Signatur verdeckt werden. Zwei Methoden haben genau dann dieselbe Signatur, wenn die Namen und
die Parameterlisten (hinsichtlich Typ und Art aller Formalparameter) übereinstimmen, während die
Rückgabetypen keine Rolle spielen.
Bisher steht in der Kreis-Klasse zur Ortsangabe die geerbte Methode Wo(), welche die Position
der linken oberen Ecke eines Objekts ausgibt. In der Kreis-Klasse kann aber eine bessere Ortsangabenmethode realisiert werden, weil hier auch die rechte untere Ecke definiert ist:1
using System;
public class Kreis : Figur {
double radius = 75.0;
public Kreis(double x, double y, double rad) : base(x, y) {
radius = rad;
}
public Kreis() { }
public double Radius {
get {return radius;}
}
public new void Wo() {
base.Wo();
Console.WriteLine("Unten Rechts:\t(" + (xpos + 2 * radius) +
", " + (ypos + 2 * radius) + ")");
}
}
Im Definitionskopf der verdeckenden Methode sollte man den Modifikator new 2 angeben, um die
folgende Warnung des Compilers zu vermeiden:
Kreis.cs(7,17): warning CS0108: Kreis.Wo() blendet den geerbten Member
Figur.Wo() aus. Verwenden Sie das Schlüsselwort "new", wenn das
Ausblenden beabsichtigt ist.
1
2
Falls Sie sich über die Berechnungsvorschrift für die Y-Koordinate der rechten unteren Kreis-Ecke wundern: Bei
der Grafikausgabe von Computersystemen ist die Position (0, 0) meist in der oberen linken Ecke des Bildschirms
bzw. des aktuellen Fensters angesiedelt. Die X-Koordinaten wachsen (wie aus der Mathematik gewohnt) von links
nach rechts, während die Y-Koordinaten von oben nach unten wachsen. Wir wollen uns im Hinblick auf die in absehbarer Zukunft anstehende Programmierung graphischer Benutzeroberflächen schon jetzt daran gewöhnen.
Dieser Modifikator darf nicht mit dem Operator new verwechselt werden.
234
Kapitel 6: Vererbung und Polymorphie
Mit diesem Hinweis soll ein ungeplantes Verdecken durch Tippfehler oder Unachtsamkeit verhindert werden.
Im Anweisungsteil der neuen Methode kann man sich oft durch Rückgriff auf die verdeckte Methode die Arbeit erleichtern, wobei wieder das Schlüsselwort base zum Einsatz kommt.
Das folgende Programm schickt an eine Figur und an einen Kreis jeweils die Nachricht Wo(),
und beide zeigen ihr artspezifisches Verhalten:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
Figur f = new Figur(10.0, 20.0);
f.Wo();
Kreis k = new Kreis(50.0, 50.0, 10.0);
k.Wo();
}
}
Oben Links:
(10, 20)
Oben Links:
Unten Rechts:
(50, 50)
(70, 70)
Es liegt übrigens keine Verdeckung vor, wenn in der Unterklasse eine Methode mit gleichem Namen, aber abweichender Parameterliste definiert wird. In diesem Fall sind die beiden Signaturen
verschieden, und es handelt sich um eine Überladung.
Ist eine verdeckende Methode als privat deklariert, wird sie nur intern verwendet, und in der
Schnittstelle der abgeleiteten Klasse bleibt das signaturgleiche Erbstück erhalten, z.B.:
Quellcode
Ausgabe
using System;
class Basisklasse {
public void NenneTyp() {
Console.WriteLine("Basisklasse");
}
}
class Abgeleitet : Basisklasse {
new void NenneTyp() {
Console.WriteLine("Abgeleitet");
}
public void DeinTyp() {
NenneTyp();
}
}
class Prog {
static void Main() {
Basisklasse b = new Basisklasse();
b.NenneTyp();
Abgeleitet a = new Abgeleitet();
a.NenneTyp(); a.DeinTyp();
}
}
Basisklasse
Basisklasse
Abgeleitet
Eine solche Konstruktion ist aber potentiell verwirrend und wohl nur selten nützlich.
Neben objektbezogenen können auch statische Methoden verdeckt werden, wobei die Basisklassenvariante durch Voranstellen des Klassennamens anzusprechen ist, z.B.:
235
Abschnitt 6.5 Erbstücke durch spezialisierte Varianten verdecken
Quellcode
Ausgabe
using System;
class Basisklasse {
public static void M() {
Console.WriteLine("Basisklasse");
}
}
class Abgeleitet : Basisklasse {
public new static void M() {
Console.WriteLine("Abgeleitet");
}
}
class Prog {
static void Main() {
Basisklasse.M();
Abgeleitet.M();
}
}
Basisklasse
Abgeleitet
Schließlich ist das Verdecken ist nicht nur bei Methoden erlaubt, sondern auch bei Eigenschaften
und Indexern.
6.5.2 Geerbte Felder verdecken
Auch geerbte Instanz- und Klassenvariablen lassen sich verdecken, was aber im Sinne eines möglichst leicht nachvollziehbaren Quelltextes nur in Ausnahmefällen geschehen sollte. Verwendet man
z.B. in der abgeleiteten Klasse AK für eine Instanzvariable einen Namen, der bereits eine Variable
der beerbten Basisklasse BK bezeichnet, dann wird die Basisvariable verdeckt. Sie ist jedoch weiterhin vorhanden und kommt in folgenden Situationen zum Einsatz:


Von BK geerbte Methoden greifen weiterhin auf die BK-Variable zu, während die zusätzlichen Methoden der AK-Klasse auf die AK-Variable zugreifen.
In AK-Methoden steht die verdeckte Variante über das Schlüsselwort base zur Verfügung.
Im folgenden Beispielprogramm führt ein AK-Objekt eine BK- und eine AK-Methode aus, um die
beschriebenen Zugriffsvarianten zu demonstrieren:
Quellcode
Ausgabe
using System;
class BK {
protected string x = "Bast";
public void BM() {
Console.WriteLine("x in BK-Methode:\t"+x);
}
}
class AK : BK {
new int x = 333;
public void AM() {
Console.WriteLine("x in AK-Methode:\t"+x);
Console.WriteLine("base-x in AK-Methode:\t"+
base.x);
}
}
class Prog {
static void Main() {
AK ako = new AK();
ako.BM();
ako.AM();
}
}
x in BK-Methode:
x in AK-Methode:
base-x in AK-Methode:
Bast
333
Bast
Kapitel 6: Vererbung und Polymorphie
236
In der Deklaration einer verdeckenden Variablen sollte man den Modifikator new 1 angeben, um die
folgende Warnung des Compilers zu vermeiden:
Verdecken.cs(9,6): warning CS0108: Sohn.x blendet den geerbten Member Vater.x
aus. Verwenden Sie das Schlüsselwort "new", wenn das Ausblenden
beabsichtigt ist.
Mit diesem sehr aufmerksamen Hinweis soll ein ungeplantes Verdecken durch Tippfehler oder Unachtsamkeit verhindert werden.
6.6 Verwaltung von Objekten über Basisklassenreferenzen
Eine Basisklassenreferenzvariable darf die Adresse eines beliebigen Unterklassenobjektes aufnehmen. Schließlich besitzt Letzteres die komplette Ausstattung der Basisklasse und kann z.B. auf dort
definierte Methodenaufrufe geeignet reagieren. Ein Objekt steht nicht nur zur eigenen Klasse in der
„ist-ein“-Beziehung, sondern erfüllt diese Relation auch in Bezug auf die direkte Basisklasse sowie
in Bezug auf alle indirekten Basisklassen in der Ahnenreihe. Angewendet auf das Beispiel in Abschnitt 6.2 ergibt sich die sehr plausible Feststellung, dass jeder Kreis auch eine Figur ist.
Andererseits verfügt ein Basisklassenobjekt in der Regel nicht über die Ausstattung von abgeleiteten (erweiterten bzw. spezialisierten) Klassen. Daher ist es sinnlos und verboten, die Adresse eines
Basisklassenobjektes in einer Unterklassen-Referenzvariablen abzulegen.
Über Referenzvariablen vom Typ einer gemeinsamen Basisklasse lassen sich also Objekte aus unterschiedlichen Klassen verwalten. Im Rahmen eines Grafik-Programms kommt vielleicht ein Array
mit dem Elementtyp Figur zum Einsatz, dessen Elemente auf Objekte aus der Basisklasse oder
aus einer abgeleiteten Klasse wie Kreis oder Rechteck zeigen:
Array fa mit Elementtyp Figur
fa[0]
fa[1]
FigurObjekt
RechteckObjekt
fa[2]
KreisObjekt
fa[3]
KreisObjekt
fa[4]
FigurObjekt
Ein Array vom Typ Figur kann Referenzen auf Figuren und Kreise aufnehmen, z.B.:
1
Dieser Modifikator darf nicht mit dem Operator new verwechselt werden.
Abschnitt 6.6 Verwaltung von Objekten über Basisklassenreferenzen
237
using System;
class Prog {
static void Main() {
Figur[] fa = new Figur[5];
fa[0] = new Figur(10.0, 10.0);
fa[1] = new Rechteck(20.0, 20.0, 20.0, 20.0);
fa[2] = new Kreis(30.0, 30.0, 30.0);
fa[3] = new Kreis(40.0, 40.0, 30.0);
fa[4] = new Figur(50.0, 50.0);
foreach (Figur e in fa)
e.Wo();
}
}
Bei Ansprache per Basisklassenreferenz führen die Objekte nicht die verdeckende, artspezifische
Wo()-Methode aus, sondern die Basisklassenvariante:
Oben
Oben
Oben
Oben
Oben
Links:
Links:
Links:
Links:
Links:
(10,
(20,
(30,
(40,
(50,
10)
20)
30)
40)
50)
In Abschnitt 6.7 über die Polymorphie werden Sie erfahren, dass man in der Basisklasse eine virtuelle Methode definieren und diese in den abgeleiteten Klassen überschreiben muss, damit auch bei
Ansprache per Basisklassenreferenz ein artspezifisches Verhalten resultiert.
Über eine Figur-Referenzvariable, die auf ein Kreis-Objekt zeigt, sind Erweiterungen der
Kreis-Klasse nicht unmittelbar zugänglich. Wenn (auf eigene Verantwortung des Programmierers) eine Basisklassenreferenz als Unterklassenreferenz behandelt werden soll, um eine unterklassenspezifische Methode, Eigenschaft oder Variable anzusprechen, dann muss eine explizite Typumwandlung vorgenommen werden, z.B.:
((Kreis)fa[1]).Radius
Geschieht dies zu Unrecht, tritt ein Ausnahmefehler auf, z.B.:
Unbehandelte Ausnahme: System.InvalidCastException: Das Objekt des Typs
"Figur" kann nicht in Typ "Kreis" umgewandelt werden.
Im Zweifelsfall sollte man sich über den is-(Typtest-)Operator vergewissern, ob das referenzierte
Objekt tatsächlich zur vermuteten Klasse gehört, z.B.:
for (int i = 0; i < 2; i++) {
fa[i].Wo();
if (fa[i] is Kreis)
Console.WriteLine("Radius:
" + ((Kreis)fa[i]).Radius);
}
An Stelle des gewohnten Typumwandlungsoperators
((Kreis)fa[1]).Radius
kann im Beispiel auch der as-Operator eingesetzt werden:
(fa[2] as Kreis).Radius
Die wichtigsten Regeln für den as-Operator:


Der Zieltyp im zweiten Operanden muss ein Referenztyp oder ein null-fähiger Typ (siehe
Abschnitt 7.3) sein.
Im ersten Operanden verlangt der Compiler ist ein Ausdruck, dessen Wert potentiell in den
Zieltyp konvertiert werden kann. Dies ist z.B. bei einer Variablen vom Typ Object unabhängig vom konkreten Zieltyp stets der Fall. Weitere Details finden sich in der C# 3.0 Sprachspezifikation (Microsoft 2007a, S. 198).
Kapitel 6: Vererbung und Polymorphie
238

Stellt sich zur Laufzeit eine Konvertierung als unmöglich heraus, liefert der as-Operator im
Unterschied zum gewohnten Typumwandlungsoperator keinen Ausnahmefehler, sondern
den Ergebniswert null.
6.7 Polymorphie (Methoden überschreiben)
Eine abgeleitete Klasse kann mit Hilfe gleich zu beschreibender Schlüsselwörter eine geerbte Instanzmethode überschreiben, statt sie zu verdecken. Wird ein Unterklassenobjekt über eine Variable
vom Unterklassentyp referenziert, zeigt es bei überschreibenden und bei verdeckenden Methoden
das Unterklassenverhalten. Bei Ansprache über eine Referenz vom Basisklassentyp gilt hingegen:


Bei verdeckenden Methoden kommt die Basisklassenvariante zum Einsatz.
Bei überschreibenden Methoden wird die Unterklassenvariante benutzt.
Während bei einer Verdeckung die auszuführende Methode schon beim Übersetzen festliegt, wird
im Fall der Überschreibung erst zur Laufzeit mit Hilfe einer Verweistabelle die passende Methode
gewählt, was einen erhöhten Speicher- und Zeitaufwand zur Folge hat. Man spricht hier von einer
dynamischen oder späten Bindung.
Werden Objekte aus verschiedenen Klassen über Referenzvariablen eines gemeinsamen Basistyps
verwaltet, sind nur Methoden nutzbar, die schon in der Basisklasse definiert sind. Bei überschriebenen Methoden reagieren die Objekte unterschiedlich (jeweils unterklassentypisch) auf dieselbe Botschaft. Genau dieses Phänomen bezeichnet man als Polymorphie. Wer sich hier mit einem exotischen und nutzlosen Detail konfrontiert glaubt, sei an die Auffassung von Alan Kay erinnert, der
wesentlich zur Entwicklung der objektorientierten Programmierung beigetragen hat. Er zählt die
Polymorphie neben der Datenkapselung und der Vererbung zu den Grundelementen dieser Softwaretechnologie (Lahres & Rayman 2006).
Zur Demonstration der Polymorphie definieren wir in der Basisklasse des Figurenbeispiels die Methode Wo() mit dem Modifikator virtual als überschreibbar:
using System;
public class Figur {
protected double xpos = 100.0, ypos = 100.0;
public Figur(double x, double y) {
xpos = x;
ypos = y;
}
public Figur() { }
public virtual void Wo() {
Console.WriteLine("\nOben Links:\t(" + xpos + ", " + ypos + ") ");
}
}
In der abgeleiteten Klasse Kreis wird mit dem Schlüsselwort override das Überschreiben der
geerbte Wo()-Methode angeordnet:
using System;
public class Kreis : Figur {
double radius = 75.0;
public Kreis(double x, double y, double rad) : base(x, y) {
radius = rad;
}
public Kreis() { }
public double Radius {
get {return radius;}
}
public override void Wo() {
base.Wo();
Console.WriteLine("Unten Rechts:\t(" + (xpos+2*radius) +
", " + (ypos+2*radius) + ")");
}
}
239
Abschnitt 6.7 Polymorphie (Methoden überschreiben)
Ein Array vom Typ Figur kann nach den Erläuterungen in Abschnitt 6.6 Referenzen auf Figuren
und Kreise aufnehmen, z.B.:
using System;
class Prog {
static void Main() {
Figur[] fa = new Figur[3];
fa[0] = new Figur();
fa[1] = new Kreis();
fa[0].Wo();
fa[1].Wo();
Console.WriteLine("Radius:
"+((Kreis)fa[1]).Radius);
Console.Write("\nWollen Sie zum Abschluss noch eine" +
" Figur oder einen Kreis erleben?" +
"\nWählen Sie durch Abschicken von \"f\" oder \"k\": ");
if (Console.ReadLine().ToUpper()[0] == 'F')
fa[2] = new Figur();
else
fa[2] = new Kreis();
fa[2].Wo();
Console.ReadLine();
}
}
Beim Ausführen der virtuellen und überschriebenen Wo()-Methode durch ein per Basisklassenreferenz angesprochenes Objekt stellt das Laufzeitsystem die tatsächliche Klassenzugehörigkeit (den
dynamischen Typ der Referenzvariablen) fest und wählt die passende Methode aus (späte bzw. dynamische Bindung):
Oben Links:
(100, 100)
Oben Links:
Unten Rechts:
Radius:
(100, 100)
(250, 250)
75
Wollen Sie zum Abschluss noch eine Figur oder einen Kreis erleben?
Wählen Sie durch Abschicken von "f" oder "k": k
Oben Links:
Unten Rechts:
(100, 100)
(250, 250)
Zum „Beweis“, dass tatsächlich eine späte Bindung erfolgt, darf im Beispielprogramm die Klasse
des Array-Elementes fa[2] vom Benutzer festgelegt werden.
Weil bei statischen Methoden keine späte Bindung stattfinden kann, sind hier die Modifikatoren
virtual und override verboten. Das per new-Modifikator signalisierte Verdecken einer geerbten
statischen Methode ist jedoch sinnvoll und erlaubt.
In der folgenden Tabelle werden die beiden Ersetzungsarten für Methoden (Überschreiben und Verdecken) in semantischer und syntaktischer Hinsicht gegenübergestellt:
Ersetzungsart
Unterstützung
der Polymorphie
Überschreiben
Ja
Verdecken
Nein
Syntax
Im Kopf der Basisklassenmethode muss der Modifikator
virtual und im Kopf der Unterklassenmethode der Modifikator override stehen.
Statische Methoden können nicht überschrieben werden.
Mit dem Modifikator new im Kopf der Unterklassenmethode wird unabhängig von der Basismethodendefinition
das Verdecken gewählt.
Beide Ersetzungsarten sind auch bei Eigenschaften und Indexern anwendbar.
Kapitel 6: Vererbung und Polymorphie
240
Die Flexibilität der Polymorphie ist nicht kostenlos zu haben, und in zeitkritischen Programmsituationen muss eventuell eine hohe Zahl von polymorphen Methodenaufrufen vermieden werden.
6.8 Abstrakte Methoden und Klassen
Um die eben beschriebene gemeinsame Verwaltung von Objekten aus diversen Unterklassen über
Referenzvariablen vom Basisklassentyp realisieren und dabei Polymorphie nutzen zu können, müssen die beteiligten Methoden in der Basisklasse vorhanden sein. Wenn es für die Basisklasse zu
einer Methode keine sinnvolle Implementierung gibt, erstellt man dort eine abstrakte Methode:


Man beschränkt sich auf den Methodenkopf, dem der Modifikator abstract vorangestellt
wird.
Den Methodenrumpf ersetzt man durch ein Semikolon.
Im Figurenbeispiel ergänzen wir eine Methode namens Wachse(), mit der eine Figur zu artspezifischem Wachsen (oder Schrumpfen) um den per Parameter festgelegten Faktor aufgefordert werden kann. Ein Kreis wird auf diese Botschaft hin seinen Radius verändern, während ein Rechteck
Breite und Höhe anzupassen hat. Weil die Methode in der Basisklasse Figur nicht sinnvoll
realisierbar ist, wird sie hier abstrakt definiert:
public abstract class Figur {
. . .
public abstract void Wachse(double faktor);
. . .
}
Abstrakte Methoden sind grundsätzlich virtuell (überschreibbar, vgl. Abschnitt 6.6), wobei das
Schlüsselwort virtual überflüssig und verboten ist.
Enthält eine Klasse mindestens eine abstrakte Methode, dann handelt es sich um eine abstrakte
Klasse, und bei der Klassendefinition muss der Modifikator abstract angegeben werden.
Aus einer abstrakten Klasse kann man zwar keine Objekte erzeugen, aber andere Klassen ableiten.
Implementiert eine abgeleitete Klasse die abstrakten Methoden, lassen sich Objekte daraus herstellen; anderenfalls ist sie ebenfalls abstrakt.
Wir leiten aus der nunmehr abstrakten Klasse Figur die konkreten Klassen Kreis und Rechteck ab, welche die abstrakte Figur-Methode Wachse() implementieren:
public class Kreis : Figur {
double radius = 75.0;
. . .
public override void Wachse(double faktor) {
if (faktor > 0)
radius *= faktor;
}
. . .
}
public class Rechteck : Figur {
double breite = 50.0, hoehe = 50.0;
. . .
public override void Wachse(double faktor) {
if (faktor > 0) {
breite *= faktor;
hoehe *= faktor;
}
}
. . .
}
Von den beiden Ersetzungsarten kommt bei einer abstrakten (und damit grundsätzlich virtuellen)
Basisklassenmethode Wachse() nur das Überschreiben in Frage, wobei das zugehörige Schlüsselwort override explizit anzugeben ist.
241
Abschnitt 6.8 Abstrakte Methoden und Klassen
Obwohl sich aus einer abstrakten Klasse keine Objekte erzeugen lassen, kann sie doch als Datentyp
verwendet werden. Referenzen dieses Typs sind ja auch unverzichtbar, wenn Objekte diverser Unterklassen gemeinsam verwaltet werden sollen:
Quellcode
Ausgabe
Fläche Figur 0:
using System;
class Prog {
Fläche Figur 1:
static void Main() {
Figur[] fa = new Figur[2];
Gesamtfläche:
fa[0] = new Kreis(50.0, 50.0, 5.0);
fa[1] = new Rechteck(10.0, 10.0, 5.0, 5.0);
fa[0].Wachse(2.0);
fa[1].Wachse(2.0);
double ges = 0.0;
for (int i = 0; i < fa.Length; i++) {
Console.WriteLine("Fläche Figur {0}: {1,10:f2}\n",
i, fa[i].Inhalt);
ges += fa[i].Inhalt;
}
Console.WriteLine("Gesamtfläche:
{0,10:f2}",ges);
Console.ReadLine();
}
}
314,16
100,00
414,16
Neben den Methoden können auch die Eigenschaften und Indexer abstrakt definiert werden. Im
Figurenbeispiel soll mit der Eigenschaft Inhalt die Möglichkeit geschaffen werden, den Flächeninhalt eines Objekts bequem zu erfragen. Weil eine polymorphe Nutzung gewünscht ist, muss die
Eigenschaft schon in der Basisklasse vorhanden sein. Dort ist aber keine sinnvolle Flächenberechnung möglich, sodass die Eigenschaft abstrakt definiert wird:
public abstract double Inhalt {
get;
}
In den abgeleiteten Klassen Kreis und Rechteck wird die Eigenschaft individuell realisiert:
public class Kreis : Figur {
double radius = 75.0;
. . .
public override double Inhalt {
get {return Math.PI * radius * radius;}
}
. . .
}
public class Rechteck : Figur {
double breite = 50.0, hoehe = 50.0;
. . .
public override double Inhalt {
get {return breite * hoehe;}
}
. . .
}
Mit Hilfe der in Abschnitt 8.3 vorzustellenden Schnittstellen werden wir noch mehr Flexibilität gewinnen und polymorphe Methodenaufrufe für Typen ohne gemeinsame Basisklasse realisieren.
242
Kapitel 6: Vererbung und Polymorphie
6.9 Versiegelte Methoden und Klassen
Gelegentlich möchte man das Verdecken 1 einer Methode verhindern, damit sich der Nutzer einer
Unterklasse darauf verlassen kann, dass dort die geerbte Methode nicht überschrieben worden ist.
Dient etwa die Methode Passwd() einer Klasse Acl zum Abfragen eines Passwortes, will ihr
Programmierer eventuell verhindern, dass Passwd() in einer von Acl abstammenden Klasse Bcl
überschrieben wird. Damit kann dem Nutzer der Klasse Bcl die ursprüngliche Funktionalität von
Passwd() garantiert werden.
Um das Verdecken einer Methode zu verhindern, gibt man in der Definition den Modifikator sealed
(versiegelt) an. Dies ist allerdings nur bei Methoden möglich, die eine virtuelle Methode überschreiben, also den Modifikator override besitzen, z.B.:
class Basis {
public virtual void Passwd() {}
}
class TopSek : Basis {
public sealed override void Passwd() {}
}
Die Aussagen zum Versiegeln von Methoden gelten analog für Eigenschaften.
Die eben für das Versiegeln von Methoden genannten Sicherheitsüberlegungen können auch zum
Entschluss führen, eine komplette Klasse mit dem Schlüsselwort sealed zu fixieren, so dass sie
zwar verwendet, aber nicht beerbt werden kann. Für das Versiegeln einer Klasse können aber noch
weitere Gründe sprechen wie das Beispiel der versiegelten FCL-Klasse String zeigt.
6.10 Übungsaufgaben zu Kapitel 6
1) Warum kann der folgende Quellcode nicht übersetzt werden?
using System;
class Basisklasse {
int ibas = 3;
public Basisklasse(int i) { ibas = i; }
public virtual void Hallo() {
Console.WriteLine("Hallo-Methode der Basisklasse");
}
}
class Abgeleitet : Basisklasse {
public override void Hallo() {
Console.WriteLine("Hallo-Methode der abgeleiteten Klasse");
}
}
class Prog {
static void Main() {
Abgeleitet s = new Abgeleitet();
s.Hallo();
}
}
1
Das Überschreiben einer Methode ist leicht zu verhindern, weil es eine explizite Erlaubnis durch den Modifikator
virtual voraussetzt.
Abschnitt 6.10 Übungsaufgaben zu Kapitel 1488H6
243
2) Im folgenden Beispiel wird die Klasse Kreis aus der Klasse Figur abgeleitet:
class Figur {
double xpos, ypos;
}
class Kreis : Figur {
double radius;
public Kreis(double x, double y, double rad) {
xpos = x;
ypos = y;
radius = rad;
}
public Kreis() { }
}
Trotzdem erlaubt der Compiler den Kreis-Objekten keinen direkten Zugriff auf ihre geerbten Instanzvariablen xpos und ypos (im initialisierenden Kreis-Konstruktor). Wie ist das Problem zu
erklären und zu lösen?
3) Erläutern Sie die folgenden Begriffe:



Überladen von Methoden
Verdecken von Methoden
Überschreiben von Methoden
Welche von den drei genannten Programmiertechniken ist bei statischen Methoden nicht anwendbar?
7 Generische Typen und Methoden
Nach der Vererbung lernen Sie jetzt noch eine weitere Technik zur intelligenten Mehrfachverwendung von Quellcode (ohne Copy & Paste) kennen.
7.1 Motive für die Einführung generischer Klassen in .NET 2.0
In Abschnitt 5.3.8 haben wir die Klasse ArrayList aus Namensraum System.Collections als Container für Objekte beliebigen Typs verwendet:
ArrayList al = new ArrayList();
al.Add("Text");
al.Add(3.14);
al.Add(13);
Im Unterschied zu einem gewöhnlichen Array (siehe Abschnitt 5.3) bietet die Klasse ArrayList
eine automatische Größenanpassung.
Die im Beispiel ausgenutzte Typflexibilität ist nicht wünschenswert, wenn ein Container (dynamischer Array) für Variablen bzw. Objekte eines bestimmten (festen) Typs gefragt ist (z.B. zur Verwaltung von String-Objekten):



Wenn beliebige Objekte zugelassen sind, die intern über Referenzvariablen vom Typ Object
verwaltet werden, kann der Compiler keine Typsicherheit garantieren. Viele Programmierfehler werden also erst zur Laufzeit (womöglich vom Benutzer) entdeckt.
Wenn Variablen mit Werttyp verwaltet werden, resultieren leistungsschädliche (Un)BoxingOperationen.
Entnommene Objekte können erst nach einer expliziten Typumwandlung die Methoden ihres Klasse ausführen.
Im folgenden Beispielprogramm sollen String-Objekte in einem ArrayList-Container verwaltet
werden.
using System;
using System.Collections;
class Prog {
static void Main() {
ArrayList al = new ArrayList();
al.Add("Otto");
al.Add("Rempremerding");
al.Add('.');
int i = 0;
foreach (Object s in al)
Console.WriteLine("Laenge von Zeile {0}: {1}\n", ++i, ((String)s).Length);
}
}
Bevor ein String-Element des Containers nach seiner Länge befragt werden kann, ist eine lästige
Typanpassung fällig, weil der Compiler nur die Typangabe Object kennt:
((String)s).Length
Weil der dritte Add()-Aufruf einen Wert vom Typ char per (Autoboxing!) in den Container befördert, endet das Programm mit einem Ausnahmefehler:
Unbehandelte Ausnahme: System.InvalidCastException: Das Objekt des Typs
"System.Char" kann nicht in Typ "System.String" umgewandelt werden.
bei Prog.Main() in Prog.cs:Zeile 11.
Es ist nicht schwer, einen speziellen Container zur Verwaltung von String-Objekten zu erstellen,
um die beiden Probleme (syntaktische Umständlichkeit, mangelnde Typsicherheit) zu vermeiden.
Vermutlich werden analoge funktionierende Behälter aber auch für alternative Elementklassen benötigt, und entsprechend viele strukturgleiche Klassen zu definieren, die sich nur durch den Inhalts-
246
Kapitel 7: Generische Typen und Methoden
typ unterscheiden, ist nicht rationell. Für eine solche Aufgabenstellung bietet C# seit der Version
2.0 die generischen Klassen. Durch Verwendung von Typparametern wird die gesamte Handlungskompetenz der Klasse typunabhängig formuliert. Bei jeder Instantiierung wird der Typ konkretisiert, so dass Typsicherheit und syntaktische Eleganz resultieren.
Mit der generischen Klasse List<T> im Namensraum System.Collections.Generic enthält die FCL
seit der Version 2.0 eine perfekte ArrayList-Alternative zur Verwaltung einer dynamischen Liste
von Elementen mit identischem Typ. Das obige Beispielprogramm ist schnell auf die neue Technik
umgestellt:
using System;
using System.Collections.Generic;
class Prog {
static void Main() {
List<String> gl = new List<String>(); ;
gl.Add("Otto");
gl.Add("Rempremerding");
gl.Add(".");
int i = 0;
foreach (String s in gl)
Console.WriteLine("Laenge von Zeile {0}: {1}\n", ++i, s.Length);
Console.ReadLine();
}
}
Bei der Erstellung eines Objekts ist an Stelle des Typparameters T ein konkreter Datentyp anzugeben:
List<String> gl = new List<String>();
und der Compiler verhindert die Aufnahme von Elementen mit abweichendem Typ:
Die Elemente des auf String-Objekte spezialisierten Containers beherrschen ohne Typanpassung
die Methoden ihrer Klasse, z.B.:
foreach (String s in gl)
Console.WriteLine("Laenge von Zeile {0}: {1}\n",++i,s.Length);
Bei einem dynamischen Container für Elemente mit einem festen Werttyp erspart die Klasse
List<T> im Vergleich zu ArrayList die zeitaufwändigen (Un)boxing-Operationen. Mit diesem
Thema sollen Sie sich im Rahmen einer Übungsaufgabe beschäftigen.
Für Container zur Aufnahme von Elemente unterschiedlichen Typs ist die Klasse ArrayList weiterhin gefragt.
7.2 Generische Klassen
Aus der Entwicklerperspektive besteht der wesentliche Vorteil einer generischen Klasse darin, dass
mit einer Definition beliebig viele konkrete Klassen für spezielle Datentypen geschaffen werden.
Dieses Konstruktionsprinzip ist speziell bei den Kollektionsklassen sehr verbreitet (siehe FCLNamensraum System.Collections.Generic), aber keinesfalls auf Container mit ihrer weitgehend
inhaltstypunabhängigen Verwaltungslogik beschränkt.
Analog zu den generischen Klassen bietet C# auch generische Strukturen, Schnittstellen und Delegaten. Wir befassen uns in Abschnitt 7 hauptsächlich mit generischen Klassen und Strukturen, machen aber auch schon erste Erfahrungen mit generischen Schnittstellen, die wir in Abschnitt 8 vertiefen werden.
Abschnitt 7.2 Generische Klassen
247
7.2.1 Definition
Bei der generischen Klassendefinition verwendet man Typformalparameter, die im Kopf der Definition hinter dem Klassennamen zwischen spitzen Klammern und durch Kommata getrennt angegeben werden. Wir erstellen als Beispiel eine generische Klasse namens EinfachStapel<T>,
die einen LIFO-Stapel (last-in-first-out) verwaltet und mit einem Typformalparameter für den beliebig wählbaren Elementtyp auskommt. In der Praxis wird man bei solchen Standardaufgaben allerdings eine fertige Container-Klasse aus dem FCL-Namensraum System.Collections.Generic
verwenden.
using System;
public class EinfachStapel<T> {
int maxHoehe = 5;
T[] daten;
int aktHoehe;
public EinfachStapel() {
daten = new T[maxHoehe];
}
public EinfachStapel(int max) {
maxHoehe = max;
daten = new T[maxHoehe];
}
public bool Auflegen(T element) {
if (aktHoehe < maxHoehe) {
daten[aktHoehe++] = element;
return true;
} else
return false;
}
public bool Abheben(out T element) {
if (aktHoehe > 0) {
element = daten[--aktHoehe];
return true;
} else {
element = default(T);
return false;
}
}
}
Mit der Methode Auflegen() legt man ein neues Element auf den Stapel, sofern seine Kapazität
nicht erschöpft ist. Solange der Vorrat reicht, kann man das jeweils oberste Element Abheben().
Ist der Stapel leer, wird dem Ausgabeparameter vom generischem Typ T der Wert default(T) zugewiesen, d.h.:


null, wenn beim Erstellen des EinfachStapel-Objekts für T ein Referenztyp angegeben wurde,
die passende numerische Null bei EinfachStapel-Objekten mit numerischem Typaktualparameter.
Innerhalb der Klassendefinition wird der Typformalparameter wie ein Datentyp verwendet, z.B.:


als Elementtyp für den internen Array mit den Daten des Stapels
als Datentyp für den Formalparameter der Methode Auflegen()
Vielleicht vermissen Sie beim EinfachStapel die Größendynamik der Kollektionsklasse ArrayList (vgl. Abschnitt 5.3.8). Um dieses automatische Wachstum (aus der Sicht des Benutzers) zu
realisieren, kann man den intern zur Datenspeicherung benutzten Array in leistungsoptimierend
geplanten Stufen durch ein größeres Exemplar ersetzen, z.B. mit jeweils doppelter Länge. Dabei
Kapitel 7: Generische Typen und Methoden
248
sind die bisherigen Elemente zu kopieren. Eventuell haben die FCL-Designer Lösungen gefunden,
den Aufwand bei der „Verlängerung“ zu reduzieren.
Als Beispiel für eine generische Klasse mit zwei Typformalparametern findet sich in der FCL die
Klasse Dictionary, die eine Tabelle mit Name-Wert - Paaren verwaltet:
public class Dictionary<TKey, TValue>
Wie bei generischen Klassen (siehe Abschnitt 7) sind auch bei generischen Schnittstellen Restriktionen für die Typformalparameter möglich.
Bei der Verwendung eines generischen (offenen) Typs durch Wahl konkreter Datentypen an Stelle
der Typformalparameter entsteht ein geschlossener Typ (Richter 2006, S. 385).
7.2.2 Restringierte Typformalparameter
Häufig muss eine generische Klassendefinition bei den konkreten Klassen, welche einen Typparameter konkretisieren dürfen, gewisse Handlungskompetenzen voraussetzen. Soll z.B. ein generischer Container seine Elemente sortieren, dann muss jeder konkrete Elementtyp die Schnittstelle
IComparable<T> erfüllen, d.h. es muss eine Methode namens CompareTo() mit folgender Signatur vorhanden sein (hier beschrieben unter Verwendung des Typparameters T):
public int CompareTo(T element)
In Abschnitt 5.4.1.3.2 haben Sie erfahren, dass die Klasse String eine solche Methode besitzt, und
wie CompareTo() das Prüfergebnis über den Rückgabewert signalisiert. Damit sollte klar genug
sein, was die Schnittstelle (das Interface) IComparable<T> von einem Typ verlangt. Mit dem generellen Thema Schnittstellen werden wir uns in Abschnitt 8 ausführlich beschäftigen. Dabei wird
sich herausstellen, dass zur generischen Schnittstelle IComparable<T> auch noch die ältere, nichtgenerische Variante IComparable existiert, die eine Methode
public int CompareTo(Object element)
vorschreibt. Weil die generische Variante eine Typprüfung durch den Compiler ermöglicht, ist sie
zu bevorzugen.
Wir erstellen eine generische Klasse zur Verwaltung einer Liste, die eingefügte Elemente automatisch einsortiert und daher ihren Typformalparameter auf den Schnittstellentyp IComparable<T>
einschränkt:
using
class
T[]
int
System;
SimpleSortedList<T> where T : IComparable<T> {
elements;
firstFree;
public SimpleSortedList(int len) {
if (len > 0)
elements = new T[len];
}
249
Abschnitt 7.2 Generische Klassen
public void Add(T element) {
if (firstFree == elements.Length)
return;
bool inserted = false;
for (int i = 0; i < firstFree; i++) {
if (element.CompareTo(elements[i]) <= 0) {
for (int j = firstFree; j > i; j--)
elements[j] = elements[j-1];
elements[i] = element;
inserted = true;
break;
}
}
if (!inserted)
elements[firstFree] = element;
firstFree++;
}
public bool Get(int index, out T value) {
if (index >= 0 && index < firstFree) {
value = elements[index];
return true;
} else {
value = default(T);
return false;
}
}
}
Bei der Formulierung von Einschränkungen (engl.: constraints) für einen Typparameter wird das
Schlüsselwort where verwendet, wobei u.a. folgende Regeln gelten:






Man kann eine Basisklasse vorschreiben.
Mit dem Schlüsselwort class wird vereinbart, dass nur Referenztypen erlaubt sind.
Mit dem Schlüsselwort struct wird vereinbart, dass nur Werttypen erlaubt sind.
Man kann auch mehrere Restriktionen angeben, die durch Kommata abzugrenzen sind.
Während nur eine Basisklasse vorgeschrieben werden darf, sind beliebig viele Schnittstellen
(vgl. Abschnitt 8) erlaubt, die ein konkreter Typ alle erfüllen muss.
Mit dem Listeneintrag new() wird für die konkreten Typen ein parameterfreier Konstruktor
vorgeschrieben.
Eine ausführliche Darstellung der möglichen Typrestriktionen finden Sie z.B. bei Richter (2006, S.
394ff).
Im folgenden Programm wird aus der offenen Klasse SimpleSortedList<T> eine geschlossene Klasse zur Verwaltung einer sortierten int-Liste erzeugt:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
SimpleSortedList<int> si = new SimpleSortedList<int>(5);
si.Add(11);si.Add(2);si.Add(1);si.Add(4);
int result;
for (int i = 0; i < 4; i++)
if (si.Get(i, out result))
Console.WriteLine(result);
}
}
1
2
4
11
Kapitel 7: Generische Typen und Methoden
250
7.2.3 Generische Klassen und Vererbung
Bei der Definition einer generischen Klasse kann man als Basisklasse verwenden:

eine nicht-generische Klasse, z.B.
class Derived : BaseClass {
. . .
}

eine konkretisierte generische Klasse
class Derived : GenBase<int, double> {
. . .
}

eine generische Klasse mit kompatiblen Typformalparametern (siehe unten)
Durch das Konkretisieren von Typparametern ändert sich die Basisklasse nicht, z.B.:
Quellcode
Ausgabe
using System;
class BaseClass {
}
class GenDerived<T> : BaseClass {
}
class Prog {
static void Main() {
Console.WriteLine((new GenDerived<int>()).GetType().BaseType);
Console.Read();
}
}
BaseClass
Soll eine generische Basisklasse beerbt werden, müssen ihre Typformalparameter entweder konkretisiert oder auf Typformalparameter der ebenfalls generischen abgeleiteten Klasse gesetzt werden,
wobei Typrestriktionen der Basisklasse ggf. zu wiederholen sind, z.B.:
class GenBase<T1, T2> where T2 : IComparable <T2> {
. . .
}
class Derived : GenBase<int, double> {
. . .
}
class GenDerived<T> : GenBase<int, T> where T : IComparable<T> {
. . .
}
Im letzten Beispiel hat die konkretisierte Version GenDerived<int> die Basisklasse
GenBase<int,int>.
Offenbar ist die nachträgliche Integration der generischen Typen in das Common Type System
(CTS) der .NET - Plattform gut gelungen. Im Zusammenhang mit den generischen Schnittstellen
werden wir aber doch auf ein Design-Problem im Zusammenhang mit der Vererbung stoßen (vgl.
Abschnitt 8.2).
Abschnitt 7.3 Nullable<T> als Beispiel für generische Strukturen
251
7.3 Nullable<T> als Beispiel für generische Strukturen
In diesem Abschnitt wird vor allem die generische Struktur Nullable<T> aus der FCL vorgestellt: 1
public struct Nullable<T> where T : struct {
private bool hasValue;
internal T value;
public Nullable(T value) {
this.value = value;
this.hasValue = true;
}
public bool HasValue {
get {
return hasValue;
}
}
public T Value {
get {. . .}
}
. . .
}
Mit den Instanzen einer Konkretisierung lassen sich Werte einer Struktur (z.B. Werte eines elementaren Datentyps) so verpacken, dass neben den normalen Werten auch der Ausnahmewert null zur
Verfügung steht.
In Abschnitt 7.2.1 haben wir einen EinfachStapel<T> als Beispiel für eine generische Klasse
definiert und dabei eine Methode zum Abheben entworfen, die das oberste Element per outParameter abliefert und per Rückgabewert meldet, ob überhaupt ein Element vorhanden war:
public class EinfachStapel<T> {
. . .
public bool Abheben(out T element) {
if (aktHoehe > 0) {
element = daten[--aktHoehe];
return true;
} else {
element = default(T);
return false;
}
}
. . .
}
Die Methode Abheben() wäre etwas leichter zu verwenden, wenn sie auf einen Parameter verzichten und als Rückgabe das oberste Stapelelement oder den Ausnahmewert null abliefern würde.
Bei unrestringiertem Typparameter muss aber null durch default(T) ersetzt werden, so dass bei
einem numerischen Werttyp die numerische Null abgeliefert wird. Dieses Verhalten ist riskant, weil
man eine Ausnahmenull nicht von einer Stapelnull unterscheiden kann.
Seit .NET 2.0 lässt sich aber jede Struktur in eine Konkretisierung der generischen Struktur Nullable<T> verpacken, so dass z.B. einer Nullable<double> - Instanz der Wert null zugewiesen werden kann. Wie der obige Quellcode zeigt, beschränkt sich der zusätzliche Speicherplatzbedarf einer
null-fähigen Strukturinstanz im Vergleich zum Grundtyp auf eine bool-Variable:
private bool hasValue;
internal T value;
Man behält den Performanzvorteil von Strukturen, wenn in einer zeitkritischen Programmphase
zahlreiche Instanzen eines Typs benötigt werden.
1
Der Quellcode stammt aus Microsofts Shared Source Common Language Infrastructure 2.0.
252
Kapitel 7: Generische Typen und Methoden
Wir können in der generischen Klasse EinfachStapel<T> eine zusätzliche Überladung der
Methode Abheben() mit dem vereinfachten Verhalten anbieten:
public T Abheben() {
if (aktHoehe > 0) {
return daten[--aktHoehe];
} else {
return default(T);
}
}
Sie sollte nur zusammen mit einem null-fähigen Elementtyp verwendet werden, z.B. bei der EinfachStapel-Konkretisierung
EinfachStapel<Nullable<double>> ds = new EinfachStapel<Nullable<double>>(3);
Die null-fähige Variante zu einem Strukturtyp lässt sich auch durch den Namen des Grundtyps und
ein angehängtes Fragezeichen ausdrücken, z.B.:
EinfachStapel<double?> ds = new EinfachStapel<double?>(3);
Weil der Grundtyp double implizit (automatisch) in den Typ double? konvertiert wird, kann der
Methode Auflegen() ein double - oder ein double? - Wert übergeben werden, z.B.:
double? d = 77.7;
ds.Auflegen(3.141); ds.Auflegen(d);
Beim Abheben() ist wegen des potentiell zu erwartenden null-Werts jedoch eine Nullable<double>-Instanz erforderlich:
double? d;
. . .
d = ds.Abheben();
Eine solche Instanz informiert in der booleschen Eigenschaft HasValue darüber, ob ein definierter
Wert vorhanden ist, und hält diesen Wert ggf. in der Eigenschaft Value für den ausschließlich lesenden Zugriff bereit (siehe Quellcode am Beginn des aktuellen Abschnitts).
Das folgende Programm zeigt einen kompletten Kurzeinsatz des Strukturtyps Nullable<double>
als EinfachStapel<T> - Elementtyp:
Quellcode
Ausgabe
using System;
Oben lag: 2,718
class Prog {
Oben lag: 3,141
static void Main() {
Stapel war leer.
EinfachStapel<double?> ds=new EinfachStapel<double?>(3); d.HasValue = False
ds.Auflegen(3.141); ds.Auflegen(2.718);
double? d;
for (int i = 1; i <= 3; i++) {
d = ds.Abheben();
if (d != null)
Console.WriteLine("Oben lag: " + d);
else
Console.WriteLine("Stapel war leer." +
"\n d.HasValue = " + d.HasValue);
}
}
}
Während der Grundtyp implizit in den zugehörigen Nullable-Typ konvertiert wird, ist für den umgekehrten Übergang eine explizite Konvertierung erforderlich, z.B.:
double? d = 77.7;
double dn = (double) d;
253
Abschnitt 7.4 Generische Methoden
Die beim Grundtyp unterstützten Operatoren sind auch bei der null-fähigen Verschachtelung erlaubt, z.B.:
double? d1 = 1.0, d2 = 2.0;
double? s = d1 + d2;
Hat ein beteiligter Operand den Wert null, so erhält auch der Ausdruck diesen Wert, z.
double? d1 = 1.0, d2 = null;
double? s = d1 + d2;
Console.WriteLine(s.HasValue); // liefert false
Man kann einer gewöhnlichen (nicht null-fähigen) Strukturinstanz den Wert null nicht zuweisen.
Ein Vergleich mit diesem Wert ist hingegen erlaubt, wobei das Ergebnis stets false ist, z.B. beim
Vergleich:
0 == null
Mit dem so genannten Null-Koaleszenz - Operator, der durch zwei Fragezeichen ausgedrückt
wird, lässt sich die Zuweisung einer null-fähigen Strukturinstanz an eine Variable des Grundtyps
samt Ausnahme für die kritische null-Situation bequem formulieren, z.B.:
Quellcodesegment
Ausgabe
int? ni = 4;
int i = ni ?? 13;
Console.WriteLine(i);
4
Ist der linke ??-Operand von null verschieden, liefert er den Wert des Ausdrucks. Anderenfalls
kommt der rechte Operand zum Zug, der vom Grundtyp und initialisiert sein muss.
Das Bemühen von Compiler und CLR, eine Nullable-Instanz wie einen Wert des zugehörigen
Grundtyps zu behandeln, geht so weit, dass bei einer GetType()-Anfrage der Grundtyp genannt
wird, z.B.:
Quellcodesegment
Ausgabe
double? d = 3.0;
Console.WriteLine(d.GetType());
System.Double
7.4 Generische Methoden
Wenn mehrere überladene Methoden identische Operationen enthalten, stellt eine generische Methode oft die bessere Lösung dar. Im folgenden Beispiel wird das Maximum zweier Argumente
geliefert, wobei der gemeinsame Datentyp T die Schnittstelle IComparable<T> erfüllen, also eine
Methode CompareTo() besitzen muss:
Quellcode
Ausgabe
using System;
class Prog {
static T Max<T>(T x, T y) where T : IComparable<T> {
return x.CompareTo(y) > 0 ? x : y;
}
int-max:
double-max:
13
47,11
public static void Main() {
Console.WriteLine("int-max:\t" + Max(12, 13));
Console.WriteLine("double-max:\t" + Max(2.16, 47.11));
}
}
Man benennt die Typformalparameter einer generischen Methode hinter dem Methodennamen
zwischen spitzen Klammern und ggf. voneinander durch Kommata getrennt. Sie sind als Datenty-
254
Kapitel 7: Generische Typen und Methoden
pen für den Rückgabewert, Parameter und lokale Variablen erlaubt. Wie bei generischen Klassen
kann man Restriktionen für Typparameter formulieren.
Bei generischen Methoden sind selbstverständlich Überladungen erlaubt, auch unter Beteiligung
von gewöhnlichen Methoden, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static T Max<T>(T x, T y) where T : IComparable<T> {
return x.CompareTo(y) > 0 ? x : y;
}
trad. int-max: 13
double-max:
47.11
static int Max(int x, int y) {
Console.Write("trad. ");
return x > y ? x : y;
}
public static void Main() {
Console.WriteLine("int-max:\t" + Max(12, 13));
Console.WriteLine("double-max:\t" + Max(2.16, 47.11));
Console.Read();
}
}
Der Compiler ermittelt zu einem konkreten Aufruf die am besten passende kompatible Methode
und beschwert sich bei Zweifelsfällen.
7.5 Übungsaufgaben zu Kapitel 7
1) Erstellen Sie eine Variante der in Abschnitt 5.6 vorgestellten Personenverwaltung, wobei die
Klasse PersonDB (Eigenbau einer verketteten Liste mit Indexer) durch die generische Kollektionsklasse List<T> ersetzt wird.
2) Als dynamisch wachsender Container für Elemente mit einem festen Werttyp (z.B. int)ist die
Klasse ArrayList nicht gut geeignet, weil der Elementtyp Object zeitaufwändigen (Un)boxingOperationen erfordert. Dieser Aufwand entfällt bei einer passenden Konkretisierung der generischen Klasse List<T>, welche dieselbe Größendynamik bietet. Vergleichen Sie mit einem Testprogramm den Zeitaufwand beim Einfügen von 1 Mio. int-Werten in einen ArrayList- bzw.
List<int>- Container.
8 Interfaces
Zu vielen Klassen oder Strukturen führt die FCL-Dokumentation hinter dem Namen und einem
Doppelpunkt mehrere Typen auf, z.B. bei der Klasse String:
public sealed class String : IComparable, ICloneable, IConvertible, IComparable<string>,
IEnumerable<string>, IEnumerable, IEquatable<string>
Um sieben Basisklassen kann es sich nicht handeln, weil C# keine Mehrfachvererbung unterstützt.
Außerdem ist der FCL-Dokumentation zu entnehmen, dass die Klasse String direkt von der Urahnklasse Object abstammt. Am Anfangsbuchstaben I sind in der FCL-Dokumentation zuverlässig die
von einer Klasse oder Struktur implementierten Schnittstellen (englisch: Interfaces) zu erkennen.
Hierbei handelt es sich um Verpflichtungserklärungen von Klassen oder Strukturen gegenüber
dem Compiler. Ein Interface definiert eine Reihe von Handlungskompetenzen abstrakt (ohne Implementierung) über Signaturen von Methoden oder anderen ausführbaren Membern (z.B. Eigenschaften). Wenn sich ein Typ zu einem Interface bekennt, muss er die dort geforderten Handlungskompetenzen implementieren. Als Gegenleistung werden seine Instanzen vom Compiler überall
akzeptiert, wo die jeweiligen Schnittstellenkompetenzen vorausgesetzt werden.
Die Liste der von einem Typ implementierten Interfaces liefert also wichtige Informationen über
die Handlungskompetenzen seiner Instanzen. Diese Informationen sind in erster Linie für den
Compiler gedacht, doch sind sie (neben anderen Informationsquellen) auch bei der Verwendung
eines Typs durch andere Programmierer relevant. Über die Klasse String ist u.a. zu erfahren:

IComparable, IComparable<string>
Die Klasse implementiert das traditionelle Interface IComparable und die moderne Konkretisierung IComparable<String> der generischen Schnittstelle IComparable<T>, die
beide zum Namensraum System gehören.
C# unterstützt also auch bei den Schnittstellen generische Varianten mit Typparametern.
Wie bei den generischen Klassen gewinnt man damit Typsicherheit zur Übersetzungszeit
und spart in manchen Situationen (Un)boxing-Operationen.
Weil String die Schnittstelle IComparable implementiert, muss eine Methode
public int CompareTo(Object obj)
vorhanden sein. Um den Vertrag IComparable<String> zu erfüllen, wird eine Methode mit
der folgenden Signatur benötigt:
public int CompareTo(String str)
Seit der C# - Version 2.0 sind beide Überladungen in der Klasse String vorhanden. Weil der
Compiler solche Aufrufe
Console.WriteLine("Alpha".CompareTo(3));
nicht verhindern kann, muss die Methode CompareTo(object obj) darauf vorbereitet sein.
Sie reagiert sinnvoll mit einem ArgumentException-Ausnahmefehler. Aus Kompatibilitätsgründen muss die Klasse String an der Methode CompareTo(object obj) und am Bekenntnis zur Schnittstelle IComparable festhalten.
Nun beschäftigen wir uns endlich mit den nützlichen Konsequenzen der String-Verpflichtungserklärung. Weil String-Objekte die Fähigkeit zum Vergleich mit Artgenossen besitzen,
kann z.B. ein Array mit Elementen dieses Typs bequem über die (statische) Methode
Array.Sort() sortiert werden:
Kapitel 8: Interfaces
256

Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
String[] star = {"eins", "zwei", "drei"};
Array.Sort(star);
foreach (String s in star)
Console.WriteLine(s);
}
}
drei
eins
zwei
IClonable
Die Klasse String implementiert auch das Interface ICloneable (aus dem Namensraum System) und besitzt folglich eine Methode, welche eine Kopie des angesprochenen Objekts erzeugt:
public Object Clone()
Weil die Rückgabe den deklarierten Typ Object besitzt, ist eine explizite Typumwandlung
erforderlich, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
String s1 = "eins";
String s2 = (String) s1.Clone();
Console.WriteLine(s2);
}
}
eins
Zum Interface ICloneable gibt es erstaunlicherweise keine generische Alternative. Ein
Grund könnte darin bestehen, dass die Clone()-Methode und damit das ICloneableInterface durch eine Implementierungsunklarheit stark an Nutzen eingebüßt haben. Es geht
um den Unterschied zwischen der tiefen Kopie, die auch alle direkten und indirekten Member-Objekte dupliziert, und der flachen Kopie, die Member-Objekte des Originals weiterverwendet. Bei vorhandenen Klassen ist potentiell unklar, welche Clone()-Implementierung
sie verwenden. Für eine eigene Klasse kann man eine tiefe Kopie nicht garantieren, sobald
Member-Objekte vorhanden sind.
Schnittstellen definieren Verhaltenskompetenzen abstrakt durch Methoden, Eigenschaften, Indexer
und Ereignisse (siehe unten) ohne Anweisungsteil. Man kann sie in erster Näherung als Klassen mit
ausschließlich abstrakten Methoden beschreiben. Ein Interface ist also ein Datentyp. Es lassen sich
zwar keine Instanzen von diesem Typ erzeugen, aber Referenzvariablen sind erlaubt und als Abstrakationsmittel sehr nützlich. Sie dürfen auf Instanzen beliebiger Typen zeigen, welche die Schnittstelle implementieren. Somit können Instanzen unabhängig von den Vererbungsbeziehungen ihrer
Typen gemeinsam verwaltet werden (z.B. in einem Array), wobei Methodenaufrufe polymorph erfolgen (mit später bzw. dynamischer Bindung).
Implementiert eine Klasse oder Struktur ein Interface, dann …


muss sie die im Interface enthaltenen Methoden implementieren,
werden Variablen vom Typ dieser Klasse vom Compiler überall akzeptiert, wo der Interface-Datentyp vorgeschrieben ist.
Im Programmieralltag kommen wir auf unterschiedliche Weise mit Schnittstellen in Kontakt, z.B.:

Bei der Verwendung von Instanzen fremder Typen in eigenen Methodendefinitionen nutzen
wir Handlungskompetenzen, die durch Schnittstellen-Verpflichtungen dieser Typen garantiert sind. Es ist nicht unbedingt erforderlich, die fremden Typen namentlich zu kennen. Bei
Abschnitt 8.1 Interfaces definieren


257
einer eigenen Methodendefinition kann es z.B. sinnvoll sein, Parameterdatentypen über
Schnittstellen zu definieren. Damit wird Typsicherheit ohne überflüssige Einengung erreicht.
Implementierung von vorhandenen Schnittstellen in einer eigenen Typdefinition
Damit werden Variablen dieses Typs vom Compiler überall akzeptiert (z.B. als Aktualparameter), wo die jeweiligen Schnittstellenkompetenzen gefordert sind.
Definition von eigenen Schnittstellen
Beim Entwurf eines Softwaresystems, das als Halbfertigprodukt (oder Programmgerippe)
für verschiedene Aufgabenstellungen durch spezielle Klassen und Strukturen mit bestimmten Verhaltenskompetenzen zu einem lauffähigen Programm komplettiert werden soll, definiert man eigene Schnittstellen, um die Interoperabilität der Typen sicher zu stellen. In diesem Fall spricht man von einem Framework. Ein wichtiges „Halbfertigprodukt“, aus dem
durch Ihre Klassen und Strukturen vollständige Programme entstehen, kennen Sie mit dem
.NET - Framework ja schon. Auch bei einem Entwurfsmuster (engl.: design pattern), das für
eine konkrete Aufgabe bewährte Lösungsverfahren vorschreibt, spielen oft Schnittstellen eine wichtige Rolle.
8.1 Interfaces definieren
Wir behandelt zuerst das im Programmieralltag vergleichweise seltene Definieren einer Schnittstelle, weil dabei Inhalt und Funktion gut zu erkennen sind. Allerdings verzichten wir auf ein eigenes
Beispiel und betrachten stattdessen die angenehm einfach aufgebaute und außerordentlich wichtige
Schnittstelle IComparable<T> aus der FCL:
public interface IComparable<T>
{
// Interface does not need to be marked with the serializable attribute
// Compares this object to another object, returning an integer that
// indicates the relationship. An implementation of this method must return
// a value less than zero if this is less than object, zero
// if this is equal to object, or a value greater than zero
// if this is greater than object.
//
int CompareTo(T other);
}
Wie der Kommentar im obigen .NET - Originalquellcode zeigt, sind bei einer Schnittstellen-Definition neben den fixierbaren syntaktischen Forderungen meist auch semantische Vorstellungen im
Spiel. Der Compiler kann aber z.B. aufgrund der obigen IComparable<T>-Definition sinnlose
CompareTo()-Implementierungen nicht verhindern.
Einige Regeln für Schnittstellendefinitionen:

Interface-Modifikator public
Wird public nicht angegeben, ist die Schnittstelle nur innerhalb ihres Assemblies verwendbar (Schutzstufe internal).

Interface-Modifikator abstract
Schnittstellen sind grundsätzlich abstract. Der Modifikator abstract ist überflüssig und
verboten.

Schlüsselwort interface
Das obligatorische Schlüsselwort dient zur Unterscheidung von Klassen- und Strukturdefinitionen.

Schnittstellenname
Per Konvention beginnt der Interfacename mit einem großen I.
Kapitel 8: Interfaces
258
Bei generischen Schnittstellen folgen die Typformalparameter dem Namen zwischen spitzen
Klammern und durch Kommata getrennt. Wie bei generischen Klassen können auch bei generischen Schnittstellen Restriktionen für die Typparameter formuliert werden (vgl. Abschnitt 7.2.2).

Erlaubte Interface-Member
Als Interface-Member sind nur instanzbezogene Methoden, Eigenschaften, Indexer und Ereignisse erlaubt. Verboten sind Konstruktoren, Felder, Konstanten sowie statische Member.

Definition von Methoden, Eigenschaften, Indexern und Ereignissen
In einer Schnittstelle sind alle Methoden etc. grundsätzlich public und abstract (folglich
auch virtual). Die drei Schlüsselwörter sind überflüssig und verboten.
Bei der Implementierung einer Schnittstellenmethode etc. (siehe Abschnitt 8.2) muss (und
darf) der Modifikator override nicht angegeben werden, weil keine Alternative zum Überschreiben besteht.
Ein Interface kann andere Interfaces beerben (bzw. erweitern), wobei dieselbe Syntax wie bei Klassen zu verwenden ist. In der FCL wird z.B. das Interface IEnumerable vom Interface ICollection
(beide im Namensraum System.Collections) erweitert:
public interface ICollection : IEnumerable
{
void CopyTo(Array array, int index);
int Count { get; }
Object SyncRoot { get; }
bool IsSynchronized { get; }
}
Oft werden generische Schnittstellen als Erweiterung der älteren, nicht-generischen Variante definiert, z.B.:
public interface IEnumerable<T> : IEnumerable
{
. . .
}
Bei der Implementation einer erweiternden Schnittstelle durch eine Klasse oder Struktur sind auch
die Handlungskompetenzen der Basisschnittstellen zu realisieren.
Während bei Klassen die Mehrfachvererbung nicht unterstützt wird, ist sie bei Schnittstellen möglich (und oft auch sinnvoll).
Weil die Schnittstellenhierarchie von der Klassenhierarchie unabhängig ist, kann ein Interface von
beliebigen Klassen und Strukturen implementiert werden.
8.2 Interfaces implementieren
Soll für eine Klasse oder Struktur angezeigt werden, dass ihre Instanzen auch die Datentypen bestimmter Schnittstelle erfüllen, müssen die Interfaces im Kopf der Typdefinition aufgelistet werden.
Man setzt hinter den Typbezeichner einen Doppelpunkt, gibt bei Klassen ggf. zunächst eine Basisklasse an und listet dann die Schnittstellen auf, untereinander und von der Basisklasse jeweils durch
ein Komma getrennt.
Als Beispiel dient eine Klasse namens Figur, die nur begrenzte Ähnlichkeit mit namensgleichen
früheren Beispielklassen besitzt. Sie implementiert das Interface IComparable, damit FigurKollektionen bequem sortiert werden können:
Abschnitt 8.2 Interfaces implementieren
259
using System;
public class Figur : IComparable {
string name = "unbenannt";
double xpos, ypos;
public Figur(string n, double x, double y) {
name = n; xpos = x; ypos = y;
}
public Figur() {}
public String Name {
get { return name; }
}
public int CompareTo(object v) {
Figur vergl = (Figur) v;
if (xpos < vergl.xpos)
return -1;
else
if (xpos > vergl.xpos)
return 1;
return 0;
}
}
Alle Handlungskompetenzen einer im Kopf angemeldeten Schnittstelle müssen implementiert werden. Bei der Schnittstelle IComparable ist nur eine public-Methode namens CompareTo() mit
einem Parameter vom Typ Object und einem Rückgabewert vom Typ int erforderlich. In semantischer Hinsicht soll CompareTo() eine Figur beauftragen, sich mit dem per Aktualparameter bestimmten Artgenossen zu vergleichen. Bei obiger Realisation werden Figuren nach der XKoordinate ihrer linken oberen Ecke verglichen:



Liegt die angesprochene Figur links vom Vergleichspartner, dann wird die Zahl -1 zurück
gemeldet.
Haben beide Figuren in der linken oberen Ecke dieselbe X-Koordinate, lautet die Antwort 0.
Ansonsten wird eine 1 gemeldet.
Um über den (per IComparable-Definition vorgeschriebenen) Parameter vom Typ Object die speziellen Member von Figur-Objekten ansprechen zu können, ist eine explizite Typanpassung erforderlich:
Figur vergl = (Figur) v;
Ist das bei einem konkreten Aufruf an die Figur-Implementation von CompareTo() übergebene
Vergleichsobjekt keine Figur, dann führt die explizite Typanpassung zu einer System.InvalidCastException (siehe Abschnitt 8.3). Warum nicht die konkretisierte generische Schnittstelle
IComparable<Figur> zum Einsatz kommt, wird später begründet.
Weil die Methoden einer Schnittstelle grundsätzlich public sind, muss diese Schutzstufe auch für
die implementierenden Methoden gelten, wozu in deren Definition der Zugriffsmodifikator explizit
anzugeben ist. Anderenfalls äußert sich der Compiler so:
Figur.cs(3,14): error CS0536: Figur implementiert den Schnittstellenmember
System.IComparable.CompareTo(object) nicht. Figur.CompareTo(object) ist
statisch, nicht öffentlich oder hat den falschen Rückgabewert.
Für die implementierenden Methoden muss (und darf) das Schlüsselwort override (im Unterschied
zur Situation beim Überschreiben von abstrakten Methoden) nicht angegeben werden.
Soll eine implementierende Methode überschreibbar sein, ist das Schlüsselwort virtual anzugeben.
Kapitel 8: Interfaces
260
Eine Klasse kann auf das Implementieren einiger Interface-Handlungskompetenzen verzichten und
diese (wie auch sich selbst) als abstract deklarieren.
Weil die Figur-Objekte verglichen werden können, gelingt das Sortieren ganzer Kollektionen
mühelos, z.B.:
Quellcode
Ausgabe
using System;
using System.Collections.Generic;
class Prog {
static void Main() {
List<Figur> lf = new List<Figur>();
lf.Add(new Figur("A", 250.0, 50.0));
lf.Add(new Figur("B", 150.0, 50.0));
lf.Add(new Figur("C", 50.0, 50.0));
Console.WriteLine(lf[0].Name+" "+lf[1].Name+" "+lf[2].Name);
lf.Sort();
Console.WriteLine(lf[0].Name+" "+lf[1].Name+" "+lf[2].Name);
}
A B C
C B A
Auch Schnittstellen ändern nichts daran, dass für C# - Klassen eine Mehrfachvererbung ausgeschlossen ist. Diese Möglichkeit wurde wegen einiger Risiken bewusst nicht aus C++ übernommen.
Allerdings erlauben Schnittstellen in vielen Fällen eine Ersatzlösung, denn:


Eine Klasse darf beliebig viele Schnittstellen implementieren, so dass ihre Objekte entsprechend viele Datentypen erfüllen. So könnte man z.B. die Schnittstellen ITuner und
IAmplifier sowie die Klasse Receiver derart definieren, dass sich ein ReceiverObjekt …
o wie ein ITuner
o und wie ein IAmplifier
verhalten kann.
Wie wir inzwischen wissen, wird einer Klasse beim Implementieren von Schnittstellen aber
nichts geschenkt, sondern sie gibt Verpflichtungserklärungen ab und muss die entsprechenden Leistungen erbringen.
Bei Schnittstellen ist Mehrfachvererbung erlaubt.
Im Zusammenhang mit dem Thema Vererbung ist noch von Bedeutung, dass eine Unterklasse auch
die Schnittstellen-Implementationen ihrer Basisklasse erbt. Wird z.B. die Klasse Kreis von der
oben vorgestellten Klasse Figur abgeleitet, so übernimmt sie auch die Schnittstelle IComparable,
und die statische Sort()-Methode der Klasse Array kann auch mit Kreis-Objekten arbeiten, z.B.:
Quellcode
Ausgabe
using System;
using System.Collections.Generic;
class Prog {
static void Main() {
List<Kreis> kl = new List<Kreis>();
kl.Add(new Kreis("A", 250.0, 50.0, 10.0));
kl.Add(new Kreis("B", 150.0, 50.0, 20.0));
kl.Add(new Kreis("C", 50.0, 50.0, 30.0));
Console.WriteLine(kl[0].Name+" "+kl[1].Name+" "+kl[2].Name);
kl.Sort();
Console.WriteLine(kl[0].Name+" "+kl[1].Name+" "+kl[2].Name);
}
A B C
C B A
Abschnitt 8.2 Interfaces implementieren
261
Im Zusammenhang mit diesen Erbschaftsangelegenheiten kommen wir zurück zur Frage, ob in der
Figur-Definition nicht besser die konkretisierte generische Schnittstelle IComparable<Figur>
implementiert werden sollte, z.B.:
using System;
public class Figur : IComparable<Figur> {
. . .
public int CompareTo(Figur vergl) {
if (xpos < vergl.xpos)
return -1;
else
if (xpos > vergl.xpos)
return 1;
return 0;
}
}
Diese durchaus erwägenswerte Vorgehensweise hat leider auch zur Folge, dass der abgeleiteten
Klasse Kreis die geerbte Methode CompareTo(Figur) vom Compiler nicht mehr als Implementierung der Schnittstelle IComparable<Kreis> anerkannt wird. Das folgende Programm wird
kritiklos übersetzt,
using System;
using System.Collections.Generic;
class Prog {
static void Main() {
List<Kreis> kl = new List<Kreis>();
kl.Add(new Kreis("A", 250.0, 50.0, 10.0));
kl.Add(new Kreis("B", 150.0, 50.0, 20.0));
kl.Add(new Kreis("C", 50.0, 50.0, 30.0));
Console.WriteLine(kl[0].Name + " "
+ kl[1].Name + " " + kl[2].Name);
kl.Sort();
Console.WriteLine(kl[0].Name + " "
+ kl[1].Name + " " + kl[2].Name);
Console.Read();
}
}
läuft jedoch auf eine InvalidOperationException. Eine zur Verbesserung der Typüberwachung
durch den Compiler gedachte Technik (Implementierung von IComparable<Figur> statt IComparable) hat also eine neue, recht subtile und vom Compiler unbemerkte Fehlerquelle geschaffen.
Um die beim Ableiten verloren gegangene IComparable<T> - Implementierung bei KreisObjekten wieder verfügbar zu machen, kann man diese über Figur-Referenzen ansprechen, z.B.:
List<Figur> kl = new List<Figur>();
An dieser Stelle zeigt sich, dass die generischen Typen und Schnittstellen nachträglich (mit der
Version 2.0) in das .NET - Framework aufgenommen wurden und (noch) nicht perfekt mit der Vererbungstechnik harmonieren.
Bei einer (grundsätzlich nicht beerbbaren!) Struktur oder bei einer versiegelten Klasse entfällt das
gerade diskutierte Problem der Schnittstellenimplementierungsvererbung, so dass hier nach Möglichkeit ein generisches Interface gegenüber der nicht-generischen Variante bevorzugt werden sollte, um Typsicherheit zur Übersetzungszeit zu gewinnen und (bei Strukturen) Boxing-Operationen
einzusparen. Dies soll bei einer einfachen Struktur für Punkte in der Zahlenebene mit einem Vergleich anhand der X-Koordinate demonstriert werden. In der ersten Variante wird die traditionelle
Schnittstelle IComparable implementiert, also eine CompareTo()-Methode mit Object-Parameter
realisiert:
262
Kapitel 8: Interfaces
using System;
public struct Punkt : IComparable {
double xpos, ypos;
public Punkt(double x, double y) {
xpos = x; ypos = y;
}
public int CompareTo(object v) {
Punkt vergl = (Punkt) v;
if (xpos < vergl.xpos)
return -1;
else
if (xpos > vergl.xpos)
return 1;
return 0;
}
}
In CompareTo() ist eine explizite Typumwandlung von Object zu Punkt erforderlich, die zu einer vom Compiler nicht zu verhindernden InvalidCastException führen kann, z.B. im folgenden
Programm:
using System;
class Prog {
static void Main() {
Punkt p1 = new Punkt(1.0, 2.0),
p2 = new Punkt(2.0, 3.0);
Console.WriteLine(p1.CompareTo(p2)); // Boxing
Console.WriteLine(p1.CompareTo(13)); // Laufzeitfehler
}
}
Wie der MSIL-Code der Main()-Methode zeigt, erfordert außerdem jeder gelungene CompareTo()
- Aufruf eine Boxing -Operation:
Implementiert die Struktur stattdessen das Interface IComparable<Punkt>,
using System;
public struct Punkt : IComparable<Punkt> {
double xpos, ypos;
public Punkt(double x, double y) {
xpos = x; ypos = y;
}
public int CompareTo(Punkt vergl) {
if (xpos < vergl.xpos)
return -1;
else
if (xpos > vergl.xpos)
return 1;
return 0;
}
}
kann die fehlerhafte Quellcodezeile nicht mehr übersetzt werden,
und ein gelungener CompareTo() - Aufruf geht ohne Boxing über die Bühne:
263
Abschnitt 8.3 Interfaces als Referenzdatentypen
8.3 Interfaces als Referenzdatentypen
Mit der Definition einer Schnittstelle wird ein neuer Referenzdatentyp vereinbart, der anschließend
in Variablendeklarationen und Parameterlisten einsetzbar ist. Eine Referenzvariable des neuen Typs
kann auf Instanzen einer implementierenden Klasse oder Struktur zeigen, z.B.:
Quellcode
Ausgabe
using System;
interface IType {
string SagWas();
}
class K1 : IType {
public string SagWas() { return "K1"; }
}
class K2 : IType {
public string SagWas() { return "K2"; }
}
struct S : IType {
public string SagWas() { return "S"; }
}
class Prog {
static void Main() {
IType[] ida = {new K1(), new K2(), new S()};
foreach (IType idin in ida)
Console.WriteLine(idin.SagWas());
}
}
K1
K2
S
Damit wird es möglich, Instanzen von beliebigen Klassen und Strukturen, die dasselbe Interface
implementieren, in einem Array (oder einer anderen Kollektion) gemeinsam zu verwalten. Über
eine Interface-Variable können die Methoden der Schnittstelle sowie die Methoden der Klasse Object aufgerufen werden.
Interface-Typen sind grundsätzlich Referenztypen, so dass eine Variable mit einem solchen Datentyp nur eine Objektadresse aufnehmen kann. Wird einer solchen Referenzvariablen eine Strukturinstanz zugewiesen, ist ein Boxing fällig. Das gilt auch für konkretisierte generische Schnittstellen,
z.B. für IComparable<Punkt> (vgl. Abschnitt 8.2):
IComparable<Punkt> ip = new Punkt(3.0, 3.0); //Boxing!
Weil Referenzvariablen vom Typ IComparable<Punkt> nur auf Objekte zeigen können, deren
Typ das Interface IComparable<Punkt> implementiert, wird es vermutlich kaum Verwendungszwecke für diesen Datentyp geben.
Weil die Methoden einer Schnittstelle grundsätzlich virtuell sind, erfolgt ihr Aufruf mit dynamischer Bindung (polymorph). In zeitkritischen Programmsituationen muss eine hohe Zahl von polymorphen Methodenaufrufen und Boxing-Operationen eventuell vermieden werden.
Kapitel 8: Interfaces
264
8.4 Explizite Interface-Implementationen
In den bisherigen Beispielen wurden Interface-Verpflichtungserklärungen durch public-deklarierte
Methoden des implementierenden Typs realisiert. Bei dieser sogenannten impliziten Implementation
kann es zu Namenskollisionen kommen, wenn …


ein Typ mehrere Schnittstellen implementiert,
zwei oder mehrere Schnittstellen eine Methode mit demselben Namen und derselben Parameterliste haben, für die aber unterschiedliche Funktionsweisen vorgesehen sind.
Für diese relativ seltene Situation bietet C# die so genannte explizite Schnittstellen-Implementation,
wobei der implementierende Typ …



die Methode mehrfach implementiert,
bei jeder Implementation dem Methodennamen den Namen der zugehörigen Schnittstelle
durch Punkt getrennt voranstellt,
keine Zugriffsmodifikatoren angibt.
Im folgenden Beispiel implementiert die Klasse M die Methoden I1.M() und I2.M() durch explizite Angabe der jeweiligen Schnittstelle:
public interface I1 {
int M();
}
public interface I2 {
int M();
}
public class K : I1, I2 {
int I1.M() {
return 13;
}
int I2.M() {
return 4711;
}
}
Ihre Objekte können beide Methoden ausführen, sofern sie über den passenden Interface-Datentyp
angesprochen werden, z.B.:
Quellcode
Ausgabe
using System;
class Prog {
static void Main() {
K k = new K();
I1 i1 = k;
I2 i2 = k;
Console.WriteLine(i1.M());
Console.WriteLine(i2.M());
}
}
13
4711
Über eine Referenzvariable von eigenen Datentyp angesprochen, ist jedoch keine Methode namens
M() verfügbar, so dass die folgende Anweisung
Console.WriteLine(k.M());
den Compiler zu der Fehlermeldung veranlasst:
"K" enthält keine Definition für "M"
Würde die Klasse K die Methode M() auf bisher gewohnte Weise implementieren, wäre dasselbe
Verhalten über Referenzen der Typen I1, I2 und K abrufbar.
Abschnitt 8.5 Übungsaufgaben zu Kapitel 1506H8
265
Richter (2006, S. 343) beschreibt, wie man durch explizite Implementation bei einer einzelnen
nicht-generischen Schnittstelle für Compiler-garantierte Typsicherheit sorgen und Boxing-Operationen vermeiden kann. Derselbe Autor rät aber auch von übermäßigem Gebrauch der expliziten
Implementation ab. Es erschwert die Verwendung einer Klasse, wenn die zu einer implementierten
Schnittstelle gehörigen Methoden nur indirekt (über den Schnittstellentyp) verfügbar sind.
8.5 Übungsaufgaben zu Kapitel 8
1) Erstellen Sie eine Variante unserer Bruch-Klasse (vgl. z.B. Abschnitt 4.1.3), welche die Interfaces IComparable<T> und ICloneable implementiert. Die vorhandene Bruch-Methode Klone()
public Bruch Klone() {
return new Bruch(zaehler, nenner, etikett);
}
wird dabei nicht überflüssig, weil sie im Gegensatz zur ICloneable-Variante eine Bruch-Referenz
abliefert und damit Typumwandlungen erspart.
2) Wie unterscheiden sich Interfaces von abstrakten Klassen?
3) In welchem Sinn kann die .NET-Schnittstellentechnik partiell die Mehrfachvererbung von C++
ersetzen?
9 Einstieg in die WinForms - Programmierung
Mit den Eigenschaften und Vorteilen einer graphischen Benutzeroberfläche (engl.: Graphical User
Interface) sind Sie sicher sehr gut vertraut. Eine GUI-Anwendung präsentiert dem Benutzer ein
oder mehrere Fenster, die neben Bereichen zur Ausgabe bzw. Bearbeitung von programmspezifischen Dokumenten (z.B. Texten oder Grafiken) in der Regel zahlreiche Bedienelemente zur Benutzerinteraktion besitzen (z.B. Menüzeile, Befehlsschalter, Kontrollkästchen, Textfelder, Auswahllisten). Die von einer Plattform (in unserem Fall also vom .NET - Framework) zur Verfügung gestellten Bedienelemente bezeichnet man oft als Steuerelemente, controls oder widgets (Wortkombination aus window und gadgets). Weil die Steuerelemente intuitiv (z.B. per Maus) und in verschiedenen
Programmen weitgehend konsistent zu bedienen sind, erleichtern Sie den Umgang mit moderner
Software erheblich.
Im Vergleich zu Konsolenprogrammen geht es nicht nur intuitiver, sondern vor allem auch ereignisreicher 1 und mit mehr Mitspracherechten für den Anwender zu. Ein Konsolenprogramm entscheidet
selbst darüber, welche Anweisung als nächstes ausgeführt wird, und wann der Benutzer eine Eingabe machen darf. Um seine Aufgaben zu erledigen, verwendet ein Konsolenprogramm diverse
Dienste des Laufzeitsystems, z.B. bei der Aus- oder Eingabe von Zeichen. Für den Ablauf einer
Applikation mit graphischer Benutzeroberfläche ist ein ereignisorientiertes und benutzergesteuertes Paradigma wesentlich, wobei das Laufzeitsystem als Vermittler oder (seltener) als Quelle
von Ereignissen in erheblichem Maße den Ablauf mitbestimmt, indem es Methoden der GUIApplikation aufruft, z.B. zum Zeichnen von Fensterinhalten. Ausgelöst werden die Ereignisse in der
Regel vom Benutzer, der mit der Hilfe von Eingabegeräten wie Maus, Tastatur, Touch Screen, Eingabestift (bei Tablet PCs) etc. praktisch permanent in der Lage ist, unterschiedliche Wünsche zu
artikulieren. Ein GUI-Programm präsentiert mehr oder weniger viele Bedienelemente und wartet
die meiste Zeit darauf, dass eine der zugehörigen Ereignisbehandlungmethoden durch ein (meistens) vom Benutzer ausgelöstes Ereignis aufgerufen wird.
Im Vergleich zu einem Konsolenprogramm ist bei einem GUI-Programm die dominante Richtung
im Kontrollfluss zwischen Anwendung und Laufzeitsystem invertiert. Die Ereignisbehandlungmethoden einer GUI-Anwendung sind Beispiele für so genannte Call Back - Routinen. Man spricht
auch vom Hollywood-Prinzip, weil in dieser Gegend oft nach der Divise kommuniziert wird:
„Don’t call us. We call you“.
Betrachten wir zur Illustration eine Konsolen- und eine GUI-Anwendung zum Kürzen von Brüchen.
Bei der Konsolenanwendung (vgl. Abschnitt 4.1.3)
wird der gesamte Ablauf vom Programm diktiert:


1
Es fragt nach dem Zähler.
Es fragt nach dem Nenner.
Momentan wird bewusst ein starker Kontrast zwischen den bisher benutzten Konsolenanwendungen und den nun
vorzustellenden GUI-Anwendungen hinsichtlich der ereignisorientierten Programmierung herausgearbeitet. Allerdings kann grundsätzlich auch eine .NET - Konsolenanwendung mit Ereignissen umgehen. Wir werden z.B. in Abschnitt 12.3.3 ein Konsolenprogramm erstellten, das auf Ereignisse im Dateisystem (z.B. auf das Erstellen, Umbenennen oder Löschen von Dateien) reagiert.
Kapitel 9: Einstieg in die WinForms - Programmierung
268

Es schreibt das Ergebnis auf die Konsole.
Im Unterschied zu diesem programmgesteuerten Ablauf wird bei der GUI-Variante
das Geschehen vom Benutzer diktiert, der die vier Bedienelemente (zwei Eingabefelder und zwei
Schaltflächen) in beliebiger Reihenfolge verwenden kann, wobei das Programm mit seinen Ereignisbehandlungsmethoden reagiert (benutzergesteuerter Ablauf).
Grundsätzlich ist das Erstellen einer GUI-Anwendung für Windows mit erheblichem Aufwand verbunden. Allerdings enthält die FCL (Framework Class Library) der .NET -Plattform außerordentlich leistungsfähige Klassen zur GUI-Programmierung, deren Verwendung durch Hilfsmittel der
Entwicklungsumgebungen (z.B. Formulardesigner) zusätzlich erleichtert wird. Neben dem traditionellen, sehr gut ausgestatteten und momentan noch zu bevorzugenden Windows Forms - Framework (kurz: WinForms-Framework) ist die mit .NET 3.0 vorgestellte und von Microsoft seitdem
mit großem Aufwand als neue Grafikbasis entwickelte Windows Presentation Foundation (ehemals als WinFX bezeichnet) zu beachten. Als WPF - Vorteile werden u.a. attraktivere Bedienoberflächen mit 3D-Grafik und Animationen (Audio/Video) versprochen. Als Nachteile der neuen
Technik sind derzeit die Beschränkung auf Betriebssysteme mit .NET 3.0 - Unterstützung (ab Windows XP mit SP2) und die im Vergleich zum WinForms - Framework fehlenden Steuerelemente
(z.B. DataGridView zur Datenbankverwaltung) zu nennen. Microsoft hat zugesichert, die WinForms-Plattform noch viele Jahre zu unterstützten, so dass diese ausgereifte und vom Visual Studio
2008 sehr gut unterstützte Technik momentan zu bevorzugen ist, wenn 3D-Grafiken und Animationen keine Rolle spielen. Die folgende Tabelle aus einem Whitepaper von Microsoft 1 beschreibt die
Leistungsumfänge verschiedener Techniken und zeigt deutlich die Zielvorgabe bei der WPFEntwicklung:
WinForms
Forms, Controls
Complex text
Images
Video / Audio
2D Graphics
3D Graphics
PDF
X
WinForms Windows Direct3D
+ GDI
Media
Player
X
X
X
X
X
X
WPF
X
X
X
X
X
X
Außerdem soll die WPF-Plattform …


die Kooperation zwischen Softwareentwicklern und Grafikdesignern erleichtern,
den Unterschied zwischen Windows- und Webanwendungen (auf Silverlight-Basis) reduzieren.
Wir verwenden in diesem Manuskript das WinForms-Framework, weil es …
1
Im Internet zu finden über: http://windowsclient.net/wpf/white-papers/when-to-adopt-wpf.aspx
Abschnitt 9.1 Ein Projektrahmen zum Üben



269
eine höhere Produktivität bietet (geringerer Zeitaufwand für die Entwicklung)
von den Entwicklungswerkzeugen besser unterstützt wird
deutlich geringere Anforderungen an die Hard- und Software der Anwender stellt.
Diese Argumente finden sich bei Schwichtenberg (2009b) ebenso wie die Schlussfolgerung:
„Wer heute noch bewusst auf Windows Forms setzt, macht nichts falsch.“
In diesem Abschnitt soll ein grundlegendes Verständnis von Aufbau und Funktionsweise einer
WinForms-Anwendung vermittelt werden. Dabei verzichten wir vorübergehend auf die Assistenten
der Entwicklungsumgebungen, die einen relativ komplexen Quellcode produzieren, der den Blick
auf das Wesentliche erschwert 1. Sobald wir die Assistentenprodukte verstehen können, steht ihrer
Verwendung zur Vereinfachung von Routineaufgaben nichts im Wege.
9.1 Ein Projektrahmen zum Üben
Legen Sie mit der Visual C# Express Edition nach der Beschreibung in Abschnitt 2.2.2.2 ein leeres
Projekt mit dem Namen GuiEinstieg sowie eine Quellcodedatei mit dem Namen GuiEinstieg.cs an. Anschließend präsentiert die Entwicklungsumgebung ein Übungsprojekt ohne eine einzige Zeile Assistenten-Quellcode.
Weil wir eine WinForms-Anwendung planen (ohne eine entsprechende Projektvorlage verwendet
zu haben), müssen wir folgende DLL-Assemblies in die Verweisliste des Projekts aufnehmen:


System.dll
System.Windows.Forms.dll
Spätere Beispielprogramme benötigen meist auch eine Referenz auf

System.Drawing.dll
Wählen Sie im Projektmappen-Explorer aus dem Kontextmenü zum Eintrag Verweise die
Option Verweise hinzufügen:
In der folgenden Dialogbox lassen sich die benötigten Verweise leicht auswählen:
Das Ergebnis im Projektmappen-Explorer:
1
Diese Vorgehensweise wird übrigens von Charles Petzold, einem exzellenten und sehr erfahrenen Autor von Büchern zur Windows-Programmierung, generell empfohlen. In seinem Buch zur Windows-Programmierung mit C#
(Petzold 2002) schreibt er auf S. 45: „In diesem Buch werden Sie und ich den Code selbst schreiben“.
270
Kapitel 9: Einstieg in die WinForms - Programmierung
Um das Erscheinen eines Konsolenfensters beim Programmstart zu vermeiden, sollten Sie nach
Projekt > GuiEinstieg-Eigenschaften > Anwendung
den Ausgabetyp auf Windows-Anwendung ändern:
9.2 Elementare Klassen im Windows Forms - Framework
Für ein minimalistisches WinForms-Programm in C# sind nur wenige Zeilen erforderlich:
using System.Windows.Forms;
class GuiEinstieg : Form {
GuiEinstieg() {
Text = "WinForms-Einstieg";
}
static void Main() {
GuiEinstieg hf = new GuiEinstieg();
Application.Run(hf);
}
}
In diesem Beispielprogramm könnten sogar etliche Zeilen unter Verzicht auf Konsistenz mit späteren, komplexeren Programmen eingespart werden.
Auch ein GUI-Programm besteht aus Klassen, wobei eine Startklasse mit einer statischen Main()Methode vorhanden sein muss (vgl. Abschnitt 1.1.4). Beim Programmstart wird die Startklasse vom
Laufzeitsystem aufgefordert, ihre Main() - Methode auszuführen. Ein Hauptzweck dieser Methode
besteht darin, Objekte zu erzeugen und somit Leben auf die objektorientierte Bühne zu bringen.
Beim nun verwendeten WinForms-Framework finden vermehrt Aktivitäten hinter der Bühne statt,
die gleich erläutert werden sollen.
Allzu Funktionsumfang sollte man vom Einstiegsbeispiel nicht erwarten:
Abschnitt 9.2 Elementare Klassen im Windows Forms - Framework
271
Immerhin kann man das Anwendungsfenster (dank Windows und .NET – Framework) verschieben,
seine Größe ändern, die Titelzeilen-Standardschaltflächen zum Minimieren, Maximieren und Beenden benutzen usw.
Aus dem sehr umfangreichen Namensraum System.Windows.Forms (mit mehreren hundert Klassen und sonstigen Typen), den man bei einer WinForms-Anwendung am besten per using-Direktive
importiert, werden Sie im Abschnitt 9 folgende Klassen kennen lernen:




Form
Aus dieser Klasse stammen alle Anwendungs- oder Dialogfenster ab, die auch als Formulare bezeichnet werden.
Application
Diese Klasse bietet u.a. essentielle statische Methoden zum Betreiben eines WinFormsProgramms (z.B. Run(), Exit()).
Delegatenklassen (z.B. EventHandler)
Bei den in Abschnitt 9.3.1 behandelten Delegaten, die unsere Sammlung von C# - Datentypen vervollständigen, handelt es sich um Klassen, deren Objekte auf Methoden mit einer bestimmten Signatur zeigen.
Klassen für Steuerelemente (z.B. Label, Button, TextBox)
Durch die using-Direktive in der ersten Zeile des Beispielprogramms wird dafür gesorgt, dass der
Compiler dem Namen jeder Klasse, die nicht im projekteigenen Quellcode definiert ist, die Namensraumbezeichnung System.Windows.Forms voranstellt und dann die referenzierten Assemblies nach dem vervollständigten Namen durchsucht. Im Beispielprogramm geschieht dies bei
den Klasen Form und Application.
9.2.1 Anwendungsfenster und die Klasse Form
Alle Formulare (Fenster) einer WinForms-Anwendung werden über Objekte einer von Form abstammenden Klasse verwaltet. Form erbt seine Funktionalität wiederum zum großen Teil von allgemeineren .NET – Klassen:
Kapitel 9: Einstieg in die WinForms - Programmierung
272
System.Object
System.MarshalByRefObject
System.ComponentModel.Component
System.Windows.Forms.Control
System.Windows.Forms.ScrollableControl
System.Windows.Forms.ContainerControl
System.Windows.Forms.Form
Drei Basisklassen sollen kurz skizziert werden:



System.Windows.Forms.Control implementiert die für Bedienelemente der Benutzerschnittstelle (z.B. Schaltflächen, Fenster) erforderlichen Methoden und Eigenschaften zur
Bildschirmanzeige sowie zum Umgang mit Maus- und Tastaturereignissen, z.B.:
o Über die Methode Show() veranlasst man ein Steuerelement, auf dem Bildschirm zu
erscheinen.
o Über die Eigenschaften Height bzw. Width lässt sich die Höhe bzw. Breite eines
Steuerelements ermitteln und/oder setzen.
In der Klasse System.Windows.Forms.ScrollableControl kommen Bildlaufleisten hinzu.
Die Klasse System.Windows.Forms.ContainerControl ist leicht irreführend bezeichnet,
weil jedes Control-Objekt untergeordnete Steuerelemente aufnehmen kann. Was die Klasse
ContainerControl zusätzlich bietet, ist die Kompetenz zur Fokusverwaltung für enthaltene
Steuerelemente (z.B. durch passende Reaktionen auf die Tabulatortaste).
Das obige Beispielprogramm besteht aus der von Form abgeleiteten Klasse GuiEinstieg
class GuiEinstieg : Form
und erzeugt in seiner Main()-Methode ein Objekt aus dieser Klasse (also ein entsprechendes Formular):
GuiEinstieg hf = new GuiEinstieg();
Als Aktualparameter im Methodenaufruf Application.Run()
Application.Run(hf);
wird das GuiEinstieg-Objekt hf zum Vertreter des Anwendungs- oder Hauptfensters im Programm. Unter den Fenstern eines Programms zeichnet sich das Hauptfenster durch folgende Besonderheiten aus:



Der Run()-Aufruf sorgt u.a. für die Anzeige des Hauptfensters. Sein Auftritt muss also nicht
über die Control-Methode Show() oder die Control-Eigenschaft Visible veranlasst werden.
Beim Schließen des Hauptfensters kehrt der Run()-Aufruf zurück, so dass mit der Main()Methode schließlich auch das Programm endet.
In der Regel werden erhebliche Teile der Programm-Funktionalität im Hauptfenster angeboten.
So wie im Konstruktor des Beispielprogramms mit der Anweisung
Text = "WinForms-Einstieg";
Abschnitt 9.2 Elementare Klassen im Windows Forms - Framework
273
die Titelzeilenbeschriftung des Fensters über die Form-Eigenschaft Text gewählt wird, sind zahlreiche weitere Eigenschaften dieser Klasse veränderbar, z.B.:

Die Startgröße eines Formulars kann per Size-Eigenschaft (vom Typ System.Drawing.Size)
oder über die Eigenschaften Height und Width (vom Typ int) geändert werden, z.B.:
Height = 150; Width = 300;

Über die booleschen Eigenschaften ControlBox, HelpBox, MaximizeBox und MinimizeBox wählt man die Ausstattung der Titelzeile mit Standardschaltflächen, z.B.
MaximizeBox = false; MinimizeBox = false;

Indem man der Eigenschaft FormBorderStyle einen Wert der Enumeration FormBorderStyle aus dem Namensraum System.Windows.Forms zuordnet, kann man das Design
und/oder die Funktionalität eines Formulars beeinflussen. Z.B. führt der Wert FixedSingle
zu einem Fenster mit fixierter Größe:
FormBorderStyle = FormBorderStyle.FixedSingle;

Mit der Eigenschaft BackColor setzt man die Hintergrundfarbe des Klientenbereichs (Fensterfläche abzüglich Rahmen, Titelzeile, Menüzeile, Rollbalken und ggf. Symbolleisten),
z.B.:
BackColor = System.Drawing.Color.Beige;
Man kann z.B. über statische Eigenschaften der Struktur System.Drawing.Color zwischen
vordefinierten Standardfarben wählen oder mit der statischen Color-Methode FromArgb(),
eine eigene Farbe kreieren. Wir werden uns in Abschnitt 15.5.1 ausführlich mit der .NET Farbtechnik beschäftigen.
Beim äußerst simplen Einstiegsbeispiel hätten wir auf eine eigene, von Form abstammende GUIKlasse verzichten und ein Form-Objekt als Hauptfenster verwenden können:
using System.Windows.Forms;
class GuiEinstieg {
static void Main() {
Form hf = new Form();
hf.Text = "WinForms-Einstieg";
Application.Run(hf);
}
}
Die oben und in allen weiteren GUI-Programmen verwendete Architektur hat aber z.B. den Vorteil,
dass wir das Hauptfenster im Konstruktor seiner Klasse initialisieren können, anstatt z.B. umständlich und mit kaum objektorientiertem Programmstil in der Main()-Methode. Außerdem werden wir
später als protected deklarierte virtuelle Methoden der Klasse Form überschreiben, was nur in abgeleiteten Klassen möglich ist.
Beim Einsatz eines Formulardesigners erleichtert ein spezielles Fenster der Entwicklungsumgebung
die Eigenschaftsmodifikation zur Entwurfszeit, und der zugehörige Quellcode wird automatisch
geschrieben.
Kapitel 9: Einstieg in die WinForms - Programmierung
274
Wir verzichten momentan auf diesen Komfort, um die Grundstruktur einer WinForms-Anwendung
an einem übersichtlichen Beispiel studieren zu können.
Bald werden wir die Fenster unserer Programme mit Bedienelementen wie Menüleiste, Symbolleiste und Statusleiste ausstatten, z.B.:
Menüleiste
Symbolleiste
Statusleiste
Klientenbereich
9.2.2 Windows-Nachrichten und die Klasse Application
Durch die von Windows registrierten Benutzeraktivitäten (z.B. Mausklicks, Tastenschläge) und
sonstige Ursachen entstehen Ereignisse (im Sinne des Betriebssystems), die zu Nachrichten an
betroffene Anwendungen führen. Wird z.B. ein Fenster vom Benutzer aus der Taskleiste zurückgeholt, dann fordert Windows die Anwendung mit der WM_PAINT-Nachricht auf, den Klientenbereich des Fensters neu zu zeichnen.
Um diese laufend eintreffenden und in eine Warteschlange eingereihten Nachrichten kümmert sich
bei einer WinForms-Anwendung eine per Application.Run() gestartete Routine des Laufzeitsystems in einer while-Schleife. Hat der Programmierer zu einer Nachricht eine Behandlungsmethode
erstellt und zugeordnet (siehe unten), so wird diese aufgerufen. Man kann ein GUI-Programm als
Ansammlung von Behandlungsmethoden auffassen, die beim Eintreffen einer passenden Nachricht
aufgerufen werden. Solange eine Behandlungsmethode läuft, kann keine weitere gestartet werden.
Zu manchen Nachrichten werden von Windows oder von der CLR (Common Language Runtime)
ohne Zutun des Programmierers Behandlungsroutinen bereitgestellt. So kann unser Beispielprogramm z.B. auf die Standardschaltflächen in der Titelzeile (zum Maximieren, Maximieren oder
Schließen) reagieren, ohne dass wir dazu eine Zeile Quellcode geschrieben haben.
Die folgende Abbildung zeigt einige Details zum Nachrichtenverkehr zwischen dem Betriebssystem, der von Application.Run() initiierten Nachrichtenbehandlungsschleife und den Ereignisbehandlungsmethoden des Programms:
Abschnitt 9.2 Elementare Klassen im Windows Forms - Framework
275
Betriebssystem
Warteschlange mit
Windows-Nachrichten
für das Programm
Programm
Ereignisse
(z.B. Mausklicks,
Tastenschläge)
z.B.
WM_QUIT
Von Application.Run() erzeugte
Nachrichtenbehandlungsschleife
und
CLR
Ereignisbehandlungsmethode
Ereignisbehandlungsmethode
Wird die Nachricht WM_QUIT aus der Warteschlange gefischt, endet die Nachrichtenbehandlungsschleife, und der Run()-Aufruf kehrt zurück. Oft wird die Nachricht WM_QUIT von einer
(automatisch eingerichteten) Ereignisroutine zum Hauptfenster abgeschickt, das so auf den Befehl
des Benutzers zum Schließen des Fensters reagiert. Man kann die Nachricht aber auch im Programm durch Aufruf der Methode Application.Exit() generieren. Sind beim Eintreffen der
WM_QUIT – Nachricht noch Fenster einer Anwendung offen, so werden diese allesamt geschlossen. Beim Einsatz des folgenden Beispielprogramms
using System;
using System.Windows.Forms;
class F1F2 : Form {
F1F2(String titel) {
Text = titel;
}
static void Main() {
F1F2 p1 = new F1F2("F1");
F1F2 p2 = new F1F2("F2");
p2.Show();
Application.Run(p1);
}
}
lässt sich das mit F2 beschriftete Fenster separat beenden, während beim Schließen des mit F1 betitelten Hauptfensters das Programm endet und damit auch das zweite Fenster verschwindet.
Kapitel 9: Einstieg in die WinForms - Programmierung
276
Fassen wir leicht vereinfachend zusammen, welche Bedeutung der Methodenaufruf Application.Run() in einer WinForms-Anwendung hat:



Das Hauptfenster wird geöffnet.
Das Programm 1 wird um eine Nachrichtenschleife erweitert, welche regelmäßig die von
Windows für das Programm verwaltete Nachrichtenwarteschlange inspiziert und auf Ereignisse mit dem Aufruf der zuständigen Methode reagiert.
Beim Eintreffen der Nachricht WM_QUIT enden die Nachrichtenschleife und die Run()Methode, wobei auch alle Fenster der Anwendung geschlossen werden. In der Regel stellt
eine GUI-Anwendung nach der Rückkehr des Run()-Aufrufs ihre Tätigkeit ein (siehe Beispiel). Ein erneuter Aufruf der Run()-Methode ist nicht möglich.
Weil das Windows-API (Application Programming Interface) durch das .NET – Framework gekapselt wird, muss sich ein C# - Programmierer nicht direkt um Windows-Nachrichten kümmern, sondern kann die von vielen Klassen präsentierten Ereignisse im .NET – Sinn (siehe unten) durch eigene Methoden behandeln. Z.B. stellt die Klasse Application zur Windows-Nachricht WM_QUIT
das Ereignis ApplicationExit zur Verfügung, bei dem .NET – Programmierer eine eigene Methode
per Delegatenobjekt (siehe unten) registrieren können, wenn sie auf das Ereignis (bzw. auf die
zugrunde liegende Windows-Nachricht) regieren möchten. Eine registrierte Methode wird automatisch aufgerufen, wenn das zugehörige Ereignis eintritt. Wir werden uns gleich näher mit den Ereignissen im Sinne des .NET – Frameworks beschäftigen, wobei es sich um spezielle Member von
Klassen oder Strukturen handelt. Insgesamt kann man unterscheiden (vgl. Louis & Strasser 2002, S.
614):
Ereignisse auf
der Ebene des
Betriebssystems,
z.B. Mausklicks,
Tastenschläge
WindowsNachrichten, z.B.
WM_PAINT,
WM_QUIT
Ereignisse als
Member von
.NET - Typen
9.3 Delegaten und Ereignisse
9.3.1 Delegaten
In diesem Abschnitt lernen Sie die Delegatentypen kennen, die zunächst etwas abstrakt wirken, aber
speziell im Kontext der ereignisorientierten Programmierung unverzichtbar sind. Es handelt sich um
Klassen, deren Objekte auf Methoden mit einer bestimmten Signatur (definiert durch Rückgabetyp
und Formalparameterliste) zeigen. Dazu enthalten Delegatenobjekte eine (ein- oder mehrelementige) Aufrufliste mit kompatiblen Methoden. Diese Liste kann Instanz- und Klassenmethoden enthalten.
1
Genau genommen ist für jeden Thread (vgl. Abschnitt 13), der ein Fenster auf dem Bildschirm präsentiert, eine
Nachrichtenwarteschlange und dementsprechend eine Nachrichtenschleife erforderlich.
277
Abschnitt 9.3 Delegaten und Ereignisse
Über ein Delegatenobjekt lassen sich mit einem Aufruf alle Methoden seiner Aufrufliste nacheinander starten, wobei die zuletzt ausgeführte Methode ggf. den Rückgabewert des Aufrufs liefert 1.
Vielleicht helfen die folgenden Begriffserläuterungen, Missverständnisse zu vermeiden:
Ein Delegatentyp besitzt:


einen Namen, z.B.
DelType
eine Signatur, z.B.
delegate void DelType(double d, int i);
Ein Delegatenobjekt besitzt:


einen Typ, z.B.
DelType
eine veränderbare Aufrufliste mit kompatiblem Instanz- und Klassenmethoden
Ein Delegatenvariable besitzt:


einen Typ, z.B.
DelType
einen Wert, z.B.
null oder
die Adresse eines DelType-Delegatenobjekts
Alle Delegatentypen stammen implizit von der Klasse MulticastDelegate im Namensraum System
ab:
System.Object
System.Delegate
System.MulticastDelegate
Wir verwenden Delegaten später hauptsächlich in GUI-Programmen, erarbeiten uns die neuen Begriffe aber aus didaktischen Gründen im Rahmen einer Konsolenanwendung.
9.3.1.1 Delegatentypen definieren
Mit der folgenden Anweisung wird nach dem einleitenden Schlüsselwort delegate der Delegatentyp
DemoGate definiert:
delegate void DemoGate(int w);
Über Objekte dieses Typs können Instanz- oder Klassenmethoden mit dem Rückgabetyp void und
einem einzigen Wertparameter vom Typ int aufgerufen werden. Eine Inspektion mit dem Windows-SDK - Hilfsprogramm ILDasm zeigt, dass die Klasse DemoGate aufgrund der obigen Definition u.a. einen Instanzkonstruktor und die Instanzmethode Invoke() besitzt:
1
Somit bietet C# mit den Delegaten eine objektorientierte Variante der Funktions-Pointer aus anderen Programmiersprachen (z.B. C++, Pascal, Modula).
Kapitel 9: Einstieg in die WinForms - Programmierung
278
Das leicht vereinfachte Syntaxdiagramm zur Definition eines Delegatentyps:
Delegatendefinition
delegate
Rückgabetyp
Name
(
Parameterdeklaration
)
;
Modifikator
,
9.3.1.2 Delegatenobjekte erzeugen und aufrufen
In diesem Abschnitt wird an einem möglichst einfachen Beispiel demonstriert, …



wie der Aufruf einer Methode,
welche die Signatur eines bestimmten Delegatentyps besitzt,
über ein Objekt dieses Delegatentyps erfolgen kann.
Die folgende Klasse
class DeleDemo {
static void SagA(int w){
for (int i = 1; i <= w; i++)
Console.Write('A');
Console.WriteLine();
}
static void Main() {
DemoGate DemoVar = new DemoGate(SagA);
DemoVar(3);
}
}
besitzt zwei statische Methoden:

Die Methode SagA() schreibt eine per Parameter wählbare Anzahl von A’s auf die Konsole. Sie erfüllt den Delegatentyp DemoGate (definiert in Abschnitt 9.3.1.1).

In der Main()-Methode wird die lokale Referenzvariable DemoVar vom Delegatentyp
DemoGate deklariert und initialisiert. Über den implizit definierten DemoGateKonstruktor wird ein Objekt des Delegatentyps erzeugt, das auf die Methode SagA() zeigt.
Die Adresse dieses Objekts landet in der Referenzvariablen:
DemoGate DemoVar = new DemoGate(SagA)
Einen parameterfreien DemoGate-Konstruktor gibt es übrigens nicht.

In der Aufrufliste des über die Referenzvariable DemoVar ansprechbaren DemoGateObjekts befindet sich ausschließlich die statische Methode SagA(). Ein Aufruf der Delegatenobjekts bewirkt daher die Ausgabe:
AAA
279
Abschnitt 9.3 Delegaten und Ereignisse
Natürlich unterstützen Delegatenobjekte nicht nur statische Methoden. Dem implizit definierten
Konstruktor einer Delegatenklasse übergibt man als einzigen Parameter den Namen einer statischen
Methode (nötigenfalls mit vorangestelltem Klasssennamen) oder den Namen einer Instanzmethode
(nötigenfalls mit vorangestellter Objektreferenz):
Delegatenkonstruktion
Klasse.Methode
new
Delegatentyp
(
)
Objekt.Methode
Dabei wird an den Methodennamen keine Parameterliste angehängt.
Eine Assembly-Inspektion mit dem Windows-SDK - Hilfsprogramm ILDasm zeigt, dass die Anweisungen
DemoVar = new DemoGate(SagA);
DemoVar(3);
in der DeleDemo-Methode Main()
folgendes bewirken



Es wird ein DemoGate-Objekt erstellt.
Dessen Adresse landet in der Variablen DemoVar.
Das Objekt wird beauftragt, die DemoGate-Methode Invoke() auszuführen.
An Stelle der expliziten Delegatenobjektkreation
DemoGate DemoVar = new DemoGate(SagA);
erlaubt der Compiler die Abkürzung:
DemoGate DemoVar = SagA;
9.3.1.3 Delegatenobjekte kombinieren
Nach dem Motto „Wer A sagt, muss auch B sagen“ erweitern wir die Klasse DeleDemo um die
Methode SagB():
static void SagB(int w){
for (int i = 1; i <= w; i++)
Console.Write('B');
Console.WriteLine();
}
In der Main()-Methode ergänzen wir die Anweisung:
DemoVar += new DemoGate(SagB);
Kapitel 9: Einstieg in die WinForms - Programmierung
280
Es entsteht zunächst ein weiteres DemoGate-Objekt mit der statischen Methode SagB() in seiner
einelementigen Aufrufliste. Dieses Objekt wird anschließend per „+=“ - Operator mit dem von
DemoVar referenzierten Objekt kombiniert, wobei ein weiteres DemoGate-Delegatenobjekt mit
einer zweielementigen Aufrufliste entsteht, dessen Adresse schließlich in der Referenzvariablen
DemoVar landet. Die beiden Delegatenobjekte mit einelementiger Aufrufliste werden zu obsoletem Müll (vgl. ECMA 2006, S. 365). Delegatenobjekte sind ebenso unveränderlich wie z.B. die
Objekte der Klasse String (vgl. Abschnitt 5.4.1.1).
Beim Aufruf des neuen Delegatenobjektes werden nacheinander zwei Methoden ausgeführt:
AAA
BBB
Über den „-=“ - Operator kann man ein Delegatenobjekt mit verkürzter Aufrufliste erzeugen, z.B.:
DemoVar -= SagB;
Beim kompletten Entleeren der Aufrufliste entsteht aber kein leeres Delegatenobjekt, sondern die
Referenzvariable erhält den Wert null.
Neben den Aktualisierungsoperatoren += und -= kann man auch den Additions- und den Subtraktionsoperator verwenden, z.B.:
DemoVar = DemoVar + new DemoGate(SagB);
9.3.1.4 Anonyme Methoden
Statt beim Erzeugen eines Delegatenobjekts den Namen einer vorhandenen, andernorts definierten,
Methode zu übergeben, kann man seit C# 2.0 auch einen Anweisungsblock setzen, dem das Schlüsselwort delegate samt Delegatentyp-konformer Parameterliste vorangeht.
Anonyme Methode
delegate
Parameterliste
Anweisungsblock
Dies wird in der folgenden Variante des Beispiels aus Abschnitt 9.3.1.2 demonstriert:
Quellcode
Ausgabe
using System;
AAA
delegate void DemoGate(int w);
class AnoMeth {
static void Main() {
DemoGate DemoVar =
delegate(int w) {
for (int i = 1; i <= w; i++)
Console.Write('A');
Console.WriteLine();
};
DemoVar(3);
}
}
Man kann die gewünschte Funktionalität vor Ort realisieren und darf außerdem auf lokale Variablen
und Wertparameter der umgebenden Methode zugreifen, was in der folgenden Beispielvariante mit
der Main()-Variablen c passiert:
281
Abschnitt 9.3 Delegaten und Ereignisse
Quellcode
Ausgabe
using System;
delegate void DemoGate(int w);
class AnoMeth {
static void Main() {
char c = 'A';
DemoGate DemoVar =
AAA
delegate(int w) {
for (int i = 1; i <= w; i++)
Console.Write(c);
Console.WriteLine();
};
DemoVar(3);
}
}
Ein Nachteil anonymer Methoden besteht darin, dass sie im Unterschied zu benannten nicht an anderen Stellen benutzt werden können.
In C# 3.0 wurde zur Unterstützung der LINQ-Technik (siehe Kapitel 27) mit den so genannten
Lambda-Ausdrücken eine zu den anonymen Methoden funktional äquivalente und dabei deutlich
flexiblere Syntax eingeführt, die von Microsoft (2007, Abschnitt 7.14) nachdrücklich bevorzugt
wird.
9.3.1.5 Generische Delegaten
Neben generischen Klassen, Strukturen, Schnittstellen und Methoden (siehe Abschnitt 7) unterstützt
C# auch generische Delegaten. Das folgende Beispiel aus der FCL
public delegate int Comparison<T> (T x, T y);
kommt in einer Sort()-Überladung der generischen Kollektionsklasse List<T> als Parameterdatentyp zum Einsatz. Über ein Comparison<String> - Objekt, das auf eine geeignet konstruierte
String-Vergleichsmethode zeigt, wird im folgenden Beispiel dafür gesorgt, dass in einer sortierten
Namensliste „Anton“ stets der Größte ist:
Quellcode
Ausgabe
using System;
using System.Collections.Generic;
class Prog {
static int Compar(String a, String b) {
if (a.Equals("Anton"))
return 1;
else
if (b.Equals("Anton"))
return -1;
else
return a.CompareTo(b);
}
static void Main() {
List<String> li = new List<String>();
li.Add("Fritz"); li.Add("Anton");
li.Add("Anita"); li.Add("Theo");
li.Sort(new Comparison<String>(Compar));
foreach (String s in li)
Console.WriteLine(s);
Console.Read();
}
}
Anita
Fritz
Theo
Anton
Kapitel 9: Einstieg in die WinForms - Programmierung
282
An Stelle der expliziten Delegatenobjektkreation
li.Sort(new Comparison<String>(Compar));
erlaubt der Compiler die Abkürzung:
li.Sort(Compar);
9.3.2 Ereignisse
Möchte eine .NET – Klasse anderen Programmeinheiten die Möglichkeit geben, zu besonderen Gelegenheiten eine Botschaft, d.h. einen bestimmten Methodenaufruf, zu erhalten, dann bietet sie ein
Ereignis (engl. event) an. Dabei handelt es sich im Wesentlichen um eine Delegatenvariable, die
unter bestimmten Umständen aufgerufen wird.
Ereignisse stellen im .NET – Framework ein wichtiges Kommunikationsmittel dar. Sie ermöglichen
es einer Klasse oder einem Objekt, andere Akteure darüber zu informieren, dass etwas Bestimmtes
passiert ist. So kann z.B. das Objekt zu einem Befehlsschalter registrierte Interessenten darüber informieren, dass der Benutzer den Schalter ausgelöst (angeklickt) hat. Das zugehörige Instanzereignis der Klasse System.Windows.Forms.Button (siehe unten) heißt Click.
Obwohl Ereignisse in der Regel als public deklariert werden (siehe Abschnitt 9.3.2.2), resultiert in
der veröffentlichenden Klasse stets eine private Delegatenvariable, der also fremde Methoden z.B.
nicht den Wert null zuweisen können. Der Compiler ergänzt jedoch öffentliche Zugriffsmethoden
zum Erweitern und Verkürzen der Aufrufliste durch fremde Klassen (siehe Beispiel in Abschnitt
9.3.2.2). Diese sind über die Aktualisierungsoperatoren mit dem Ereignisnamen als linkem Argument zu verwenden:
+=
-=
nimmt eine Behandlungsmethode in die Aufrufliste des zum Ereignis gehörigen Delegatenobjekts auf
entfernt eine Behandlungsmethode aus der Aufrufliste des zum Ereignis gehörigen Delegatenobjekts
Bei sinnvollen Zugriffen kann ein Ereignis also von fremden Klassen wie eine öffentliche Delegatenvariable behandelt werden. Im Unterschied zu Delegatenvariablen dürfen Ereignisse (auch bei
public-Deklaration) in klassenfremden Methoden aber ausschließlich als linkes Argument des „=“– oder „+=“ – Operators auftreten. Dies ist keine Einschränkung, sondern eine sinnvolle Sicherheitsmaßnahme.
Die in Abschnitt 9.2.2 vorgestellte Klasse Application bietet z.B. mit dem Ereignis ApplicationExit anderen Klassen die Möglichkeit, sich über das bevorstehende Programmende informieren zu
lassen. In der FCL-Dokumentation sind die (allesamt statischen) Ereignisse der Klasse Application
beschrieben:
Abschnitt 9.3 Delegaten und Ereignisse
283
9.3.2.1 Behandlungsmethoden registrieren
Um auf ein Ereignis einer .NET - Klasse reagieren zu können, …



implementiert man eine Methode, deren Signatur mit dem Delegatentyp des Ereignisses
kompatibel ist,
erzeugt man per new-Operator ein Delegatenobjekt, das auf diese Methode zeigt,
weist man dem Ereignis dieses Delegatenobjekt per „+=“ - Operator zu.
In Abschnitt 9.3.1.3 wurde im Detail beschrieben, wie bei einer solchen Zuweisung die Aufrufliste des zum Ereignis gehörigen Delegatenobjekts erweitert wird.
In der folgenden Variante des GUI-Einstiegsbeispiels aus Abschnitt 9.2 wird die Methode
ApplicationOnExit() implementiert und beim Ereignis ApplicationExit der Klasse Application registriert, um auf das bevorstehende Programmende reagieren zu können:
using System;
using System.Windows.Forms;
class ApplicationExitDemo : Form {
ApplicationExitDemo() {
Text = "ApplicationExit-Ereignisbehandlung";
Width = 340;
Application.ApplicationExit += new EventHandler(ApplicationOnExit);
}
void ApplicationOnExit(object sender, EventArgs e) {
MessageBox.Show("Vielen Dank für den Einsatz dieser Software!",
"WinForms-Einstieg");
}
static void Main() {
Application.Run(new ApplicationExitDemo());
}
}
Zu welchem Delegatentyp eine Ereignisbehandlungsmethode kompatibel sein muss, erfährt man in
der FCL-Dokumentation. Beim Ereignis ApplicationExit handelt es sich um den Typ EventHandler aus dem Namensraum System:
Kapitel 9: Einstieg in die WinForms - Programmierung
284
Nach einem Mausklick auf den Delegatentyp EventHandler ist bekannt, welchen Rückgabetyp und
welche Parameter eine kompatible Methode benötigt, z.B.:
Die EventHandler-Signatur verlangt von kompatiblen Behandlungsmethoden zwei Parameter:


Mit dem Objekt-Parameter sender wird die Ereignisquelle identifiziert.
Es ist oft sinnvoll, eine Behandlungsmethode bei mehreren Ereignisquellen anzumelden,
z.B. bei mehreren Befehlsschaltern. Weil der Methode beim Aufruf die Ereignisquelle im
Parameter sender mitgeteilt wird, kann sie situationsgerecht reagieren. Den beim Ereignis
Application.ApplicationExit registrierten Methoden wird allerdings der irrelevante senderWert null übergeben. Trotzdem muss natürlich in der Definition einer ApplicationExitBehandlungsmethode der erste Parameter vom Typ Object sein.
Behandlungsmethoden erhalten im Allgemeinen über den zweiten Parameter nähere Informationen zum Ereignis. Dabei wird meist ein Objekt aus der Klasse System.EventArgs
oder aus einer abgeleiteten Klasse verwendet. Weil die Ereignisbeschreibungs-Basisklasse
EventArgs keinerlei Instanz-Member enthält, sind ihre Objekte ziemlich informationsarm.
Genau ein solches Dummy-Informationsobjekt aus der Klasse EventArgs wird den beim
Abschnitt 9.3 Delegaten und Ereignisse
285
Ereignis Application.ApplicationExit registrierten Methoden beim Aufruf übergeben.
Trotzdem muss in der Definition einer ApplicationExit-Behandlungsmethode der zweite
Parameter vom Typ EventArgs sein.
Die FCL-Designer haben auch beim Ereignis ApplicationExit, wo weder eine Absenderangabe
noch eine Ereignisbeschreibung erforderlich ist, den Delegatentyp EventHandler gewählt. Sie halten sich damit konsequent an das von Microsoft vorgeschlagene Entwurfsmuster zur Ereignisbehandlung.
Im frei wählbaren Namen einer Behandlungsmethode nennt man in der Regel die Quelle (im Beispiel: Klasse Application) und das Ereignis (im Beispiel abgekürzt: Exit), häufig mit dem Verbindungswort On.
In der folgenden Anweisung wird ein EventHandler-Delegatenobjekt erzeugt, das auf die Methode
ApplicationOnExit() zeigt:
Application.ApplicationExit += new EventHandler(this.ApplicationOnExit);
Weil es sich um eine Instanzmethode handelt, ist prinzipiell anzugeben, welches Objekt die Methode ausführen soll. Im Beispiel ist das gerade im Entstehen begriffene ApplicationExitDemo-Objekt
gemeint, so dass die Objektreferenz entfallen oder mit dem Schlüsselwort this ausgedrückt werden
kann.
Statt den beteiligten Delegatenkonstruktor explizit zu notieren, kann man die folgende Abkürzung
verwenden:
Application.ApplicationExit += ApplicationOnExit;
Das neue EventHandler–Delegatenobjekt wird per „+=“ – Operator beim Ereignis registriert, wobei ggf. eine vorhandene Aufrufliste verlängert wird (vgl. Abschnitt 9.3.1.3). Über den „-=“ – Operator lässt sich eine Behandlungsmethode aus der Aufrufliste zu einem Ereignis entfernen.
Im Beispiel kommt die Methode ApplicationOnExit() z.B. zum Einsatz,
wenn der Benutzer auf das Schließkreuz in der Titelzeile des Anwendungsfensters klickt (vgl. Abschnitt 9.2.2). Dann wird nach einigen Zwischenschritten in einer Methode der Klasse Application
das Ereignis ApplicationExit aufgerufen: 1
if (Application.ApplicationExit != null)
Application.ApplicationExit(null, EventArgs.Empty);
Es ist übrigens bemerkenswert, dass die private Methode ApplicationOnExit() der Klasse
ApplicationExitDemo durch eine Methode der fremden Klasse Application aufgerufen werden kann. Dies ist aber keine Aufweichung der strengen .NET- Zugriffskontrolle, weil die Registrierung
Application.ApplicationExit += ApplicationOnExit;
durch die berechtigte (klasseneigene) Main()-Methode vorgenommen wurde.
1
Der Quellcode stammt aus dem Mono-Projekt.
286
Kapitel 9: Einstieg in die WinForms - Programmierung
9.3.2.2 Ereignisse anbieten
Das Registrieren eigener Behandlungsmethoden bei Ereignissen von FCL-Klassen ist für uns erheblich relevanter als das Anbieten von eigenen Ereignissen. Trotzdem erstellen wir ein entsprechendes
Beispiel, um einen besseren Einblick in die Technik zu gewinnen. Objekte der folgenden Klasse
EventProd, die aus der Klasse Button für Befehlsschalter abgeleitet ist, ermitteln nach einem
Klick auf ihre Schaltfläche zehn ganze Zufallszahlen aus der Menge {0, 1, .., 9}. Bei jedem Auftreten der Zahl Sieben wird das Ereignis Seven ausgelöst. Weil zur Demonstration von ereignisorientierter Kommunikation ein GUI-Programm eindeutig am besten geeignet ist, wird ein Vorgriff auf
den Abschnitt 9.4 über Befehlsschalter und sonstige Steuerelemente in Kauf genommen:
class EventProd : Button {
public event MyDelegate Seven;
Random zzg = new Random();
protected override void OnClick(EventArgs e) {
base.OnClick(e);
for (int i = 1; i <= 10; i++) {
if (zzg.Next(10) == 7 && Seven != null) {
MyEventArgs mea = new MyEventArgs();
mea.Pos = i;
Seven(this, mea);
break;
}
}
}
}
Warum die überschreibende Methode OnClick() bei einem Mausklick auf den Befehlsschalter aufgerufen wird, erfahren Sie später.
Im Unterschied zu einer Variablendeklaration ist bei Ereignissen das Schlüsselwort event anzugeben:
public event MyDelegate Seven;
Als Datentyp kommt nur ein Delegat in Frage, der im Beispiel folgendermaßen definiert wird:
delegate void MyDelegate(object sender, MyEventArgs e);
Zur Ereignisbeschreibung dient hier die Klasse MyEventArgs, die von EventArgs abgeleitet
wird:
class MyEventArgs : EventArgs {
public int Pos;
}
Man darf ein Ereignis nur dann auslösen, wenn tatsächlich ein Delegatenobjekt vorhanden ist: 1
if (zzg.Next(10) == 7 && Seven != null) {
. . .
Seven(null, mea);
}
Das Delegatenobjekt zu einem Ereignis entsteht beim Registrieren der ersten Behandlungsmethode
und verschwindet ggf. beim Entleeren seiner Aufrufliste.
1
Bei einer Multi Thread - Anwendung (siehe Abschnitt 13) sollte man sogar durch eine geeignete Synchronisation
verhindern, dass ein Delegatenobjekt zwischen Existenzprüfung und Aufruf durch einen anderen Thread verworfen
wird, z.B. mit der Anweisung:
Seven = null;
Abschnitt 9.3 Delegaten und Ereignisse
287
Die folgende WinForms-Anwendung enthält einen Schalter aus der Klasse EventProd auf Ihrem
Formular:
class EventConsumer : Form {
EventConsumer() {
Height = 75; Width = 266;
Text = "Schalter sendet Ereignisse";
EventProd ep = new EventProd();
ep.Text = "Teste Dein Glück!";
ep.Top = 10;
ep.Left = 30;
ep.Width = 200;
Controls.Add(ep);
ep.Seven += new MyDelegate(this.EventProdOnSeven);
}
void EventProdOnSeven(object sender, MyEventArgs e) {
MessageBox.Show("7 an Position " + e.Pos + " gezogen",
"Ereignis-Verarbeitung");
}
static void Main() {
Application.Run(new EventConsumer());
}
}
Um auf das Ereignis Seven reagieren zu können, implementiert die Klasse EventConsumer
eine Methode mit kompatibler Signatur und registriert diese beim Ereignis:
ep.Seven += new SimpleDelegate(this.EventProdOnSeven);
Bei seiner Reaktion auf die Benachrichtigung wertet der Konsument auch die Ereignisbeschreibung
im MyEventArgs–Objekt aus, das der Methode EventProdOnSeven() beim Aufruf übergeben wird, z.B.:
Eine Assembly-Inspektion mit dem Windows-SDK - Hilfsprogramm ILDasm bestätigt die zu Beginn von Abschnitt 9.3.2 formulierte Bemerkung über die Ereignis-Innenarchitektur. In der Klasse
EventProd befinden sich aufgrund der Seven-Ereignisdefinition neben einem privaten Feld vom
Typ MyDelegate zwei öffentliche Methoden zur Veränderung der Ereignisaufrufliste
(add_Seven() und remove_Seven()):
Kapitel 9: Einstieg in die WinForms - Programmierung
288
9.4 Steuerelemente
Das WinForms-Framework bietet zahlreiche Klassen zur Realisation von Steuerelementen (Schaltflächen, Textfeldern, Kontrollkästchen, Listen etc.) an, so dass sich für zahllose Programmieraufgaben auf recht bequeme Weise ergonomische und attraktive Bedienoberflächen erstellen lassen. Als
Besonderheiten dieser Klassen (z.B. im Vergleich zu Klassen wie String oder Math) sind zu nennen:



Ihre Objekte können als (Kind-)fenster auf dem Bildschirm auftreten und dabei selbständig mit dem Benutzer interagieren. Wenn wir z.B. ein Kontrollkästchen in ein Formular
einbauen, erscheint bzw. verschwindet bei einem Mausklick des Benutzers die Markierung,
ohne dass wir uns um diese Anpassung der Optik kümmern müssten.
Die Steuerelemente kommunizieren über Ereignisse (im Sinn von Abschnitt 9.3.2) mit anderen Klassen. Will man z.B. über den Markierungswechsel bei einem Kontrollkästchen informiert werden, registriert man eine Behandlungsmethode bei seinem CheckedChanged–
Ereignis.
Ihre Eigenschaften (z.B. Text, Height, Left, Font, TextAlign) können zur Entwurfszeit
über Werzeuge der Entwicklungsumgebungen konfiguriert werden (siehe z.B. Abschnitt
4.10.4). Wir machen aus didaktischen Gründen von dieser Option zunächst nur wenig
Gebrauch. In der Alltagsroutine trägt sie aber zur Produktivität der Software-Entwicklung
bei.
In diesem Abschnitt werden einfache Steuerelemente vorgestellt und wichtige Fragen zur Kooperation zwischen einem Formular und den dort als Kindfenster enthaltenen Steuerelementen behandelt.
In späteren Abschnitten werden zahlreiche weitere elementare oder komplexe Steuerelemente vorgestellt.
Neben der Hauptfenster-Basisklasse Form stammen auch zahlreiche Klassen für Steuerelemente
von der Klasse Control im Namensraum System.Windows.Forms ab, z.B.:
Control
Label
ScrollableControl
ButtonBase
TextBoxBase
Button
TextBox
ContainerControl
Form
Bei den für Steuerelemente häufig erforderlichen Positionsangaben in Bildschirmpunkten (Pixeln)
liegt das folgende Koordinatensystem mit dem Ursprung (Nullpunkt) in der linken oberen Ecke des
Formular-Klientenbereichs zugrunde:
289
Abschnitt 9.4 Steuerelemente
(0, 0)
X
Y
Wird ein Steuerelement allerdings nicht direkt in ein Formular, sondern in einen untergeordneten
Container eingefügt, dann beziehen sich die Positionskoordinaten auf den Klientenbereich des Containers.
9.4.1 Einsatz von Steuerelementen am Beispiel des Labels
Wir betrachten zunächst das Label-Steuerelement zur Anzeige von Texten und besprechen anhand
eines einfachen Beispielprogramms,
wie man …



ein Steuerelement erzeugt und konfiguriert,
in ein Formular (allgemein: in den übergeordneten Container) aufnimmt
und mit einer Behandlungsmethode auf Ereignisse eines Steuerelements regiert.
Dazu wird die aus Form abgeleitete Klasse LabelDemo
class LabelDemo : Form {
. . .
}
sukzessiv vorgestellt.
Wie bei den meisten WinForms-Anwendungen empfiehlt sich der Import der folgenden Namensräume:
using System;
using System.Windows.Forms;
using System.Drawing;
Dementsprechend benötigen wir Verweise auf die folgenden Assemblies:
Kapitel 9: Einstieg in die WinForms - Programmierung
290
9.4.1.1 Steuerelemente erzeugen und konfigurieren
Im Konstruktor der Formularklasse
LabelDemo() {
Size = new Size(400, 200);
BackColor = Color.LightGray;
Text = "Label-Demo";
Label info = new Label();
info.Text = "Demo-Label";
info.Size = new Size(120, 30);
info.Left = (ClientSize.Width-info.Width)/2;
info.Top = (ClientSize.Height-info.Height)/2;
info.Font = new Font("Times New Roman", 15);
info.TextAlign = ContentAlignment.MiddleCenter;
info.BorderStyle = BorderStyle.FixedSingle;
. . .
}
wird nach Anpassung der Form-Eigenschaften Height, Width, BackColor und Text eine LabelInstanzvariable deklariert und ein Label-Objekt erzeugt
Label info = new Label();
Anschließend werden Eigenschaften des Label-Objekts modifiziert:


Mit der Eigenschaft Text legt man die Beschriftung des Labels fest. Während Benutzer diese Beschriftung nicht direkt beeinflussen können, sind einer Veränderung durch das Programm wenig Grenzen gesetzt.
Eine der wenigen Grenzen für die Wahl einer (sinnvollen) Label-Beschriftung resultiert aus
der aktuellen Größe des Steuerelements. Man kann sie per Size-Eigenschaft (vom Typ
System.Drawing.Size, vgl. Abschnitt 15.4.2)
info.Size = new Size(120, 30);
oder (bei minimal höherem Schreibaufwand) über die Eigenschaften Width und Height
(vom Typ int)
info.Width = 120;
info.Height = 30;



verändern. Alle genannten Eigenschaften sind schon in der Basisklasse Control definiert.
Sind Breite und Höhe eines Steuerelements zu setzen, werden wir meist eine Instanz der
Size-Struktur verwenden und so die zweifache Nennung des Referenzvariablennamens vermeiden.
Mit den Control-Eigenschaften Left bzw. Top spricht man die Position der linken bzw.
oberen Grenze eines Steuerelements bezogen auf den Klientenbereich des umgebenden Containers an. Im Beispiel werden diese Eigenschaften so aus der Größe des FormularKlientenbereichs abgeleitet, dass ein horizontal und vertikal zentriertes Label resultiert.
Steuerelemente übernehmen die Vorder- und Hintergrundfarbe sowie die Schriftart des umgebenden Containers.
Um für ein Steuerelement eine alternative Schrift zu wählen, übergibt man seiner FontEigenschaft eine Referenz auf ein (nötigenfalls neu zu erzeugendes) Font-Objekt, z.B.:
info.Font = new Font("Times New Roman", 15);
Wir werden uns später noch ausführlich mit Schriftarten beschäftigen.
Abschnitt 9.4 Steuerelemente


291
Über die Eigenschaft TextAlign bestimmt man die Textposition innerhalb der rechteckigen
Label-Fläche, wobei die Werte des Aufzählungstyps System.Drawing.ContentAlignment
zu verwenden sind.
Ist ein Rahmen gewünscht, steht die Label-Eigenschaft BorderStyle bereit, wobei die Werte des Aufzählungstyps System.Windows.Forms.BorderStyle zu verwenden sind. Weil
Labels üblicherweise zur Beschriftung anderer Steuerelemente dienen, haben sie per Voreinstellung keinen Rahmen.
Eine Formularklasse benötigt zu einem enthaltenen Steuerelement nur dann eine Instanzreferenzvariable, wenn das Steuerelement auch außerhalb des Konstruktors (also in anderen Methoden) angesprochen werden soll. Im gerade entstehenden Demonstrationsprogramm mit Label-Steuerelement
ist dies nicht der Fall, so dass im Fensterkonstruktor eine lokale Referenzvariable erzeugt wird:
class LabelDemo : Form {
LabelDemo() {
Label info = new Label();
. . .
}
. . .
}
In der alternativen Situation wäre z.B. die folgende Konstruktion angemessen:
class LabelDemo : Form {
Label info;
LabelDemo() {
info = new Label();
. . .
}
. . .
}
Eine überflüssige Instanzvariable erhöht das Risiko eines Programmierfehlers (z.B. durch eine ungeplante Steuerelement-Modifikation per Tippfehler).
9.4.1.2 Ab in den Container
Die Objekte der von Control abstammenden Klassen können Steuerelemente als Kindfenster aufnehmen, wobei jedoch erst in der Klasse ContainerControl die Fähigkeit zur Fokusverwaltung für
untergeordnete Steuerelemente vorhanden ist. Ein Anwendungsfenster taugt als Form-Objekt in
jedem Fall als funktionstüchtiger Container.
Die in einem Container enthaltenen Steuerelemente werden über eine Liste verwaltet, welche über
die Controls-Eigenschaft des Containers anzusprechen ist. Es handelt sich um ein Objekt der Klasse ControlCollection, die innerhalb der Klasse Control definiert ist. Dieses Objekt beherrscht diverse Methoden zur Aufnahme oder zum Entfernen von Steuerelementen und erlaubt auch einen
Indexzugriff auf die Elemente.
Mit der Methode Add() kann ein Steuerelement in die Liste aufgenommen werden, z.B.:
LabelDemo() {
. . .
Label info = new Label();
. . .
Controls.Add(info);
. . .
}
Ein äquivalentes Aufnahmeverfahren besteht darin, eine Referenz auf den umgebenden Container in
die Parent-Eigenschaft des Steuerelements zu schreiben, z.B.:
Kapitel 9: Einstieg in die WinForms - Programmierung
292
info.Parent = this;
Alle aufgenommenen Steuerelemente erscheinen im Klientenbereich des elterlichen Containers
(Gesamtfläche ohne Rahmen, Titelzeile etc.), ihre Positionseigenschaften beziehen sich auf diese
Fläche, und darüber hinaus ragende Steuerelementteile werden abgeschnitten.
Ein Steuerelement verwendet nicht nur die Klientenfläche des übergeordneten Containers, sondern
übernimmt auch einige Eigenschaften (z.B. Font, BackColor und ForeColor), wobei diese Voreinstellungen natürlich durch individuelle Werte ersetzt werden können.
Ist die Neuanzeige eines Containers erforderlich, werden entsprechende Nachrichten automatisch an
die Kindfenster propagiert. Weil die Steuerelemente aus der .NET – Standardbibliothek mit passenden Ereignisbehandlungsmethoden ausgestattetet sind, müssen wir uns bei den im Abschnitt 9 vorgestellten GUI-Anwendungen um das außerordentlich wichtige und häufige Paint-Ereignis (definiert in der Klasse Control) nicht kümmern (vgl. Abschnitt 15.3).
Statt Bedienelemente direkt auf ein Formular zu setzen, werden bei komplexen Formularen auf dieser Ebene oft Container-Steuerelemente (z.B. aus den Klassen Panel oder TableLayoutPanel, siehe unten) eingesetzt, die dann wiederum Steuerelemente aufnehmen und räumlich zusammenfassen.
9.4.1.3 Ereignisbehandlungsmethoden
Nach Bedarf werden in der Formularklasse Behandlungsmethoden definiert und bei Ereignissen des
Formulars oder der Steuerelemente registriert. Im Beispiel wird die LabelDemo-Instanzmethode
InfoOnClick() definiert und beim Click-Ereignis des Label-Objekts info registriert:
LabelDemo() {
. . .
info.Click += new EventHandler(InfoOnClick);
}
void InfoOnClick(object sender, EventArgs e) {
if (BackColor == Color.Beige)
BackColor = Color.LightGray;
else
BackColor = Color.Beige;
}
InfoOnClick() schaltet die Hintergrundfarbe des Formulars zwischen Color.Beige und Color.LightGray um, wobei die Farben über statische Eigenschaften der Struktur Color aus dem Namensraum System.Drawing angesprochen werden.
Beim Registrieren einer Ereignisbehandlungsmethode wird …


ein Objekt des geforderten Delegatentyps (im Beispiel: EventHandler) erzeugt,
dem Ereignis (also letztlich einer Delegatenvariablen) die Adresse des neuen Delegatenobjekts zugewiesen.
Ist bereits ein Delegatenobjekt für das Ereignis vorhanden, entsteht ein neues Objekt mit einer entsprechend verlängerten Aufrufliste (vgl. Abschnitt 9.3).
Zur Benennung von Ereignisbehandlungsmethoden wird folgendes Schema vorgeschlagen:
Abschnitt 9.4 Steuerelemente

Am Anfang steht eine Bezeichnung für die Ereignisquelle. Ist eine Methode für alle Steuerelemente aus einer Klasse zuständig, bietet sich der Klassenname an. Ansonsten kommt z.B.
der Instanzvariablenname eines einzelnen Steuerelements in Frage, z.B.:
o
o

293
LabelOnClick()
InfoOnClick()
Es folgt das Wort On und schließlich der Ereignisname.
Der Quellcode zum Programm LabelDemo im Überblick:
using System;
using System.Windows.Forms;
using System.Drawing;
class LabelDemo : Form {
LabelDemo() {
Size = new Size(400, 200);
BackColor = Color.LightGray;
Text = "Label-Demo";
Label info = new Label();
info.Text = "Demo-Label";
info.Size = new Size(120, 30);
info.Left = (ClientSize.Width-info.Width)/2;
info.Top = (ClientSize.Height-info.Height)/2;
info.TextAlign = ContentAlignment.MiddleCenter;
info.BorderStyle = BorderStyle.FixedSingle;
Controls.Add(info);
info.Click += new EventHandler(InfoOnClick);
}
void InfoOnClick(object sender, EventArgs e) {
if (BackColor == Color.Beige)
BackColor = Color.LightGray;
else
BackColor = Color.Beige;
}
static void Main() {
Application.Run(new LabelDemo());
}
}
9.4.2 Befehlsschalter
Befehlsschalter werden im WinForms-Framework durch die Klasse Button realisiert.
9.4.2.1 Beispiel
Im folgenden Multi Purpose Counter, der z.B. zur Verkehrszählung taugt, kommen zwei ButtonObjekte zum Einsatz:
Das Erzeugen und Konfigurieren der Button-Steuerelemente erfolgt im Fensterkonstruktor. Weil
die beiden Befehlsschalter und das Label-Objekt für den Zählerstand auch in Ereignisbehandlungsroutinen angesprochen werden, sind Instanzvariablen erforderlich:
Kapitel 9: Einstieg in die WinForms - Programmierung
294
using System;
using System.Windows.Forms;
using System.Drawing;
class ButtonDemo : Form {
Label stand;
long anzahl;
Button count, reset;
ButtonDemo() {
Width = 350; Height = 120;
Text = "Multi Purpose Counter";
FormBorderStyle = FormBorderStyle.FixedSingle;
stand = new Label();
stand.Text = anzahl.ToString();
stand.Font = new Font("Arial", 15);
stand.Left = 20; stand.Top = 30;
stand.Width = 100;
stand.TextAlign = ContentAlignment.MiddleRight;
stand.BorderStyle = BorderStyle.FixedSingle;
Controls.Add(stand);
count = new Button();
count.Text = "Count";
count.Left = 150; count.Top = 30;
Controls.Add(count);
reset = new Button();
reset.Text = "Reset";
reset.Left = 250; reset.Top = 30;
Controls.Add(reset);
EventHandler eh = new EventHandler(ButtonOnClick);
count.Click += eh;
reset.Click += eh;
}
void ButtonOnClick(object sender, EventArgs e) {
if (sender == count)
anzahl++;
else
anzahl = 0;
stand.Text = anzahl.ToString();
}
static void Main() {
Application.Run(new ButtonDemo());
}
}
Weil eine Änderung der Formulargröße nicht erwünscht ist, erhält die Form-Eigenschaft
FormBorderStyle den Wert FixedSingle aus der Enumeration FormBorderStyle im Namensraum
System.Windows.Forms:
FormBorderStyle = FormBorderStyle.FixedSingle;
Die Height-Eigenschaften der drei Steuerelemente werden nicht spezifiziert, und C# verwendet
infolgedessen den voreingestellten Wert 23, der vermutlich folgendermaßen zu Stande kommt:
Höhe der Standardschrift (= 13 Pixel) plus jeweils 5 Pixel für den oberen und den unteren Rand
Abschnitt 9.4 Steuerelemente
295
Um für das Label-Steuerelement eine alternative Schrift zu wählen, erhält seine Font-Eigenschaft
eine Referenz auf ein neues Font-Objekt mit den gewünschten Eigenschaften:
stand.Font = new Font("Arial", 15);
Außerdem erhält das Label einen Rahmen und die (bei einer Zahlenanzeige übliche) rechtsbündige
Textausrichtung:
stand.TextAlign = ContentAlignment.MiddleRight;
stand.BorderStyle = BorderStyle.FixedSingle;
Die für das Click-Ereignis beider Button-Steuerelemente registrierte Ereignisbehandlungsmethode
ButtonOnClick() wertet den Parameter sender aus und orientiert ihr Verhalten an der Ereignisquelle. Nachdem das Feld anzahl seinen neuen Wert erhalten hat, wird das Label-Steuerelement auf den neuen Stand gebracht:
stand.Text = anzahl.ToString();
9.4.2.2 Eingabefokus und Standardschaltfläche
Obwohl beide Schalter des Multi-Counters im Fensterkonstruktor analog konfiguriert werden, sehen sie nach dem Programmstart etwas unterschiedlich aus: 1
Die zuerst auf das Formular gesetzte Schaltfläche (ansprechbar über die Instanzvariable count)
besitzt den Eingabefokus und ist mit einer durchgezogenen schwarzen Umrisslinie dekoriert. Folglich werden alle Tastaturereignisse, die nicht zur Fokussteuerung durch den Container dienen, an
den count-Schalter geleitet. Hier wird durch die Enter- und die Leertaste 2 wie durch einen linken
Mausklick das Click-Ereignis ausgelöst, was die Bedienung des Programms erleichtert.
Zum kompletten Dekor einer Schaltfläche mit Eingabefokus gehört auch eine innen liegende gepunktete Linie:
1
2
In Abschnitt 9.6.4 werden wir vom Visual Studio 2008 - Quellcode-Assistenten lernen, wie man die in WindowsVersionen ab XP enthaltenen visuellen Stile unterstützt (mit einer zusätzlichen Anweisung in der Main()-Methode).
Dann werden die Steuerelemente per Voreinstellung ( abhängig von den Einstellungen des Benutzers) attraktiver
aussehen, wobei aber die Ausführungen des aktuellen Abschnitts gültig bleiben.
Die Leertaste löst das Click-Ereignis eines fokussierten Schalters nicht aus, wenn eine Behandlungsmethode beim
Ereignis KeyDown registriert ist.
296
Kapitel 9: Einstieg in die WinForms - Programmierung
Bei älteren Windows-Versionen (vor XP) erhält der count-Button schon beim Programmstart das
komplette Fokusdekor, bei Windows XP und Vista erscheint die gepunktete Linie erst nach einem
benutzerinitiierten Fokuswechsel. 1
Dank der Fokusverwaltung durch das Formular können Benutzer per Tabulator- oder Pfeiltaste den
Eingabefokus zwischen eingabetauglichen Steuerelementen wandern lassen, wobei die Aktivierungsreihenfolge von den TabIndex-Werten der Steuerelemente abhängt. Neben Schaltflächen
können z.B. auch Kontrollkästchen und Listenfelder den Eingabefokus erhalten.
Für jedes Formular kann über die Form-Eigenschaft AcceptButton eine Standardschaltfläche
festgelegt werden. Ihr Click-Ereignis wird per Enter-Taste ausgelöst, sofern keine andere Schaltfläche des Formulars den Eingabefokus besitzt. Im Beispielprogramm zum TextBox-Steuerelement
(siehe Abschnitt 9.4.3) erhält der Schalter zur Anforderung einer persönlichen Glückszahl die Rolle
der Standardschaltfläche:
AcceptButton = cbGluecksZahl;
Folglich kann der Schalter auch dann per Enter-Taste ausgelöst werden, wenn eines der Textfelder
den Eingabefokus besitzt (erkennbar an der Texteinfügemarke):
Im Glückszahlenbeispiel bietet es sich sogar an, den Schalter über den Wert false seiner TabStopEigenschaft aus der Liste der per Tastatur fokussierbaren Steuerelemente zu entfernen, damit die
Tabulatortaste nur noch zwischen den beiden Textfeldern wechselt:
cbGluecksZahl.TabStop = false;
Analog zum AcceptButton kann für jedes Formular über die Form-Eigenschaft CancelButton
eine Escape-Schaltfläche festgelegt werden. Ihr Click-Ereignis wird per Esc-Taste ausgelöst, sofern keine andere Schaltfläche des Formulars den Eingabefokus besitzt.
1
In Windows XP lässt sich das Fokusdekorations-Verhalten der älteren Windows-Versionen folgendermaßen reaktivieren:
 Im Systemsteuerungsapplet Anzeige auf der Registerkarte Darstellung mit einem Klick auf den Schalter
Effekte den gleichnamigen Dialog öffnen
 Beim Kontrollkästchen Unterstrichene Buchstaben für Tastaturnavigation ausblenden die Markierung entfernen
Abschnitt 9.4 Steuerelemente
297
9.4.2.3 Bitmaps auf Schaltflächen
Um dem oben vorgestellten Mehrzweck-Zählprogramm ein individuelles Design zu geben, kann
man die Beschriftungen der Schaltflächen durch Bitmaps ersetzten, z.B.:
Zunächst sind Bitmap-Dateien mit einer passenden Pixelmatrix zu erstellen (hier gewählt: 50 Zeilen
und 100 Spalten), was z.B. mit der Windows-Zugabe Paint geschehen kann. Im Formularkonstruktor sind im Vergleich zu beschrifteten Schaltflächen (siehe Abschnitt 9.4.2.1) nur wenige Änderungen erforderlich:
count = new Button();
count.Image = new Bitmap("count.bmp");
count.Left = 120; count.Top = 18;
count.Width = count.Image.Width + 8;
count.Height = count.Image.Height + 8;
Controls.Add(count);
reset = new Button();
reset.Image = new Bitmap("reset.bmp");
reset.Left = 230; reset.Top = 18;
reset.Width = reset.Image.Width + 8;
reset.Height = reset.Image.Height + 8;
Controls.Add(reset);
Den Image-Eigenschaften der Schaltflächen werden Bitmap-Objekte zugewiesen, die aus bmpDateien entstehen. Beim empfehlenswerten Verzicht auf eine Pfadangabe werden die Dateien im
Verzeichnis mit dem Assembly erwartet (z.B. in …\Button\Bitmap\bin\Debug). Später werden Sie
eine Möglichkeit kennen lernen, Bitmap-Dateien als Ressourcen in ein Assembly aufzunehmen.
Wählen Sie die Größe eines zu bemalenden Schalters so, dass um das Bild herum mindestens vier
Pixel Rand verbleiben, damit dort ggf. der gepunktete Fokusrahmen (siehe Abschnitt 9.4.2.2) angezeigt werden kann, z.B.:
count.Width = count.Image.Width + 8;
count.Height = count.Image.Height + 8;
Mit etwas Zusatzaufwand macht man die Hintergrundfarbe der verwendeten Bitmaps transparent,
so dass die bemalten Schalter unabhängig vom eingestellten Windows-Design optisch mit den restlichen Fensterbestandteilen harmonieren, z.B.:
Bitmap bmp = new Bitmap("count.bmp");
bmp.MakeTransparent(bmp.GetPixel(1, 1));
count.Image = bmp;
Wenn im Beispielprogramm ausschließlich der linke Schalter eine transparente Hintergrundfarbe
erhält, resultiert unter Windows Vista folgendes Erscheinungsbild:
298
Kapitel 9: Einstieg in die WinForms - Programmierung
9.4.3 Textfelder
Kurze Texteingaben der Benutzer erfasst man im WinForms-Framework mit Steuerelementen der
Klasse TextBox. Auf dem bereits in Abschnitt 9.4.2.2 präsentierten Formular eines Programms zur
Berechnung der persönlichen Glückszahl in Abhängigkeit vom Vor- und Nachnamen werden zwei
TextBox-Objekte verwendet:
Für Steuerelemente eines Programms, die nicht nur im Formularkonstruktor, sondern auch in anderen Methoden (z.B. Ereignisbehandlungsroutinen) angesprochen werden sollen, sind Instanzvariablen erforderlich:
class TextBoxDemo : Form {
Label lbInfo;
TextBox tbVorname, tbNachname;
. . .
}
Das Erzeugen und Konfigurieren der TextBox-Steuerelemente erfolgt im Konstruktor der Formularklasse:
TextBoxDemo() {
Width = 450; Height = 150;
Text = "Glückszahlengenerator";
FormBorderStyle = FormBorderStyle.FixedSingle;
Label lbVorname = new Label(); Label lbNachname = new Label();
lbVorname.Text = "Vorname:"; lbNachname.Text = "Nachname:";
lbVorname.Width = 60; lbNachname.Width = 65;
lbVorname.Left = 20; lbVorname.Top = 30;
lbNachname.Left = 230; lbNachname.Top = 30;
Controls.Add(lbVorname); Controls.Add(lbNachname);
tbVorname = new TextBox(); tbNachname = new TextBox();
tbVorname.Left = 80; tbVorname.Top = 30; tbVorname.Width = 125;
tbNachname.Left = 295; tbNachname.Top = 30; tbNachname.Width = 125;
EventHandler eh = new EventHandler(TextBoxOnTextChanged);
tbVorname.TextChanged += eh;
tbNachname.TextChanged += eh;
Controls.Add(tbVorname); Controls.Add(tbNachname);
Button cbGluecksZahl = new Button();
cbGluecksZahl.Text = "Glückszahl";
cbGluecksZahl.Top = 80; cbGluecksZahl.Left = 20; cbGluecksZahl.Width = 70;
Controls.Add(cbGluecksZahl);
cbGluecksZahl.TabStop = false;
cbGluecksZahl.Click += new EventHandler(ButtonOnClick);
AcceptButton = cbGluecksZahl;
lbInfo = new Label();
lbInfo.Text = strInst;
lbInfo.Left = 120; lbInfo.Top = 85;
lbInfo.AutoSize = true;
Controls.Add(lbInfo);
}
Über die TextBox-Eigenschaft Text kann man auf den Inhalt eines Texteingabefeldes zugreifen,
z.B. in der folgenden Behandlungsmethode zum Click-Ereignis des Befehlsschalters:
Abschnitt 9.4 Steuerelemente
299
void ButtonOnClick(object sender, EventArgs e) {
String vn = tbVorname.Text.ToUpper();
String nn = tbNachname.Text.ToUpper();
int seed = 0; // Startwert des Pseudozufallszahlengenerators
if (vn.Length > 0 && nn.Length > 0) {
foreach (char c in vn)
seed += (int) c;
foreach (char c in nn)
seed += (int)c;
Random zzg = new Random(DateTime.Today.Day + seed);
lbInfo.Text="Vertrauen Sie heute der Zahl "+
(zzg.Next(100)+1).ToString()+"!";
bValToRemove = true;
}
}
Über das Ereignis TextChanged kann man auf jede Veränderung der Text-Eigenschaft eines
TextBox-Objekts reagieren. Im Beispielprogramm wird dafür gesorgt, dass eine Glückszahlanzeige
verschwindet, sobald sich die zugehörigen Texte geändert haben:
void TextBoxOnTextChanged(object sender, EventArgs e) {
if (bValToRemove) {
lbInfo.Text = strInst;
bValToRemove = false;
}
}
TextBox-Steuerelemente besitzen etliche Kompetenzen, die den Benutzer erfreuen und dabei den
Programmierer wenig belasten, z.B.:





Textmarkierung per Maus oder Tastatur
Kommunikation mit der Zwischenablage über die Tastenkombinationen Strg+C, Strg+X
und Strg+V
Rücknahme der letzten Änderung über Strg+Z
Kontextmenü mit Bearbeiten-Funktionen
Automatische Textvervollständigung, wobei allerdings der Programmierer eine AutoCompleteSource beisteuern muss
Den vollständigen Quellcode des Beispielprogramms finden Sie im Ordner
…\BspUeb\WinForms\Steuerelemente\Texterfassung\TextBox
Um ein mehrzeiliges Textfeld zu erhalten, setzt man die TextBox-Eigenschaft Multiline auf den
Wert true und sorgt für genügend Platz. Sind vertikale Bildlaufleisten erwünscht, setzt man auch
die TextBox-Eigenschaft ScrollBars auf den Wert true. Für die folgende Variante des Glückszahlenprogramms
sind im Vergleich zum bisherigen Stand nur drei zusätzliche Zeilen erforderlich (im Fensterkonstruktor):
tbVorname.Multiline = true;
tbVorname.Height = 32;
tbVorname.ScrollBars = ScrollBars.Vertical;
Außerdem sollte man auf eine Standardschaltfläche verzichten, also die Anweisung
Kapitel 9: Einstieg in die WinForms - Programmierung
300
AcceptButton = cbGluecksZahl;
entfernen, weil anderenfalls im mehrzeiligen Textfeld keine Zeilenwechsel per Enter-Taste möglich sind.
Trotz der Multiline-Option eignet sich die Klasse TextBox nur für kurze Texteingaben. Wir werden später mit Hilfe des leistungsfähigeren Steuerelements RichTextBox einen kompletten Texteditor erstellen.
9.4.4 Spezielle Layout-Techniken
In diesem Abschnitt werden spezielle Aspekte der WinForms - Layoutlogik behandelt. Zunächst
geht es um die Effekte einer (partiellen) Überlagerung von Steuerelementen am selben Ort. Dann
werden Techniken behandelt, die für eine ergonomische Steuerelementanordnung bei veränderlicher Formulargröße sorgen können.
9.4.4.1 Z-Anordnung
Wird ein Steuerelement A vor einem Steuerelement B in die (per Controls-Eigenschaft ansprechbare) Steuerelementliste des gemeinsamen Containers eingefügt, dann hat A den kleineren ControlsIndex, und A liegt in der Z-Anordnung über B, deckt also B eventuell (teilweise) ab.
Im folgenden Programm zur Demonstration der Stapelung werden die drei Label-Steuerelemente
lbRed, lbGreen und lbBlue mit den Hintergrundfarben Rot, Grün und Blau nacheinander in
ein Formular aufgenommen:
using System;
using System.Drawing;
using System.Windows.Forms;
class Stapelung : Form {
Stapelung() {
Text = "Stapelung";
ClientSize = new Size(300,150);
Label lbRed = new Label();
lbRed.Size = new Size(250,75);
lbRed.Left = 0;
lbRed.BackColor = Color.Red;
lbRed.Parent = this;
lbRed.Click += new EventHandler(LabelOnClick);
Label lbGreen = new Label();
lbGreen.Size = new Size(200,100);
lbGreen.Left = 100;
lbGreen.BackColor = Color.Green;
lbGreen.Parent = this;
lbGreen.Click += new EventHandler(LabelOnClick);
Label lbBlue = new Label();
lbBlue.Size = new Size(100,150);
lbBlue.Left = 200;
lbBlue.BackColor = Color.Blue;
lbBlue.Parent = this;
lbBlue.Click += new EventHandler(LabelOnClick);
}
void LabelOnClick(object sender, EventArgs e) {
(sender as Control).BringToFront();
}
static void Main() {
Application.Run(new Stapelung());
}
}
301
Abschnitt 9.4 Steuerelemente
Im gemeinsamen Click-Ereignishandler der Label-Steuerelemente gelangt das getroffene Objekt
durch Aufruf der Methode BringToFront() in den Vordergrund und erhält den neuen ControlsIndex Null, z.B.:
Nach dem Start
Nach einem Mausklick auf das blaue Label
9.4.4.2 Verankern
Ist ein Steuerelement an einer Formularseite verankert, behält es bei einer Veränderung der Fenstergröße nach Möglichkeit seinen Abstand zu dieser Seite konstant. Gesteuert wird dieses Verhalten
durch die Anchor-Eigenschaft, der eine beliebige bitorientierte ODER-Verknüpfung von Werten
der AnchorStyles-Enumeration aus dem Namensraum System.Windows.Forms zugewiesen werden kann:
Name
None
Top
Bottom
Left
Right
Wert
0
1
2
4
8
Bei der bitorientierten ODER-Verknüpfung (vgl. Abschnitt 3.5.6) von zwei Argumenten mit integralem Typ (z.B. int) entsteht ein neuer Wert desselben Typs. Im Bitmuster des Funktionswerts
sind alle Bits eingeschaltet, die in mindestens einem Argument eingeschaltet waren.
In den AnchorStyles-Werten ist entweder kein oder ein Bit eingeschaltet, und per ODERVerknüpfung lässt sich eine beliebige Teilmenge der vier Seitenbits einschalten. Per Voreinstellung
behalten Steuerelemente bei Veränderungen der Fenstergröße ihren Abstand zum linken und zum
oberen Rand bei, weil ihre Anchor – Eigenschaft den Wert
AnchorStyles.Left | AnchorStyles.Top
besitzt.
Bei der folgenden Variante des Mehrzweck-Zählprogramms aus Abschnitt 9.4.2 mit neu angeordneten Steuerelementen ist das Label mit dem Zählerstand links und rechts verankert, so dass es mit
zunehmender Formularbreite mehr Platz bietet:
Mit den folgenden AnchorStyles–Zuweisungen wird außerdem dafür gesorgt, dass der countSchalter am linken und der reset-Schalter am rechten Fensterrand verbleibt:
302
Kapitel 9: Einstieg in die WinForms - Programmierung
stand.Anchor = AnchorStyles.Left | AnchorStyles.Right;
count.Anchor = AnchorStyles.Left;
reset.Anchor = AnchorStyles.Right;
Weil die drei Steuerelemente weder oben noch unten verankert sind, bleiben sie bei einer Änderung
der Fensterhöhe als Gruppe vertikal zentriert:
Über die Form-Eigenschaft MinimumSize kann verhindert werden, dass ein Benutzer durch extremes Verkleinern des Formulars in Schwierigkeiten gerät und die Hotline des Software-Herstellers mit der Klage belästigt, die Bedienelemente seien plötzlich verschwunden:
MinimumSize = new Size(Width, Height);
Nach den beschriebenen Modifikationen sieht der Fensterkonstruktor so aus:
Veranken() {
Width = 248; Height = 150;
Text = "Multi Purpose Counter";
MinimumSize = new Size(Width, Height);
stand = new Label();
stand.Text = anzahl.ToString();
stand.Font = new Font("Arial", 24);
stand.Left = 20; stand.Top = 60;
stand.Width = 200;
stand.Height = stand.Font.Height;
stand.BorderStyle = BorderStyle.FixedSingle;
stand.TextAlign = ContentAlignment.MiddleRight;
stand.Anchor = AnchorStyles.Left | AnchorStyles.Right;
Controls.Add(stand);
count = new Button();
count.Text = "Count";
count.Left = 20; count.Top = 20;
count.Width = 70;
count.Anchor = AnchorStyles.Left;
Controls.Add(count);
reset = new Button();
reset.Text = "Reset";
reset.Left = 150; reset.Top = 20;
reset.Width = 70;
reset.Anchor = AnchorStyles.Right;
Controls.Add(reset);
EventHandler eh = new EventHandler(ButtonOnClick);
count.Click += eh;
reset.Click += eh;
}
Abschnitt 9.4 Steuerelemente
303
9.4.4.3 Andocken
Oft ist es erwünscht, dass ein Steuerelement nahtlos an einer Formularseite andockt und sich bei
jeder Fenstergröße über die komplette Seitenlänge erstreckt. Dieses Verhalten wäre durch Verankern an drei Seiten zu erzwingen (siehe Abschnitt 9.4.4.2), ist aber bequemer über die DockEigenschaft zu realisieren, der ein einzelner Wert (keine ODER-Kombination!) der DockStyleEnumeration zugewiesen werden kann:
DockStyle-Wert
None
Top
Bottom
Left
Right
Fill
Beschreibung
Nicht andocken
Oben andocken
Unten andocken
Links andocken
Rechts andocken
Allseitig andocken, also die gesamte Fläche des Containers belegen
Bei folgender Variante des Zählprogramms sind die beiden Schalter oben bzw. unten angedockt:
Die erforderlichen Anweisungen im Formularkonstruktor:
count.Dock = DockStyle.Top;
reset.Dock = DockStyle.Bottom;
Das Label-Steuerelement ist wie bei der Programmvariante in Abschnitt 9.4.4.2 links und rechts
verankert:
stand.Anchor = AnchorStyles.Left | AnchorStyles.Right;
Wollen mehrere Steuerelemente an derselben Seite ihres gemeinsamen Containers andocken, hängt
das Ergebnis von der Z-Anordnung, d.h. von der Aufnahmereihenfolge ab. Wird das Steuerelement
A vor dem Steuerelement B aufgenommen, dann rückt A nach Möglichkeit vom Rand ab, um Platz
für B zu schaffen. Dies ist jedoch nicht möglich, wenn B auf DockStyle.Fill eingestellt ist. Daher
sollte nur das zuerst angedockte Steuerelement diesen DockStyle erhalten.
Im folgenden Fensterkonstruktor werden die drei Label-Steuerelemente lbRed, lbGreen und
lbBlue nacheinander links angedockt, wobei lbRed über die Dock-Eigenschaftsausprägung
DockStyle.Fill auch an den drei restlichen Seiten andockt:
GemeinsameDockseite() {
Text = "Gemeinsame Dockseiten: Links";
ClientSize = new Size(300, 100);
ForeColor = Color.White;
Label lbRed = new Label();
lbRed.BackColor = Color.Red;
lbRed.Text = "0";
lbRed.Parent = this;
lbRed.Dock = DockStyle.Fill;
Label lbGreen = new Label();
lbGreen.Width = 100;
lbGreen.BackColor = Color.Green;
lbGreen.Text = "1";
lbGreen.Parent = this;
Kapitel 9: Einstieg in die WinForms - Programmierung
304
lbGreen.Dock = DockStyle.Left;
Label lbBlue = new Label();
lbBlue.Width = 100;
lbBlue.BackColor = Color.Blue;
lbBlue.Parent = this;
lbBlue.Text = "2";
lbBlue.Dock = DockStyle.Left;
}
Das zuerst aufgenommene Steuerelement lbRed (siehe Controls-Indexnummern links oben, vergeben in der Aufnahmereihenfolge) macht für Neulinge Platz und ist schließlich am weitesten vom
linken Fensterrand entfernt:
Bei einer Verbreiterung des Fensters passt sich lbRed wegen seiner Dock-Eigenschaftsausprägung
DockStyle.Fill an, während die anderen Label-Objekte nicht reagieren:
Nach der folgenden Änderung im Fensterkonstruktor
lbBlue.Dock = DockStyle.Right;
überlässt das dienstälteste und auf allseitiges Andocken eingestellte Label-Steuerelement (Indexnummer Null, DockStyle.Fill) gleich an zwei Seiten jüngeren Dock-Konkurrenten den Fensterrand:
Der DockStyle.Fill eignet sich z.B. für ein RichTextBox-Steuerelement (also für einen kompletten
Texteditor). Hier soll die gesamte Formularfläche genutzt werden, wobei aber an einem Rand klebende Bedienelemente Vorrang haben müssen.
9.4.4.4 TableLayoutPanel
Seit der Version 2.0 bietet das .NET - Framework mit dem TableLayoutPanel einen Container mit
Tabellenstruktur, der in jeder Zelle ein Steuerelement aufnehmen kann. An Stelle gewöhnlicher
Steuerelemente können prinzipiell auch weitere TableLayoutPanel-Objekte in die Zellen gesteckt
werden.
Hauptzweck eines TableLayoutPanel-Objekts ist nicht die Unterstützung bei der statischen Anordnung von Steuerelementen, sondern der Entwurf von Formularen, die bei einer Größenänderung
den verfügbaren Platz sinnvoll neu verteilen. Daher sind für die Zeilen und Spalten neben absoluten
auch prozentuale Größenangeben erlaubt.
Abschnitt 9.4 Steuerelemente
305
Der Bequemlichkeit halber verwenden wir zur Demonstration erneut das in Abschnitt 9.4.2 vorgestellten Mehrzweck-Zählprogramm, obwohl für sein einfaches Formular eigentlich kein TableLayoutPanel erforderlich ist. In den folgenden Anweisungen des Formularkonstruktors
TableLayoutPanel table = new TableLayoutPanel();
table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F));
table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25F));
table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25F));
Controls.Add(table);
table.Dock = DockStyle.Fill;
wird eine Tabelle erstellt und gestaltet:



Die Anzahl der Zeilen und Spalten ermittelt der Compiler automatisch.
Die angegebenen prozentualen Spaltenbreiten werden den ersten drei Spalten nacheinander
zugeordnet. Dazu sind ColumnStyle-Objekte mit einem Wert der SizeType-Enumeration
und einer Größenangabe (mit Datentyp float) als Konstruktor-Parameter zu erzeugen und
per Add()-Methode in die Kollektion der Spaltenstile einzufügen. Wir reservieren 50 % der
verfügbaren Breite für den Zählerstand und jeweils 25 % für die beiden Schalter.
Das TableLayoutPanel-Steuerelement wird auf das Formular gesetzt und darf den gesamten dort verfügbaren Platz belegen (DockStyle.Fill).
Das Label und die beiden Button-Objekte bleiben in ihrer Zelle bei jeder Änderung der Fenstergröße horizontal und vertikal zentriert (AnchorStyles.None):
table.Controls.Add(stand, 0, 0);
stand.Anchor = AnchorStyles.None;
. . .
table.Controls.Add(count, 1, 0);
count.Anchor = AnchorStyles.None;
. . .
table.Controls.Add(reset, 2, 0);
reset.Anchor = AnchorStyles.None;
Die verfügbare Breite wird stets in der festgelegten Weise verteilt:
Den vollständigen Quellcode des Beispielprogramms finden Sie im Ordner
…\BspUeb\WinForms\Spezielle Layout-Techniken\TableLayoutPanel
Microsoft warnt wegen potentieller Schwierigkeiten bei der Fehlersuche davor, TableLayoutPanel-Steuerelemente zu schachteln 1. Im folgenden Beispielprogramm wird allerdings genau diese
Technik problemlos für ein flexibles Formulardesign verwendet:
1
Siehe: http://msdn.microsoft.com/en-us/library/ms171689.aspx
Kapitel 9: Einstieg in die WinForms - Programmierung
306
Es handelt sich um den Lösungsvorschlag zu einer Übungsaufgabe in Abschnitt 14.5 (Chat-Server
und -Client).
Eine weitere Option zur Gestaltung größenflexibler Formulare ist das (ebenfalls seit .NET 2.0 in der
FCL vorhandene) Steuerelement FlowLayoutPanel.
9.5 Ereignisse und On-Methoden
Zur bisher vorgesellten Technik der Ereignisbehandlung gibt es eine oft benutzte Alternative, deren
Behandlung relevantes Hintergrundwissen vermittelt. Jedes im Namensraum System.Windows.Forms definierte Ereignis wird in einer (meist von System.Windows.Forms.Control geerbten) Methode ausgelöst, deren Name aus dem Präfix On und dem Ereignisnamen besteht, z.B.


OnClick()
OnMouseEnter()
Andererseits können wir uns darauf verlassen, dass dank Windows und .NET - Plattform die OnMethode zu einem .NET - Ereignis im passenden Moment aufgerufen werden, z.B. nach einem
Mausklick auf einen Befehlsschalter. Man kann das Geschehen so skizzieren:
Mausklick
auf den
Befehlsschalter
count
Das Objekt
count führt
OnClick() aus
In OnClick()
wird das
Ereignis Click
aufgerufen
Beim Ereignis
Click registrierte
Methoden werden
aufgerufen
Weil die On-Methoden als virtual definiert sind, kann man sie in abgeleiteten Klassen überschreiben, um die gewünschte Reaktion auf ein Ereignis direkt in der On-Methode vorzunehmen. Diese
Vorgehensweise ist speziell beim Paint-Ereignis sehr verbreitet, mit dem wir uns in Kapitel 15 intensiv beschäftigen werden (siehe speziell Abschnitt 15.3.2).
Das Überschreiben der zugehörigen On-Methode ist bei den Ereignissen von Steuerelementen nicht
unbedingt die bevorzugte Technik, weil dazu eine eigene Klassendefinition erforderlich ist. Bei den
Formularereignissen ist die Technik hingegen leicht nutzbar, weil wir die (von Control abstammende) Klasse Form regelmäßig beerben. Im folgendem Programm ist die OnMouseDown-
Abschnitt 9.5 Ereignisse und On-Methoden
307
Methode des Formulars überschrieben und außerdem die Methode FormOnMouseDown() beim
MouseDown-Ereignis des Formulars registriert:
using System;
using System.Windows.Forms;
class OnMethodenDemo : Form {
Label lab;
OnMethodenDemo() {
Text = "OnMethoden-Demo";
lab = new Label();
lab.Parent = this;
MouseDown += FormOnMouseDown;
}
protected override void OnMouseDown(MouseEventArgs e) {
base.OnMouseDown(e);
lab.Text = "(" + e.X.ToString() + "; " + e.Y.ToString() + ")";
}
void FormOnMouseDown(object sender, MouseEventArgs e) {
lab.Left = e.X;
lab.Top = e.Y;
}
static void Main() {
Application.Run(new OnMethodenDemo());
}
}
Beide Methoden teilen sich bei einem Mausklick folgende Arbeiten zur Aktualisierung der Eigenschaften eines Label-Objekts:


OnMouseDown() legt die linke obere Ecke des Labels neu auf die Klickstelle fest, die der
Methode über Eigenschaften eines MouseEventArgs-Objekts bekannt wird.
FormOnMouseDown() verarbeitet die Koordinaten der Klickstelle zum neuen Wert der
Text-Eigenschaft des Labels.
Damit die Kooperation gelingt, muss OnMouseDown() die überschriebene Basisklassenmethode
aufrufen. Aufgrund der umständlichen (aber lehrreichen) Ereignisbehandlung kann das Programm
die Koordinaten einer Klickstelle am Ort des Geschehens anzeigen, z.B.:
Unterlässt man den Aufruft der Basisklassenmethode,
protected override void OnMouseDown(MouseEventArgs e) {
lab.Text = "(" + e.X.ToString() + "; " + e.Y.ToString() + ")";
}
wird die beim MouseDown-Ereignis registrierte Methoden still gelegt, so dass zwar die LabelBeschriftung weiterhin (durch OnMouseDown()) aktualisiert wird, die Koordinaten aber unverändert bei den Initialisierungswerten (0, 0) bleiben:
308
Kapitel 9: Einstieg in die WinForms - Programmierung
Man darf also in einer überschreibenden On-Methode nur dann af den Aufruf der Basisklassenvariante verzichten, wenn ganz sicher kein Delegat beim betroffenen Ereignis registriert ist, weder aktuell noch bei einer möglichen späteren Verwendung der eigenen Klasse. Bei einem komplexen
Softwaresystem, an dem eventuell mehrere Programmierer beteiligt sind, sollte man also in einer
On-Überschreibung auf jeden Fall die Basisklassenmethode aufrufen.
In der Regel wird man den Basismethodenaufruf gleich zu Beginn der Überschreibung vornehmen.
9.6 WinForms - RAD
Mit Hilfe moderner Entwicklungsumgebungen lässt sich das Programmieren erheblich vereinfachen
und beschleunigen (Rapid Application Development, RAD). Nachdem wir in Abschnitt 4.10 das
Vorgehen mit der Visual C# 2008 Express Edition geübt haben, soll nun die Visual Studio 2008
Professional Edition zum Einsatz kommen.
9.6.1 Projekt anlegen mit Vorlage Windows Forms - Anwendung
Verwenden Sie nach
Datei > Neu > Projekt
für ein neues Projekt mit dem Namen MultiPurposeCounter die Vorlage Windows Forms
- Anwendung:
Durch den Verzicht auf ein Projektmappenverzeichnis wählen wir eine „flache“ Projektdateiverwaltung ohne Zusammenfassung von mehreren Projekten zu einer Mappe.
Abschnitt 9.6 WinForms - RAD
309
Nach einem Mausklick auf OK präsentiert die Entwicklungsumgebung im Formulardesigner einen
Rohling für das Hauptfenster der entstehenden Anwendung. Wir bringen das Formular wie in einem
Grafikprogramm mit Hilfe der Anfasser in die gewünschte rechteckige Form:
9.6.2 Steuerelemente aus der Toolbox übernehmen
Öffnen Sie das Toolbox-Fenster mit dem Menübefehl
Ansicht > Toolbox
oder per Mauszeiger durch kurzes Verharren auf der Toolbox-Schaltfläche am linken Fensterrand:
Kapitel 9: Einstieg in die WinForms - Programmierung
310
Erweitern Sie im Toolbox-Fenster die Liste mit den allgemeinen Steuerelementen, und ziehen Sie per Maus ein Label-Objekt sowie zwei Button-Objekte auf das Formular, z.B.:
Über die Optionen zur Gestaltung der Größen und Positionen von Steuerelementen wurde schon in
Abschnitt 4.10.3 berichtet.
9.6.3 Eigenschaften der Steuerelemente ändern
Im Eigenschaftsfenster der Entwicklungsumgebung, das bei Bedarf mit dem Menübefehl
Ansicht > Eigenschaftsfenster
zu öffnen ist, lassen sich die Eigenschaften des markierten Steuerelements durch direkte Werteingabe festlegen, z.B. der Text in der Titelzeile des Formulars:
Ändern Sie außerdem …


die Text - Eigenschaften der Steuerelemente
via Klappmenü die AutoSize-Eigenschaft des Labels auf den Wert false:


die BorderStyle-Eigenschaft des Labels auf den Wert FixedSingle
die TextAlign-Eigenschaft des Labels auf den Wert MiddleRight
Über das Kontextmenü zu einer Eigenschaft kann man zum voreingestellten Wert zurückkehren
oder die Eigenschaftsbeschreibung am unteren Fensterrand (de)aktivieren:
Abschnitt 9.6 WinForms - RAD
311
Das aktuell zu gestaltende Steuerelement lässt sich auch über eine Liste im Kopfbereich des Eigenschaftsfensters wählen. Außerdem kann man die Eigenschaften von mehreren gleichzeitig markierten Objekten in einem Arbeitsgang ändern.
Sehr nützlich ist die Möglichkeit, die FCL-Dokumentation zur momentan markierten Eigenschaft
über die Taste F1 aufzurufen, z.B.:
9.6.4 Der Quellcode-Generator
Aufgrund Ihrer kreativen Tätigkeit erzeugt die Entwicklungsumgebung im Hintergrund Quellcode
zu einer neuen Klasse namens Form1, die erwartungsgemäß von der Klasse Form im Namensraum System.Windows.Forms abstammt. Aber auch Sie werden signifikanten Quellcode zu
dieser Klasse beisteuern. Damit sich die beiden Autoren nicht in die Quere kommen, wird der
Quellcode der Klasse Form1 auf zwei Dateien verteilt:


Form1.cs
Hier werden die von Ihnen erstellten Methoden landen (siehe unten).
Form1.Designer.cs
Hier landet der automatisch generierte Quellcode, und Sie sollten in dieser Datei keine Änderungen vornehmen, um den Formulardesigner nicht aus dem Tritt zu bringen.
Dem C# - Compiler wird durch das Schlüsselwort partial in der Klassendefinition signalisiert, dass
der Quellcode auf mehrere Dateien verteilt ist. Vor der Erstellung von Ereignisbehandlungsmethoden (siehe unten) enthält die Datei Form1.cs vor allem einen parameterfreien Form1-Konstruktor:
Kapitel 9: Einstieg in die WinForms - Programmierung
312
using
using
using
using
using
using
using
using
System;
System.Collections.Generic;
System.ComponentModel;
System.Data;
System.Drawing;
System.Linq;
System.Text;
System.Windows.Forms;
namespace MultiPurposeCounter
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
}
}
Hier wird die private Methode InitializeComponent() aufgerufen, die der Formulardesigner in der
Datei Form1.Designer.cs implementiert.
Um eine ungefähre Vorstellung vom Wirken des Code-Generators zu erhalten, öffnen wir die
Quellcodedatei Form1.Designer.cs (ohne Änderungsabsicht) per Doppelklick auf ihren Eintrag im
Projektmappen-Explorer. Hier finden sich u.a. die Deklarationen der Instanzvariablen zu den Steuerelementen:
private System.Windows.Forms.Label label1;
private System.Windows.Forms.Button button1;
private System.Windows.Forms.Button button2;
Obwohl die Schutzstufe private für Klassen-Member voreingestellt ist, verwendet die Entwicklungsumgebung der Deutlichkeit halber den Modifikator bei jeder Deklaration.
Außerdem erstellt der Quellcode-Generator eine statische Startklasse namens Program
static class Program {
[STAThread]
static void Main() {
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
}
mit einigen erläuterungsbedürftigen Bestandteilen:


Das Attribut STAThread wird in Abschnitt 11 behandelt.
Die statische Methode Application.EnableVisualStyles() sorgt für die Unterstützung der
mit Windows XP eingeführten visuellen Stile (Themen). Bei aktivierter Unterstützung (also
vorhandenem EnableVisualStyles() - Aufruf) sieht z.B. ein Befehlsschalter unter Windows
Vista beim so aus:
Design (Thema) Windows Vista
Design (Thema) Windows - klassisch
Ohne Unterstützung für visuellen Stile (Themen) erhält man unter Windows Vista folgende
Ergebnisse:
313
Abschnitt 9.6 WinForms - RAD
Design (Thema) Windows Vista

Design (Thema) Windows - klassisch
Bei aktiviertem Vista-Stil erhält man einen dementsprechenden Rahmen, doch im Klientenbereich der .NET - Anwendung haben die Steuerelemente das althergebrachte Erscheinungsbild. Unter Windows XP hat die Methode Application.EnableVisualStyles() einen
analogen Effekt, und wir werden ab jetzt auch bei komplett selbst geschriebenen GUI-Anwendungen mit einer zusätzlichen Anweisung in der Main()-Methode eine attraktivere Optik ermöglichen.
Der Aufruf Application.SetCompatibleTextRenderingDefault(false) sorgt dafür, dass
Steuerelemente beim Zeichnen von Text die mit .NET 2.0 eingeführte Technik verwenden,
die Verbesserungen bei der Performanz und Präzision bringt. Der voreingestellte Kompatibilitätsmodus ist nur empfehlenswert bei der Aktualisierung von Anwendungen, die ursprünglich für .NET 1.x entwickelt worden sind 1.
Der Quellcode-Generator setzt abweichend von der im Manuskript üblichen Praxis eröffnende geschweifte Klammern (z.B. bei Klassen- oder Methodendefinitionen) in eine eigene Zeile. Das alternative Verhalten ist über den Menübefehl
Extras > Optionen > Text-Editor > C# > Formatierung > Neue Zeilen
erreichbar (vgl. Abschnitt 4.10.5).
9.6.5 Ereignisbehandlungsmethoden anlegen
Nun erstellen wir zu jedem Befehlsschalter eine Methode, die durch das Betätigen des Schalters
(z.B. per Mausklick) ausgelöst werden soll. Setzen Sie im Formulardesigner einen Doppelklick auf
den Befehlsschalter button1 (mit dem Wert Count für die Eigenschaft Text), so dass die Entwicklungsumgebung in Form1.cs die Instanzmethode button1_Click() mit leerem Rumpf
anlegt
private void button1_Click(object sender, EventArgs e) {
}
und die Quellcodedatei in einem Editorfenster öffnet. Außerdem wird die neue Methode beim zugehörigen Ereignis registriert (siehe Methode InitializeComponent() in der Datei Form1.Designer.cs):
this.button1.Click += new System.EventHandler(this.button1_Click);
Wir ergänzen in der Form1-Klassendefinition die int-Instanzvariable anzahl für den Zählerstand.
In der Ereignisbehandlungsmethode button1_Click() wird diese Variable inkrementiert. Abschließend ist nur noch die Text-Eigenschaft des Labels zu aktualisieren:
1
Bei der in .NET 2.0 eingeführten Neuerung geht es offenbar um die Ersetzung der in .NET 1.x enthaltenen, auf dem
GDI+ basierenden Textdarstellung durch eine alternative Technik, die auf dem älteren GDI (ohne Plus) basiert (vgl.
Abschnitt 15.7).
Kapitel 9: Einstieg in die WinForms - Programmierung
314
public partial class Form1 : Form
{
int anzahl;
public Form1()
{
InitializeComponent();
}
private void button1_Click(object sender, EventArgs e) {
anzahl++;
label1.Text = anzahl.ToString();
}
}
Erstellen Sie analog die Click-Ereignisbehandlungsmethode zum Befehlsschalter button2 (mit
dem Wert Reset für die Eigenschaft Text):
private void button2_Click(object sender, EventArgs e) {
anzahl = 0;
label1.Text = anzahl.ToString();
}
Das wars! Mit der Funktionstaste F5 können Sie Ihr fertiges Programm übersetzen und ausführen
lassen. Die Assembly-Datei MultiPurposeCounter.exe findet sich im Projektunterordner
…\bin\Debug.
Ein Steuerelement bietet in der Regel mehrere Ereignisse an, wobei ein Standardereignis festgelegt
ist (z.B. Click bei einem Button-Objekt). Wie eben zu sehen war, legt man im Formulardesigner
die Behandlungsroutine zum Standardereignis per Doppelklick auf das Steuerelement an. Um die
Behandlungsroutine zu einem anderen Ereignis per Formulardesigner anzulegen, wechselt man im
zur Liste der unterstützten Ereignisse und setzt einen
Eigenschaftsfenster über den Schalter
Doppelklick auf das zu behandelnde Ereignis, z.B.:
Das Visual Studio legt daraufhin in der Quellcodedatei Form1.cs eine neue Methode an
private void label1_MouseEnter(object sender, EventArgs e) {
}
und registriert sie beim Ereignis. Im (mäßig sinnvollen) Beispiel könnten wir die Hintergrundfarbe
des Labels ändern, sobald sich die Maus darüber befindet (Ereignis MouseEnter), wobei dann auch
noch das Ereignis MouseLeave behandelt werden sollte. Das Quellcode-Äquivalent zu dieser Eigenschaftsfenster-Repräsentation der Ereignisregistrierung:
Abschnitt 9.6 WinForms - RAD
315
findet sich in der Methode InitializeComponent() (siehe Datei Form1.Designer.cs):
this.label1.MouseEnter += new System.EventHandler(this.label1_MouseEnter);
Soll eine Ereignismethode per Formulardesigner bei mehreren Steuerelementen registriert werden,
legt man sie zum ersten Ereignisemittenden neu an und ordnet sie später den übrigen Emittenten im
Eigenschaftsdialog per Klappmenü zu:
Damit im Beispiel die Methode button1_Click() für beide Schalter taugt, muss sie natürlich
modifiziert werden (vgl. Abschnitt 9.4.2.1):
private void button1_Click(object sender, EventArgs e) {
if (sender == button1) {
anzahl++;
label1.Text = anzahl.ToString();
} else {
anzahl = 0;
label1.Text = anzahl.ToString();
}
}
9.6.6 Quellcode-Umgestaltung (Refactoring)
Nachdem im Beispiel die Ereignisbehandlungsmethode button1_Click() für beide ButtonObjekte zuständig geworden ist, passt ihr Name nicht mehr. Wir ändern ihn ab und lernen dabei
eine einfache Anwendung der außerordentlich nützlichen Quellcode-Umgestaltung kennen (engl.
Refactoring). Nach einem Rechtsklick auf die Methode und Auswahl der Kontextmenü-Option
Umgestaltung > Umbenennen
316
Kapitel 9: Einstieg in die WinForms - Programmierung
wählen wir den Namen ButtonOnClick() (vgl. Benennungsvorschlag in Abschnitt 9.4.1.3):
Die Entwicklungsumgebung gibt eine Vorschau auf den Effekt der Umbenennung:
Nach dem Übernehmen wird das Umbenennen in allen Quellcode-Dateien des Projekts ausgeführt.
Abschnitt 9.7 Zusammenfassung zu Abschnitt 1552H9
317
9.7 Zusammenfassung zu Abschnitt 9
Charakteristische Merkmale einer GUI-Anwendung sind:



Verwendung von Steuerelementen zur Benutzerinteraktion (z.B. Schalter, Optionsfelder,
Menüs etc.)
Unterstützung von grafikorientierten Eingabegeräten (z.B. Maus, Touch Screen)
Benutzergesteuerter, ereignisorientierter Ablauf
Eine GUI-Anwendung wartet die meiste Zeit darauf, dass eine Ihrer Methoden über ein
(meist vom Benutzer ausgelöstes) Ereignis aufgerufen wird.
Leicht übertreibend kann man sagen:
Ein Konsolenprogramm
kommandiert den Benutzer,
benutzt das Laufzeitsystem.
Ein GUI-Programm:
lässt den Benutzer zwischen zahlreichen Bedienelementen wählen,
ist eine Ansammlung von Ereignisbehandlungsroutinen (Call Back Routinen), die vom Laufzeitsystem aufgerufen werden.
Das WinForms-Framework bietet im Namensraum System.Windows.Forms leistungsfähige Klassen zur GUI-Programmierung. Als elementare WinForms-Klassen haben Sie kennen gelernt:


Form
Alle Formulare einer WinForms-Anwendung werden über Objekte einer von Form abstammenden Klasse verwaltet.
Das Hauptfenster einer WinForms-Anwendung spielt eine besondere Rolle:
 Es wird dem Methodenaufruf Application.Run() als Parameter übergeben und erscheint daraufhin auf dem Bildschirm.
 Ein sinnvoll entworfenes WinForms-Programm endet beim Schließen des Hauptfensters.
Application
Diese Klasse bietet u.a. essentielle statische Methoden zum Betreiben einer WinFormsAnwendung. Durch den Methodenaufruf Application.Run() wird …
 das Programm um eine Nachrichtenschleife erweitert,
 das Hauptfenster der Anwendung geöffnet.
Delegaten
Ein Objekt eines Delegatentyps zeigt auf eine Methode (oder auf eine Liste von Methoden) mit einer bestimmten Signatur. Beim Aufruf eines Delegatenobjekts werden alle Methoden seiner Aufrufliste nacheinander ausgeführt.
Ereignisse
Ein .NET - Ereignis ist im Wesentlichen eine Delegatenvariable, die vom Ereignisanbieter bei bestimmten Gelegenheiten aufgerufen werden (z.B. bei einem Klick auf einen Schalter). Um auf ein
Ereignis reagieren zu können, registriert man dort eine Behandlungsmethode:


Man definiert eine Methode mit passender Signatur
und befördert diese per Aktualisierungsoperator „+=“ in die Aufrufliste zum Ereignisobjekt.
Steuerelemente
Das WinForms-Framework bietet zahlreiche Klassen zur Realisation von Steuerelementen (z.B.
Befehlsschalter, Textfelder, Kontrollkästchen, Auswahllisten) an. Objekte dieser Klassen besitzen
einige Besonderheiten:
Kapitel 9: Einstieg in die WinForms - Programmierung
318




Sie erscheinen als (Kind-)fenster eines Containers auf dem Bildschirm.
Sie interagieren selbständig mit dem Benutzer.
Sie kommunizieren über Ereignisse mit anderen Klassen.
Ihre Eigenschaften (z.B. Text, Height, Left, Font, TextAlign) können zur Entwurfszeit
über Werzeuge der Entwicklungsumgebungen konfiguriert werden.
Einfache Beispiele:



Label
Button
TextBox
Die folgenden Layout-Techniken sind u.a. bei der dynamischen Positions- und Größenpassung der
Steuerelemente an eine vom Benutzer geänderte Formulargröße relevant:



Verankern
Andocken
TableLayoutPanel
9.8 Übungsaufgaben zu Kapitel 9
1) Welche von den folgenden Aussagen sind richtig?
1. In der Klassendefinition zu einem Formular wird für jedes aufzunehmende Steuerelement
eine Instanzvariable benötigt.
2. Steuerelemente können über Ereignisse mit dem elterlichen Formular (Control-Eigenschaft
Parent) kommunizieren.
3. Bei den Ereignissen eines Steuerelements kann jeweils nur eine Behandlungsmethode registriert werden.
4. Steuerelemente übernehmen die Schriftart vom elterlichen Container (z.B. vom Formular).
2) Erstellen Sie (ohne Formular-Designer) ein WinForms-Programm, das DM-Beträge in EuroBeträge konvertiert (per Division durch 1,95583), z.B. mit folgender Benutzeroberfläche:
Ein angezeigter Euro-Betrag sollte verschwinden, sobald sich der korrespondierende Inhalt des
DM-Textfelds ändert.
Um die attraktive Optik der Steuerelemente zu ermöglichen, müssen Sie in der Main()-Methode die
Unterstützung der seit Windows XP vorhandenen visuellen Stile aktivieren (vgl. Abschnitt 9.6.4),
z.B.:
static void Main() {
Application.EnableVisualStyles();
Application.Run(new DM2Euro());
}
3) Welche Programmierhilfen bietet der Quellcode-Editor unserer Entwicklungsumgebung (Visual
C# 2008 Express Edition oder Visual Studio 2008)?
10 Ausnahmebehandlung
Durch Programmierfehler (z.B. versuchter Feldzugriff mit ungültigem Indexwert) oder durch besondere Umstände (z.B. z.B. irreguläre Eingabedaten, Speichermangel, unterbrochene Netzverbindungen) kann die reguläre Ausführung einer Methode scheitern. Solche Probleme sollten entdeckt,
behoben oder zusammen mit hilfreichen Informationen an den Aufrufer der Methode und eventuell
schlussendlich an den Benutzer gemeldet werden, statt zu einem Absturz und sogar zu einem Datenverlust zu führen.
C# bietet ein modernes Verfahren zur Meldung und Behandlung von Problemen: An der Unfallstelle wird ein Ausnahmeobjekt aus der Klasse Exception aus dem Paket java.lang oder aus einer
problemspezifischen Unterklasse erzeugt und der unmittelbar verantwortlichen Methode „zugeworfen“. Diese wird über das Problem informiert und mit relevanten Daten für die Behandlung versorgt.
Die Initiative beim Auslösen einer Ausnahme kann ausgehen …


von der CLR
Wir dürfen annehmen, dass die CLR praktisch immer stabil bleibt. Entdeckt sie einen Fehler, der nicht zu schwerwiegend ist und vom Benutzerprogramm behoben werden kann (z.B.
versuchter Feldzugriff mit ungültigem Indexwert), wirft sie ein Ausnahmeobjekt aus einer
Klasse, die meist von SystemException abstammt.
vom Benutzerprogramm, wozu auch die verwendeten Bibliotheksklassen gehören
In jeder Methode kann mit der throw-Anweisung (siehe Abschnitt 10.5) eine Ausnahme erzeugt werden.
Die unmittelbar von einer Ausnahme betroffene Methode steht oft am Ende einer Sequenz verschachtelter Methodenaufrufe, und entlang der Aufrufersequenz haben die beteiligten Methoden
jeweils folgende Reaktionsmöglichkeiten:


Ausnahmeobjekt abfangen und das Problem behandeln
Dabei ist der im Ausnahmeobjekt enthaltene Unfallbericht von Nutzen. Scheidet die Fortführung des ursprünglichen Handlungsplans auch nach der Ausnahmebehandlung aus, sollte
erneut ein Ausnahmeobjekt geworfen werden, entweder das ursprüngliche oder ein informativeres.
Ausnahmeobjekt ignorieren und dem Vorgänger in der Aufrufersequenz überlassen
Wir werden uns anhand verschiedener Versionen eines Beispielprogramms damit beschäftigen,




was bei unbehandelten Ausnahmen geschieht,
wie man Ausnahmen abfängt,
wie man selbst Ausnahmen wirft,
wie man eigene Ausnahmeklassen definiert.
Man kann von keinem Programm erwarten, dass es unter allen widrigen Umständen normal funktioniert. Doch müssen Datenverluste verhindert werden, und der Benutzer sollte nach Möglichkeit
eine nützliche Information zum aufgetretenen Problem erhalten. Bei vielen Methodenaufrufen ist es
realistisch und erforderlich, auf ein Scheitern vorbereitet zu sein. Dies folgt schon aus Murphy’s
Law (zitiert nach Wikipedia):
„Whatever can go wrong, will go wrong.“
10.1 Unbehandelte Ausnahmen
Findet die CLR zu einer geworfenen Ausnahme entlang der Aufrufersequenz bis hinauf zur Main()Methode keine Behandlungsroutine, wird das Programm mit einer Fehlermeldung beendet.
Kapitel 10: Ausnahmebehandlung
320
Das folgende Programm soll die Fakultät zu einer Zahl berechnen, die beim Start als Kommandozeilenargument übergeben wird. Dabei beschränkt sich die Main()-Methode auf die eigentliche
Fakultätsberechnung und überlässt die Konvertierung und Validierung des übergebenen Strings der
Methode Kon2Int(). Diese wiederum stützt sich bei der Konvertierung auf die statische Methode
ToInt32() der FCL-Klasse Convert:
using System;
class Fakul {
static String instr;
static int Kon2Int() {
int arg = Convert.ToInt32(instr);
if (arg >= 0 && arg <= 170)
return arg;
else
return -1;
}
static void Main(string[] args) {
if (args.Length == 0) {
Console.WriteLine("Kein Argument angegeben");
Environment.Exit(1);
} else
instr = args[0];
int argument = Kon2Int();
if (argument != -1) {
double fakul = 1.0;
for (int i = 1; i <= argument; i++)
fakul = fakul * i;
Console.WriteLine("Fakultät von {0}: {1}", instr, fakul);
}
else
Console.WriteLine("Keine ganze Zahl im Intervall [0, 170]: "
+ instr);
}
}
Das Programm ist durchaus bemüht, einige kritische Situationen zu vermeiden. Main() überprüft,
ob args[0] tatsächlich vorhanden ist, bevor dieser String beim Aufruf der Methode Kon2Int()
als Parameter verwendet wird. Damit wird verhindert, dass es zu einer IndexOutOfRangeException kommt, wenn der Benutzer das Programm ohne Kommandozeilenargument startet. In
diesem Fall reagiert Main() mit dem sofortigen Abbruch des Programms durch Aufruf der Methode
Environment.Exit(), der als Aktualparameter ein Exitcode übergeben wird. Dieser landet beim
Betriebsystem und steht in der Umgebungsvariablen ERRORLEVEL zur Verfügung, z.B.:
>fakul
Kein Argument angegeben
>echo %ERRORLEVEL%
1
Nach einem störungsfrei verlaufenen Programmeinsatz enthält ERRORLEVEL den Exitcode 0.
Dies kann als akzeptable Fehlerbehandlung gelten:


An Stelle der für Benutzer irritierenden und wenig hilfreichen Ausnahmemeldung durch das
Laufzeitsystem erscheint eine verwertbare Information.
Falls das Programm von einem anderen Programm (z.B. einer Kommando-Prozedur) gestartet worden ist, steht dem Aufrufer ein Exitcode zur Verfügung.
Die Methode Kon2Int() überprüft, ob die aus dem übergebenen String-Parameter ermittelte intZahl außerhalb des zulässigen Wertebereichs für eine Fakultätsberechnung (mit double-Ergebniswert) liegt, und meldet ggf. den Wert -1 als Fehlerindikator zurück. Main() erkennt die spezielle
Bedeutung dieses Rückgabewerts, so dass z.B. unsinnige Fakultätsberechnungen für negative Ar-
321
Abschnitt 10.2 Ausnahmen abfangen
gumente vermieden werden. Diese traditionelle Fehlerbehandlung per Rückgabewert ist nicht
grundsätzlich als überholt und ineffizient zu bezeichnen, aber in vielen Situationen doch der gleich
vorzustellenden Kommunikation über Ausnahmeobjekte unterlegen (siehe Abschnitt 10.3 zum Vergleich von Fehlerrückmeldung und Ausnahmebehandlung).
Trotz seiner präventiven Bemühungen ist das Programm leicht aus dem Tritt zu bringen, indem man
es mit einer nicht konvertierbaren Zeichnfolge füttert (z.B. „Vier“). Die zunächst betroffene, FCLintern aufgerufene, Methode Number.StringToNumber() wirft daraufhin eine FormatException.
Diese wird vom Laufzeitsystem entlang der Aufrufsequenz an alle beteiligten Methoden bis hinauf
zu Main() gemeldet:
Main()
Kon2Int()
System.Convert.ToInt32()
System.Number.ParseInt32()
System.Number.StringToNumber()
System.FormatException
Weil kein Aufrufer eine geeignete Behandlungsroutine bereithält, endet das Programm mit einer
Fehlermeldung:
Unbehandelte Ausnahme: System.FormatException: Die Eingabezeichenfolge hat das
falsche Format.
bei System.Number.StringToNumber(String str, ..., Boolean parseDecimal)
bei System.Number.ParseInt32(String s, NumberStyles style, NumberFormatInfo info)
bei System.Convert.ToInt32(String value)
bei Fakul.Kon2Int(String instr) in U:\Eigene Dateien\ ... \Fakul.cs:Zeile 6.
bei Fakul.Main(String[] args) in U:\Eigene Dateien\ ... \Fakul.cs:Zeile 19.
10.2 Ausnahmen abfangen
Die Startversion des obigen Programms zur Fakultätsberechnung beherrscht weder das Behandeln
noch das Werfen von Ausnahmen. Wir machen uns nun daran, diese kommunikativen Kompetenzen nachzurüsten.
10.2.1 Die try-catch-finally - Anweisung
In C# wird die Behandlung von Ausnahmen über die try-catch-finally - Anweisung unterstützt:
Kapitel 10: Ausnahmebehandlung
322
try {
Überwachter Block mit Anweisungen für den normalen Programmablauf
}
catch (Ausnahmeklasse Parametername) {
Anweisungen für die Behandlung von Ausnahmen der ersten Ausnahmeklasse
}
// Optional können weitere Ausnahmen abgefangen werden:
catch (Ausnahmeklasse Parametername) {
Anweisungen für die Behandlung von Ausnahmen der zweiten Ausnahmeklasse
}
. . .
// Optionaler Block mit Abschluss- bzw. Bereinigungsarbeiten.
// Bei vorhandenem finally-Block, ist kein catch-Block erforderlich.
finally {
Anweisungen, die unabhängig vom Auftreten einer Ausnahme ausgeführt werden
}
Die Anweisungen für den ungestörten Ablauf setzt man in den try-Block. Treten bei der Ausführung dieses überwachten Blocks keine Fehler auf, wird das Programm hinter der try-Anweisung
fortgesetzt, wobei ggf. vorher noch der finally–Block ausgeführt wird.
Weil es der obigen Syntaxbeschreibung im Quellcodedesign trotz Unterstützung durch Kommentare
an Präzision fehlt, sollen Sie in einer Übungsaufgabe ein Syntaxdiagramm erstellen (siehe Abschnitt 10.7).
10.2.1.1 Ausnahmebehandlung per catch-Block
Ein catch-Block wird auch als Exception-Handler bezeichnet und besitzt im Kopfbereich eine einelementige Parameterliste. Anders als bei einer Methode kann sich Parameterliste eines catch-Blocks
jedoch auf die Typangabe beschränken oder ganz fehlen (siehe unten). Tritt im try-Block eine Ausnahme auf, wird seine Ausführung abgebrochen. Anschließend sucht Laufzeitsystem nach einem
catch-Block, dessen Formalparameter den Typ der zu behandelnden Ausnahme oder einen Basistyp
besitzt und führt dann die zugehörige Anweisung aus. Weil die Liste der catch-Blöcke von oben
nach unten durchsucht wird, müssen Ausnahmebasisklassen stets unter abgeleiteten Klassen stehen.
Freundlicherweise stellt der Compiler die Einhaltung dieser Regel sicher. Von einer try-catchfinally - Anweisung wird maximal ein catch-Block ausgeführt. Weitere Details zum Programmablauf bei der Ausnahmebehandlung folgen in Abschnitt 10.2.2.
Nun zu den angekündigten Möglichkeiten, den Kopf eines catch-Blocks zu vereinfachen:

Man kann auf die Angabe eines Formalparameternamens verzichten, hat dann aber im
catch-Block kein Ausnahmeobjekt (mit Unfallbericht, siehe unten) zur Verfügung:
catch (Ausnahmeklasse) {
Anweisungen für die Behandlung der Ausnahme
}

Fehlt bei einem catch-Block auch die Typangabe, ist die (maximal breite) AusnahmeBasisklasse Exception eingestellt, was offensichtlich nur beim letzten catch-Block sinnvoll
ist:
Abschnitt 10.2 Ausnahmen abfangen
323
catch {
Anweisungen für die Behandlung der Ausnahme
}
Welche Ausnahmen von den Methoden eines FCL-Typs zu befürchten sind, erfährt man in der Dokumentation, z.B. bei der Methode Convert.ToInt32():
Neben der bereits besprochenen System.FormatException ist bei Convert.ToInt32() auch eine
Ausnahme aus der Klasse System.OverflowException möglich. Sie wird geworfen, wenn sich bei
der Konvertierung eine ganze Zahl außerhalb des int-Wertebereichs ergibt (vgl. Abschnitt 3.6.1).
In der folgenden Variante der Methode Kon2Int() werden die von Convert.ToInt32() zu erwartenden Ausnahmen abgefangen. Die OverflowException wird lediglich entfernt, wobei
Kon2Int() den Wert -1 zurückliefert (wie bei einem int-Wert außerhalb von [0, 170]). Im
FormatException - Handler wird versucht, durch sukzessives Streichen des jeweils letzten Zeichens eine interpretierbare Teilzeichenfolge zu gewinnen. Bei Misserfolg landet wiederum der Wert
-1 beim Aufrufer. Weil beim Reparaturversuch mit hoher Wahrscheinlichkeit mehrere Fehlversuche
zu erwarten sind, ist die per Ausnahmeobjekt kommunizierende Methode Convert.ToInt32() aus
Performanzgründen ungeeignet. Stattdessen wird die Methode Int32.TryParse() verwendet, die
traditionell per bool-Rückgabewert über die Konvertierbarkeit berichtet und den resultierenden
Wert per out-Parameter übergibt.
static int Kon2Int() {
int arg = -1;
try {
arg = Convert.ToInt32(instr);
} catch (FormatException) {
String str = instr;
bool ok = false;
while (str.Length > 1 && !ok) {
str = str.Substring(0, str.Length - 1);
ok = Int32.TryParse(str, out arg);
}
if (ok)
instr = str;
else
arg = -1;
} catch (OverflowException) {}
if (arg >= 0 && arg <= 170)
return arg;
else
return -1;
}
Kapitel 10: Ausnahmebehandlung
324
Man kann sich fragen, ob Kon2Int() nicht komplett auf den potentielle Ausnahmewerfer Convert.ToInt32() und damit auch auf die Ausnahmebehandlung per try-catch - Anweisung verzichten
und ausschließlich die mit Rückgabewert arbeitende Methode Int32.TryParse() verwenden sollte.
Im konkreten Beispiel wäre gegen diese Vorgehenswiesen nicht viel einzuwenden (siehe Performanzüberlegungen in Abschnitt 10.3). Generell ist die try-catch - Ausnahmebehandlung jedoch
eine außerordentlich wichtige Option, und hier wurde ein möglichst kurzes Demonstrationsbeispiel
benötigt. Dabei sollte sich der catch-Block trotz der Kürze nicht auf eine pure Ausgabe zum Existenznachweis beschränken, sondern einen ernsthaften Reparaturversuch unternehmen. Oft wird ein
catch-Block versuchen, bereits realisierte und aufgrund der Ausnahme nunmehr unerwünschte Effekte des unterbrochenen try-Blocks wieder rückgängig zu machen. Viele catch-Blöcke betätigen
sich als Informationsvermittler und werfen selbst eine Ausnahme, um ihrem Aufrufer einen leichter
verständlichen Unfallbericht zu liefern (siehe Abschnitt 10.6).
Das Beispielprogramm, dessen Main()-Methode im Vergleich zur Version in Abschnitt 10.1 unverändert geblieben ist, endet aufgrund der Verbesserungen in Kon2Int() nun beim Auftreten einer
FormatException (z.B. wegen des Kommandozeilenarguments „Vier“) mit der Meldung:
Keine ganze Zahl im Intervall [0, 170]: Vier
Die catch-Blöcke in Kon2Int() verwenden das von Convert.ToInt32() geworfene Ausnahmeobjekt nicht und verzichten daher auf einen Parameternamen.
Man kann das Abfangen von Ausnahmen auch übertreiben. Richter (2006, S. 455f) warnt davor, in
Methoden von Bibliotheksklassen zu viele Ausnahmetypen abzufangen und damit „aus der Welt zu
schaffen“, z.B. nach dem folgenden Muster:
try {
. . .
} catch (Exception e) {
. . .
}
Eventuell möchten einige Anwendungen, welche die Bibliotheksmethode verwenden, auf manche
Ausnahmetypen reagieren. In diesem Fall ist es besser, einen Ausnahmetyp in der Bibliotheksmethode unbehandelt zu lassen oder nach einer Behandlung erneut zu werfen (siehe Abschnitt 10.6).
10.2.1.2 finally
Der finally-Block wird in jedem Fall ausgeführt, also …


nach der ungestörten Ausführung des try-Blocks
Auch einvorzeitiges Verlassen der Methode durch eine return-Anweisung im try-Block
verhindert nicht die Ausführung des finally-Blocks.
nach einer Ausnahmebehandlung in einem catch-Block
nach dem Auftreten einer unbehandelten Ausnahme
Dies ist der ideale Ort für Anweisungen, die unter möglichst allen Umständen ausgeführt werden
sollen, z.B. zur Freigabe von Ressourcen wie Datei - und Netzverbindungen. Wir verwenden (dem
Abschnitt 12 über Dateibearbeitung vorgreifend) zur finally-Demonstration eine statische Methode,
die aus einer Textdatei pro Zeile eine double-Zahl zu lesen versucht, um den Mittelwert der vorhandenen Zahlen zu berechnen:
Abschnitt 10.2 Ausnahmen abfangen
325
static void Mean(String dateiname) {
StreamReader sr = null;
FileStream fs = null;
try {
fs = new FileStream(dateiname, FileMode.Open);
} catch {
Console.WriteLine("Fehler beim Öffnen der Datei {0}", dateiname);
throw;
}
try {
String s;
int n = 0;
double summe = 0.0;
sr = new StreamReader(fs);
while ((s = sr.ReadLine()) != null) {
summe += Convert.ToDouble(s);
n++;
}
Console.WriteLine("Deskriptive Statistiken zur Datei {0}\n", dateiname);
Console.WriteLine("Anzahl:\t" + n);
Console.WriteLine("Summe:\t" + summe);
Console.WriteLine("Mittel:\t" + summe / n);
} catch {
Console.WriteLine("Fehler beim Lesen der Datei {0}", dateiname);
throw;
} finally {
sr.Close();
}
}
Das Ergebnis eines erfolgreichen Aufrufs:
Deskriptive Statistiken zur Datei daten.txt
Anzahl: 18
Summe: 90
Mittel: 5
In der ersten try-Anweisung (ohne finally-Block) werden die vom FileStream - Konstruktor, der
eine vorhandene Datei öffnen soll, zu erwartenden Ausnahmen behandelt:


System.IO.FileNotFoundException
Die Datei existiert nicht.
System.IO.IOException
Diese Ausnahme tritt z.B. auf, wenn ein anderer Prozess die Datei durch sein exklusives
Zugriffsrecht blockiert.
Der catch-Block schreibt eine Fehlermeldung und wirft per throw - Anweisung (siehe Abschnitt
10.5) dieselbe Ausnahme erneut, so dass die Methode Mean() beendet und der Aufrufer über das
Scheitern informiert wird. Um den momentan eigentlich interessanten Fall einer Störung nach dem
erfolgreichen Öffnen der Datei geht es erst in der zweiten try-Anweisung. Eine geöffnete Datei
muss möglichst früh per Close()-Aufruf geschlossen werden, um andere Programme möglichst wenig zu behindern. Das muss auch für den Ausnahmefall sicher gestellt werden, indem das Schließen
in einem finally-Block stattfindet. Im Beispiel kommt es zu einer Ausnahme bei geöffneter Datei,
wenn die Methode Convert.ToDouble() auf eine nicht konvertierbare Zeichenfolge trifft (siehe
Abschnitt 10.1). Weil der Close()-Aufruf im finally-Block steht, wird er auf jeden Fall ausgeführt.
Stünde er z.B. am Ende des try-Blocks, bliebe im eben geschilderten Ausnahmefall die Datei geöffnet bis zum Programmende.
Kapitel 10: Ausnahmebehandlung
326
10.2.2 Programmablauf bei der Ausnahmebehandlung
Tritt bei der Ausführung einer Anweisung in der Methode M() eine Ausnahme auf (von der CLR
oder von einer aufgerufenen Methode geworfen), dann sucht das Laufzeitsystem nach einem zuständigen Exception-Handler. Befindet sich die betroffene Anweisung in einem try-Block, beginnt
die Suche bei den zugehörigen catch-Blöcken. Wird das Laufzeitsystem nicht fündig, durchsucht es
den Aufrufer der Methode M() und macht ggf. weiter entlang der Aufrufsequenz. Man kann also
ganz einfach (ohne Weiterleitungsaufwand) die Behandlung einer Ausnahme der bestgerüsteten
Methode überlassen.
10.2.2.1 Beispiel
In folgendem Beispiel dürfen Sie allerdings keine optimierte Einsatzplanung erwarten. Es soll einige Programmabläufe demonstrieren, die sich bei Ausnahmen auf verschiedenen Stufen einer Aufrufhierarchie ergeben können. Um das Beispiel einfach zu halten, wird auf Nützlichkeit und Praxisnähe verzichtet. Das Programm nimmt via Kommandozeile ein Argument entgegen, interpretiert es
numerisch und ermittelt den Rest aus der Division der Zahl 10 durch das Argument:
using System;
class Sequenzen {
static int Calc(String instr) {
int erg = 0;
try {
Console.WriteLine("try-Block von Calc()");
erg = 10 % Convert.ToInt32(instr);
}
catch (FormatException) {
Console.WriteLine("FormatException-Handler in Calc()");
}
finally {
Console.WriteLine("finally-Block von Calc()");
}
Console.WriteLine("Nach try-Anweisung in Calc()");
return erg;
}
static void Main(string[] args) {
try {
Console.WriteLine("try-Block von Main()");
Console.WriteLine("10 % "+args[0]+" = "+Calc(args[0]));
}
catch (ArithmeticException) {
Console.WriteLine("ArithmeticException-Handler in Main()");
}
finally {
Console.WriteLine("finally-Block von Main()");
}
Console.WriteLine("Nach try-Anweisung in Main()");
}
}
Die Methode Main() lässt die eigentliche Arbeit von der Methode Calc() erledigen und bettet
den Aufruf in eine try-Anweisung mit catch-Block für die ArithmeticException ein, die z.B. bei
einer Division durch Null auftritt. Calc() benutzt die Klassenmethode Convert.ToInt32() sowie
den Modulo-Operator in einem try-Block, wobei nur die (potentiell von Convert.ToInt32() zu erwartende) FormatException abgefangen wird.
Wir betrachten einige Konstellationen mit ihren Konsequenzen für den Programmablauf:
a)
b)
c)
d)
Normaler Ablauf
Exception in Calc(), die dort auch behandelt wird
Exception in Calc(), die in Main() behandelt wird
Exception in Main(), die nirgends behandelt wird
Abschnitt 10.2 Ausnahmen abfangen
327
a) Normaler Ablauf
Beim Programmablauf ohne Ausnahmen (hier mit Kommandozeilen-Argument „8“) kommt es zu
folgenden Ausgaben:
try-Block von Main()
try-Block von Calc()
finally-Block von Calc()
Nach try-Anweisung in Calc()
10 % 8 = 2
finally-Block von Main()
Nach try-Anweisung in Main()
b) Exception in Calc(), die dort auch behandelt wird
Wird beim Ausführen der Anweisung
erg = 10 % Convert.ToInt32(instr);
eine FormatException an Calc() gemeldet (z.B. wegen Kommandozeilenargument „Vier“ von
Convert.ToInt32() geworfen), dann kommt der zugehörige catch-Block zum Einsatz. Dann folgen:


finally-Block in Calc()
restliche Anweisungen in Calc()
An Main() wird keine Ausnahme gemeldet, also werden nacheinander ausgeführt:



try-Block
finally-Block
restliche Anweisungen
Insgesamt erhält man die folgenden Ausgaben:
try-Block von Main()
try-Block von Calc()
FormatException-Handler in Calc()
finally-Block von Calc()
Nach try-Anweisung in Calc()
10 % acht = 0
finally-Block von Main()
Nach try-Anweisung in Main()
Zu der wenig überzeugenden Ausgabe
10 % Vier = 0
kommt es, weil die FormatException in Calc() nicht sinnvoll behandelt wird. Das aktuelle Beispiel soll ausschließlich dazu dienen, Programmabläufe bei der Ausnahmebehandlung zu demonstrieren.
c) Exception in Calc(), die in Main() behandelt wird
Wird eine ArithmeticException an Calc() gemeldet (z.B. wegen Kommandozeilenargument
„0“), findet sich in der Methode kein passender Handler. Bevor die Methode verlassen wird, um
entlang der Aufrufsequenz nach einem geeigneten Handler zu suchen, wird noch ihr finally-Block
ausgeführt. Im Aufrufer Main() findet sich ein ArithmeticException–Handler, der nun zum Einsatz kommt. Dann geht es weiter mit dem zugehörigen finally-Block. Schließlich wird das Programm hinter der try-Anweisung der Methode Main() fortgesetzt.
try-Block von Main()
try-Block von Calc()
finally-Block von Calc()
ArithmeticException-Handler in Main()
finally-Block von Main()
Nach try-Anweisung in Main()
Kapitel 10: Ausnahmebehandlung
328
d) Exception in Main(), die nirgends behandelt wird
Übergibt der Benutzer gar kein Kommandozeilen-Argument, tritt in Main() bei Zugriff auf
args[0] eine IndexOutOfRangeException auf (von der CLR geworfen). Weil sich kein zuständiger Handler findet, wird das Programm von der CLR beendet:
try-Block von Main()
Unbehandelte Ausnahme: System.IndexOutOfRangeException: Der Index war außerhalb
des Arraybereichs.
bei Sequenzen.Main(String[] args) in U:\Eigene Dateien\Sequenzen\Sequenzen.cs:Zeile
23.
finally-Block von Main()
10.2.2.2 Komplexe Fälle
In einer komplexen Methode ist es oft sinnvoll, try-Anweisungen zu schachteln, wobei sowohl innerhalb eines try- als auch innerhalb eines catch-Blocks wiederum eine komplette try-Anweisung
stehen darf. Daraus ergeben sich weitere Ablaufvarianten für eine flexible Ausnahmebehandlung.
Wenn eine per Delegatenobjekt aufgerufene Methode wegen einer unbehandelten Ausnahme vorzeitig endet, dann werden ggf. in der Delegatenaufrufliste nachfolgende Methoden nicht aufgerufen.
10.3 Ausnahmeobjekte im Vergleich mit der traditionellen Fehlerbehandlung
Die konventionelle Fehlerbehandlung verwendet meist die Rückgabewerte von Methoden zur Berichterstattung über Probleme bei der Ausführung von Aufträgen. Ein Rückgabewert kann …


ausschließlich zur Fehlermeldung dienen
Meist wird dann ein ganzzahliger Returncode mit Datentyp int verwendet, wobei die Null
einen erfolgreichen Ablauf meldet, während andere Zahlen für einen bestimmten Fehlertyp
stehen. Soll nur zwischen Erfolg und Misserfolg unterschieden werden, bietet sich der
Rückgabewert bool an.
neben den Ergebnissen einer ungestörten Ausführung über spezielle Wert Problemfälle signalisieren (siehe Methode Kon2Int() im Beispielprogramm)
Sollen z.B. drei Methoden, deren Rückgabewerte ausschließlich zur Fehlermeldung dienen, nacheinander aufgerufen werden, dann wird die vom Algorithmus diktierte simple Sequenz:
m1();
m2();
m3();
nach Ergänzen der Fehlerbehandlung zu einer länglichen und recht unübersichtlichen Konstruktion
wird (nach Mössenböck 2005, S. 254):
Abschnitt 10.3 Ausnahmeobjekte im Vergleich mit der traditionellen Fehlerbehandlung
329
returncode = m1();
if (returncode == 0) {
returncode = m2();
if (returncode == 0) {
returncode = m3();
if (returncode == 0) {
. . .
}
else {
// Behandlung für diverse m3()-Fehler}
}
else {
// Behandlung für diverse m2()-Fehler}
}
else {
// Behandlung für diverse m1()-Fehler
}
Mit Hilfe der Ausnahmetechnik bleibt beim Kernalgorithmus die Übersichtlichkeit erhalten:
try {
m1();
m2();
m3();
} catch (ExA a)
// Behandlung
} catch (ExB b)
// Behandlung
} catch (ExC c)
// Behandlung
}
{
von Ausnahmen aus der Klasse ExA
{
von Ausnahmen aus der Klasse ExB
{
von Ausnahmen aus der Klasse ExC
Ein gut gesetzter Rückgabewert nutzt natürlich nichts, wenn sich der Aufrufer nicht darum kümmert.
Neben dem unübersichtlichen Quellcode und der ungesicherten Beachtung eines Rückgabewertes
ist am klassischen Verfahren zu bemängeln, dass eine Fehlerinformation aufwändig entlang der
Aufrufersequenz nach oben gemeldet werden muss, wenn sie nicht an Ort und Stelle behandelt werden soll.
Wenn eine Methode per Rückgabewert eine Nutzinformation (z.B. ein Berechnungsergebnis) übermitteln soll, und bei einer ungestörten Methodenausführung jeder Wert des Rückgabetyps auftreten
kann, dann sind keine Werte als Fehlerindikatoren verfügbar. In diesem Fall verwendet die klassische Fehlerbehandlung eine Statusvariable als Kommunikationsmittel, wobei die Beachtung ebenso wenig garantiert ist wie bei einem Returncode.
Gegenüber der konventionellen Fehlerbehandlung hat die Kommunikation über Ausnahmeobjekte
u.a. folgende Vorteile:


Bessere Lesbarkeit des Quellcodes
Mit Hilfe einer try-catch-finally - Konstruktion erreicht man eine Trennung zwischen den
Anweisungen für den normalen Programmablauf und den diversen Ausnahmebehandlungen,
so dass der Quellcode übersichtlich bleibt.
Automatische Weitermeldung bis zur bestgerüsteten Methode
Oft ist der unmittelbare Aufrufer nicht gut gerüstet zur Behandlung einer Ausnahme, z.B.
nach dem vergeblichen Öffnen einer Datei. Dann soll eine „höhere“ Methode über das weitere Vorgehen entscheiden.
Kapitel 10: Ausnahmebehandlung
330


Bessere Fehlerinformationen für den Aufrufer
Über ein Exception-Objekt kann der Aufrufer beliebig genau über einen aufgetretenen Fehler informiert werden, was bei einem klassischen Rückgabewert nicht der Fall ist.
Garantierte Beachtung von Ausnahmen
Im Unterschied zu Returncodes oder Fehlerstatusvariablen können Ausnahmen nicht ignoriert werden. Reagiert ein Programm nicht darauf, wird es vom Laufzeitsystem beendet.
Wie die Realisation des catch-Blocks zur FormatException in Abschnitt 10.2.1 gezeigt hat, ist die
Fehlermeldung per Ausnahmeobjekt dem klassischen Rückgabewert nicht grundsätzlich überlegen.
Wenn ein Problem mit erheblicher Wahrscheinlichkeit auftritt, also keinesfalls ungewöhnlich ist, …


sollte eine routinemäßige, aktive Kontrolle stattfinden
eine auf das Problem stoßende Methode per Rückgabewert kommunizieren, also davon ausgehen, dass der Aufrufer mit dem Problem rechnet und daher den Rückgabewert beachtet.
Bei Fehlern mit geringer Wahrscheinlichkeit haben jedoch häufige, meist überflüssige Kontrollen
Performanzeinbußen und einen unübersichtlichen Quellcode zur Folge. Hier sollte man es besser
auf eine Ausnahme ankommen lassen. Eine Überwachung über Ausnahmetechnik verursacht praktisch nur dann Kosten, wenn tatsächlich eine Ausnahme geworfen wird. Diese Kosten sind allerdings deutlich größer als bei einer Fehleridentifikation auf traditionelle Art.
10.4 Ausnahme-Klassen im .NET - Framework
Das .NET – Framework kennt zahlreiche vordefinierte Ausnahmeklassen, die mit ihren Vererbungsbeziehungen eine Klassenhierarchie bilden, aus der die folgende Abbildung einen kleinen
Ausschnitt zeigt:
Object
Exception
WebException
IOException
ApplicationException
SystemException
FormatException
IndexOutOfRangeException
ArithmeticException
NullReferenceException
DivideByZeroException
OverflowException
In einem catch-Block können auch mehrere Ausnahmen durch Wahl einer entsprechend breiten
Basisklasse abgefangen werden.
Schon in der Klasse Exception sind u.a. die folgenden Eigenschaften mit Detailinformationen zu
einer Ausnahme definiert:

Message
Diese String-Eigenschaft enthält eine Fehlermeldung mit Angaben zur Ursache der Ausnahme. Der Methodenaufruf
Convert.ToInt32("Drei")
sorgt z.B. für eine FormatException mit der Message-Eigenschaft:
Die Eingabezeichenfolge hat das falsche Format.
Abschnitt 10.4 Ausnahme-Klassen im .NET - Framework

331
StackTrace
Diese String-Eigenschaft beschreibt den Aufrufstapel mit den beim Auftreten der Ausnahme aktiven Methoden. Am Ende von Abschnitt 10.1 war schon die StackTrace-Eigenschaft
der FormatException zu sehen, die in der ersten Variante des Fakultätsprogramms bei einer
irregulären Zeichenfolge von der Methode Convert.ToInt32() geworfen wird:
bei
bei
bei
bei
bei
System.Number.StringToNumber(String str, ..., Boolean parseDecimal)
System.Number.ParseInt32(String s, NumberStyles style, NumberFormatInfo info)
System.Convert.ToInt32(String value)
Fakul.Kon2Int(String instr) in U:\Eigene Dateien\ ... \Fakul.cs:Zeile 6.
Fakul.Main(String[] args) in U:\Eigene Dateien\ ... \Fakul.cs:Zeile 19.
Dateinamen und Zeilennummern enthält die Aufrufreihenfolge nur dann, wenn das betroffene Assembly mit aktiver Debug-Option übersetzt worden ist. Bei direkten Aufruf des Compilers in einem Konsolenfenster ist die Option debug anzugeben, z.B.:
csc /debug Sequenzen.cs
Im Visual Studio 2008 (Express Edition) stellt man den Umfang der Debuginformationen über
Projekt > Einstellungen > Erstellen > Erweitert
ein, wobei die folgende Voreinstellung gilt:


Durch Optimierungsmaßnahmen des Compilers (z.B. Inlining) kann die Aufrufersequenz
kürzer als erwartet ausfallen.
InnerException
Viele catch-Blöcke betätigen sich als Informationsvermittler und werfen selbst eine Ausnahme, um dem Aufrufer einen leichter verständlichen Unfallbericht zu liefern (siehe Abschnitt 10.6). Um dem Aufrufer auch die ursprüngliche Ausnahme zur Verfügung zu stellen,
kann man ihre Adresse in der Eigenschaft InnerException mitliefern.
Data
Über die Data-Eigenschaft mit dem Interface-Typ IDictionary kann man Zusatzinformationen zur Ausnahme in einer beliebig langen Schlüssel-Wert - Liste (mit Elementen vom Typ
DictionaryEntry) unterbringen. Für die Schlüssel wählt man in der Regel den Datentyp
String, für die Werte einen geeigneten Typ. Im folgenden Beispiel werden drei Einträge in
die Data-Liste eines neuen Ausnahmeobjekts aufgenommen:
if (arg < 0 || arg > 170) {
BadFakulArgException bfa =
new BadFakulArgException("Wert ausselhalb [0, 170]");
bfa.Data.Add("Input", instr);
bfa.Data.Add("Type", 3);
bfa.Data.Add("Value", arg);
throw bfa;
}
Die ToString()-Methode eines Exception-Objekts liefert:
Kapitel 10: Ausnahmebehandlung
332



den Namen der Ausnahmeklasse
Message-Zeichenfolge (die beim Erzeugen der Ausnahme formulierte Fehlermeldung)
StackTrace-Zeichenfolge (die Aufrufreihenfolge)
Beispiel:
System.FormatException: Die Eingabezeichenfolge hat das falsche Format.
bei System.Number.StringToNumber(String str, . . .)
bei System.Number.ParseInt32(String s, . . .)
bei System.Convert.ToInt32(String value)
bei Sequenzen.Calc(String instr) in U:\Eigene Dateien\ ... \Sequenzen.cs:Zeile 8.
Bis zur .NET - Version 2.0 galt die Empfehlung an Anwendungsprogrammierer, eigene Ausnahmeklassen (siehe Abschnitt 10.6) aus ApplicationException abzuleiten. Seit der .NET - Version 3.0
rät die FCL-Dokumentation hingegen:
Bei der Entwicklung von Anwendungen, in denen eigene Ausnahmen erstellt werden müssen,
wird empfohlen, benutzerdefinierte Ausnahmen von der Exception-Klasse abzuleiten. Ursprünglich sollten benutzerdefinierte Ausnahmen von der ApplicationException-Klasse abgeleitet
werden. Die Praxis hat jedoch gezeigt, dass sich hierdurch keine wesentlichen Vorteile ergeben.
10.5 Ausnahmen werfen (throw)
Unsere eigenen Methoden müssen sich nicht auf das Abfangen von Ausnahmen beschränken, die
vom Laufzeitsystem oder von Bibliotheksmethoden stammen, sondern können sich auch als Werfer
betätigen, um bei misslungenen Aufrufen den Absender mit Hilfe der flexiblen ExceptionTechnologie zu informieren. In folgender Variante der Methode Kon2Int() aus dem Standardbeispiel von Abschnitt 10 wird ein Ausnahmeobjekt aus der Klasse ArgumentOutOfRangeException erzeugt und geworfen, wenn die erfolgreiche Interpretation des Parameters instr ein
unzulässiges Fakultätsargument ergibt:
static int Kon2Int(string instr) {
int arg;
arg = Convert.ToInt32(instr);
if (arg < 0 || arg > 170)
throw new ArgumentOutOfRangeException("instr", arg,
"Argument ausserhalb [0, 170]");
else
return arg;
}
Zum Auslösen einer Ausnahme dient die throw-Anweisung. Sie enthält nach dem Schlüsselwort
throw eine Referenz auf ein Ausnahmeobjekt. Wie im Beispiel benutzt man oft den new-Operator
mit nachfolgendem Konstruktor, um vor Ort das Ausnahmeobjekt zu erzeugen und die Referenz zu
liefern.
Im Beispiel wird ein ArgumentOutOfRangeException-Konstruktor mit drei Parametern verwendet, wobei Name und Wert des irregulären Arguments und eine Fehlermeldung anzugeben sind.
Aus dem Aufruf
try {
argument = Kon2Int(args[0]);
} catch (Exception e) {
Console.WriteLine(e.Message);
}
resultiert ggf. die Meldung:
333
Abschnitt 10.6 Ausnahmen definieren
Argument ausserhalb [0, 170]
Parametername: instr
Der tatsächliche Wert war 188.
In einem catch-Block darf das Schlüsselwort throw auch ohne Ausnahmeobjekt-Referenz stehen.
In diesem Fall wird die gerade behandelte Ausnahme erneut geworfen. Dieses Verhalten kommt
z.B. in Frage, wenn eine Methode auf eine Ausnahme reagieren und einen Lösungsversuch unternehmen möchte, aber das Problem nicht aus Welt schaffen kann und daher ihren Aufrufer informieren muss.
Statt die ursprüngliche Ausnahme in einem catch-Block erneut zu werfen, kommt auch die Verwendung einer anderen Ausnahmeklasse in Frage, die aufgrund der bisherigern Analyse besser geeignet erscheint (siehe Abschnitt 10.6). Damit die ursprüngliche Ausnahme als Anlage beigefügt
werden kann, bieten die FCL-Ausnahmeklassen einen Konstruktor mit Parameter vom Typ Exception (siehe oben).
In der aktuellen Kon2Int()–Variante wird auf die (in Abschnitt 10.2.1 sehr provisorisch ausgeführte) Behandlung der von Convert.ToInt32 potentiell zu erwartenden Ausnahmen verzichtet.
Folglich wirft die aktuelle Kon2Int()-Version (als Vermittler oder Initiator) folgende Ausnahmen:



FormatException
OverflowException
ArgumentOutOfRangeException
In einer sorgfältigen Dokumentation müssen Anwender der Methode darüber informiert werden.
Dass eine Methode die selbst geworfenen Ausnahmen auch wieder auffängt, ist nicht unbedingt der
Standardfall, aber in manchen Situationen eine praktische Möglichkeit, von verschiedenen potentiellen Schadstellen aus zur selben Ausnahmebehandlung zu verzweigen, wobei der „Sender“ per
Ausnahmeobjekt übermittelt werden kann. Diese Technik wird im folgenden Beispiel ohne störenden Inhalt demonstriert:
Quellcode
Ausgabe
using System;
class Prog {
static void Nix(bool cond) {
try {
if (cond)
throw new Exception("A");
else
throw new Exception("B");
}
catch (Exception e) {
Console.WriteLine(e.Message);
}
}
static void Main() {
Nix(true);
}
}
A
10.6 Ausnahmen definieren
Mit Hilfe von Ausnahmeobjekten kann eine Methode beim Auftreten von Fehlern den Aufrufer
ausführlich und präzise über Ursachen und Begleitumstände informieren. Dabei muss man sich keinesfalls auf die im .NET – Framework vordefinierten Ausnahmeklassen beschränken, sondern kann
eigene Ausnahmen definieren, z.B.:
Kapitel 10: Ausnahmebehandlung
334
public sealed class BadFakulArgException : Exception {
int type, value;
string input;
public BadFakulArgException() : base() {
}
public BadFakulArgException(String message): base(message) {
}
public BadFakulArgException(String message, Exception innerException)
: base(message, innerException) {
}
public BadFakulArgException(string message, string input_,
int type_, int value_)
: this(message, input_, type_, value_, null) {
}
public BadFakulArgException(string message, string input_,
int type_, int value_, Exception innerException)
: this(message, innerException) {
input = input_;
type = type_;
value = value_;
}
public string Input {get {return input;}}
public int Type {get {return type;}}
public int Value {get {return value;}}
}
Wir halten uns bei der Klasse BadFakulArgException an Microsoft Empfehlungen für selbst
definierte Ausnahmeklasse (siehe z.B. http://msdn.microsoft.com/de-de/library/87cdya3t.aspx):



Als Basisklasse sollte System.Exception verwendet werden (vgl. Abschnitt 10.4).
Der Klassenname sollte mit dem Wort Exception enden.
Die folgenden allgemeinen Konstruktoren sollten mit public - Verfügbarkeit implementiert
werden:
o Ein parameterfreier Konstruktor
o Ein Konstruktor mit einem String-Parameter für die Fehlermeldung
Bei der Klasse ArgumentOutOfRangeException (siehe Abschnitt 10.5) erwartet
diese Konstruktor-Überladung allerdings den Name des betroffenen Parameters.
o Einen Konstruktor mit einem String- und einem Exception-Parameter für eine Fehlermeldung und den Verweis auf inneres Ausnahmeobjekt, das zuvor aufgefangen
wurde und nun in ein informativeres Ausnahmeobjekt als Anlage aufgenommen wird
(siehe unten).
Bei serialisierbaren Ausnahmeklassen (siehe Abschnitt 12.2.3) sollte außerdem ein Konstruktor mit Parametern vom Typ SerializationInfo und StreamingContext implementiert
werden, der zur Wiederherstellung eines zuvor per Datenstrom geschriebenen Objekts dient.
Beim parameterlosen BadFakulArgException-Konstruktor beschränken wir uns auf den (impliziten) Aufruf des parameterlosen Basisklassenkonstruktors. Bei der restlichen Konstruktoren
rufen wir explizit eine passende Überladung des Basisklassenkonstruktors auf, um die Instanzvariablen hinter den Eigenschaften Message und InnerException initialisieren zu können.
Über ein Objekt der handgestrickten Ausnahmeklasse BadFakulArgException kann ausführlich über Probleme mit Argumenten für die Fakultätsberechnung informiert werden:

In der Eigenschaft Message (geerbt von Exception) steht wie üblich eine Fehlermeldung.

In der Eigenschaft Input steht die zu konvertierende Zeichenfolge.
Abschnitt 10.6 Ausnahmen definieren

In der Eigenschaft Type wird ein numerischer Indikator für die Fehlerart angeboten:
o 1: Zeichenfolge kann nicht konvertiert werden
o 2: int-Überlauf
o 3: int-Wert außerhalb [0, 170]

in der Eigenschaft Value steht das Konvertierungsergebnis (falls vorhanden, sonst -1)
335
Die endgültige Kon2Int()-Version kümmert sich um die Convert.ToInt32() zu befürchtenden
Ausnahmen und wirft bei allen Fehlerursachen eine spezielle BadFakulArgException:
static int Kon2Int(string instr) {
int arg;
try {
arg = Convert.ToInt32(instr);
if (arg < 0 || arg > 170)
throw new BadFakulArgException("Wert ausselhalb [0, 170]",instr,3,arg);
else
return arg;
}
catch (OverflowException e) {
throw new BadFakulArgException("Integer-Überlauf",instr,2,-1,e);
}
catch (FormatException e) {
throw new BadFakulArgException("Fehler beim Konvertieren",instr,1,-1,e);
}
}
Den in catch-Blöcken geworfenen BadFakulArgException-Objekten wird das aufgefangene
Ausnahmeobjekt beigefügt, um dem Aufrufer keine Information vorzuenthalten.
In der Main()-Methode des Beispielprogramms kann eine abgefangene Ausnahme nun präzise protokolliert werden:
static void Main(string[] args) {
int argument = -1;
if (args.Length == 0) {
Console.WriteLine("Kein Argument angegeben");
Environment.Exit(1);
}
try {
argument = Kon2Int(args[0]);
}
catch (BadFakulArgException e) {
Console.WriteLine("Message:\t" + e.Message);
Console.WriteLine("Fehlertyp:\t{0}\nZeichenfolge:\t{1} ", e.Type, e.Input);
Console.WriteLine("Wert:
\t" + e.Value);
if (e.InnerException != null)
Console.WriteLine("Orig. Message:\t" + e.InnerException.Message);
Environment.Exit(1);
}
double fakul = 1.0;
for (int i = 1; i <= argument; i++)
fakul = fakul * i;
Console.WriteLine("Fakultät von {0}: {1}", args[0], fakul);
}
Bei einem Programmstart mit dem Kommandozeilenargument „Vier“ resultiert z.B. die Ausgabe:
Message:
Fehlertyp:
Zeichenfolge:
Wert:
Orig. Message:
Fehler beim Konvertieren
1
Vier
-1
Die Eingabezeichenfolge hat das falsche Format.
Hier wird auch das beigefügte innere Ausnahmeobjekt angesprochen.
336
Kapitel 10: Ausnahmebehandlung
Zum Transport von speziellen Zusatzinformationen benötigt eine selbst erstellte Ausnahmeklasse
nicht unbedingt zusätzliche Felder bzw. Eigenschaften. Alternativ kann man in der ExceptionEigenschaft Data eine beliebig lange Schlüssel-Wert - Liste (mit Elementen vom Typ DictionaryEntry) unterbringen (siehe Abschnitt 10.4). Bei der Klasse BadFakulArgException könnten
wir uns auf die folgende Standarddefinition beschränken:
public sealed class BadFakulArgException : Exception {
public BadFakulArgException() {
}
public BadFakulArgException(String message) : base(message) {
}
public BadFakulArgException(String message, Exception innerException)
: base(message, innerException) {
}
}
In die Data-Liste eines BadFakulArgException-Objekts lassen sich Schlüssel-Wert - Einträge
mit den benötigten Zusatzinformationen z.B. per Add() aufnehmen:
BadFakulArgException bfa =
new BadFakulArgException("Fehler beim Konvertieren", e);
bfa.Data.Add("Input", instr);
bfa.Data.Add("Type", 1);
bfa.Data.Add("Value", -1);
Ein catch-Block kann per Indexer
Console.WriteLine("Wert = {0}", e.Data["Value"]);
oder mit Hilfe der DictionaryEntry-Eigenschaften Key und Value
foreach (DictionaryEntry de in e.Data) {
Console.WriteLine("{0}\t:\t{1}", de.Key, de.Value);
auf die Data-Listeneinträge eines Ausnahmeobjekts zugreifen.
10.7 Übungsaufgaben zu Kapitel 10
1) Erstellen Sie ein Syntaxdiagramm zur try-catch-finally - Anweisung (vgl. Abschnitt 10.2.1).
2) Im Beispielprogramm zur Demonstration von möglichen Sequenzen bei der Ausnahmebehandlung (siehe Abschnitt 10.2.2) verzichtet die Methode Calc() darauf, die potentiell von der Methode Convert.ToInt32() zu erwartende OverflowException abzufangen (vgl. Abschnitt 10.2.1).
Bleibt die Ausnahe unbehandelt?
3) Beim Rechnen mit Gleitkommazahlen produziert C# in kritischen Situationen keine Ausnahmen,
sondern operiert mit speziellen Werten wie Double.POSITIVE_INFINITY oder Double.NaN.
Dieses Verhalten ist oft nützlich, kann aber die Fehlersuche erschweren, wenn mit den speziellen
Funktionswerten weiter gerechnet wird, und erst am Ende eines längeren Rechenweges das Ergebnis NaN auftaucht (in der Ausgabe: n. def.). In folgendem Beispiel wird eine Methode namens
Log2() zur Berechnung des dualen Logarithmus 1 verwendet, welche auf die FCL-Methode
Math.Log() zurückgreift und daher bei ungeeigneten Argumenten ( 0) als Rückgabewert
Double.NaN liefert.
1
Für eine positive Zahl a ist ihr Logarithmus zur Basis b (> 0) definiert durch:
ln(a )
log b ( a ) :
ln(b)
Dabei steht ln() für den natürlichen Logarithmus zur Basis e (Eulersche Zahl).
337
Abschnitt 10.7 Übungsaufgaben zu Kapitel 1578H10
Quellcode
Ausgabe
using System;
n. def.
class DuaLog {
static double Log2(double arg) {
return Math.Log(arg) / Math.Log(2);
}
static void Main() {
double a = Log2(-1);
double b = Log2(8);
Console.WriteLine(a*b);
}
}
Erstellen Sie eine Version, die bei ungeeigneten Argumenten eine ArgumentOutOfRangeException wirft.
4) Erstellen Sie eine Variante der in Abschnitt 7.2.1 vorgestellten generischen Stapelverwaltungsklasse mit Methoden Auflegen() und Abheben(), die bei besetztem bzw. leerem Stapel eine
InvalidOperationException-Ausnahme werfen.
11 Attribute
An Typen (Klassen, Strukturen, Schnittstellen, usw.), Member (Methoden, Eigenschaften, usw.),
Parameter und Rückgabewerte von Methoden sowie Assemblies und Module kann man Attribute
anheften, um zusätzliche Metainformationen bereit zu stellen, die beim Übersetzen und/oder zur
Laufzeit berücksichtigt werden können. Attribute sind Objekte aus speziellen Klassen (abstammend
von der abstrakten Basisklasse System.Attribute. Der Compiler legt die Attribut-Objekte per Serialisierung (siehe Abschnitt 12.2.3) in der Metadatentabelle des erzeugten Assemblies ab. Bei einfachen Attributen besteht die Information über den Träger in der schlichten An- bzw. Abwesenheit
des Attributs. Jedoch kann ein Attributobjekt auch Detailinformationen enthalten, die über Eigenschaften für Interessenten verfügbar sind.
Ein Attribut beeinflusst das Laufzeitverhalten eines Programms über seine Signalwirkung auf Methoden, welche sich über die Existenz bzw. Ausgestaltung des Attributs informieren und ihr Verhalten daran orientieren. Wir lernen also eine weitere Technik zur Kommunikation zwischen Programmbestandteilen kennen. In komplexen objektorientierten Softwaresystemen (Frameworks)
spielt generell die als Reflexion (engl.: reflection) bezeichnete Ermittlung von Informationen über
Typen und Instanzen zur Laufzeit eine zunehmende Rolle. Dabei leisten Attribute einen wichtigen
Beitrag.
Man kann die Attribute auch als moderne Option zur deklarativen Programmierung auffassen. Sie
ergänzen die im C# - Sprachumfang verankerten Modifikatoren für Typen, Methoden etc. und bieten dabei eine enorme Flexibilität.
In der FCL wird von Attributen reichlich Gebrauch gemacht, was z.B. die Dokumentation zur Klasse String zeigt:
[SerializableAttribute]
[ComVisibleAttribute(true)]
public sealed class String : IComparable, ICloneable, IConvertible, IComparable<string>,
IEnumerable<string>, IEnumerable, IEquatable<string>
Hier wird über die Klasse String ausgesagt, dass ihre Objekte serialisiert werden dürfen (siehe Abschnitt 12.2.3), und dass die Klasse für die Zusammenarbeit mit traditionellen WindowsKomponenten (nach dem Component Object Model) geeignet ist. Was das konkret bedeutet, wird
sich bei der noch ausstehenden Beschäftigung mit Objektserialisierung bzw. COM-Interoperabilität
zeigen.
Wir müssen uns nicht auf die Vergabe von FCL-Attributen beschränken, sondern können auch eigene Attribute definieren und verwenden.
Gelegentlich wird im Zusammenhang mit unserem aktuellen Thema von benutzerdefinierten Attributen gesprochen (siehe z.B. Richter 2006, S. 403ff), und wichtige Methoden zur Auswertung von
Attributen (siehe Abschnitt 11.2) führen das Wort Custom in ihrem Namen. Es ist nicht ganz klar
(uns auch nicht sonderlich wichtig), ob durch diese Wortwahl die vom Anwendungsprogrammierer
erstellten Attributklassen den bereits in der FCL definierten Attributklassen gegenübergestellt werden sollen, oder ob sich der Gegenbegriff auf Metadaten bezieht, die vom .NET - Framework verwaltet werden und die in keinem Zusammenhang mit der Klasse System.Attribute stehen. Das
Manuskript orientiert sich bei der Begriffsverwendung an der C# 3.0 - Sprachspezifikation (Microsoft 2007a, Kap. 17). Dort ist nur von Attributen die Rede, und dabei sind die von System.Attribute abstammenden Klassen gemeint, von wem auch immer programmiert.
11.1 Attribute vergeben
Sollen z.B. die Benutzer einer Klassenbibliothek dazu gebracht werden, einer neuen Klasse den
Vorzug vor einer alten zu geben, kann man der alten Klassendefinition zwischen eckigen Klammern
340
Kapitel 11: Attribute
das Attribut Obsolete voranstellen, so dass der Compiler bei Verwendung dieser Klasse automatisch eine Warnung ausgibt. Dies geschieht z.B. beim Übersetzen der folgenden Quelle:
Im Editor der Visual C# 2008 Express Edition wird der unerwünschte Zugriff auf die obsolete Klasse unterschlängelt, und der Compiler moniert:
ObsoleteClass.cs(19,9): warning CS0612: "MyClass" ist veraltet.
Wer als Klassenbibliotheksdesigner und -renovierer diese Compiler-Meldung zu dürftig findet,
kann zum Erstellen des Obsolete-Attributs eine alternative Konstruktor-Überladung verwenden und
die Message-Eigenschaft des Objekts mit einer Zeichenfolge versorgen, z.B.:
[Obsolete("Benutzen Sie bitte MyNewClass")]
Dann meldet der Compiler beim unerwünschten Zugriff:
ObsoleteClass.cs(19,9): warning CS0618: "MyClass" ist veraltet:
"Benutzen Sie bitte MyNewClass"
Bei der Vergabe eines Attributes entsteht ein Objekt der entsprechenden Klasse und ergänzt die
Metadaten der Trägers. Als syntaktische Besonderheit darf bei der Vergabe eines Attributs (also
beim Erstellen eines Attributobjekts) der Namensteil Attribute weglassen werden. Im Beispiel
kommt also die Klasse ObsoleteAttribute (aus dem Namensraum System) zum Einsatz.
Neben Klassen können auch andere Programmbestandteile mit Attributen versehen werden, z.B.
Methoden:
Abschnitt 11.2 Attribute per Reflexion auswerten
341
Beim Übersetzen meldet der Compiler:
ObsoleteMethod.cs(16,3): warning CS0618: "MyClass.Tell()" ist
veraltet: "Der Support für Tell() läuft aus. Benutzen Sie bitte
TellEx()"
In den vom Visual Studio unter Verwendung der Vorlage Windows Forms-Anwendung erstellten Projekten ist die Startmethode Main() (zu finden in der Quellcodedatei Program.cs) mit dem
Attribut STAThread dekoriert, z.B.:
[STAThread]
static void Main() {
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
Damit wird festgelegt, dass bei erforderlicher Interoperabilität mit dem Component Object Model
(COM), der noch sehr weit verbreiteten Windows-Komponententechnologie, das Threading-Modell
STA (Singlethread-Apartment) zum Einsatz kommen soll, um Synchronisationsprobleme bei Steuerelementen zu verhindern. Eine WinForms-Anwendung kann z.B. im Zusammenhang mit der
Windows-Zwischenablage, bei Standarddialogen (z.B. zur Dateiauswahl) oder beim Drag & Drop
(Ziehen & Ablegen) mit der COM-Technologie in Kontakt kommen, und Microsoft empfiehlt die
Verwendung des STAThread-Attributs nachdrücklich: 1
STAThreadAttribute gibt an, dass das COM-Threadingmodell für die Anwendung Singlethread-Apartment ist. Dieses Attribut muss am Einstiegspunkt jeder Anwendung vorhanden sein,
die Windows Forms verwendet. Wird es weggelassen, funktionieren die Windows-Komponenten
eventuell nicht richtig. Wenn das Attribut nicht vorhanden ist, verwendet die Anwendung das
Multithreaded-Apartmentmodell, das von Windows Forms nicht unterstützt wird.
Bei unseren bisherigen WinForms-Programmen haben wir das Attribut STAThread aus didaktischen Gründen weggelassen und dabei keinerlei Probleme festgestellt. Ab jetzt werden wir das Attribut aber richtlinienkonform setzen.
Im weiteren Verlauf von Abschnitt wird noch klarer, dass man bei der Vergabe von Attributen in
der Regel nicht nur den Quellcode kommentiert, sondern signalisierend den Programmablauf beeinflusst, sofern andere Typen die Attribute kennen und bei Ihrem Verhalten berücksichtigen.
11.2 Attribute per Reflexion auswerten
Das .NET - Framework bietet leistungsfähige Reflexionstechniken, die es u.a. erlauben, die Attributausstattung von Programmbestandteilen zur Laufzeit zu analysieren. Mit der statischen Methode
IsDefined() der Klasse System.Attribute kann man feststellen, ob ein bestimmtes Attribut vorhanden ist, z.B.:
1
Quelle: http://msdn.microsoft.com/de-de/library/ms182351.aspx
Kapitel 11: Attribute
342
Quellcode
Ausgabe
using System;
MyClass ist obsolet
[Obsolete("Benutzen Sie bitte MyNewClass")]
class MyClass {
public static void Tell() {
Console.WriteLine("Hallo!");
}
}
class Prog {
static void Main() {
if (Attribute.IsDefined(typeof(MyClass),
typeof(ObsoleteAttribute)))
Console.WriteLine("MyClass ist obsolet");
}
}
Die verwendete IsDefined()- Überladung erwartet als ersten Parameter ein Objekt der Klasse
MemberInfo, von der auch die Klasse Type abstammt, die einen Datentyp (Klasse, Struktur,
Schnittstelle etc.) repräsentiert. Als zweiter Parameter ist das Type-Objekt zur fraglichen Attributklasse anzugeben. Im Beispiel werden die beiden Type-Objekte per typeof-Operator erzeugt, wobei
(anders als bei der Attributvergabe, siehe Abschnitt 11.1) auch der Name der Attributsklasse vollständig (inkl. Namenbestandteil Attribute) zu schreiben ist.
Soll nicht nur die Existenz eines Attributs festgestellt, sondern auch sein Innenleben exploriert werden, eignet sich die statische Methode GetCustomAttribute() der Klasse System.Attribute. Sie
rekonstruiert das angeheftete Objekt (per Deserialisierung) aus den Assembly-Metadaten, so dass
öffentliche Felder und Eigenschaften zur Verfügung stehen. Wegen der Objektkreation sind die
Kosten eines Aufrufs höher als bei der Methode IsDefined(). Der folgende
ObsoleteMethodCheck() prüft für alle öffentlichen Methoden eines Typs, ob sie als obsolet
markiert sind:
static void ObsoleteMethodCheck(Type tt) {
Attribute attrib;
MemberInfo[] member = tt.FindMembers(MemberTypes.Method,
BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public,
Type.FilterName, "*");
Console.WriteLine("Obsolete-Prüfung für die Methoden der Klasse {0}:",
tt.FullName);
foreach (MemberInfo mi in member) {
attrib = Attribute.GetCustomAttribute(mi, typeof(ObsoleteAttribute));
if (attrib != null) {
Console.WriteLine("\nDie Methode {0}() ist obsolet", mi.Name);
Console.WriteLine("Message: " + (attrib as ObsoleteAttribute).Message);
} else
Console.WriteLine("\nDie Methode {0}() ist noch aktuell", mi.Name);
}
}
Das per Parameter übergebene Type-Objekt wird per FindMembers() aufgefordert, Information
über seine Member in einem Array vom Typ MemberInfo (aus dem Namensraum System.Reflection) zu liefern:




Im ersten FindMembers()-Parameter wählt man die Member-Kategorie.
Im zweiten Parameter legt man per ODER-Verknüpfung von Werten der Enumeration
BindingFlags fest, dass öffentliche Instanz- und Klassen-Member gefragt sind.
Der dritte Parameter sorgt für eine namensorientierte Filterung.
Im letzten Parameter werden per Jokerzeichen beliebige Namen einbezogen.
343
Abschnitt 11.2 Attribute per Reflexion auswerten
Per GetCustomAttribute() erhalten wir für jede Methode ggf. das angeheftete ObsoleteAttributeObjekt und fragen dieses Objekt nach seiner Message-Eigenschaft.
Über die Klasse
class MyClass {
[Obsolete("Der Support für Tell() läuft aus. Benutzen Sie bitte TellEx()")]
public static void Tell() {
Console.WriteLine("Hallo!");
}
public static void TellEx() {
Console.WriteLine("Hallo, Wilhelm!");
}
}
erhalten wir den Bericht:
Obsolete-Prüfung für die Methoden der Klasse MyClass:
Die Methode Tell() ist obsolet
Message: Der Support für Tell() läuft aus. Benutzen Sie bitte TellEx()
Die Methode TellEx() ist noch aktuell
Die Methode ToString() ist noch aktuell
Die Methode Equals() ist noch aktuell
Die Methode GetHashCode() ist noch aktuell
Die Methode GetType() ist noch aktuell
In der Main()-Methode des nächsten Beispielprogramms werden mit der statischen AttributeMethode GetCustomAttributes() für eine per Type-Objekt beschriebene Klasse alle angehefteten
benutzerdefinierten Attribute ermittelt, wobei geerbte Attribute nicht interessieren (Wert false für
den Parameter inherit):
Quellcode
Ausgabe
using System;
System.ObsoleteAttribute
System.SerializableAttribute
[Obsolete] [Serializable]
class MyClass {
public static void Tell() {
Console.WriteLine("Hallo!");
}
}
class Prog {
static void Main() {
Type type = typeof(MyClass);
Attribute[] atar =
Attribute.GetCustomAttributes(type, false);
foreach (Attribute at in atar)
Console.WriteLine(at);
}
}
Auf einige Assembly-Attribute (z.B. mit Versionsangaben) kann man sehr bequem über statische
Eigenschaften der Klasse Application zugreifen (siehe Abschnitt 11.4).
Kapitel 11: Attribute
344
11.3 Attribute definieren
Bei einer eigenen Attributklasse sollte man …


die Basisklasse System.Attribute verwenden,
den Klassennamen mit dem Wort Attribute enden lassen.
Um den Compiler darüber zu informieren, welchen Programmbestandteilen das Attribut angeheftet
werden darf, verwendet man ein Attribut aus der Klasse AttributeUsageAttribute. Im folgenden
Beispiel erhält der Konstruktor-Parameter validOn den Wert AttributeTargets.Class, so dass nur
Klassen das neu definierte NonsenseAttribute erhalten dürfen. Außerdem wird mit dem Wert
false für die AttributeUsageAttribute-Eigenschaft Inherited verhindert, dass eine dekorierte
Klasse das NonsenseAttribute an abgeleitete Klassen weitergibt:
[AttributeUsage(AttributeTargets.Class, Inherited=false)]
public class NonsenseAttribute : Attribute {
int level;
public NonsenseAttribute(int level_) {
level = level_;
}
public int Level {
get {
return level;
}
}
}
Mit dieser Eigenschafts-Initialisierung innerhalb der Konstruktor-Parameterliste kommt eine spezielle Syntax unter Verwendung von Name-Wert - Paaren zum Einsatz. Man spricht hier von Namensparametern, die nach den regulären Parametern (nun Positionsparameter genannt) in beliebiger Reihenfolge stehen dürfen. Namensparameter werden nicht als Konstruktorargument definiert. Stattdessen kann jede öffentliche instanzbezogene Eigenschaft oder Variable als Namensparameter verwendet und auf diese Weise initialisiert werden. Unter der Bezeichnung Objekt- bzw.
Instanz-Initialisierer besteht übrigens seit C# 3.0 auch für andere Klassen eine analoge Initialisierungsmöglichkeit (siehe Abschnitt 4.4.3 und 27.1.2).
Bei Positions- oder Namensparameter von Attribut-Konstruktoren sind ausschließlich die folgenden Datentypen erlaubt:




bool, byte, char, short, int, long, float, double
System.Object, System.String, System.Type
Aufzählungstypen
Eindimensionale Arrays mit einem Elementtyp aus der obigen Liste
11.4 Attribute für Assemblies und Module
Auch Assemblies und Module können Attribute erhalten, wobei aber mangels syntaktischer Entsprechung für diese Übersetzungseinheiten der Bezug nicht durch die Platzierung der Attribute im
Quellcode hergestellt werden kann. Stattdessen benutzt man Attribute mit expliziter Widmung,
z.B.:
[assembly:
[assembly:
[assembly:
[assembly:
AssemblyVersion("1.4.2.3")]
AssemblyDescription("Ideale Tools für Ihr Projekt")]
AssemblyCompany("Marco Soft")]
AssemblyProduct("YourTools")]
Hier wird jeweils das vom Compiler zu erzeugende Assembly als Träger des nachfolgenden Attributs festgelegt.
Abschnitt 11.4 Attribute für Assemblies und Module
345
Derartige Assembly-Attribut - Deklarationen erzeugt das Visual Studio beim Erstellen eines neuen
Projekts unter Verwendung der Vorlage Windows Forms-Anwendung automatisch, wobei die
Entwicklungsumgebung eine Datei namens AssemblyInfo.cs anlegt, z.B. (Kommentare aus Platzgründen weggelassen):
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
[assembly:
[assembly:
[assembly:
[assembly:
[assembly:
[assembly:
[assembly:
[assembly:
[assembly:
[assembly:
[assembly:
[assembly:
AssemblyTitle("RawWinForm")]
AssemblyDescription("")]
AssemblyConfiguration("")]
AssemblyCompany("Universität Trier")]
AssemblyProduct("RawWinForm")]
AssemblyCopyright("Copyright © Universität Trier 2009")]
AssemblyTrademark("")]
AssemblyCulture("")]
ComVisible(false)]
Guid("65c809f4-3766-4cf8-8b1d-0de532780974")]
AssemblyVersion("1.0.0.0")]
AssemblyFileVersion("1.0.0.0")]
Zur Modifikation dieser Attribute öffnen man die Datei AssemblyInfo.cs über den Projektmappenexplorer:
Auf einige Assembly-Attribute kann man im Programm über statische Eigenschaften der Klasse
Application (im Namensraum System.Windows.Forms) zugreifen, z.B.:
private void button1_Click(object sender, EventArgs e) {
MessageBox.Show("Version "+Application.ProductVersion,
Application.ProductName, MessageBoxButtons.OK,
MessageBoxIcon.Information);
}
Außerdem erscheinen die Assembly-Attribute im Eigenschaftsdialog einer Assembly-Datei, z.B.:
Kapitel 11: Attribute
346
11.5 Eine Auswahl nützlicher FCL-Attribute
11.5.1 Bitfelder per FlagsAttribute
Bei einem Enumerationstyp (vgl. Abschnitt 5.5) signalisiert der Designer mit dem System.FlagsAttribute, dass ein Wert als Bitfeld interpretierbar ist, d.h.:

Die ersten k Bits (mit dem niederwertigsten beginnend) des zugrunde liegenden Datentyps
(meist int) stehen als unabhängige Informationsträger jeweils für ein dichotomes Merkmal
(mit den Werten Null und Eins). Ein Enumerationswert kodiert also die Ausprägungen von
k dichotomen Merkmalen. Bei der in Abschnitt 9.4.4.2 vorgestellten Enumeration AnchorStyles stehen die ersten vier Bits (mit den dezimalen Wertigkeiten 20, 21, 22 und 23) für die
Verankerung eines Steuerelements an den vier Seiten des elterlichen Containers 1:
[Flags]
public enum AnchorStyles {
None = 0,
Top = 1,
Bottom = 2,
Left = 4,
Right = 8
}

Jede bitweise ODER-Kombination von zwei benannten Werten der Enumeration ergibt ein
sinnvoll interpretierbares Bitfeld, also eine zulässige Kombination der dichotomen Einzelmerkmale. WinForms-Steuerelements haben z.B. die folgende AnchorStyles-Voreinstellung (Verankerung links und oben):
AnchorStyles.Left | AnchorStyles.Top
1
Der Quellcode wurde leicht abgewandelt aus dem Mono-Projekt (http://www.mono-project.com/Main_Page) übernommen.
347
Abschnitt 11.5 Eine Auswahl nützlicher FCL-Attribute
Bei einem gewöhnlichen Enumerationstyp (ohne FlagsAttribute) …

stehen die Werte für die sich gegenseitig ausschließenden Ausprägungen eines Merkmals.
Bei der in Abschnitt 9.4.4.2 vorgestellten Enumeration DockStyle kodieren die ersten sechs
nicht-negativen int-Werte jeweils ein Andockverhalten gegenüber dem elterlichen Container:
public enum DockStyle {
None = 0,
Top = 1,
Bottom = 2,
Left = 3,
Right = 4,
Fill = 5
}

Die (syntaktisch erlaubte und vom Compiler nicht bemängelte) ODER-Verknüpfung von
zwei benannten Werten führt in der Regel nicht zum erwarteten Ergebnis. Z.B. liefert
DockStyle.Top | DockStyle.Left
denselben Wert Drei wie
DockStyle.Left
Das FlagsAttribute dient nicht nur zur Kommunikation zwischen dem Designer und dem Benutzer
eines Enumerationstyps. Auch der Designer der FCL-Enumerationsbasisklasse Enum überprüft die
Existenz des Attribut per IsDefined()-Aufruf (vgl. Abschnitt 11.2) 1
eT.IsDefined(typeof(System.FlagsAttribute), false)
und liefert in seiner ToString() - Überschreibung ggf. eine kommaseparierte Liste der Merkmale
mit einem angeschalteten Bit, z.B.:
Quellcode
Ausgabe
using System;
using System.Windows.Forms;
AnchorStyles-Werte:
0: None
1: Top
2: Bottom
3: Top, Bottom
4: Left
5: Top, Left
6: Bottom, Left
7: Top, Bottom, Left
class Prog {
static void Main() {
Console.WriteLine("AnchorStyles-Werte:");
for (AnchorStyles i = 0; (int) i < 8; i++)
Console.WriteLine(" {0}: {1}", (int) i, i);
Console.WriteLine("\nDockStyle-Werte:");
for (DockStyle i = 0; (int)i < 8; i++)
Console.WriteLine(" {0}: {1}", (int)i, i);
}
}
DockStyle-Werte:
0: None
1: Top
2: Bottom
3: Left
4: Right
5: Fill
6: 6
7: 7
11.5.2 Unions per StructLayoutAttribute und FieldOffsetAttribute
Bei der in Abschnitt 3.3.4.1 zur Erläuterung der binären Gleitkommadarstellung benutzten (aber
nicht erklärten) Anwendung FloatBits werden die Attribute StructLayoutAttribute und
FieldOffsetAttribute aus dem Namensraum System.Runtime.InteropServices dazu verwendet,
eine Union im Sinn der Programmiersprache C nachzubilden. In unseren Begriffen handelt es sich
1
Der Quellcode stammt aus Microsofts Shared Source Common Language Infrastructure 2.0.
348
Kapitel 11: Attribute
dabei um eine Struktur, deren Instanzvariablen im Speicher (zumindest teilweise) überlappen. In der
Regel soll damit nicht etwa Speicherplatz gespart, sondern eine unterschiedliche Interpretation desselben Speicherinhalts ermöglicht werden.
In den folgenden Zeilen wird eine Struktur mit dem (frei gewählten) Namen Union sowie Feldern
von Typ float und int definiert:
[StructLayout(LayoutKind.Explicit)]
public struct Union {
[FieldOffset(0)]
public float f;
[FieldOffset(0)]
public int i;
}
Per StuctLayoutAttribut mit Konstruktor-Parameter LayoutKind.Explicit wird dem Compiler
mitgeteilt, dass die Speicheradressen der beiden Felder explizit durch FieldOffsetAttribute festgelegt werden sollen. So wird es möglich, die beiden (gleich langen) Felder an derselben Anfangsadresse Null beginnen zu lassen.
Das Programm schreibt den vom Benutzer gewünschten float-Wert in die vier Union-Bytes und
liest anschließend aus demselben Speicherbereich einen int-Wert, der bequem über bitorientierte
Operatoren (siehe Abschnitt 3.5.6) untersucht werden kann:
using System;
using System.Runtime.InteropServices;
class FloatBits {
static void Main() {
Union uni = new Union();
float f;
Console.Write("float: ");
f = Convert.ToSingle(Console.ReadLine());
Console.WriteLine("\nBits: ");
uni.f = f;
int bits = uni.i;
Console.WriteLine("1 12345678 12345678901234567890123");
for (int i = 31; i >= 0; i--) {
if (i == 30 || i == 22)
Console.Write(' ');
if ((1 << i & bits) != 0)
Console.Write('1');
else
Console.Write('0');
}
}
}
11.6 Übungsaufgaben zu Kapitel 11
1) Ergänzen Sie im Beispiel von Abschnitt 11.3 die ziemlich sinnlose Klasse Dummy
[Serializable] [NonsenseAttribute(13)]
class Dummy {
}
und eine Testklasse mit Main()-Methode, die alle benutzerdefinierten Attribute der Klasse Dummy
und den Level-Wert des NonsenseAttribut-Objekts ausgibt.
12 Ein-/Ausgabe über Datenströme
Praktisch jedes Programm muss Daten aus externen Quellen einlesen und/oder Verarbeitungsergebnisse in externe Senken schreiben. Wir haben uns bisher auf die Eingabe per Tastatur sowie die
Ausgabe per Bildschirm beschränkt und müssen allmählich alternative Quellen bzw. Senken kennen
lernen (z.B. Dateien, Netzwerkverbindungen, Datenbankserver). Im aktuellen Abschnitt beschränken wir uns auf einfache Verfahren, um Werte elementarer Typen (z.B. int, double), Zeichenfolgen
oder beliebige Objekte in Dateien zu schreiben bzw. von dort lesen. Wesentliche Teile der erlernten
Techniken werden aber auch im späteren Abschnitt über Netzwerkprogrammierung verwendbar
sein.
Die .NET - Klassen zur Datenein- und -ausgabe befinden sich im Namensraum System.IO, der
folglich in der Regel importiert werden sollte:
using System.IO;
12.1 Datenströme aus Bytes
12.1.1 Das Grundprinzip
Im .NET - Framework wird die Ein- und Ausgabe von Daten über so genannte Ströme (engl.:
streams) abgewickelt.
Ein Programm liest Daten aus einem Eingabestrom, der aus einer Datenquelle (z.B. Datei, Eingabegerät, Netzwerkverbindung) gespeist wird:
Quelle
0
1
2
3
4
5
6
ProgrammVariablen
7
Ein Programm schreibt Daten in einen Ausgabestrom, der die Werte von Programmvariablen zu
einer Datensenke befördert (z.B. Datei, Ausgabegerät, Netzverbindung):
ProgrammVariablen
0
1
2
3
4
5
6
7
Senke
Ein- bzw. Ausgabeströme werden in .NET - Programmen durch Objekte aus geeigneten Klassen des
Namensraums System.IO repräsentiert, wobei die Auswahl u.a. von der angeschlossenen Datenquelle bzw. –senke (z.B. Datei versus Netzverbindung) sowie vom Typ der zu transportierenden
Daten abhängt.
Ziel des Datenstromkonzeptes ist es, Ein- und Ausgaben möglichst unabhängig von den Besonderheiten konkreter Datenquellen und –senken formulieren zu können.
Nach so vielen allgemeinen bzw. abstrakten Bemerkungen wird es Zeit für ein konkretes Beispiel,
wobei der Einfachheit halber auf Sinn und Sicherheit verzichtet wird. Das folgende Programm erstellt eine Datei, schreibt einen byte-Array hinein, liest die Daten wieder zurück und löscht schließlich die Datei:
Kapitel 12: Ein-/Ausgabe über Datenströme
350
Quellcode
Ausgabe
using System;
using System.IO;
0
1
2
3
4
5
6
7
class FSDemo {
static void Main() {
String name = "demo.bin";
byte[] arr = {0,1,2,3,4,5,6,7};
FileStream fs = new FileStream(name, FileMode.Create);
fs.Write(arr, 0, arr.Length);
fs.Position = 0;
fs.Read(arr, 0, arr.Length);
foreach (byte b in arr)
Console.WriteLine(b);
fs.Close();
File.Delete(name);
}
}
Für die Ein- und die Ausgabe wird ein Objekt der Klasse FileStream eingesetzt. Diese Spezialisierung der abstrakten Basisklasse Stream implementiert das Stromkonzept für Dateien. Zur Einordnung ist hier ein kleiner Ausschnitt aus der Stream-Klassenhierarchie wiedergegeben:
Object
MarshalByRefObject
Stream
FileStream
MemoryStream
NetworkStream
BufferedStream
CryptoStream
Zu den noch unklaren Details im Beispielprogramm folgen Erläuterungen in den nächsten Abschnitten.
12.1.2 Wichtige Methoden und Eigenschaften der Basisklasse Stream
Alle Ableitungen der abstrakten Basisklasse Stream verfügen u.a. über die folgenden Methoden
und Eigenschaften:

public long Position {get; set;}
Über diese Eigenschaft wird die aktuelle Position im Strom angesprochen, an der das nächste Byte gelesen bzw. geschrieben wird. Im Beispielprogramm von Abschnitt 12.1.1 wird die
Lese-/Schreibposition der geöffneten Datei mit der folgenden Anweisung auf den Dateianfang zurück gesetzt:
fs.Position = 0;
Abschnitt 12.1 Datenströme aus Bytes








351
public int ReadByte()
Mit dieser Methode wird ein Strom-Objekt aufgefordert, ein Byte per Rückgabewert vom
Typ int zu liefern und seine Position entsprechend zu erhöhen. Ist das Ende des Stroms erreicht, wird keine Ausnahme geworfen, sondern der Rückgabewert -1 geliefert.
public int Read(byte[] buffer, int offset, int count)
Mit dieser Methode wird ein Strom-Objekt aufgefordert, count Bytes zu liefern, im byteArray buffer ab Position offset abzulegen und seine Position entsprechend zu erhöhen. Als
Rückgabewert erhält man die Anzahl der tatsächlich gelieferten Bytes, die bei unzureichendem Vorrat kleiner als count ausfallen kann.
public void WriteByte()
Mit dieser Methode wird ein Strom aufgefordert, ein Byte zu schreiben und seine Position
entsprechend zu erhöhen.
public void Write(byte[] buffer, int offset, int count)
Mit dieser Methode wird ein Stream-Objekt aufgefordert, count Bytes zu schreiben, die im
byte-Array buffer ab Position offset liegen, und seine Position entsprechend zu erhöhen.
public void Flush()
Viele Strom-Objekte verwenden aus Performanzgründen einen Puffer, z.B. um die Anzahl
der zeitaufwendigen Dateizugriffe möglichst gering zu halten. Mit der Methode Flush() verlangt man die sofortige Ausgabe des Puffers, so dass der komplette Inhalt für Abnehmer zur
Verfügung steht. Bei der Klasse FileStream (siehe unten) wird eine voreingestellte Puffergröße von 4096 Bytes benutzt.
public void Close()
Mit dem Schließen eines Datenstroms per Close()-Aufruf oder durch eine alternative Technik beschäftigen wir uns gleich in Abschnitt 12.1.3.
public long Length {get;}
Mit dieser Eigenschaft wird die Länge des Stroms (z.B. die Dateigröße) in Bytes angesprochen.
public long Seek(long offset, SeekOrign origin)
Diese Methode fordert einen Strom auf, seine Position relativ zu einem per SeekOriginWert festgelegten Bezugspunkt (Begin, Current, End) neu zu setzen, z.B. vom aktuellen
Stand aus um vier Bytes zurück:
fs.Seek(-4, SeekOrigin.Current);

Als Rückgabe erhält man die neue Position.
public bool CanRead {get;}, public bool CanSeek {get;}, public bool CanWrite {get;}
Über diese Eigenschaften lässt sich feststellen, ob ein Strom das Lesen, Schreiben und Positionieren erlaubt, z.B.:
Quellcode
Ausgabe
Console.WriteLine("CanRead: " + fs.CanRead); CanRead: True
Console.WriteLine("CanSeek: " + fs.CanSeek); CanSeek: True
Console.WriteLine("CanWrite: " + fs.CanWrite); CanWrite: True
12.1.3 Schließen von Datenströmen
Mit der Methode Close() wird ein Datenstrom geschlossen, was in den von Stream abgeleiteten
Klassen folgende Maßnahmen beinhaltet:


Wenn ein Puffer vorhanden ist (z.B. bei der Klasse FileStream), wird er durch einen automatischen Flush()-Aufruf entleert.
Betriebssystem-Ressourcen (z.B. Datei-Handles, Netzwerk-Sockets) werden freigegeben.
352
Kapitel 12: Ein-/Ausgabe über Datenströme
Beispiel:
fs.Close();
Nach einem Close()-Aufruf existiert das angesprochene .NET – Objekt weiterhin, doch führen Lese- bzw. Schreibversuche zu Ausnahmefehlern. Rufen Sie Close() also nicht auf, wenn solche Ausnahmefehler möglich sind, weil im Programm noch weitere Referenzen auf das Stream-Objekt
existieren (siehe Richter 2006, S. 501).
Derartige Pannen sind ausgeschlossen, wenn Sie ein überflüssig gewordenes Datenstromobjekt dem
Garbage Collector überlassen. Über die Finalisierungsmethode der von Stream abgeleiteten Klassen ist sichergestellt, dass vor dem Entfernen eines Objekts per Garbage Collector alle verwendeten
Ressourcen (Dateien, Netzwerkverbindungen) freigegeben werden. Dabei kommt die Methode Dispose() zum Einsatz, die auch bei einem Close()-Aufruf die eigentlichen Aufräumungsarbeiten (inklusive Entleeren des Puffers) verrichtet. Die Klasse Stream muss eine Dispose()-Methode bereithalten, weil sie das Interface IDisposable (man sagt auch: das Beseitigungsmuster) implementiert.
Bei den Aufräumungsarbeiten des Garbage Collectors sind allerdings Zeitpunkt und Reihenfolge
unbestimmt, so dass bei schreibenden Datenströmen das explizite Schließen oft erforderlich ist.
Setzt z.B. im Rahmen einer schreibenden Datenstrom-Verarbeitungskette ein Ausgabeobjekt mit
eigenem Puffer (z.B. aus der Klasse StreamWriter) auf einem Stream-Objekt auf, darf man dem
Garbage Collector das Schließen auf keinen Fall überlassen (siehe Abschnitt 12.2.2). Es kommt zu
Datenverlusten wenn der Garbage Collector bei seinen Aufräumarbeiten (ohne garantierte Reihenfolge!) das Stream-Objekt vor dem StreamWriter–Objekt beseitigt.
Außerdem ist es oft wichtig, Ströme durch ein explizites Close() so früh wie möglich zu schließen,
um (exklusive) Zugriffe durch andere Programme zu ermöglichen.
Im Beispiel FSDemo von Abschnitt 12.1.1 ist das Schließen der Datei erforderlich, um sie anschließend löschen zu können.
Trotz der modernen Softwaretechnologie im .NET - Framework ist also einige Aufmerksamkeit
erforderlich, um Fehler durch voreilige oder vergessene Close()-Aufrufe zu vermeiden.
Mit der using-Anweisung (nicht zu verwechseln mit der using-Direktive zum Importieren eines
Namensraums) kann man einen Block definieren und Objekte erzeugen, die nur innerhalb des using-Blocks gültig (referenziert) sind. Beim Verlassen des Blocks werden automatisch per Dispose()-Aufruf alle verwendeten Ressourcen (Dateien, Netzwerkverbindungen) freigegeben, z.B.:
using System;
using System.IO;
class FSDemo {
static void Main() {
String name = "demo.bin";
byte[] arr = {0,1,2,3,4,5,6,7};
using (FileStream fs = new FileStream(name, FileMode.Create)) {
fs.Write(arr, 0, arr.Length);
fs.Position = 0;
fs.Read(arr, 0, arr.Length);
foreach (byte b in arr)
Console.WriteLine(b);
}
File.Delete(name);
}
}
Per using-Anweisung wird das Schließen eines Stroms elegant gelöst, wobei der Compiler im Hintergrund eine try-finally - Anweisung erstellt. In vielen Fällen ist es aber auch sinnvoll bzw. erforderlich, die von Ein-/Ausgabe – Methoden zu erwartenden Ausnahmen zu behandeln.
353
Abschnitt 12.1 Datenströme aus Bytes
12.1.4 Ausnahmen abfangen
Weil Methodenaufrufe bei Datenstromobjekten diverse Ausnahmen produzieren können (z.B.
FileNotFoundException, UnauthorizedAccessException, IOExeption), sind sie am besten in
einem try – Block untergebracht. Wenn ein Close()-Aufruf angemessen ist (vgl. die Warnungen
und Empfehlungen in Abschnitt 12.1.3), gehört er in den finally-Block der try – Anweisung, damit
er auf jeden Fall ausgeführt wird (vgl. Abschnitt 10.2.1.2). Alternativ kann eine using-Anweisung
für das garantierte Schließen sorgen (siehe Abschnitt 12.1.3). In der folgenden Variante des Einstiegsbeispiels ist das Abfangen der Ausnahmen nur angedeutet:
using System;
using System.IO;
class FSDemo {
static void Main() {
String name = "demo.bin";
byte[] arr = {0,1,2,3,4,5,6,7};
try {
using (FileStream fs = new FileStream(name, FileMode.Create)) {
fs.Write(arr, 0, arr.Length);
fs.Position = 0;
fs.Read(arr, 0, arr.Length);
foreach (byte b in arr)
Console.WriteLine(b);
}
File.Delete(name);
} catch (Exception e) {
Console.WriteLine(e);
}
}
}
Bei den Beispielen im weiteren Verlauf von Abschnitt 12 werden wir der Einfachheit halber meist
auf eine Ausnahmebehandlung verzichten.
12.1.5 FileStream
Mit einem FileStream–Objekt können Bytes aus einer Datei gelesen
Programm
byte oder
byte[]
oder dorthin geschrieben werden:
FileStream
Bytes
Datei
Kapitel 12: Ein-/Ausgabe über Datenströme
354
Programm
byte oder
byte[]
FileStream
Bytes
Datei
Ein FileStream-Objekt beherrscht prinzipiell beide Transportrichtungen, kann aber auch auf unidirektionalen Betrieb eingestellt werden.
Im folgenden FileStream-Konstruktoraufruf wird eine per Pfadnamen identifizierte Datei erstellt
und zum Lesen und/oder Schreiben geöffnet:
FileStream fs = new FileStream("demo.bin", FileMode.Create);
Falls die Datei bereits existiert, wird sie überschrieben. Für hinreichende Flexibilität bei der Ansprache und Behandlung von Dateien sorgen zahlreiche Überladungen des Konstruktors.
FileStream-Objekte verwenden einen Puffer, um die Anzahl der Zugriffe auf die angeschlossene
Datei möglichst gering zu halten. Beim Schließen einer Datei (durch den Garbage Collector oder
einen expliziten Close()-Aufruf) werden gepufferte Schreibvorgänge automatisch ausgeführt. Einige Überladungen des Konstruktors bieten einen Parameter, um die voreingestellte Puffergröße von
4096 Bytes zu ändern.
12.1.5.1 Öffnungsmodus
Beim Erstellen eines FileStream-Objekts kann man den Öffnungsmodus über einen Wert des Enumerationstyps FileMode wählen:
Modus
Append
Create
CreateNew
Beschreibung
Die Datei wird geöffnet oder neu erzeugt.
Es wird eine neue Datei angelegt oder eine vorhandene überschrieben.
Es wird eine neue Datei angelegt, oder eine IOEexeption geworfen, falls eine
Datei mit dem gewünschten Namen bereits existiert.
Es wird eine vorhandene Datei geöffnet, oder eine IOEexeption geworfen, falls
Open
keine Datei mit dem gewünschten Namen existiert.
OpenOrCreate Es wird eine neue Datei erzeugt oder eine vorhandene geöffnet, jedoch im Unterschied zu Create nicht automatisch überschrieben.
Es wird eine vorhandene Datei geöffnet und entleert, oder eine IOEexeption
Truncate
geworfen, falls keine Datei mit dem gewünschten Namen existiert.
Beim Öffnungsmodus Append befindet sich der Schreibposition am Ende der Datei, und es ist kein
lesender Zugriff möglich. Mit den anderen Öffnungsmodi sind keine Zugriffsbeschränkungen verbunden, und der Dateizeiger steht am Anfang.
12.1.5.2 Zugriffsmöglichkeiten für den eigenen Prozess
Bei einigen Überladungen des FileStream-Konstruktors lassen sich über einen Parameter vom
Enumerationstyp FileAccess die Zugriffsmöglichkeiten für den eigenen Prozess vereinbaren, wobei
auf Verträglichkeit mit dem Eröffnungsmodus zu achten ist. Es sind folgende Alternativen verfügbar:
Abschnitt 12.2 Verarbeitung von Daten mit höherem Typ



355
FileAccess.Read
FileAccess.Write
FileAccess.ReadWrite
In folgendem Beispiel wird eine Datei zum Lesen geöffnet:
FileStream fs = new FileStream("demo.bin", FileMode.Open,
FileAccess.Read);
12.1.5.3 Optionen für gemeinsamen Zugriff
Der Eröffnungsmodus ist auch relevant für die Rechte anderer Prozesse zum simultanen Dateizugriff. So erlauben z.B. die Eröffnungsmodi FileMode.Append und FileMode.Create ein zusätzliches Öffnen zum Lesen. Bei einigen FileStream-Konstruktorüberladungen kann man die Freigabe
für den gemeinsamen Zugriff über einen Parameter vom Enumerationstyp FileShare regeln. Dabei
sind im Wesentlichen die folgenden Alternativen verfügbar:




FileShare.None
FileShare.Read
FileShare.Write
FileShare.ReadWrite
In folgendem Beispiel wird die gemeinsame Nutzung komplett verweigert:
FileStream fs = new FileStream("demo.bin", FileMode.Create,
FileAccess.ReadWrite,
FileShare.None);
12.2 Verarbeitung von Daten mit höherem Typ
Bisher haben wir uns auf das Schreiben und Lesen von Bytes beschränkt. Im Abschnitt 12.2 lernen
Sie Verfahren kennen, um Daten mit einem beliebigen (aus mehreren Bytes bestehenden) Typ (z.B.
int, double, String, beliebige Klasse oder Struktur) in Dateien zu schreiben oder von dort zu lesen.
Bei den Dateien auf einem Rechner kann man unterscheiden:


Binärdateien
Ein Programm kann Daten beliebigen Typs problemlos in eine Binärdatei schreiben oder aus
einer Binärdatei mit bekanntem Aufbau lesen. Ein simpler Texteditor zeigt hingegen nach
dem Öffnen einer Binärdatei nur eine wirre Folge von (Sonder-)zeichen an. In Abschnitt
12.2.1 werden die zum Schreiben bzw. Lesen binärer Daten konstruierten Klassen BinaryWriter bzw. BinaryReader vorgestellt.
Textdateien
Diese Dateien können von Menschen mit einem Texteditor gelesen und/oder bearbeitet werden, sofern beim Erstellen der Datei eine passende Kodierung gewählt wurde. Per Programm lassen sich numerische Daten und Zeichenfolgen leicht in eine Textdatei schreiben,
doch ist das Lesen numerischer Daten aus einer Textdatei mit erhöhtem Aufwand verbunden. In Abschnitt 12.2.2 werden die zum Schreiben bzw. Lesen von Zeichenfolgen konstruierten Klassen StreamWriter bzw. StreamReader vorgestellt.
In Abschnitt 12.2.3 beschäftigen wir uns mit dem Schreiben und Lesen von kompletten Objekten
(oder auch Strukturinstanzen), wobei eine Binärdatei oder eine XML-formatierte Textdatei zum
Einsatz kommen kann.
Kapitel 12: Ein-/Ausgabe über Datenströme
356
12.2.1 Schreiben und Lesen im Binärformat
Um Werte von einem beliebigen elementaren Datentyp (z.B. int, double) sowie Zeichenfolgen in
einen binär organisierten Strom (z.B. in eine Binärdatei) zu schreiben bzw. aus einem Binärstrom
mit bekanntem Aufbau zu lesen, verwendet man ein Objekt der Klasse BinaryWriter bzw. BinaryReader.
Object
BinaryWriter
BinaryReader
Die beiden Klassen stammen nicht von Stream ab, verwenden aber für die Verbindung mit einer
Datenquelle oder –senke ein Stream-Objekt, das im Konstruktor anzugeben ist, z.B.:
public BinaryWriter(Stream ausgabestrom)
Im Unterschied zu den „bidirektionalen“ Stream-Klassen sind für das Schreiben bzw. Lesen von
elementaren Datenwerten „gerichtete“ Klassen zuständig. Schreibt man per BinaryWriter in eine
Datei, entsteht folgende Verarbeitungskette:
Programm
elementare
Datentypen,
String
BinaryWriter
FileStream
Bytes
Binärdatei
Beim Lesen aus einer Binärdatei reisen die Daten in umgekehrter Richtung:
Programm
elementare
Datentypen,
String
BinaryReader
FileStream
Bytes
Binärdatei
Das folgende Beispielprogramm schreibt einen int- und einen double-Wert sowie eine Zeichenfolge per BinaryWriter über einen FileStream in eine Datei. Anschließend werden die Daten über
eine BinaryReader - FileStream – Konstruktion eingelesen:
Abschnitt 12.2 Verarbeitung von Daten mit höherem Typ
357
using System;
using System.IO;
class BinWrtRd {
static void Main() {
String name = "demo.bin";
FileStream fso = new FileStream(name, FileMode.Create);
BinaryWriter bw = new BinaryWriter(fso);
bw.Write(4711);
bw.Write(3.1415926);
bw.Write("Nicht übel");
bw.Close();
FileStream fsi = new FileStream(name, FileMode.Open, FileAccess.Read);
BinaryReader br = new BinaryReader(fsi);
Console.WriteLine(br.ReadInt32() + "\n" +
br.ReadDouble() + "\n" +
br.ReadString());
}
}
Um das Schreiben und das Lesen unabhängig voneinander vorzuführen, wird im Beispiel jeweils
ein eigenes FileStream-Objekt mit passenden FileMode- bzw. FileAccess- Konstruktorparameterwerten erstellt. Es wäre möglich, mit einem FileStream-Objekt zu arbeiten und dessen
Position-Eigenschaft nach dem Schreiben wieder auf Null zu setzen (siehe Beispiel in Abschnitt
12.1.1).
Die von beiden FileStream-Objekten verwendete Datei wird zunächst mit dem FileMode.Create
geöffnet. Nach den Schreibzugriffen per Write()-Methode wird die Datei per Close()-Aufruf geschlossen, damit das anschließende Öffnen mit dem FileMode.Open gelingt. Der Close()-Aufruf
kann sich an das FileStream- oder an das BinaryWriter-Objekt richten, wobei er im letztgenannten Fall durchgereicht wird. Auch ein Flush()-Aufruf an ein BinaryWriter-Objekt zum Entleeren
des Puffers wird durchgereicht.
Durch den Close()-Aufruf wird der Schreibpuffer des FileStream-Objekts fso geleert, so dass die
geschriebenen Daten komplett in der Datei ankommen. Ein BinaryWriter verwaltet übrigens keinen eigenen Puffer, sondern reicht Schreibaufträge stets direkt an das angeschlossene StreamObjekt weiter 1.
Während die Klasse BinaryWriter für alle unterstützten Datentypen eine Überladung der Methode
Write() besitzt, sind in der Klasse BinaryReader typspezifisch benannte Lesemethoden vorhanden
(z.B. ReadInt32(), ReadDouble()).
Die Ausgabe des Programms:
4711
3,1415926
Nicht übel
Es macht wenig Sinn, die vom Beispielprogramm erzeugte Binärdatei mit einem Texteditor zu öffnen:
1
Z.B werden bei einem Aufruf der Write()-Methode mit int-Parameter die vier Bytes sofort an den Ausgabestrom
übergeben (Quellcode aus Microsofts Shared Source Common Language Infrastructure 2.0):
public virtual void Write(int value) {
_buffer[0] = (byte) value;
_buffer[1] = (byte)(value >> 8);
_buffer[2] = (byte)(value >> 16);
_buffer[3] = (byte)(value >> 24);
OutStream.Write(_buffer, 0, 4);
}
Kapitel 12: Ein-/Ausgabe über Datenströme
358
Im Beispiel wird die vom BinaryWriter geschriebene Zeichenfolge allerdings vom Texteditor
(fast) korrekt dargestellt. Bei der Ausgabe von String-Variablen ist ausschließlich die verwendete
Kodierung relevant, und hier verwendet die Klasse BinaryWriter per Voreinstellung dasselbe
UTF8Encoding wie die später vorzustellenden TextWriter-Klassen. Über einen BinaryWriterKonstruktor mit entsprechendem Parameter stehen auch andere Kodierungen zur Verfügung:
public BinaryWriter(Stream ausgabestrom, Encoding kodierung)
Schreibt man per BinaryWriter an Stelle der gemischten Ausgabe ausschließlich Text, kann der
Windows-Editor beim Öffnen der Ergebnisdatei die zugrunde liegende Kodierung besser erkennen:
Trotz der gemeinsamen Kodierungsvoreinstellung gibt es zwischen der Klasse BinaryWriter und
den TextWriter-Klassen doch einen kleinen Unterschied bei der Textausgabe. Der BinaryWriter
schreibt zur Unterstützung des BinaryReaders vor jede Zeichenfolge ihre Länge (Anzahl der
Bytes) und verwendet dabei den Datentyp uint mit einer speziellen 7-Bit - Kodierung: Jeweils 7
Bits werden als ein Byte ausgegeben, wobei im führenden Bit eine Eins signalisiert, dass noch ein
weiteres 7-Bit-Paket folgt. Bei der Zeichenfolge „Nicht übel“ mit 11 Bytes Länge (neun SingleByte-Zeichen und ein Double-Byte-Zeichen bei UTF-8 - Kodierung, siehe Abschnitt 12.2.2)
schreibt der BinaryWriter als Längenpräfix
uint-Bits
Längenpräfix
Länge der Zeichenfolge in Bytes
11
0…0 00000000 00001011 00001011
258
0…0..00000001 00000010 10000010 00000010
Bei einer Zeichenfolge mit 258 Bytes Länge resultiert ein Längenpräfix mit zwei Bytes (siehe Tabelle).
Es besteht übrigens kein Risiko, wenn ein Gespann aus einem BinaryWriter-Objekt und einem
FileStream-Objekt dem Garbage Collector anheim fallen, obwohl beim automatischen Finalisieren
(ohne garantierte Reihenfolge!) zuerst das FileStream-Objekt beseitigt werden könnte:


In der Klasse BinaryWriter ist keine Finalisierungsmethode vorhanden, so dass beim Abräumen kein Zugriff auf das zugrunde liegende (und eventuell nicht mehr existente)
Stream-Objekt stattfinden kann.
BinaryWriter–Objekte besitzen keinen lokaler Puffer, der beim Abräumen geleert werden
müsste.
Objekte der anschließend behandelten Klasse StreamWriter müssen aufgrund ihres lokalen Puffers
jedoch unbedingt vor dem zugrunde liegenden Stream-Objekt geschlossen werden, was nur durch
einen Close()-Aufruf (oder einen äquivalenten Dispose()-Aufruf) sicher gestellt ist (eventuell per
using-Block automatisiert).
12.2.2 Schreiben und Lesen im Textformat
Mit einem TextWriter-Objekt kann man die Zeichenfolgenrepräsentation von Variablen beliebigen
Typs ausgeben. Das Gegenstück TextReader liefert stets Zeichen ab, so dass bei der Versorgung
von numerischen Variablen aus textuellen Eingabedaten etwas Eigeninitiative gefragt ist (siehe
359
Abschnitt 12.2 Verarbeitung von Daten mit höherem Typ
Übungsaufgabe in Abschnitt 12.4). Beide Klassen sind abstrakt, doch bietet die FCL auch konkrete
Ableitungen für Stream- bzw. String-Objekte als Senken bzw. Quellen:
Object
MarshalByRefObject
TextReader
TextWriter
StreamReader
StreamWriter
StringReader
StringWriter
Um in eine Textdatei zu schreiben bzw. von dort zu lesen, verwendet man Objekte der Klassen
StreamWriter bzw. StreamReader, die jeweils über eine Instanzvariable auf ein FileStreamObjekt aufsetzen (wie bei den Klassen BinaryWriter und BinaryReader) 1. Beim Schreiben haben
wir also folgende Situation:
Programm
Variablen
von
beliebigem
Typ
StreamWriter
FileStream
Bytes
Textdatei
Beim Lesen aus einer Textdatei reisen die Daten in umgekehrter Richtung:
Programm
Zeichen
(folgen)
StreamReader
FileStream
Bytes
Textdatei
Man kann den Basisstrom für einen StreamWriter oder -Reader auch implizit erzeugen lassen,
wenn die voreingestellten Kreationsparameter akzeptabel sind, z.B.:
StreamWriter sw = new StreamWriter("demo.txt");
Hier wird implizit ein FileStream-Objekt mit der voreingestellten Puffergröße 4096 erzeugt, das
auf eine Datei mit dem Eröffnungsmodus FileMode.Create zugreift.
1
Die Bezeichnungen StreamWriter und StreamReader sind nicht ganz glücklich, weil auch ein BinaryWriter in
ein Stream-Objekt schreibt und ein BinaryReader aus einem Stream-Objekt liest.
Kapitel 12: Ein-/Ausgabe über Datenströme
360
Das folgende Beispielprogramm schreibt einen int- und einen double-Wert sowie eine Zeichenfolge per StreamWriter über ein implizit erzeugtes FileStream-Objekt in eine Datei. Anschließend
werden die Daten über einen StreamReader eingelesen, der sich auf ein explizit erzeugtes FileStream-Objekt stützt:
using System;
using System.IO;
class StreamWrtRd {
static void Main() {
String name = "demo.txt";
StreamWriter sw = new StreamWriter(name);
sw.WriteLine(4711);
sw.WriteLine(3.1415926);
sw.WriteLine("Nicht übel");
sw.Close();
StreamReader sr = new StreamReader(
new FileStream(name, FileMode.Open, FileAccess.Read));
Console.WriteLine("Inhalt der Datei {0}\n",
((FileStream)sr.BaseStream).Name);
for (int i = 0; sr.Peek() >= 0; i++ ) {
Console.WriteLine("{0}:\t{1}", i, sr.ReadLine());
}
}
}
Das Beispielprogramm liefert folgende Ausgabe:
Inhalt der Datei U:\Eigene Dateien\C#\EA\StreamWrtRd\bin\Debug\demo.txt
1:
2:
3:
4711
3,1415926
Nicht übel
Auch die erzeugte Textdatei ist ansehnlich:
Bei den TextWriter-Methoden Write() und WriteLine() treffen wir im Wesentlichen auf dieselben Signaturen wie bei den gleichnamigen Console-Methoden, die Ihnen aus zahlreichen Beispielen vertraut sind.
Im Unterschied zu einem BinaryWriter (siehe Abschnitt 12.2.1) besitzt ein StreamWriter einen
lokalen Puffer (Datentyp: char[], voreingestellte Größe: 1024). Daher muss ein StreamWriter unbedingt nach Gebrauch per Close() (oder Dispose()) geschlossen werden. Das zugrunde liegende
Stream-Objekt wird dabei automatisch ebenfalls geschlossen. Es wäre riskant, das Schließen dem
Garbage Collector zu überlassen, für den keine Arbeitsreihenfolge garantiert ist. Wenn er das
Stream-Objekt vor dem StreamWriter–Objekt schließt, kann letzteres seinen Puffer nicht mehr
ausgeben.
361
Abschnitt 12.2 Verarbeitung von Daten mit höherem Typ
Über die boolesche StreamWriter-Eigenschaft AutoFlush (Voreinstellung: false) wird festgelegt,
ob die per Write() oder WriteLine() geschriebenen Zeichen sofort in den Ausgabestrom wandern
(bei bestimmten Geräten sinnvoll) oder zwischengepuffert werden (höhere Performanz) 1.
Arbeitet ein StreamWriter mit einem FileStream zusammen, findet eine Doppelpufferung statt:


Ein StreamWriter–Objekt enthält als Puffer einen char-Array (voreingestellte Größe:
1024).
Ein FileStream-Objekt enthält als Puffer einen byte-Array (voreingestellte Größe: 4096).
Beim Flush()-Aufruf an ein StreamWriter–Objekt …


wird zunächst der interne Puffer ausgegeben
und dann ein Flush()-Aufruf an das Stream-Objekt gerichtet.
Hinweise zu einigen TextReader-Methoden:



public String ReadLine()
Diese Methode liest eine Zeile, liefert das Ergebnis als String-Objekt ab und verschiebt die
Position des Eingabestroms entsprechend.
public int Read()
Diese Methode liefert als Rückgabewert die Unicode-Nummer des nächsten Zeichens oder
aber den Wert -1, wenn der Strom kein Zeichen mehr enthält, und verschiebt die Position
des Eingabestroms entsprechend.
public int Peek()
Diese Methode liefert wie Read() die Unicode-Nummer des nächsten Zeichens oder aber
den Wert -1, wenn der Strom kein Zeichen mehr enthält. Die Position des Stroms bleibt dabei aber unverändert.
Per Voreinstellung schreiben bzw. lesen die StreamWriter bzw. –Reader Unicode-Zeichen unter
Verwendung der Platz sparenden UTF8-Kodierung. Bei diesem Schema werden die UnicodeZeichen durch eine variable Anzahl von Bytes kodiert. So können alle Unicode-Zeichen ausgegeben werden, ohne die Speicherplatzverschwendung durch führende Null-Bytes bei den sehr oft auftretenden ASCII-Zeichen (mit Unicode-Nummern  127) in Kauf nehmen zu müssen:
von
\u0000
\u0001
\u0080
\u0800
Unicode-Zeichen
bis
\u0000
\u007F
\u07FF
\uFFFF
Anzahl
Bytes
2
1
2
3
Bei einigen Überladungen des StreamWriter-Konstruktors lassen sich auch alternative Kodierungen einstellen, z.B.:
FileStream fs = new FileStream("unicode.txt", FileMode.Create);
StreamWriter swUnicode = new StreamWriter(fs, Encoding.Unicode);
1
Die folgende Implementierung der Write()-Methode mit char-Parameter aus der Klasse StreamWriter zeigt, wie
sich die öffentliche AutoFlush-Eigenschaft über das private autoFlush-Feld auf das Pufferungsverhalten auswirkt
(Quellcode aus Microsofts Shared Source Common Language Infrastructure 2.0):
public override void Write(char value) {
if (charPos == charLen) Flush(false, false);
charBuffer[charPos++] = value;
if (autoFlush) Flush(true, false);
}
362
Kapitel 12: Ein-/Ausgabe über Datenströme
Die statische Eigenschaft Unicode der Klasse Encoding im Namensraum System.Text zeigt auf
ein Objekt der Klasse UnicodeEncoding, das die Kodierung übernimmt. Es verzichtet auf Platzsparmaßnahmen und verwendet für jedes Zeichen 2 Bytes.
Auch bei Objekten der Klasse StreamReader lässt sich die voreingestellte UTF8-Kodierung über
alternative Konstruktoren ersetzen, was z.B. beim Lesen der häufig anzutreffenden ANSI-Textdateien erforderlich ist. Im folgenden Programm
using System;
using System.IO;
using System.Text;
class AnsiTextLesen {
static String name = "AnsiText.txt";
static void Main() {
FileStream fs = new FileStream(name, FileMode.Open, FileAccess.Read);
StreamReader sr = new StreamReader(fs);
Console.WriteLine("Mit UTF8-Kodierung gelesen:");
while (sr.Peek() >= 0)
Console.WriteLine(sr.ReadLine());
fs.Position = 0;
sr = new StreamReader(fs, Encoding.Default);
Console.WriteLine("\nMit ANSI-Kodierung gelesen:");
while (sr.Peek() >= 0)
Console.WriteLine(sr.ReadLine());
Console.ReadLine();
}
}
wird eine ANSI-Textdatei mit folgendem Inhalt
ANSI-kodierte Umlaute: üöä
zuerst mit der ungeeigneten UTF8- und dann mit dem korrekten ANSI-Kodierung eingelesen, was
zu folgender Ausgabe führt:
Mit UTF8-Kodierung gelesen:
ANSI-kodierte Umlaute:
Mit ANSI-Kodierung gelesen:
ANSI-kodierte Umlaute: üöä
Über die statische Encoding-Eigenschaft Default erhält man eine zur aktuellen ANSI-Codepage
des Systems passende Kodierung.
12.2.3 Serialisieren von Objekten
Wer objektorientiert programmiert, möchte natürlich auch objektorientiert speichern und laden.
Erfreulicherweise können in C# Objekte von Klassen tatsächlich genau so einfach wie elementare
Datentypen in einen Datenstrom geschrieben bzw. von dort gelesen werden. Die Übersetzung eines
Objekts mit all seinen Instanzvariablen und den enthaltenen (d.h. von Feldern referenzierten) Objekten in einen Bytestrom bezeichnet man recht treffend als Objektserialisierung. Beim Einlesen
werden alle Objekte mit ihren Instanzvariablen wiederhergestellt und die Referenzen zwischen den
Objekten in den Ausgangszustand gebracht. Die FCL spricht von (De)serialisieren eines Objektdiagramms. Dank Autoboxing lassen sich auch Strukturinstanzen (de)serialisieren.
Das Serialisieren der Instanzen eines Typs muss explizit über das Attribut Serializable oder das
Implementieren der Schnittstelle ISerializable erlaubt werden. Bei der letztgenannten Variante gewinnt man Kontrolle über die (De)serialisierung, muss aber auch einen hohen Eigenbeitrag leisten.
Wir beschränken uns auf bequeme Attribut-Vergabe, die aber nicht unbedacht erfolgen darf, weil
die Serialisierbarkeit aller Datentypen von Instanzvariablen erforderlich ist. Aus nahe liegenden
Abschnitt 12.2 Verarbeitung von Daten mit höherem Typ
363
Gründen haben die FCL-Designer das Attribut Serializable als nicht vererbbar definiert, so dass es
nicht auf abgeleitete Klassen übertragen wird.
Bei Bedarf können einzelne Felder über das Attribut NonSerialized ausgeschlossen werden. Dies
kommt z.B. in Frage, wenn ...



ein Feld aus Sicherheitsgründen nicht in den Ausgabestrom gelangen soll,
ein Feld temporäre Daten enthält, so dass ein Speichern überflüssig bzw. sinnlos ist,
ein Feld einen nicht-serialisierbaren Datentyp hat.
Wird ein solches Feld nicht von der Serialisierung ausgeschlossen, kommt es ggf. zu einer
SerializationException.
Die in folgender Quelle definierte Klasse Kunde ist als serialisierbar definiert, wobei jedoch für
das Feld stimmung eine Ausnahme gemacht wird:
using System;
using System.Runtime.Serialization;
[Serializable]
public class Kunde {
int nr;
string vorname;
string name;
[NonSerialized]
int stimmung;
int nkaeufe;
double aussen;
public Kunde(int nr_, string vorname_, string name_, int stimmung_,
int nkaeufe_, double aussen_) {
nr = nr_;
vorname = vorname_;
name = name_;
stimmung = stimmung_;
nkaeufe = nkaeufe_;
aussen = aussen_;
}
public void prot() {
Console.WriteLine("Kundennummer:
Console.WriteLine("Name: \t\t" +
Console.WriteLine("Stimmung: \t"
Console.WriteLine("Anz.Einkäufe:
Console.WriteLine("Aussenstände:
}
\t" + nr);
vorname + " " + name);
+ stimmung);
\t" + nkaeufe);
\t" + aussen+ "\n");
}
Den nicht gerade trivialen Job der (De)Serialisierung übernimmt ein Objekt aus einer Klasse, die
das Interface IFormatter (aus dem Namensraum System.Runtime.Serialization) implementiert.
Wir arbeiten anschließend mit der Klasse BinaryFormatter (aus dem Namensraum
System.Runtime.Serialization.Formatters.Binary), die ein kompaktes Binärformat verwendet.
Beim Abspeichern in eine Datei resultiert die folgende Verarbeitungskette:
Kapitel 12: Ein-/Ausgabe über Datenströme
364
Programm
Objekt
(ggf. mit
Member-Objekten)
BinaryFormatter
FileStream
Bytes
Binärdatei
Im folgenden Programm werden zwei Kunde–Objekte mitsamt den enthaltenen String-Objekten,
aber ohne das Feld stimmung (de)serialisiert:
using
using
using
using
System;
System.IO;
System.Runtime.Serialization;
System.Runtime.Serialization.Formatters.Binary;
class Serialisierung {
static string name = "demo.bin";
static void Main() {
Kunde[] kunden = new Kunde[2];
kunden[0] = new Kunde(1, "Fritz", "Orth", 1, 13, 426.89);
kunden[1] = new Kunde(2, "Ludwig", "Knüller", 2, 17, 89.10);
Console.WriteLine("Zu sichern:\n");
foreach (Kunde k in kunden)
k.prot();
FileStream fs = new FileStream(name, FileMode.Create);
IFormatter bifo = new BinaryFormatter();
bifo.Serialize(fs, kunden);
fs.Position = 0;
Console.WriteLine("\nRekonstruiert:\n");
Kunde[] desKunden = (Kunde[]) bifo.Deserialize(fs);
foreach(Kunde k in desKunden)
k.prot();
}
}
Pro Serialize()-Aufruf wird ein Wurzelobjekt mitsamt den Werttyp-Feldern sowie den direkt oder
indirekt referenzierten Objekten geschrieben. Um weitere Wurzelobjekte in denselben Datenstrom
zu befördern, sind entsprechend viele Aufrufe erforderlich. Das Beispielprogramm hätte die KundeObjekte auch einzeln serialisieren können.
Beim Lesen eines Objekts durch die Methode Deserialize() wird zunächst die zugehörige Klasse
festgestellt und in die Laufzeitumgebung geladen (falls noch nicht vorhanden). Dann wird das Objekt auf dem Heap angelegt, und die Instanzvariablen erhalten die rekonstruierten Werte, wobei kein
Konstruktor aufgerufen wird. Das ganze wiederholt sich (ggf. auf mehreren Ebenen) für die referenzierten Objekte.
Weil Deserialize() den Rückgabewert Object hat, ist eine Typumwandlung erforderlich. Ein
Deserialize()-Aufruf liest das nächste im Datenstrom befindliche Wurzelobjekt samt Anhang (also
ein Objektdiagramm). Um weitere Wurzelobjekte aus demselben Datenstrom zu lesen, sind entsprechend viele Aufrufe erforderlich.
365
Abschnitt 12.2 Verarbeitung von Daten mit höherem Typ
Das Beispielprogramm produziert folgende Ausgabe:
Zu sichern:
Kundennummer:
Name:
Stimmung:
Anz.Einkäufe:
Aussenstände:
1
Fritz Orth
1
13
426,89
Kundennummer:
Name:
Stimmung:
Anz.Einkäufe:
Aussenstände:
2
Ludwig Knüller
2
17
89,1
Rekonstruiert:
Kundennummer:
Name:
Stimmung:
Anz.Einkäufe:
Aussenstände:
1
Fritz Orth
0
13
426,89
Kundennummer:
Name:
Stimmung:
Anz.Einkäufe:
Aussenstände:
2
Ludwig Knüller
0
17
89,1
Die Instanzvariable stimmung der eingelesenen Kunden besitzen den Initialwert Null, während
die übrigen Elementvariablen bei der (De)serialisierung ihre Werte behalten.
In der folgenden Abbildung wird die Rekonstruktion der Objekte skizziert:
Programm
Objekt
(ggf. mit
Member-Obj.)
BinaryFormatter
FileStream
Auch zirkuläre Referenzen wie in folgender Situation
Bytes
Binärdatei
Kapitel 12: Ein-/Ausgabe über Datenströme
366
A-Objekt a
bref
name
B-Objekt b
cref
name
C-Objekt c
aref
name
bringen den BinaryFormatter nicht aus dem Tritt. Wenn man Objekte a, b und c aus den Klassen
A, B und C geeignet initialisiert
A a = new A(); B b
a.bref = b; a.name
b.cref = c; b.name
c.aref = a; c.name
=
=
=
=
new B(); C c = new C();
"a-Obj";
"b-Obj";
"c-Obj";
und anschließend das Objekt a serialisiert,
FileStream fs = new FileStream("demo.bin", FileMode.Create);
IFormatter bifo = new BinaryFormatter();
bifo.Serialize(fs, a);
dann landen alle drei Objekte im Datenstrom. Beim Deserialisieren von a
fs.Position = 0;
A na = (A) bifo.Deserialize(fs);
Console.WriteLine("Rekonstruiert: " + na.bref.cref.aref.name);
werden auch die beiden anderen Objekte rekonstruiert, so dass der WriteLine()-Aufruf zu folgender Ausgabe führt:
Rekonstruiert: a-Obj
Unter ihren früheren Namen (a, b und c) sind die rekonstruierten Objekte nicht mehr ansprechbar,
weil es sich hier um die Namen von lokalen Variablen (der Methode Main()) handelt, die beim Serialisieren nicht berücksichtigt werden.
Ist eine XML-basierte Ausgabe gefragt, kommen an Stelle des BinaryFormatters die folgenden
FCL-Klassen in Frage:

SoapFormatter
Diese Klasse produziert das XML-Format des SOAP-Internetprotokolls (Simple Object Access Protocol) benutzt. Allerdings wird die Klasse nicht mehr weiterentwickelt und unterstützt z.B. keine generischen Typen.

XmlSerializer
Diese Klasse erlaubt über Attribute eine Gestaltung der XML-Ausgabe. Im Unterschied zu
den Klassen BinaryFormatter und SoapFormatter werden keine privaten Felder und keine zirkulären Referenzen unterstützt.
367
Abschnitt 12.3 Verwaltung von Dateien und Verzeichnissen
12.3 Verwaltung von Dateien und Verzeichnissen
Zur Verwaltung von Dateien bzw. Verzeichnissen enthält die FCL jeweils eine Klasse mit statischen Methoden (File bzw. Directory) sowie eine Klasse mit Instanzmethoden (FileInfo bzw.
DirectoryInfo):
Object
Directory
File
MarshalByRefObject
DriveInfo
FileSystemInfo
DirectoryInfo
FileInfo
Man kann Dateien bzw. Verzeichnisse löschen, kopieren, umbenennen und verschieben sowie diverse Datei- bzw. Verzeichnisattribute einsehen und verändern. Im Zusammenhang mit dem TreeView-Steuerelement werden wir später noch die Klasse DriveInfo behandeln, die ein komplettes
Laufwerk repräsentiert.
12.3.1 Dateiverwaltung
Im folgenden Beispielprogramm werden einige Methoden und Eigenschaften der Klassen File und
FileInfo demonstriert:
using System;
using System.IO;
class DateiVerwaltung {
static void Main() {
const string PFAD1 = @"U:\Eigene Dateien\C#\EA\demo.txt";
const string PFAD2 = @"U:\Eigene Dateien\C#\EA\kopie.txt";
const string PFAD3 = @"U:\Eigene Dateien\C#\EA\nn.txt";
FileStream fs = File.Create(PFAD1);
fs.Close();
StreamWriter sw = File.CreateText(PFAD1);
sw.WriteLine("File-Demo");
sw.Close();
File.Copy(PFAD1, PFAD2, true);
if (File.Exists(PFAD3))
File.Delete(PFAD3);
File.Move(PFAD1, PFAD3);
File.SetCreationTime(PFAD3, new DateTime(2005, 12, 29, 22, 55, 44));
File.SetLastWriteTime(PFAD3, new DateTime(2005, 12, 29, 22, 55, 44));
FileInfo fi = new FileInfo(PFAD3);
Console.WriteLine("Die Datei
{0} wurde\n erstellt:
{1}"+
"\n zuletzt geändert: {2}", fi.Name, fi.CreationTime, fi.LastWriteTime);
fi.Delete();
}
}
Kapitel 12: Ein-/Ausgabe über Datenströme
368
Wie in Abschnitt 3.3.8.5 über die Syntax von Zeichenkettenliteralen besprochen, wird mit dem Präfix „@“ vor einer Zeichenkette die Auswertung von Escape-Sequenzen abgeschaltet, so dass die in
Windows-Pfadnamen üblichen Rückwärtsschrägstrich nicht mehr durch Verdoppeln von ihrer Sonderfunktion befreit beraubt werden müssen.
Anschließend werden wichtige Methoden der Klasse File vorgestellt, die allesamt statisch sind und
meist noch weitere Überladungen besitzen:

public static FileStream Create(string pfadname)
Die File-Methode Create() erzeugt eine Datei und ein zugehöriges FileStream-Objekt, so
dass die Datei anschließend mit FileMode.Create geöffnet ist. Die Anweisung
FileStream fs = File.Create(PFAD1)
ist äquivalent mit
FileStream fs = new FileStream(PFAD1, FileMode.Create)

public static StreamWriter CreateText(string pfadname)
Die File-Methode CreateText() erzeugt eine Datei und ein StreamWriter–Objekt (mit
UTF8-Kodierung). Es entsteht auch das vermittelnde FileStream-Objekt, und die Datei ist
anschließend mit FileMode.Create geöffnet. Die Anweisung
StreamWriter sw = File.CreateText(PFAD1)
ist äquivalent mit
StreamWriter sw = new StreamWriter(PFAD1)

public static bool Exists(string pfadname)
Mit File.Exists() überprüft man die Existenz einer Datei.

public static void Delete(string pfadname)
Mit der File-Methode Delete() kann man eine Datei löschen.

public static void Copy(string pfadQuelle, string pfadZiel, bool überschreiben)
Bei dieser Copy()-Überladung erlaubt der Wert true des dritten Parameters das Überschreiben einer vorhandenen Zieldatei.

public static void Move(string pfadQuelle, string pfadZiel)
Zum Umbenennen oder Verschieben einer Datei verwendet man die File-Methode Move().
Bei identischem Ordner von Quelle und Ziel wird die Datei umbenannt, anderenfalls wird
die Datei verschoben.

public static void SetLastWriteTime(string pfadname, DateTime letzteÄnderung)
Von den zahlreichen Methoden zum Modifizieren von Dateiattributen wird im obigen Beispiel SetLastWriteTime() verwendet. Damit lässt sich das Datum der letzten Änderung setzen.
Zu praktisch allen File-Klassenmethoden finden sich Entsprechungen in der Klasse FileInfo (als
Instanzmethoden oder Eigenschaften). Exemplarisch wird im Beispiel das Löschen einer Datei per
FileInfo-Objekt vorgeführt.
Wie die Ausgabe des Programms zeigt, lassen sich wichtige Dateieigenschaften leicht fälschen:
Die Datei
nn.txt wurde
erstellt:
29.12.2005 22:55:44
zuletzt geändert: 29.12.2005 22:55:44
Abschnitt 12.3 Verwaltung von Dateien und Verzeichnissen
369
12.3.2 Ordnerverwaltung
Im folgenden Beispielprogramm werden einige Methoden der Klassen Directory und
DirectoryInfo demonstriert:
using System;
using System.IO;
class OrdnerVerwaltung {
static void Main() {
const string DIR1 = @"U:\Eigene Dateien\C#\EA\";
const string DIR2 = @"U:\Eigene Dateien\C#\EA\Sub\";
Directory.SetCurrentDirectory(DIR1);
Directory.CreateDirectory(DIR2);
DirectoryInfo di = new DirectoryInfo(".");
FileInfo[] fia = di.GetFiles("*.txt");
Console.WriteLine("Textdateien in {0}\n", Directory.GetCurrentDirectory());
Console.WriteLine("{0, 20} {1, 20}", "Name", "Letzte Änderung");
foreach (FileInfo fi in fia)
Console.WriteLine("{0, 20} {1, 20}", fi.Name, fi.LastWriteTime);
DirectoryInfo[] dia = di.GetDirectories();
Console.WriteLine("\n\nOrdner in {0}\n", di.FullName);
Console.WriteLine("{0, 20} {1, 20}", "Name", "Letzte Änderung");
foreach (DirectoryInfo die in dia)
Console.WriteLine("{0, 20} {1, 20}", die.Name, die.LastWriteTime);
Directory.Delete(DIR2, true);
}
}
Wichtige statische Methoden der Klasse Directory:

public static String GetCurrentDirectory()
public static void SetCurrentDirectory(string pfadname)
Mit GetCurrentDirectory() bzw. SetCurrentDirectory() kann man das aktuelle Verzeichnis zum laufenden Programm ermitteln bzw. setzen.

public static bool Exists(string pfadname)
Mit Exists() überprüft man die Existenz eines Ordners.

public static DirectoryInfo CreateDirectory(string pfadname)
public static void Delete(string pfadname)
Zum Erzeugen bzw. Löschen eines Ordners stehen die Methoden CreateDirectory() bzw.
Delete() bereit. Mit der angegebenen Delete() - Überladung lässt sich nur ein leerer Ordner
löschen. Bei einer alternativen Überladung mit Parameter vom Typ bool kann man auch ein
rekursives Löschen von Unterverzeichnissen und Dateien erzwingen.
Im Konstruktor der Klasse DirectoryInfo ist ein Ordnerpfad anzugeben, wobei der aktuelle Pfad
des Programms über einen Punkt angesprochen werden kann. Wichtige Instanzmethoden der Klasse
DirectoryInfo:

public FileInfo[] GetFiles()
Bei Aufruf seiner Instanzmethode GetFiles() liefert ein DirectoryInfo-Objekt einen Array
mit FileInfo-Objekten zu allen Dateien im Ordner, wobei auch Dateiauswahlfilter mit Jokerzeichen möglich sind.

public DirectoryInfo[] GetDirectories()
Analog liefert die Instanzmethode GetDirectories() einen Array mit DirectoryInfo-Objekten zu den Unterordnern.
Kapitel 12: Ein-/Ausgabe über Datenströme
370
12.3.3 Überwachung von Ordnern
Mit einem Objekt der Klasse FileSystemWatcher aus dem Namensraum System.IO lassen sich die
Veränderungen in einem Ordner überwachen (Erzeugen, Löschen, Umbenennen von Einträgen).
Das folgende Programm überwacht die Veränderungen bei den Textdateien (Extension .txt) in dem
per Kommandozeile angegebenen Ordner:
using System;
using System.IO;
public class TxtWatcher {
static void Main(String[] args) {
FileSystemWatcher watcher = new FileSystemWatcher(args[0]);
// Zu Überwachen: Ändern und Umbenennen von Dateien
watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName;
// Filter für Dateinamen
watcher.Filter = "*.txt";
// Ereignisbehandlungsroutinen registrieren
watcher.Changed += new FileSystemEventHandler(FsoChanged);
watcher.Created += new FileSystemEventHandler(FsoChanged);
watcher.Deleted += new FileSystemEventHandler(FsoChanged);
watcher.Renamed += new RenamedEventHandler(FsoRenamed);
// Überwachung aktivieren
watcher.EnableRaisingEvents = true;
Console.WriteLine("TxtWatcher gestartet. Beenden mit 'q'\n");
Console.WriteLine("Überwachter Ordner: "+args[0]+"\n");
ConsoleKeyInfo cki;
do
cki = Console.ReadKey(true);
while (cki.KeyChar != 'q');
}
// Ereignisroutinen implementieren
static void FsoChanged(object source, FileSystemEventArgs e) {
Console.WriteLine("Datei: " + e.Name + " " + e.ChangeType);
}
static void FsoRenamed(object source, RenamedEventArgs e) {
Console.WriteLine("Datei: {0} umbenannt in {1}", e.OldName, e.Name);
}
}
Im Übrigen demonstriert das Programm, dass Ereignisse nicht unbedingt von GUI-Komponenten
stammen müssen, und dass auch Konsolenanwendungen auf Ereignisse reagieren können.
Eine Beispielausgabe:
TxtWatcher gestartet. Beenden mit 'q' + Enter
Überwachter Ordner: U:\Eigene Dateien\C#\EA
Datei
Datei
Datei
Datei
Neu Textdokument.txt Created
Neu Textdokument.txt umbenannt in neu.txt
neu.txt Changed
neu.txt Deleted
Mit der ab .NET 2.0 verfügbaren Console-Methode ReadKey() kann man sofort auf Tastendrücke
reagieren und dabei die Ausgabe von Zeichen auf dem Bildschirm verhindern (Wert true für den
ersten und einzigen Parameter). Man erhält eine Instanz der Struktur ConsoleKeyInfo, die u.a. das
zu einer Taste gehörige Unicode-Zeichen kennt.
371
Abschnitt 12.4 Übungsaufgaben zu Kapitel 1615H12
12.4 Übungsaufgaben zu Kapitel 12
1) Erstellen Sie bitte ein Statistikprogramm zur Berechnung des Mittelwerts, das als Eingabe eine
Textdatei mit Daten akzeptiert, wobei das Semikolon als Trennzeichen dient. In folgender Beispieldatei liegen drei Variablen (Spalten) für fünf Fälle vor:
12;3;345
7;5;298
9;4;411
10;2;326
5;6;195
4;sieben;120
Die gültigen Werte der zweiten Spalte haben z.B. den Mittelwert 4. Ihr Programm sollte auf irreguläre Daten folgendermaßen reagieren:


Warnung ausgeben
mit den verfügbaren Werten rechnen
Auf obige Daten sollte Ihr Programm ungefähr so reagieren:
Mittelwertsberechnung für die Datei daten.txt
Warnung: Token 2 in Zeile 6 ist keine Zahl.
Variable
1
2
3
Mittelwert
7,833
4,000
282,500
Valide Werte
6
5
6
2) Wie kann man den Quellcode des folgenden Programms vereinfachen und dabei auch noch die
Laufzeit erheblich reduzieren?
using System;
using System.IO;
class AutoFlushDemo {
static void Main() {
long zeit = DateTime.Now.Ticks;;
StreamWriter sw = new StreamWriter("demo.txt");
sw.AutoFlush = true;
for (int i = 1; i < 30000; i++) {
sw.WriteLine(i);
}
sw.Close();
Console.WriteLine("Zeit: "+((DateTime.Now.Ticks-zeit)/1.0e4)+
" Millisek.");
}
}
13 Threads
Wir sind längst daran gewöhnt, dass moderne Betriebssysteme mehrere Programme (Prozesse) parallel betreiben können, so dass z.B. ein längerer Ausdruck keine Zwangspause des Benutzers zur
Folge hat. Während der Druckertreiber die Ausgabeseiten aufbaut, kann z.B. ein C# - Programm
entwickelt oder im Internet recherchiert werden. Sofern nur ein Prozessor vorhanden ist, der den
einzelnen Programmen bzw. Prozessen reihum vom Betriebssystem zur Verfügung gestellt wird,
reduziert sich zwar die Ausführungsgeschwindigkeit jedes Programms im Vergleich zum Solobetrieb, doch ist in den meisten Anwendungen trotzdem ein flüssiges Arbeiten möglich.
Als Ergänzung zum gerade beschriebenen Multitasking, das ohne Zutun der Programmierer vom
Betriebssystem bewerkstelligt wird, ist es oft sinnvoll oder gar unumgänglich, auch innerhalb einer
Anwendung nebenläufige Ausführungsfäden zu realisieren, wobei man hier vom Multithreading
spricht. Bei einem Internet-Browser muss man z.B. nicht untätig den quälend langsamen Aufbau
einer Seite abwarten, sondern kann in einem anderen Browser-Fenster Suchbegriffe eingeben etc.
Die Multithreading-Technik kommt aber nicht nur dann in Frage, wenn eine Anwendung mehrere
Aufgaben gleichzeitig erledigen soll. Sind auf einem Rechner mehrere Prozessoren oder Prozessorkerne verfügbar, dann sollten aufwändige Einzelaufgaben (z.B. das Rendern einer 3D-Ansicht, Virenanalyse einer kompletten Festplatte) in Teilaufgaben zerlegt werden, um die CPU-Kerne auszulasten und Zeit zu sparen.
Beim Multithreading ist allerdings eine sorgfältige Einsatzplanung erforderlich, denn:



Thread-Wechsel sind mit einem gewissen Zeitaufwand verbunden und sollten daher nicht zu
häufig stattfinden.
Das Laufzeitsystem wird durch die Verwaltung von Threads zusätzlich belastet.
In der Regel erfordert das Synchronisieren von Threads einige Aufmerksamkeit beim Programmierer (siehe Abschnitt 13.2).
Die zu einem Prozess gehörenden Threads laufen im selben Adressraum ab und verwenden einen
gemeinsamen Heap. Allerdings benötigt jeder Thread als selbständiger Kontrollfluss bzw. Ausführungsfaden einen eigenen Stack.
Bei C# ist die Multithreading-Unterstützung in Sprache, Standardbibliothek (siehe Namensraum
System.Threading) und Laufzeitumgebung integriert. Folglich gehört diese Technik in C# nicht
zum Guru-HighTech - Repertoire, sondern kann von jedem Programmierer ohne großen Aufwand
genutzt werden.
Übrigens sind auch ohne unser Zutun in jeder .NET – Anwendung mehrere Threads aktiv; so läuft
z.B. der Garbage Collector stets in einem eigenen Thread.
Wir erarbeiten uns zunächst ein Multithreading-Basiswissen durch den Einsatz von dedizierten, für
einen bestimmten Zweck erstellten Threads. In der Praxis geht es darum, mit möglichst wenigen
Threads eine gute Performanz zu erzielen, wobei im .NET - Framework das Asynchronous Programming Model (APM) eine wichtige Rolle spielt. Statt für eine konkrete Aufgabe (z.B. Bedienung eines Webzugriffs) jeweils einen neuen Thread zeitaufwändig zu erzeugen und anschließend
wieder zu zerstören, kommt hier ein Pool von Arbeits-Threads zum Einsatz. Eingehende Aufträge
werden einem freien Thread zugeteilt oder in eine Warteschlange gestellt.
Kapitel 13: Threads
374
13.1 Threads erzeugen
Ein Thread wird in C# über ein Objekt der gleichnamigen Klasse aus dem Namensraum System.Threading realisiert. Das gilt sowohl für die dedizierten (zur Bewältigung einer speziellen
Aufgabe erstellten) Threads, als auch für die Pool-Threads.
Jede Instanz- oder Klassenmethode, die entweder den Delegatentyp
public delegate void ThreadStart()
oder den Delegatentyp
public delegate void ParameterizedThreadStart(Object obj)
erfüllt, kann in einem eigenen (dedizierten) Thread gestartet werden, indem ein zugehöriges Delegatenobjekt erzeugt und dem Thread-Konstruktor übergeben wird, z.B.:
Thread pt = new Thread(new ThreadStart(pro.Run));
Man kann sich das explizite Notieren des Delegaten-Konstruktors sparen:
Thread pt = new Thread(pro.Run);
Diese Thread-Kreation stammt aus einem „betriebswirtschaftlichen“ Beispielprogramm mit einem
Objekt aus einer Klasse Produzent und einem Objekt aus einer Klasse Konsument, die auf
einen Lagerbestand einwirken, der von einem Objekt der Klasse Lager gehütet wird. Produzent
und Konsument entfalten ihre Tätigkeit jeweils im Rahmen einer Methode namens Run(), die in
einem eigenen Thread läuft.
Das Lager-Objekt führt die Aufträge Ergaenze() und Liefere() aus, solange nicht eine
Maximalzahl von Lagerzugriffen überschritten ist:
using System;
using System.Threading;
public class Lager {
int bilanz;
int anz;
const int MANZ = 20;
const int STARTKAP = 100;
System.Globalization.CultureInfo ci = new
System.Globalization.CultureInfo("de-DE");
public Lager(int start) {
bilanz = start;
}
public bool Ergaenze(int add) {
if (anz < MANZ) {
bilanz += add;
anz++;
Rumoren();
Console.WriteLine("Nr. {0,2}: {1,10} ergänzt {2,3} um {3} Uhr. Stand: {4}",
anz, Thread.CurrentThread.Name, add, DateTime.Now.ToString("T", ci), bilanz);
return true;
} else {
Console.WriteLine("\nLieber " + Thread.CurrentThread.Name +
", es ist Feierabend!");
return false;
}
}
Abschnitt 13.1 Threads erzeugen
375
public bool Liefere(int sub) {
if (anz < MANZ) {
bilanz -= sub;
anz++;
Rumoren();
Console.WriteLine("Nr. {0,2}: {1,10} entnimmt {2, 3} um {3} Uhr. Stand: {4}",
anz, Thread.CurrentThread.Name, sub, DateTime.Now.ToString("T", ci), bilanz);
return true;
} else {
Console.WriteLine("\nLieber " + Thread.CurrentThread.Name +
", es ist Feierabend!");
return false;
}
}
void Rumoren() {
double d;
for (int i = 0; i < 40000; i++)
d = i * i;
}
static void Main() {
Lager lager = new Lager(STARTKAP);
Console.WriteLine("Der Laden ist offen (Bestand: {0})\n", STARTKAP);
Produzent pro = new Produzent(lager);
Konsument kon = new Konsument(lager);
Thread pt = new Thread(pro.Run);
Thread kt = new Thread(kon.Run);
pt.Name = "Produzent";
kt.Name = "Konsument";
pt.Start();
kt.Start();
}
}
Die Main()-Methode der Klasse Lager erzeugt als Startmethode des Programms die beteiligten
Objekte: 1

einen Lageristen (Objekt lager aus der Klasse Lager)

einen Produzenten (Objekt pro aus der Klasse Produzent)

einen Konsumenten (Objekt kon aus der Klasse Konsument)

einen Thread, dessen Ausführung mit der Run()-Methode des Produzenten startet (Objekt
pt aus der Klasse Thread)

einen Thread, dessen Ausführung mit der Run()-Methode des Konsumenten startet (Objekt
kt aus der Klasse Thread)
Schließlich erhalten die Threads einen Namen und werden gestartet.
Unmittelbar vor dem Ende der Main()-Methode sind drei Threads aktiv:


1
Der primäre Thread des Programms lebt, solange die Main()-Methode läuft.
Außerdem agieren zu diesem Zeitpunkt die beiden zusätzlich gestarteten sekundären
Threads. Sie enden mit ihrer Startmethode (prod.Run() bzw. kon.Run()), sofern sie
nicht zuvor abgebrochen werden (siehe unten).
Das Beispiel mit seinem recht reichhaltigen Objekt-Ensemble demonstriert übrigens, dass einige Objekte direkt aus
der Abbildung des Anwendungsbereichs stammen (Lagerist, Produzent, Konsument), während andere Objekte einen
informationstechnologischen Ursprung haben (die beiden Threads).
Kapitel 13: Threads
376
Main()
pt.Start();
prod.Run()
kt.Start();
kon.Run()
Die Aufrufe der Thread-Methode Start() kehren praktisch unmittelbar zurück, und anschließend
endet mit der Main()-Methode auch der primäre Thread. Die beiden sekundären Threads leben weiter bis zum Ende ihrer jeweiligen Startmethode, und das Programm endet mit seinem letzten Vordergrund-Thread 1.
Dass beim Ende der Main()-Methode die einzigen Referenzen auf die Thread-Objekte (pt und kt)
verschwinden, spielt keine Rolle.
Weil Produzent und Konsument mit dem Lager-Objekt kooperieren sollen, erhalten sie als Konstruktor-Parameter eine entsprechende Referenz.
Der Lagerist beherrscht die folgenden Methoden, um den Produzenten oder den Konsumenten zu
bedienen:

public bool Ergaenze(int add)
Diese Methode wird vom Produzenten genutzt, um Ware virtuell einzuliefern. Ist das Lager
bereits geschlossen, wird false zurückgemeldet, sonst true.

public bool Liefere(int sub)
Diese Methode wird vom Konsumenten genutzt, um Ware virtuell zu beziehen. Ist das Lager bereits geschlossen, wird false zurückgemeldet, sonst true.

void rumoren()
Diese private Methode dient dazu, Aufwand beim Ausführen der Aufträge zu simulieren.
In den Methoden Ergaenze() und Liefere() wird zur formatierten Zeitausgabe eine spezielle
Überladung der DateTime-Methode ToString()
DateTime.Now.ToString("T", ci)
verwendet, wobei ein Objekt der Klasse CultureInfo (Namensraum System.Globalization) beteiligt ist.
Produzent und Konsument kommen mit einer recht simplen Klassendefinition aus:
1
Ist ein Thread über seine Eigenschaft IsBackground als Hintergrund-Thread markiert, dann kann er einen Prozess
nicht aufrecht erhalten, sondern wird ggf. automatisch mit dem letzten Vordergrund-Thread beendet.
Abschnitt 13.1 Threads erzeugen
377
using System;
using System.Threading;
public class Produzent {
Lager pl;
bool offen;
public Produzent(Lager ptr) {
pl = ptr;
}
public void Run() {
Random rand = new Random(1);
do {
offen = pl.Ergaenze(5 + rand.Next(100));
Thread.Sleep(1000 + rand.Next(3000));
} while (offen);
}
}
public class Konsument {
Lager pl;
bool offen;
public Konsument(Lager ptr) {
pl = ptr;
}
public void Run() {
Random rand = new Random(2);
do {
offen = pl.Liefere((5 + rand.Next(100)));
Thread.Sleep(1000 + rand.Next(3000));
} while (offen);
}
}
Neben dem Konstruktor ist jeweils nur eine Methode namens Run() vorhanden, die sich auf eine
do-while-Schleife beschränkt. In jedem Durchgang wird ein Auftrag zum Ein- bzw. Auslagern einer zufallsbestimmten Menge an das Lager-Objekt geschickt. Zwischen zwei Aufträgen machen
die Run()-Methoden eine Pause von zufallsabhängiger Länge, indem Sie die statische ThreadMethode Sleep() aufrufen. Diese befördert den Thread vom Zustand Running in den Zustand
WaitSleepJoin (siehe unten). Solange die festen Startwerte für die Pseudozufallszahlengeneratoren
beibehalten werden, resultiert stets derselbe Lagerverlauf.
Wie bereits bekannt, entsteht aus den Run()-Methoden jeweils ein Delegatenobjekt vom Typ
ThreadStart und daraus wiederum ein Objekt der Klasse Thread:
Thread pt = new Thread(new ThreadStart(pro.Run));
Thread kt = new Thread(new ThreadStart(kon.Run));
Der über das Thread–Objekt pt ansprechbare Thread startet also mit der Ausführung der Run()Methode durch das Objekt pro aus der Klasse Produzent.
Ein Thread endet, wenn seine Startmethode abgearbeitet ist. Er befindet sich dann im Zustand
Stopped und kann nicht erneut gestartet werden. Im Beispiel passiert dies, sobald eine der Run()Methoden am Ende eines do-while - Schleifendurchgangs ein geschlossenes Lager festgestellt,
wenn also der Methodenaufruf lager.Ergaenze() bzw. lager.Liefere() zum Rückgabewert false führt.
Der primäre Thread des Programms ist zu diesem Zeitpunkt ebenfalls bereits Geschichte, weil er
mit der Lager-Methode Main() seine Tätigkeit einstellt. Folglich endet das Programm, wenn Produzenten- und Konsumenten-Thread sich verabschiedet haben.
Kapitel 13: Threads
378
Im Ausführungsfaden pt wird die Startmethode Run() von einem Objekt aus der Klasse Produzent ausgeführt. Alle von einer Startmethode via Methodenaufruf direkt oder indirekt initiierten
Aufträge an andere Objekte oder Klassen laufen ebenfalls im selben Thread ab, so dass in einem
Ausführungsfaden beliebig viele Akteure tätig werden können.
Andererseits kann ein einzelner Akteur (z.B. ein Objekt) in mehreren Threads arbeiten, wenn er
entsprechende Botschaften erhält. Im Beispiel kommt das Lager-Objekt sowohl im Produzentenals auch im Konsumenten-Thread zum Einsatz: Die Methoden Ergaenze() und Liefere()
erhöhen oder reduzieren den Lagerbestand, aktualisieren die Anzahl der Lagerveränderungen und
protokollieren jede Maßnahme. Dazu besorgen sie sich mit der stationären Thread-Methode
CurrentThread() eine Referenz auf den aktuell ausgeführten Thread und ermitteln dessen NameEigenschaft.
Wenn die Vorstellung eines Lageristen stört, der simultan in zwei Threads tätig ist, dann stelle man
sich ein Team von Lagerarbeitern vor, was der Realität vieler Betriebe recht gut entspricht.
In einem typischen Ablaufprotokoll des Programms zeigen sich einige Ungereimtheiten, verursacht
durch das unkoordinierte Agieren der beiden Threads:
Der Laden ist offen (Bestand: 100)
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
2:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
Produzent
Konsument
Produzent
Konsument
Produzent
Konsument
Produzent
Konsument
Konsument
Produzent
Konsument
Konsument
Produzent
Konsument
Produzent
Konsument
Konsument
Produzent
Konsument
Konsument
ergänzt
entnimmt
ergänzt
entnimmt
ergänzt
entnimmt
ergänzt
entnimmt
entnimmt
ergänzt
entnimmt
entnimmt
ergänzt
entnimmt
ergänzt
entnimmt
entnimmt
ergänzt
entnimmt
entnimmt
29
82
51
21
70
15
40
85
27
15
81
5
7
43
37
75
78
73
13
37
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
02:01:58
02:01:58
02:02:00
02:02:01
02:02:03
02:02:04
02:02:05
02:02:06
02:02:09
02:02:09
02:02:10
02:02:11
02:02:12
02:02:13
02:02:14
02:02:15
02:02:17
02:02:18
02:02:18
02:02:20
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
47
47
98
77
147
132
172
87
60
75
-6
-11
-4
-47
-10
-85
-163
-90
-103
-140
Lieber Produzent, es ist Feierabend!
Lieber Konsument, es ist Feierabend!
U.a. fällt negativ auf:



Im ersten Protokolleintrag wird berichtet, dass vom Startwert 100 ausgehend eine Lieferung
von 29 Einheiten zu einem Bestand von 47 Einheiten geführt habe, und auch die Auftragsnummer 2 ist falsch.
Im zweiten Eintrag wird behauptet, dass die Entnahme von 82 Einheiten ohne Effekt auf den
Lagerbestand geblieben sei.
Der Bestand wird negativ, was in einem realen Lager nicht passieren kann.
13.2 Threads synchronisieren
Wenn es sich nicht vermeiden lässt, dass mehrere Threads gemeinsame Daten verwenden und dabei
auch schreibend zugreifen, sind Maßnahmen zur Synchronisation der Zugriffe erforderlich.
Abschnitt 13.2 Threads synchronisieren
379
13.2.1 Die lock-Anweisung
Am Anfang des oben wiedergegebenen Ablaufprotokolls stehen zwei „wirre“ Einträge, die folgendermaßen durch eine so genannte Race Condition zu erklären sind:

Der etwas früher gestartete Produzenten-Thread „kommt als erster beim Lager an“, ruft die
Lager-Methode Ergaenze() mit dem Parameterwert 29 auf und bringt mit den Anweisungen
bilanz += add;
anz++;
die bilanz auf den Wert 129 sowie die Auftragsnummer auf den Wert 1.

Dann unterbricht das Laufzeitsystem den Produzenten-Thread und aktiviert den Konsumenten-Thread.

Dieser ruft die Lager-Methode Liefere() mit dem Parameterwert 82 auf und bringt mit
den Anweisungen
bilanz -= sub;
anz++;
die Lagerbilanz auf 47 sowie die Auftragsnummer auf den Wert 2.
Nun kommt der Produzenten-Thread wieder zum Zug und schreibt seinen Protokolleintrag,
wobei er die mittlerweile vom Konsumenten-Thread veränderten Werte von anz und
bilanz verwendet.


Dann schreibt auch der Konsumenten-Thread seine Protokollzeile. Allerdings ist der Stand
von 47 nur dann nachvollziehbar, wenn man die vorherige, nicht korrekt protokollierte Lieferung berücksichtigt.
Offenbar muss verhindert werden, dass während eines Lagerzugriffs ein Thread-Wechsel stattfindet. Dies ist in C# leicht zu realisieren, indem per lock-Anweisung der kritische Anweisungsblock
mit einer Sperre versehen wird, z.B.:
public bool Ergaenze(int add) {
lock(this) {
if (anz < MANZ) {
bilanz += add;
anz++;
Rumoren();
Console.WriteLine("Nr. {0,2}: {1,10} ergänzt {2,3} um {3} Uhr. Stand: {4}",
anz, Thread.CurrentThread.Name, add, DateTime.Now.ToString("T", ci), bilanz);
return true;
} else {
Console.WriteLine("\nLieber " + Thread.CurrentThread.Name +
", es ist Feierabend!");
return false;
}
}
}
Hier wird als Sperre das handelnde Lager-Objekt verwendet, jedoch kommt auch jedes andere
Objekt in Frage, das beiden Threads bekannt ist, z.B.:
object sperre = new object();
...
lock(sperre) {
...
}
Richter (2006, S. 642ff) rät zur Verwendung eines privaten Member-Objekts, weil ein öffentlich
bekanntes Sperrobjekt zum Blockieren der Anwendung missbraucht werden könnte. Ein Schädling
muss lediglich den geschützten Bereich betreten und sich dort auf ewig niederlassen. In unserem
Kapitel 13: Threads
380
Beispiel sind die Prozessbeteiligten aber so leicht zu überschauen, dass mit dem Einschleusen von
böswilliger Software nicht zu rechnen ist.
Im Beispiel muss auch der kritische Bereich in der Methode Liefere() durch dasselbe Objekt
gesperrt werden. Beim Betreten eines geschützten Bereichs setzt ein Thread per lock die Sperre.
Man kann sich vorstellen, dass er den einzigen Schlüssel für die von einem Objekt geschützten Bereiche erwirbt. Jedem anderen Thread wird der Zutritt verwehrt, und er muss warten. Beim Verlassen eines geschützten Bereichs wird die Sperre aufgehoben, und ggf. kann ein wartender Thread
seine Arbeit fortsetzen. Ein Anweisungsblock mit lock-reguliertem Zugang wird auch als synchronisiert bezeichnet.
Weil ein Sperrobjekt ähnlich wirkt wie ein Eisenbahnsignal, das an einem Mast befestigt die Durchfahrt erlaubt oder verbietet, wird es oft als Semaphor bezeichnet (griech.: Signalträger). Meist wird
auch der Ausdruck Mutex (engl. mutual exclusion) in derselben Bedeutung verwendet.
Um andere Threads möglichst wenig zu behindern, muss ein Sperrobjekt so schnell wie möglich
wieder frei gegeben werden.
In unserem Beispielprogramm unterbleiben nun die wirren Protokolleinträge, doch es kommt nach
wie vor zu einem negativen Lagerzustand:
Der Laden ist offen (Bestand: 100)
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
Konsument
Produzent
Produzent
Konsument
Produzent
Konsument
Produzent
Konsument
Konsument
Produzent
Konsument
Konsument
Produzent
Konsument
Produzent
Konsument
Konsument
Produzent
Konsument
Konsument
entnimmt
ergänzt
ergänzt
entnimmt
ergänzt
entnimmt
ergänzt
entnimmt
entnimmt
ergänzt
entnimmt
entnimmt
ergänzt
entnimmt
ergänzt
entnimmt
entnimmt
ergänzt
entnimmt
entnimmt
82
29
51
21
70
15
40
85
27
15
81
5
7
43
37
75
78
73
13
37
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
um
02:02:25
02:02:25
02:02:27
02:02:27
02:02:30
02:02:31
02:02:32
02:02:33
02:02:36
02:02:36
02:02:37
02:02:38
02:02:39
02:02:40
02:02:41
02:02:42
02:02:44
02:02:45
02:02:45
02:02:47
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
18
47
98
77
147
132
172
87
60
75
-6
-11
-4
-47
-10
-85
-163
-90
-103
-140
Lieber Produzent, es ist Feierabend!
Lieber Konsument, es ist Feierabend!
13.2.2 Die Klasse Monitor
Für den synchronisierten Zugriff von Threads auf geschützte Bereiche sorgt letztlich die Klasse
Monitor aus dem Namensraum System.Threading. Sie eignet sich weder zum Ableiten neuer
Klassen noch zum Erzeugen von Objekten, spielt aber neben der Klasse Thread eine zentrale Rolle
bei Multithreading-Anwendungen im .NET - Framework. Die lock-Anweisung
lock(sperre) {
...
}
wird vom Compiler umgesetzt in:
Abschnitt 13.2 Threads synchronisieren
381
Monitor.Enter(sperre);
try {
...
} finally{
Monitor.Exit(sperre);
}
Hier kommen zwei statische Monitor-Methoden zum Einsatz:


Enter()
Die Sperre wird gesetzt.
Exit()
Die Sperre wird aufgehoben. Durch den Exit()-Aufruf im finally-Block ist sichergestellt,
dass der kritische Block auch nach einem Ausnahmefehler verlassen wird.
Um einer Blockade vorzubeugen, kann sich ein Thread mit der Methode TryEnter() um den exklusiven Zugang zum kritischen Block bewerben. Diese Methode endet auf jeden Fall sofort und informiert mit einem Rückgabewert vom Typ bool über den Erfolg der Bewerbung.
13.2.3 Koordination per Wait() und Pulse()
Mit Hilfe der statischen Monitor-Methoden Wait() und Pulse() können in unserem betriebswirtschaftlichen Beispiel negative Lagerbestände verhindert werden. Trifft eine Konsumenten-Anfrage
auf einen unzureichenden Lagerbestand, dann wird der zugehörige Thread mit der Methode Wait()
in den Zustand WaitSleepJoin versetzt:
public bool Liefere(int sub) {
lock (this) {
if (anz < MANZ) {
while (bilanz < sub) {
Console.WriteLine("!!!!!!! {0,10} muss warten: " +
"Keine {1, 3} Einheiten vorhanden um {2} Uhr.",
Thread.CurrentThread.Name, sub, DateTime.Now.ToString("T", ci));
Monitor.Wait(this);
}
bilanz -= sub;
anz++;
Rumoren();
Console.WriteLine("Nr. {0,2}: {1,10} entnimmt {2, 3} um {3} Uhr. Stand:
{4}",
anz, Thread.CurrentThread.Name, sub, DateTime.Now.ToString("T", ci),
bilanz);
return true;
} else {
Console.WriteLine("\nLieber " + Thread.CurrentThread.Name +
", es ist Feierabend!");
return false;
}
}
}
Mit dem Wait()-Aufruf wird das exklusive Zutrittsrecht für den synchronisierten Block zurückgegeben, so dass im Beispiel der Produzenten-Thread zum Zug kommt und für Nachschub sorgen
kann. Als Parameter wird im Wait()-Aufruf das synchronisierende Objekt angegeben.
Um eine erfolgreiche Kooperation zu gewährleisten, muss der Produzenten-Thread nach jeder Lieferung die Monitor-Methode Pulse() aufrufen, um den Konsumenten-Thread zu reaktivieren, d.h.
in den Zustand Started zu versetzen:
Kapitel 13: Threads
382
public bool Ergaenze(int add) {
lock (this) {
if (anz < MANZ) {
bilanz += add;
anz++;
Rumoren();
Console.WriteLine("Nr. {0,2}: {1,10} ergänzt {2,3} um {3} Uhr. Stand:
{4}",
anz, Thread.CurrentThread.Name, add, DateTime.Now.ToString("T", ci),
bilanz);
Monitor.Pulse(this);
return true;
} else {
Console.WriteLine("\nLieber " + Thread.CurrentThread.Name +
", es ist Feierabend!");
return false;
}
}
}
Als Parameter wird im Pulse()-Aufruf das synchronisierende Objekt angegeben, das die Warteschlange verwaltet. Der reaktivierte Konsumenten-Thread bewirbt sich wieder um Prozessorzeit
und Lagerzugangsberechtigung. Weil keinesfalls sicher ist, dass Konsument nach der Reaktivierung
einen ausreichenden Vorrat antrifft, findet der Wait() - Aufruf in einer while-Schleife mit einleitender Bedingungsprüfung statt.
Nun produziert das Beispielprogramm nur noch realistische Lagerprotokolle, z.B.:
Der Laden ist offen (Bestand: 100)
Nr. 1:
Nr. 2:
Nr. 3:
Nr. 4:
Nr. 5:
Nr. 6:
Nr. 7:
Nr. 8:
Nr. 9:
Nr. 10:
!!!!!!!
Nr. 11:
Nr. 12:
!!!!!!!
Nr. 13:
Nr. 14:
!!!!!!!
Nr. 15:
Nr. 16:
!!!!!!!
Nr. 17:
Nr. 18:
!!!!!!!
Nr. 19:
Nr. 20:
Konsument
Produzent
Produzent
Konsument
Produzent
Konsument
Produzent
Konsument
Konsument
Produzent
Konsument
Produzent
Konsument
Konsument
Produzent
Konsument
Konsument
Produzent
Konsument
Konsument
Produzent
Konsument
Konsument
Produzent
Konsument
entnimmt 82
ergänzt
29
ergänzt
51
entnimmt 21
ergänzt
70
entnimmt 15
ergänzt
40
entnimmt 85
entnimmt 27
ergänzt
15
muss warten:
ergänzt
7
entnimmt 81
muss warten:
ergänzt
37
entnimmt
5
muss warten:
ergänzt
73
entnimmt 43
muss warten:
ergänzt
33
entnimmt 75
muss warten:
ergänzt
75
entnimmt 78
um 03:08:29 Uhr. Stand: 18
um 03:08:29 Uhr. Stand: 47
um 03:08:30 Uhr. Stand: 98
um 03:08:31 Uhr. Stand: 77
um 03:08:33 Uhr. Stand: 147
um 03:08:35 Uhr. Stand: 132
um 03:08:36 Uhr. Stand: 172
um 03:08:37 Uhr. Stand: 87
um 03:08:39 Uhr. Stand: 60
um 03:08:40 Uhr. Stand: 75
Keine 81 Einheiten vorhanden
um 03:08:43 Uhr. Stand: 82
um 03:08:43 Uhr. Stand: 1
Keine
5 Einheiten vorhanden
um 03:08:44 Uhr. Stand: 38
um 03:08:44 Uhr. Stand: 33
Keine 43 Einheiten vorhanden
um 03:08:48 Uhr. Stand: 106
um 03:08:48 Uhr. Stand: 63
Keine 75 Einheiten vorhanden
um 03:08:51 Uhr. Stand: 96
um 03:08:51 Uhr. Stand: 21
Keine 78 Einheiten vorhanden
um 03:08:54 Uhr. Stand: 96
um 03:08:54 Uhr. Stand: 18
um 03:08:40 Uhr.
um 03:08:44 Uhr.
um 03:08:47 Uhr.
um 03:08:50 Uhr.
um 03:08:53 Uhr.
Lieber Konsument, es ist Feierabend!
Lieber Produzent, es ist Feierabend!
An Stelle der Methode Pulse(), die den Thread mit dem ältesten Wait()-Aufruf anspricht, ist oft die
Methode PulseAll() sinnvoller, die alle wartenden Threads weckt.
Die Monitor-Methoden Wait(), Pulse() und PulseAll() dürfen nur in einem synchronisierten Block
aufgerufen werden.
383
Abschnitt 13.3 Threads stoppen
13.3 Threads stoppen
Ein Thread endet „auf natürlichem Weise“ mit der zugrunde liegenden Startmethode. Um ihn früher
zu stoppen, kann man die Thread–Methode Abort() aufrufen. Diese befördert den Thread in den
Zustand AbortRequested und löst eine ThreadAbortException aus, so dass die betroffenen Methoden per Ausnahmebehandlung für einen sinnvollen Abgang sorgen können. Am Ende eines entsprechenden catch-Blocks wird die Ausnahme automatisch erneut ausgelöst, so dass im Fall einer
Aufrufverschachtelung auch vorgeordnete Methoden Gelegenheit zu Terminierungsmaßnahmen
erhalten.
Als Beispiel soll eine kundenfeindliche Variante des Produzenten-Lager-Konsumenten-Beispiels
dienen: der Lagerverwalter terminiert den Konsumenten-Thread, sobald dieser mit einem Wunsch
über den Lagerbestand hinausgeht:
public bool Liefere(int sub) {
lock (this) {
if (anz < MANZ) {
if (bilanz < sub) {
Console.WriteLine("!!!!!!! {1,10} fordert {2, 3}" +
" um {3} Uhr und wird abgewiesen.",
anz, Thread.CurrentThread.Name, sub, DateTime.Now.ToString("T", ci));
Thread.CurrentThread.Abort();
}
anz++;
bilanz -= sub;
Rumoren();
Console.WriteLine("Nr. {0,2}: {1,10} entnimmt {2, 3} um {3} Uhr. Stand: {4}",
anz, Thread.CurrentThread.Name, sub, DateTime.Now.ToString("T", ci), bilanz);
return true;
} else {
Console.WriteLine("\nLieber " + Thread.CurrentThread.Name +
", es ist Feierabend!");
return false;
}
}
}
Der Konsument nutzt die Ausnahmebehandlung für eine Beschwerde:
public void Run() {
Random rand = new Random(2);
try {
do {
offen = pl.Liefere((5 + rand.Next(100)));
Thread.Sleep(1000 + rand.Next(3000));
} while (offen);
} catch (ThreadAbortException) {
Console.WriteLine("Als Kunde muss ich mir so etwas " +
"nicht gefallen lassen!");
}
}
Nach der Ausnahmebehandlung gelangt der Konsumententen-Thread in der Zustand Stopped, und
im Lager taucht nur noch der Produzent auf:
Der Laden ist offen (Bestand: 100)
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
Nr.
1:
2:
3:
4:
5:
6:
7:
Produzent
Konsument
Produzent
Konsument
Produzent
Konsument
Produzent
ergänzt
entnimmt
ergänzt
entnimmt
ergänzt
entnimmt
ergänzt
29
82
51
21
70
15
40
um
um
um
um
um
um
um
03:22:27
03:22:27
03:22:29
03:22:30
03:22:32
03:22:34
03:22:34
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Uhr.
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
Stand:
129
47
98
77
147
132
172
Kapitel 13: Threads
384
Nr. 8: Konsument
Nr. 9: Konsument
Nr. 10: Produzent
!!!!!!! Konsument
Als Kunde muss ich
Nr. 11: Produzent
Nr. 12: Produzent
Nr. 13: Produzent
Nr. 14: Produzent
Nr. 15: Produzent
Nr. 16: Produzent
Nr. 17: Produzent
Nr. 18: Produzent
Nr. 19: Produzent
Nr. 20: Produzent
entnimmt 85
entnimmt 27
ergänzt
15
fordert
81
mir so etwas
ergänzt
7
ergänzt
37
ergänzt
73
ergänzt
33
ergänzt
75
ergänzt
99
ergänzt
21
ergänzt
84
ergänzt
84
ergänzt
87
um 03:22:36 Uhr. Stand: 87
um 03:22:38 Uhr. Stand: 60
um 03:22:38 Uhr. Stand: 75
um 03:22:39 Uhr und wird abgewiesen.
nicht gefallen lassen!
um 03:22:41 Uhr. Stand: 82
um 03:22:43 Uhr. Stand: 119
um 03:22:47 Uhr. Stand: 192
um 03:22:50 Uhr. Stand: 225
um 03:22:53 Uhr. Stand: 300
um 03:22:56 Uhr. Stand: 399
um 03:22:57 Uhr. Stand: 420
um 03:22:59 Uhr. Stand: 504
um 03:23:01 Uhr. Stand: 588
um 03:23:03 Uhr. Stand: 675
Lieber Produzent, es ist Feierabend!
Reagiert ein Thread nicht auf die ThreadAbortException, wird er ebenfalls gestoppt.
Ein Thread im Zustand AbortRequested kann aber durchaus die Terminierung vermeiden und in
den Zustand Running zurückkehren. Dazu muss er lediglich in seiner ThreadAbortExceptionAusnahmebehandlung die Thread-Methode ResetAbort() aufrufen, was in der folgenden Variante
unseres Beispiels der Konsument tut:
public void Run() {
Random rand = new Random(2);
do {
try {
offen = pl.Liefere((5 + rand.Next(100)));
} catch (ThreadAbortException) {
Console.WriteLine("Als Kunde muss ich mir so etwas " +
"nicht gefallen lassen!");
Thread.ResetAbort();
}
Thread.Sleep(1000 + rand.Next(3000));
} while (offen);
}
13.4 Thread-Lebensläufe
In diesem Abschnitt wird zunächst die Vergabe von Arbeitsberechtigungen für konkurrierende
Threads behandelt. Dann fassen wir unsere Kenntnisse über die verschiedenen Zustände eines
Threads und über Anlässe für Zustandswechsel zusammen.
13.4.1 Scheduling und Prioritäten
Die Zuweisung von Rechenzeit auf den real oder logisch vorhandenen CPU-Kernen an die Threads
im Zustand Running überlässt die CLR dem Betriebssystem, wo für diese Aufgabe der so genannte
Scheduler zuständig ist.
Er orientiert sich u.a. an den Prioritäten der Threads, die sich über ihre Priority-Eigenschaft ermitteln und verändern lässt. Erlaubt sind die folgenden Werte des Enumerationstyps ThreadPriority:





Highest
AboveNormal
Normal
BelowNormal
Lowest
Per Voreinstellung haben Threads die Priorität Normal.
385
Abschnitt 13.4 Thread-Lebensläufe
Der Scheduler bevorzugt Threads mit höherer Priorität in einem strengen Sinn und verwendet bei
gleicher Priorität ein preemtives Zeitscheibenverfahren. Auf einem Einprozessor-System resultiert folgendes Verhalten:


Ein Thread kann nur dann Zugang zum Prozessor erhalten, wenn kein Thread mit höherer
Priorität arbeitswillig ist. Ein Verhungern (engl. starvation) von Threads mit niedriger Priorität, die permanent den Kürzeren ziehen, wird also nicht verhindert.
Die Threads gleicher Priorität werden reihum (Round-Robin) jeweils für eine festgelegte
Zeitspanne ausgeführt.
13.4.2 Zustände von Threads
In der folgenden Abbildung werden wichtige Thread-Zustände (bezeichnet durch Werte der Enumeration ThreadState) und Anlässe für Zustandsübergänge dargestellt:
new-Operator
Unstarted
Start()
Schlafzeit beendet
oder Interrupt()
WaitSleepJoin
Sleep()
Running
Wait(s)
Pulse(s), PulseAll(s)
WaitSleepJoin
oder Interrupt()
t2 beendet
oder Interrupt()
t2.Join()
Startmethode endet
WaitSleepJoin
Stopped
Der Übersichtlichkeit halber folgt eine separate Abbildung für das Abbrechen bzw. Unterbrechen
eines Threads:
Suspended
Thread realisiert
Suspendierung
(z.B. nach
WaitSleepJoin)
SuspendRequested
Suspend()
WaitSleepJoin
Abort()
Resume()
AbortRequested
Veraltet!
Abort()
Suspended
Suspend()
Running
keine
ThreadAbortException Behandlung mit
ResetAbort()
Resume()
Stopped
Einige in den Abbildungen enthaltene Thread-Instanzmethoden wurden bisher noch nicht angesprochen:
Kapitel 13: Threads
386

Join()
Der aufrufende Thread wartet bis der angesprochene Thread beendet ist. Führt z.B. der
Thread t1 die folgende Anweisung
t2.Join();
aus, dann wartet t1 anschließend auf das Ende von t2.


Interrupt()
Diese Instanzmethode dient dazu, einen Thread vom Zustand WaitSleepJoin in den Zustand
Running zu versetzen. Dazu wird dem angesprochenen Thread eine ThreadInterruptedException zugeworfen, wenn er sich (jetzt oder später) im WaitSleepJoin-Zustand befindet. Ein Interrupt()-Aufruf kann also prophylaktisch erfolgen. Begibt sich der angesprochene Thread nie in den Zustand WaitSleepJoin, bleibt der Interrupt()-Aufruf ohne Folgen.
Suspend(), Resume()
Mit diesem als veraltet (engl.: deprecated) eingestuften Methoden kann man einen Thread
anhalten bzw. wieder starten. Die Methoden sind in Misskredit geraten, weil ihr Einsatz zu
Deadlock-Situationen (siehe Abschnitt 13.5) führen kann.
13.5 Deadlock
Wer sich beim Einsatz von exklusiven Blöcken zur Thread-Synchronisation ungeschickt anstellt,
kann einen genannten Deadlock produzieren, wobei sich Threads gegenseitig blockieren. Im folgenden Beispiel begeben sich die Threads T1 und T2 jeweils in einen exklusiven Block, der durch
die Objekte lock1 bzw. lock2 geschützt ist:
using System;
using System.Threading;
class DeadLock {
static object lock1 = new object();
static object lock2 = new object();
static void M1() {
lock (lock1) {
Console.WriteLine(Thread.CurrentThread.Name+" in M1");
Thread.Sleep(100);
Console.WriteLine(Thread.CurrentThread.Name+" möchte M2 aufrufen");
M2();
}
}
static void M2() {
lock (lock2) {
Console.WriteLine(Thread.CurrentThread.Name+" in M2");
Thread.Sleep(100);
Console.WriteLine(Thread.CurrentThread.Name+" möchte M1 aufrufen");
M1();
}
}
static void Main() {
Thread t1 = new Thread(new ThreadStart(M1));
Thread t2 = new Thread(new ThreadStart(M2));
t1.Name="T1"; t1.Start();
t2.Name="T2"; t2.Start();
}
}
Abschnitt 13.6 Treadpool und APM
387
Ein kurzes Schläfchen sorgt dafür, dass beide Threads ihren „eigenen“ Block ungestört betreten
können. Anschließend versuchen beide, in den jeweiligen fremden Block zu gelangen, und das Programm hängt fest:
Das vorgestellte Problem hat folgende Struktur:




Thread 1 besetzt den exklusiven Block A.
Thread 2 besetzt den exklusiven Block B.
Thread 1 möchte Block B betreten, wartet also darauf, dass Thread 2 diesen Block verlässt.
Dies wird Thread 2 aber nicht tun, bevor er in Block A gewesen ist. Thread 2 wartet also
darauf, dass Thread 1 diesen Block verlässt.
13.6 Treadpool und APM
Durch eine große Zahl von Threads mit kurzer Lebensdauer wird eine Anwendung eher ausgebremst als beschleunigt. Statt für viele einzelne Aufgaben jeweils einen neuen Thread zeitaufwändig zu erzeugen und anschließend wieder zu zerstören, sollte der von .NET-Framework zur Verfügung gestellte Pool von Ein-/Ausgabe - und Arbeits-Threads genutzt werden. Eingehende Aufträge
gelangen in eine Warteschlange des Pools und werden vom nächsten freien Thread aus dem „Bereitschaftsteam“ übernommen.
Es ist wohl nur selten sinnvoll, mit der ThreadPool-Methode SetMaxThreads() die Entscheidung
der CLR über die angemessene Zahl von Ein-/Ausgabe - bzw. Arbeits-Threads im Pool zu beeinflussen.
Anstelle eines Threadpool - Auftrags ist ein eigener (gem. Abschnitt 13.1 erstellter) Thread z.B.
dann erfoderlich, ...
 wenn ein Vordergrund-Thread benötigt wird.
Im Pool sind nur Hintergrund-Threads tätig, die nach dem Ende des letzten
Vordergrund-Threads von der CLR automatisch gestoppt werden.
 wenn eine spezielle Thread-Priorität gewünscht ist.
Alle Pool-Threads haben eine normale Priorität (siehe Abschnitt 13.4.1).
13.6.1 Die ThreadPool-Methode QueueUserWorkItem()
Soll eine Methode im Hintergrund durch einen Arbeits-Thread ausgeführt werden, muss sie den
Delegatentyp WaitCallback:
public delegate void WaitCallback(Object state)
erfüllen, z.B.
static void HintergrundAktion(object anz) {
long summe;
for (int i = 0; i < (int)anz; i++) {
summe = 0;
for (int j = 0; j < 10000000; j++)
summe += zzg.Next(100);
Console.WriteLine("Aktuelle Zufallssumme: " + summe);
}
Console.WriteLine("\nDer Arbeits-Thread ist fertig");
}
Kapitel 13: Threads
388
Diese Methode berechnet eine wählbare Anzahl von Summen, jeweils bestehend aus reichlich vielen Zufallszahlen.
Zur Übergabe eines WaitCallback-Arbeitsauftrags an den Threadpool dient die ThreadPoolMethode QueueUserWorkItem() mit den folgenden Überladungen:
public static bool QueueUserWorkItem(WaitCallback callBack)
public static bool QueueUserWorkItem(WaitCallback callBack, Object state)
Der state-Parameter der zweiten Überladung ist für Daten vorgesehen, die der WaitCallbackMethode beim Aufruf übergeben werden sollen. Im Beispiel lässt so festlegen, wie viele Zufallssummen berechnet werden sollen.
In der folgenden Main()-Methode wird ein Arbeitsauftrag in die Warteschlange des Threadpools
gestellt. Statt im Vordergrund andere Arbeiten auszuführen, legt sich der primäre Thread schlafen:
static void Main() {
bool b = ThreadPool.QueueUserWorkItem(HintergrundAktion, 5);
Console.WriteLine("Arbeitsauftrag erfolgreich in die "+
"Threadpool-Warteschlange gestellt: {0}\n",b);
Console.WriteLine("Der primäre Thread schläft 5 Sekunden lang\n");
Thread.Sleep(5000);
Console.WriteLine("\nDer primäre Thread ist aufgewacht.\n");
Console.ReadLine();
}
Ein Ergebnis dieser „Arbeitsteilung“:
Arbeitsauftrag erfolgreich in die Threadpool-Warteschlange gestellt: True
Der primäre Thread schläft 5 Sekunden lang
Aktuelle
Aktuelle
Aktuelle
Aktuelle
Aktuelle
Zufallssumme:
Zufallssumme:
Zufallssumme:
Zufallssumme:
Zufallssumme:
495135074
990079908
1485023745
1979998759
2474842407
Der Arbeits-Thread ist fertig
Der primäre Thread ist aufgewacht.
13.6.2 Die Delegatenmethoden BeginInvoke() und EndInvoke()
In diesem Abschnitt werden zentrale Typen und Methoden im APM (Asynchronous Programming
Model) vorgestellt, das den effizienten Einsatz von Hintergrund-Threads erlaubt und dabei ohne
explizite Thread-Kreation durch Anwendungsprogrammierer auskommt.
Zu jeder Delegatendefinition, z.B.:
public delegate long[] Zumme(int anz);
erstellt der Compiler eine Klasse mit den Methoden BeginInvoke() und EndInvoke(), wie die folgende IlDasm-Ausgabe für den Typ Zumme zeigt:
Abschnitt 13.6 Treadpool und APM
389
Um das recht komplexe Zusammenspiel verschiedener Methoden im APM anschaulich erläutern zu
können, betrachten wir eine konkrete (nicht sonderlich sinnvolle) Methode, welche den Delegatentyp Zumme erfüllt:
static long[] RandomSums(int anz) {
long[] erg = new long[anz];
for (int i = 0; i < anz; i++) {
for (int j = 0; j < 10000000; j++)
erg[i] += zzg.Next(100);
}
return erg;
}
Sie liefert einen Array mit long-Elementen, die jeweils eine Summe von 10000000 Zufallszahlen
enthalten. Über den Parameter wählt man die Anzahl der Array-Elemente. Wir bezeichnen
RandomSums() unten als Arbeitsmethode.
Wir erstellen aus RandomSums() ein Delegatenobjekt vom Typ Zumme (gleich als Arbeitsdelegat
bezeichnet) und beauftragen dieses Objekt, seine Methode BeginInvoke() auszuführen:
Zumme rs = new Zumme(RandomSums);
IAsyncResult ar = rs.BeginInvoke(7, null, null);
Als Rückgabewert liefert BeginInvoke() ein Objekt vom Typ IAsyncResult, das die asynchron
ausgeführte Operation identifiziert. Wir bezeichnen es gleich als Operationsobjekt. Es …


muss der Arbeitsdelegatenmethode EndInvoke() als Parameter übergeben werden, um das
Ergebnis der abgeschlossenen Operation (im Beispiel: die long[]-Rückgabe der Arbeitsmethode) zu erhalten,
kann mit seiner booleschen Eigenschaft IsCompleted über den Abschluss der Arbeiten informieren.
BeginInvoke() besitzt alle Parameter der Delegatendefinition und außerdem am Ende seiner Parameterliste:

AsyncCallBack
Dieser Delegatentyp hat folgende Signatur:
public delegate void AsyncCallback(IAsyncResult ar)
Beim BeginInvoke()-Aufruf kann eine Rückrufmethode angegeben werden, die bei Beendigung des Hintergrund-Threads aufgerufen wird und das Operationsobjekt als Parameter erhält. In der ersten Variante des Beispielprogramms wird noch auf eine Rückrufmethode verzichtet und dem BeginInvoke()-Aufruf null an Stelle eines Rückruf-Delegatenobjekts übergeben.
Kapitel 13: Threads
390

Object
Dieses Objekt ist in der IAsyncResult-Rückgabe über die Eigenschaft AsyncState ansprechbar. Wir benutzen diesen Parameter später, um der (noch im Pool-Thread) aufgerufenen Rückrufmethode den Arbeitsdelegaten bekannt zu machen. In der ersten Variante des
Beispielprogramms wird auf diese Option verzichtet und dem BeginInvoke()-Aufruf null
als vierter Parameter übergeben.
Durch den BeginInvoke()-Aufruf an den Arbeitsdelegaten gelangt über die ThreadPool-Methode
QueueUserWorkItem() ein Arbeitsauftrag in die Warteschlange des Threadpools.
In der ersten Variante des Beispielprogramms stellen wir noch unbeholfen, ohne jeden Nutzen aus
der APM-Technik, über Eigenschaft IsCompleted des Operationsobjekts fest, ob der bearbeitende
Hintergrund-Thread fertig ist:
int sek = 0;
while (!ar.IsCompleted) {
Console.WriteLine("Warte seit {0} Sekunden auf den Hintergrund-Thread",sek);
Thread.Sleep(1000);
sek++;
}
Hat der Pool-Thread seine Arbeiten abgeschlossen, kann man beim Arbeitsdelegaten per
EndInvoke()-Aufruf die Ergebnisse anfordern, wobei das Operationsobjekt als Parameter zu übergeben ist:
foreach (long zs in rs.EndInvoke(ar))
Console.WriteLine(" " + zs);
Die EndInvoke()-Methode hat denselben Rückgabewert wie die die Arbeitsmethode (im Beispiel:
die long[]).
Statt in der obigen while-Schleife zwischen zwei IsCompleted-Abfragen den primären Thread
schlafen zu legen, könnten wir die Wartezeit für nützliche Arbeiten verwenden. Damit wäre eine
APM-Nutzung erreicht, die Richter (2006, S. 612) als Polling bezeichnet und als ineffizient einstuft.
Eine noch weniger empfehlenswerte Alternative besteht darin, ohne Prüfung des Bearbeitungszustands die EndInvoke()-Methode des Arbeitsdelegaten aufzurufen, weil diese Methode auf das
Auftragsende wartet und damit den primären Thread lahm legt.
Mit der Rückruftechnik kommen wir ohne Polling und ohne Warten an die Ergebnisse einer asynchronen Auftragsabwicklung heran. Dazu definieren wir eine Rückrufmethode, die den Delegatentyp AsyncCallBack (siehe oben) erfüllt, z.B.:
static void Report(IAsyncResult ar) {
Zumme z = (Zumme)ar.AsyncState;
Console.WriteLine("Report der ermittelten Summen:");
foreach (long zs in z.EndInvoke(ar))
Console.WriteLine(" "+zs);
}
Das zugehörige Delegatenobjekt wird im BeginInvoke()-Aufruf als dritter Parameter übergeben,
z.B.:
rs.BeginInvoke(3, new AsyncCallback(Report), rs);
Sobald der Hintergrund-Thread die Arbeitsmethode ausgeführt hat, ruft er die Rückrufmethode auf
und übergibt das Operationsobjekt. Im Beispiel soll die Rückrufmethode Report() das Operationsobjekt in einem EndInvoke()-Aufruf an den Arbeitsdelegaten verwenden, um die Ergebnisse
anzufordern. Die nötige Referenz wird folgendermaßen in die Rückrufmethode transportiert:
Abschnitt 13.7 Timer


391
Im BeginInvoke()-Aufruf an den Arbeitsdelegaten wird seine eigene Adresse als vierter Parameter übergeben.
Wie oben erläutert, ist dieser Parameter im Operationsobjekt enthalten und über die Eigenschaft AsyncState ansprechbar.
Das folgende Programm
using System;
using System.Threading;
public delegate long[] Zumme(int anz);
class Prog {
static Random zzg = new Random();
static long[] RandomSums(int anz) {
. . .
}
static void Report(IAsyncResult ar) {
. . .
}
static void Main() {
Zumme rs = new Zumme(RandomSums);
rs.BeginInvoke(3, new AsyncCallback(Report), rs);
Console.WriteLine("Der Arbeitsdelegat soll per Poolthread 3 " +
"Summen von Zufallszahlen ermitteln.\n");
Console.WriteLine("Der primäre Thread schläft 5 Sekunden.\n");
Thread.Sleep(5000);
Console.WriteLine("\nDer primäre Thread ist aufgewacht.\n");
Console.ReadLine();
}
}
demonstriert die APM-Rückruftechnik, verzichtet aber der Übersichtlichkeit halber auf eine sinnvolle Aktivität im Vordergrund-Thread.
Der Arbeitsdelegat soll per Poolthread 3 Summen von Zufallszahlen ermitteln.
Der primäre Thread schläft 5 Sekunden.
Report der ermittelten Summen:
494898976
494910016
494969138
Der primäre Thread ist aufgewacht.
Auf einen BeginInvoke()-Aufruf sollte unbedingt ein EndInvoke()-Aufruf an denselben Arbeitsdelegaten folgen, weil ansonsten von der asynchronen Operation belegte CLR-Ressourcen nicht mehr
frei gegeben werden (Richter 2006, S. 621).
13.7 Timer
Mit Hilfe der Klasse Timer im Namensraum System.Threading kann man die CLR beauftragen,
eine Methode regelmäßig in einem Pool-Thread auszuführen. Im diesem Beispiel
tim = new System.Threading.Timer(this.HintergrundAktion, null, 0, 1000);
kommt eine Konstruktor-Überladung mit folgenden Parameter-Datentypen zum Einsatz:
Kapitel 13: Threads
392

TimerCallback callback
Dieser Delegatentyp verlangt für die regelmäßig auszuführende Methode folgende Signatur:
void TimerCallback (Object state)
Im obigen Beispiel kommt die vereinfachte Syntax mit impliziter Konstruktion des Delegatenobjekts zum Einsatz.
Object state
Die regelmäßig auszuführenden Methode erhält beim Aufruf das im zweiten Konstruktorparameter anzugebende Objekt, so dass ihr Verhalten gesteuert werden kann. Ist (wie im
Beispiel) kein Parameter erforderlich, übergibt man eine null-Referenz.
long dueTime
Die Zeit bis zum ersten Aufruf in Millisekunden
long period
Die Zeit zwischen zwei Aufrufen in Millisekunden



Bei aufwendigen Arbeiten kann es in Abhängigkeit von der gewählten Wartezeit zwischen zwei
Aufrufen dazu kommen, dass die TimerCallback-Methode von mehreren Threads simultan ausgeführt wird. Wenn die Methode gemeinsame Daten (z.B. Instanz- oder Klassenvariablen) verändert,
kommt eine Thread-Synchronisation in Frage (siehe Abschnitt 13.2). Eine ständig wachsende Zahl
von simultanen Ausführungen der TimerCallback-Methode muss natürlich verhindert werden.
Neben der Wahl eines passenden Konstruktorparameters besteht auch die Möglichkeit, für ein bereits aktives Timer-Objekt mit der Methode Change() die Intervalldauer zu verändern, z.B.:
tim.Change(0, 2000);
Wir stellen uns die Aufgabe, in einer GUI-Anwendung eine umfangreiche Aktivität regelmäßig per
Threadpool im Hintergrund erledigen zu lassen, wobei die Benutzeroberfläche verzögerungsfrei
bedienbar bleiben soll. Im folgenden Programm berechnet die Methode HintergrundAktion
einmal pro Sekunde die Summe aus 10 Millionen Zufallszahlen:
using System;
using System.Windows.Forms;
using System.Threading;
delegate void LabelUpdateDelegate(string s);
class ThreadingTimer : Form {
Label anzeige;
System.Threading.Timer tim;
Random zzg;
LabelUpdateDelegate laup;
public ThreadingTimer() {
Height = 130; Width = 350;
Text = "System.Threading.Timer";
anzeige = new Label();
anzeige.Width = 300;
anzeige.Left = 20; anzeige.Top = 20;
Controls.Add(anzeige);
zzg = new Random();
laup = new LabelUpdateDelegate(this.UpdateLabel);
tim = new System.Threading.Timer(this.HintergrundAktion, null, 0, 1000);
TextBox eingabe = new TextBox();
eingabe.Left = 20; eingabe.Top = 50; eingabe.Width = 125;
Controls.Add(eingabe);
}
Abschnitt 13.7 Timer
393
void HintergrundAktion(object info) {
long l = 0;
for (int i = 0; i < 10000000; i++)
l += zzg.Next(100);
BeginInvoke(laup, l.ToString());
}
void UpdateLabel(string s) {
anzeige.Text = "Aktuelle Zufallssumme: "+s+" berechnet um "+
DateTime.Now.ToLongTimeString();
}
[STAThread]
static void Main() {
Application.EnableVisualStyles();
Application.Run(new ThreadingTimer());
}
}
Trotz des erheblichen CPU-Zeitverbrauchs durch die Hintergrundaktivität reagiert die Benutzeroberfläche des Programms verzögerungsfrei:
Im .NET-Framework sind die meisten Eigenschaften und Methoden der Steuerelemente bewusst
nicht Thread - sicher, um Leistungsverluste durch die Thread-Synchronisation zu vermeiden. Folglich müssen Zugriffe auf Steuerelemente dem Thread vorbehalten bleiben, der sie erzeugt hat.
Zugriffe durch fremde Threads werden von der CLR verhindert:
Wie kann nun aber ein Hintergrund-Thread Aktualisierungen der Benutzeroberfläche veranlassen?
Dies ist die übliche Lösung:




Man erstellt eine GUI - Aktualisierungsmethode (im Beispiel: LabelUpdater()),
definiert einen passenden Delegatentyp (im Beispiel: LabelUpdaterDelegate),
erzeugt ein Delegatenobjekt (new LabelUpdateDelegate(LabelUpdater))
und veranlasst den GUI-Thread über die Invoke()-Methode oder die die BeginInvoke()Methode des Formulars, das Delegatenobjekt auszuführen.
Beim Invoke()-Aufruf wartet der Hintergrund-Thread, bis der GUI-Thread die Aktualisierungsmethode beendet hat. Beim BeginInvoke()-Aufruf wartet der Hintergrund-Thread nicht auf das Ende
der Aktualisierung durch den GUI-Thread. In der Regel ist die asynchrone Variante (BeginInvoke()) zu bevorzugen. In unserem Beispiel wird so für das möglichst frühzeitige Ende der TimerCallback-Methode HintergrundAktion() gesorgt, die einen Pool-Thread belegt. Während zu
Kapitel 13: Threads
394
jedem BeginInvoke() - Aufruf an ein Delegatenobjekt (siehe Abschnitt 13.6.2) der zugehörig EndInvoke() - Aufruf durchgeführt werden sollte, ist im vorliegenden Fall ausnahmsweise kein EndInvoke()-Aufruf erforderlich.
Im Namensraum System.Windows.Forms ist ebenfalls eine Klasse namens Timer vorhanden.
Diese arbeitet ereignisorientiert und ist sehr bequem in eine GUI-Anwendung einzubinden, z.B.:
using System;
using System.Windows.Forms;
class WinFormsTimer : Form {
Label anzeige;
Random zzg;
public WinFormsTimer() {
Height = 130; Width = 350;
Text = "System.Windows.Forms.Timer";
anzeige = new Label();
anzeige.Width = 300;
anzeige.Left = 20; anzeige.Top = 20;
Controls.Add(anzeige);
zzg = new Random();
Timer tim = new Timer();
tim.Tick += new EventHandler(TimerAktion);
tim.Interval = 1000;
tim.Start();
TextBox eingabe = new TextBox();
eingabe.Left = 20; eingabe.Top = 50; eingabe.Width = 125;
Controls.Add(eingabe);
}
void TimerAktion(Object myObject, EventArgs myEventArgs) {
long l = 0;
for (int i = 0; i < 10000000; i++)
l += zzg.Next(100);
anzeige.Text = "Aktuelle Zufallssumme: " + l.ToString() +
" berechnet um " + DateTime.Now.ToLongTimeString();
}
[STAThread]
static void Main() {
Application.EnableVisualStyles();
Application.Run(new WinFormsTimer());
}
}
Diesmal laufen die vom Timer angestoßenen Methodenaufrufe jedoch im GUI-Thread ab, und bei
gleicher Rechenlast resultiert eine unergonomisch zähe Benutzeroberfläche.
Derartige Probleme lassen sich gelegentlich beherrschen, indem eine zeitaufwändige Ereignisbehandlungsmethode ihre Tätigkeit unterbricht, um die Bearbeitung anderer Ereignisse zu ermöglichen. Diese Form von Kooperation innerhalb des GUI-Threads wird durch Aufrufe der statischen
Methode DoEvents() der Klasse Application realisiert, z.B.:
Abschnitt 13.8 Übungsaufgaben zu Kapitel 1623H13
395
void TimerAktion(Object myObject, EventArgs myEventArgs) {
long l = 0;
for (int j = 0; j < 100; j++) {
Application.DoEvents();
for (int i = 0; i < 100000; i++)
l += zzg.Next(100);
}
anzeige.Text = "Aktuelle Zufallssumme: " + l.ToString() +
" berechnet um " + DateTime.Now.ToLongTimeString();
}
Im Beispiel wird eine Schleife mit sehr hoher Umdrehungszahl durch eine Doppelschleife ersetzt
und bei jedem Durchlauf der äußeren Schleife die Application-Methode DoEvents() aufgerufen.
Durch derartige Maßnahmen wird ein Algorithmus nicht übersichtlicher, und die eventuell vorhandene Rechenleistung weiterer CPU-Kerne bleibt ungenutzt.
Für das regelmäßige Starten von Methoden mit geringem Rechenzeitbedarf ist die WinForms - Timer-Komponente aber durchaus geeignet und wegen der einfachen Verwendbarkeit attraktiv (siehe
Abschnitt 16.6).
13.8 Übungsaufgaben zu Kapitel 13
1) Welche von den folgenden Aussagen sind richtig?
1. Ein Thread im Zustand Stopped lässt sich mit der Methode Start() reaktivieren
2. Die Methoden der WinForms – Steuerelemente sind in der Regel Thread – sicher.
3. Bei der lock-Anweisung
lock(sperre) {
...
}
ist sichergestellt, dass der kritische Block auch beim Auftreten einer Ausnahme verlassen
wird.
2) Erstellen Sie eine GUI-Anwendung, welche die Größe eines Ordners (inklusive aller Unterordner) in einem eigenen Thread berechnet, so dass die Bedienelemente des Formulars stets verzögerungsfrei reagieren, z.B.:
14 Netzwerkprogrammierung
Die FCL enthält zahlreiche Klassen zur Netzwerkprogrammierung, wobei man zwischen einem
bequemen Zugriff auf Netzwerkressourcen über Standardprotokolle (z.B. HTTP) und einer Programmierung auf elementaren Protokollebenen mit einer entsprechend weiter reichenden Kontrolle
wählen kann.
Die serverseitige Webprogrammierung wird in diesem Manuskript nicht behandelt. Dabei geht es
vor allem um folgende Projekttypen:


Dynamische Webseiten
Auf der Serverseite werden (oft in Kooperation mit ADO.NET) HTML-Seiten aufgrund einer speziellen Anforderung individuell erstellt und dann zum klientenseitigen Browser gesandt. Benutzer produzieren über die GUI-Elemente der HTML-Seite Ereignisse für das
Serverprogramm.
Webdienste
Mit dieser Technik wird der Aufruf von Prozeduren auf entfernten Rechnern (Remote Procedure Call) via Internet unterstützt 1. Dabei kommt das XML-basierte SOAP-Protokoll
(Simple Object Access Protocol) zum Einsatz. So kann z.B. eine lokale Anwendung vom
Server einer Fluglinie Daten über Verbindungen, freie Plätze und Preise beschaffen.
14.1 Wichtige Konzepte der Netzwerktechnologie
Als Netzwerk bezeichnet man eine Anzahl von Systemen (z.B. Rechnern), die über ein gemeinsames Medium (z.B. Ethernet-Kabel, WLAN, Infrarotkanal) verbunden sind und über ein gemeinsames Protokoll (z.B. TCP/IP) Daten austauschen können.
Unter einem Protokoll ist eine Menge von Regeln zu verstehen, die für eine erfolgreiche Kommunikation von allen beteiligten Systemen eingehalten werden müssen.
Bei den meisten aktuellen Netzwerkprotokollen werden Daten paketweise übertragen. Zwischen
zwei Kommunikationspartnern jeweils eine feste Leitung zu schalten und auch in „Funkpausen“
aufrecht zu erhalten, wäre unökonomisch. Wenn über dieselbe Leitung, z.B. zwischen den Verbindungsknoten K1 und K2, Pakete zwischen verschiedenen Kommunikationspartnern, z.B. (A  D),
(B  E), ausgetauscht werden, ist eine Adressierung der Pakete unabdingbar.
A
B
C
D
K1
K2
E
F
Von der Anwendungsebene (z.B. Versandt einer E-Mail über einen SMTP-Server (Simple Mail
Transfer Protocol)) bis zur physikalischen Ebene (z.B. elektromagnetische Wellen auf einem Ethernet-Kabel) sind zahlreiche Übersetzungen vorzunehmen bzw. Aufgaben zu lösen, jeweils unter Beachtung der zugehörigen Regeln. Im nächsten Abschnitt werden die beteiligten Ebenen mit ihren
jeweiligen Protokollen behandelt, wobei wir uns auf Themen mit Relevanz für die Anwendungsentwicklung konzentrieren.
1
Server und Klienten für Webdienste müssen allerdings nicht als .NET – Anwendungen realisiert werden.
Kapitel 14: Netzwerkprogrammierung
398
14.1.1 Das OSI-Modell
Nach dem OSI – Modell (Open System Interconnection) der ISO (International Standards Organisation) werden sieben aufeinander aufbauende Schichten (engl.: layers) mit jeweiligen Zuständigkeiten und zugehörigen Protokollen unterschieden. Bei der anschließenden Beschreibung dieser
Schichten sollen wichtige Begriffe und vor allem die heute üblichen Internet-Protokolle (z.B. IP,
TCP, UDP, ICMP) eingeordnet werden.
1. Physikalische Ebene (Bit-Übertragung, z.B. über Kupferdrahtleitungen)
Hier wird festgelegt, wie von der Netzwerk-Hardware Bits zwischen zwei direkt verbundenen Stationen zu übertragen sind. Im einfachen Beispiel einer seriellen Verbindung über Kupferkabel wird
z.B. festgelegt, dass zur Übertragung einer 0 eine bestimmte Spannung während einer festgelegten
Zeit angelegt wird, während eine 1 durch eine gleichlange Phase der Spannungsfreiheit ausgedrückt
wird.
2. Link-Ebene (gesicherte Frame-Übertragung, z.B. per Ethernet)
Hier wird vereinbart, wie ein Frame zu übertragen ist, der aus einer Anzahl von Bits besteht und
durch eine Prüfsumme gesichert ist. In der Regel gehören zum Protokoll dieser Ebene auch Startund Endmarkierungen, damit sich die beteiligten Geräte rechtzeitig auf eine Informationsübertragung einstellen können.
Im Beispiel der seriellen Datenübertragung kann z.B. dieser Frame-Aufbau verwendet werden:
Startbit:
0
8 Datenbits
Parität:
odd (siehe unten)
Stoppbit:
1
In folgender Abbildung sind drei Frames zu sehen, die nacheinander über eine serielle Leitung gesendet werden:
Zeit
0 1 0 0 0 1 1 1 1 0 1 0 0 1 0 0 1 0 0 0 1 1 0 1 1 0 0 0 0 1 1 1 1
Daten
0
Startbit
Daten
Prüfbit (odd)
Daten
1
Stoppbit
Das odd-Prüfbit wird so gesetzt, dass es die acht Datenbits zu einer ungeraden Summe ergänzt.
Bei einem Ethernet-Frame ist der Aufbau etwas komplizierter (siehe Spurgeon 2000, S. 40ff):


Der Header enthält u.a. die MAC-Adressen (Media Access Control) von Sender und Empfänger. Diese Level-2 - Adressen sind nur für die subnetz-interne Kommunikation relevant.
Es können zwischen Daten im Umfang von 46 bis 1500 Byte transportiert werden.
3. Netzwerkebene (Paketübertragung, z.B. per IP)
Die Frames der zweiten Ebene hängen von der verwendeten Netzwerktechnik ab, so dass auf der
Strecke vom Absender bis zum Empfänger in der Regel mehrere Frame-Architekturen beteiligt sind
(z.B. auf der Telefonstrecke zum Provider eine andere als auf dem weiteren Weg über Ethernetoder ATM-Verbindungen). Auf der dritten Ebene kommen hingegen Informations-Pakete zum Einsatz, die auf der gesamten Strecke (im Intra- und/oder im Internet) unverändert bleiben und beim
Wechsel der Netzwerktechnik in verschiedene Schicht 2 - Container umgeladen werden (siehe Abschnitt 14.1.2).
Abschnitt 14.1 Wichtige Konzepte der Netzwerktechnologie
399
Durch die Protokolle der Schicht 3 sind u.a. folgende Aufgaben zu erfüllen:


Adressierung (über Subnetzgrenzen hinweg gültig)
Jedes Paket enthält eine Absender- und eine Zieladresse mit globaler Gültigkeit (über Subnetzgrenzen hinweg).
Routing
In komplexen (und ausfallsicheren) Netzen führen mehrere Wege vom Absender eines Paketes zum Ziel. Vermittlungsrechner (sog. Router) entscheiden darüber, welchen Weg ein
Paket nehmen soll.
Bei aktuellen Netzwerken kommt auf der Ebene 3 überwiegend das IP-Protokoll zum Einsatz. Seine Pakete bezeichnet man auch als IP-Datagramme. In der heute noch üblichen IP-Version 4
(IPv4) besteht eine Adresse aus 32 Bits, üblicherweise durch vier per Punkt getrennte Dezimalzahlen (aus dem Bereich von 0 bis 255) dargestellt, z.B.:
192.168.178.12
Bei der kommenden IP-Version 6 (IPv6) besteht eine Adresse aus 128 Bits, welche durch acht per
Doppelpunkt getrennte Hexadezimalzahlen (aus dem Bereich von 0 bis FFFF) dargestellt werden,
z.B.:
2001:88c7:c79c:0000:0000:0000:88c7:c79c
Innerhalb eines Blocks dürfen führende Nullen weggelassen werden, z.B.:
2001:88c7:c79c:0:0:0:88c7:c79c
Eine Gruppe aufeinanderfolgender Blöcke mit dem Wert 0000 bzw. 0 darf durch zwei Doppelpunkte ersetzt werden, z.B.:
2001:88c7:c79c::88c7:c79c
Der OSI-Ebene 3 wird auch das Internet Control Message Protocol (ICMP) zugerechnet, das zur
Übermittlung von Fehlermeldungen und verwandten Informationen dient. Wenn z.B. ein Router ein
IP-Datagramm verwerfen muss, weil seine Maximalzahl von Weiterleitungen (Time To Live, TTL)
erreicht wurde, dann schickt er in der Regel eine Time Exceeded – Meldung an den Absender. Auch
die von ping - Anwendungen versandten Echo Requests und die zugehörigen Antworten zählen zu
den ICMP - Nachrichten.
4. Transportschicht (gesicherte Paketübertragung, z.B. per TCP)
Zwar bemüht sich die Protokollebene 3 darum, Pakete auf möglichst schnellem Weg vom Absender
zum Ziel zu befördern, sie kann jedoch nicht garantieren, dass alle Pakete in korrekter Reihenfolge
ankommen. Dafür sind die Protokolle der Transportschicht zuständig, wobei momentan vor allem
das Transmission Control Protocol (TCP) zum Einsatz kommt. Das TCP wiederholt z.B. die Übertragung von Paketen, wenn innerhalb einer festgelegten Zeit keine Bestätigung eingetroffen ist.
Eine weitere Aufgabe der dritten Protokollebene besteht in der Datenflusskontrolle zur Vermeidung von Überlastungen.
5. Sitzungsebene (Übertragung von Byte-Strömen zwischen Endpunkten, z.B. per TCP)
Auf dieser Ebene sind Regeln angesiedelt, die den Datenaustausch zwischen zwei Anwendungen
(meist auf verschiedenen Rechnern) ermöglichen. Auch solche Aufgaben werden in der heute üblichen Praxis vom Transmission Control Protocol (TCP) abgedeckt, das folglich für die OSISchichten 4 und 5 zuständig ist.
Damit eine spezielle Anwendung auf Rechner A mit einer speziellen Anwendung auf Rechner B
kommunizieren kann, werden so genannte Ports verwendet. Hierbei handelt es sich um Zahlen zwischen 0 und 65535 (216 - 1), die eine kommunikationswillige bzw. -fähige Anwendung identifizieren. So wird es z.B. möglich, auf einem Rechner verschiedene Serverprogramme zu installieren, die
Kapitel 14: Netzwerkprogrammierung
400
trotzdem von Klienten aufgrund ihrer verschiedenen Ports (z.B. 21 für einen FTP-Server, 80 für
einen WWW-Server) gezielt angesprochen werden können. Während die Ports von 0 bis 1023 (210 1) für Standarddienste fest definiert sind, werden die höheren Ports nach Bedarf vergeben, z.B. zur
temporären Verwendung durch kommunikationswillige Klientenprogramme.
Eine TCP-Verbindung ist also bestimmt durch:


Die IP-Adresse des Serverrechners und die Portnummer des Dienstes
Die IP-Adresse des Klientenrechners und die dem Klientenprogramm für die Kommunikation zugeteilte Portnummer
Weitere Eigenschaften einer TCP-Verbindung:


Das TCP-Protokoll stellt eine virtuelle Verbindung zwischen zwei Anwendungen her.
Auf beiden Seiten steht eine als Socket bezeichnete Programmierschnittstelle zur Verfügung. Die beiden Sockets kommunizieren über Datenströme miteinander. Aus der Sicht des
Anwendungsprogrammierers werden per TCP keine Pakete übertragen, sondern Ströme von
Bytes.
Von den Internet-Protokollen ist auch das User Datagram Protocol (UDP) auf der Ebene 5 anzusiedeln. Es sorgt ebenfalls für eine Kommunikation zwischen Anwendungen und nutzt dazu Ports
wie das TCP. Allerdings sind die Ports praktisch die einzige Erweiterung gegenüber der IP-Ebene.
Es handelt sich also um einen ungesicherten Paketversandt ohne Garantie für eine vollständige Auslieferung in korrekter Reihenfolge. Aufgrund der somit eingesparten Verwaltungskosten eignet sich
das UDP zur Übertragung größerer Datenmengen, wenn dabei der Verlust einzelner Pakete zu verschmerzen ist (z.B. beim Multimedia - Streaming).
6. Präsentation
Hier geht es z.B. um die Verschlüsselung oder Komprimierung von Daten. Die TCP/IP - Protokollfamilie kümmert sich nicht darum, sondern überlässt derlei Arbeiten den Anwendungen.
7. Anwendung (Protokolle für Endbenutzer-Dienstleistungen, z.B. per HTTP oder SMTP)
Hier wird für verschiedene Dienste festgelegt, wie Anforderungen zu formulieren und Antworten
auszuliefern sind. Einem SMTP-Server - Programm (Simple Mail Transfer Protocol), der an Port 25
lauert, kann ein Klientenprogramm z.B. folgendermaßen eine Mail übergeben:
Klient
Serverantwort
telnet srv.srv-dom.de 25
HELO mainpc.client-dom.de
MAIL FROM: [email protected]
RCPT TO: [email protected]
DATA
From: [email protected]
To: [email protected]
Subject: Thema
Dies ist der Inhalt!
.
QUIT
220
250
250
250
354
srv.srv-dom.de ESMTP Postfix
srv.srv-dom.de
Ok
Ok
End data with <CR><LF>.<CR><LF>
250 Ok: queued as 43A7D6D91AC
221 Bye
Der Mailempfänger glaubt hoffentlich nicht an die angezeigten Adressen:
401
Abschnitt 14.1 Wichtige Konzepte der Netzwerktechnologie
Noch häufiger als die Mail-Protokolle kommt im Internet auf Anwendungsebene das HTTPProtokoll (Hyper Text Transfer Protocol) für den Austausch zwischen Web-Server und -Browser
zum Einsatz.
14.1.2 Zur Funktionsweise von Protokollstapeln
Möchte eine Anwendung (genauer: eine aktive Anwendungsinstanz) auf dem Rechner A über ein
TCP/IP – Netzwerk eine gemäß zugehörigem Protokoll (z.B. SMTP) zusammengestellte Sendung
an eine korrespondierende Anwendung auf dem Rechner B schicken, dann übergibt sie eine Serie
von Bytes an die TCP-Schicht von Rechner A, welche daraus TCP-Pakete erstellt. Wir beschränken
uns auf den einfachen Fall, dass alle Daten in ein TCP-Paket passen, und machen die analoge Annahme auch für alle weiteren Neuverpackungen:
Daten
Daten in einem Ausgabe-Bytestrom der Anwendungsebene

TCP-Paket auf der Transport- bzw. Sitzungsebene
TCP-Header
Daten

IP-Paket auf der Netzwerkebene
IP-Header
TCP-Paket

Ethernet-Frame
Ethernet-Header
IP-Paket
Wichtige Bestandteile des TCP-Headers sind:


Die Portnummern der Quell- und Zielanwendung
TCP-Flags
Hierzu gehört z.B. das zur Gewährleistung der Auslieferung von TCP-Paketen benutzte
ACK-Bit. Weil es bei allen Paketen einer Verbindung mit Ausnahme des initialen Pakets
gesetzt ist, kann z.B. eine Firewall-Software an diesem Bit erkennen, ob ein von Außen eintreffendes Paket zur (unerwünschten) Verbindungsaufnahme dienen soll.
Das TCP-Paket wird weiter „nach unten“ durchgereicht zur IP-Schicht, die ihren eigenen Header
ergänzt, der u.a. folgende Informationen enthält:



Die IP-Adressen von Quell- und Zielrechner
Typ des eingepackten Protokolls (z.B. TCP oder UDP)
Time-To-Live (TTL)
Beim Routing kann es zu Schleifen kommen. Damit ein Paket nicht ewig rotiert, startet es
mit einer Time-To-Live - Angabe mit der maximalen Anzahl von erlaubten Router - Passagen, die von jedem Router dekrementiert wird. Muss ein Router den TTL-Wert auf null setzen, verwirft er das Paket und informiert den Absender eventuell per ICMP-Paket über den
Vorfall.
Wenn der nächste Router auf dem Weg zum Zielrechner über ein lokales Netzwerk mit EthernetTechnik erreicht wird, muss das IP-Paket in einen Ethernet-Frame verpackt werden, wobei der zusätzliche Header z.B. die MAC-Adresse des Routers aufnimmt.
Kapitel 14: Netzwerkprogrammierung
402
Auf dem Zielrechner wird der umgekehrte Weg durchlaufen: Jede Schicht entfernt ihren eigenen
Header und reicht den Inhalt an die nächst höhere Ebene weiter, bis die übertragenen Daten schließlich in einem Eingabestrom der zuständigen Anwendung (identifiziert über die Portnummer im
TCP-Header) gelandet sind.
14.1.3 Optionen zur Netzwerkprogrammierung in C#
C# (bzw. das .NET – Framework) unterstützt sowohl die Socket - orientierte TCP - Kommunikation
(auf der Ebene 4/5 des OSI - Modells) als auch die Nutzung wichtiger Protokolle auf der Anwendungsebene. Unterhalb der Socket - Ebene ist keine Netzwerkprogrammierung mit verwaltetem
Code (MSIL) möglich. Entsprechende Dienste aus dem Win32-API (z.B. für die Inter-ProzessKommunikation über named pipes) können jedoch über die später zu behandelnde PInvoke - Technik genutzt werden.
Die in Abschnitt 14 zu behandelnden FCL-Klassen zur Netzwerkprogrammierung befinden sich
meist in den Namensräumen System.Net und System.Net.Sockets, wobei ein Verweis auf das
GAC - Assembly System erforderlich ist.
14.2 Internet - Ressourcen per Request/Response – Modell nutzen
Auf Internet-Ressourcen, die über einen so genannten Uniform Resource Identifier (URI) ansprechbar sind, kann man in C# genau so einfach zugreifen wie auf lokale Dateien.
Ein URI wie z.B.
http://www.egal.de:81/cgi-bin/beispiel/cgi.pl?vorname=Kurt
ist folgendermaßen aufgebaut:
Syntax:
Protokoll
Beispiel: http
://
://
User:Pass@
(optional)
Rechner
:Port
(optional)
Pfad
? URL-Parameter
(optional)
www.egal.de
:81
/cgi-bin/beispiel/cgi.pl
?vorname=Kurt
Bei vielen statischen Webseiten kann am Ende der Pfadangabe durch # eingeleitet noch ein seiteninternes Sprungziel genannt werden, z.B.:
http://www.cs.tut.fi/~jkorpela/forms/methods.html#fund
Die URL-Parameter dienen zur Anforderung von individuellen bzw. dynamisch erstellten Webseiten unter Verwendung der GET-Methode aus dem HTTP-Protokoll (siehe Abschnitt 14.2.3.3).
Durch das Zeichen & getrennt dürfen auch mehrere Parameter (Name-Wert - Paare) angegeben
werden, z.B.:
?vorname=Kurt&nachname=Schmidt
14.2.1 Statische Webinhalte anfordern
Zum Abrufen einer per URI beschriebenen Ressource bietet das .NET – Framework die Request/Response – Architektur. Man ruft zunächst die statische Methode Create() der Klasse
WebRequest mit einem URI als Parameter auf, um ein Objekt aus einer zum Protokoll passenden
WebRequest – Ableitung erzeugen zu lassen, z.B.:
WebRequest request = WebRequest.Create("http://www.uni-trier.de/");
Enthält der URI z.B. die Protokollbezeichnung http (Hyper Text Transfer Protocol) oder https (sicheres HTTP), dann liefert Create() ein Objekt der Klasse HttpWebRequest.
Ist eine protokollspezifische Konfiguration der Anforderung erforderlich, kann man nach einer expliziten Typumwandlung die entsprechenden Eigenschaften der zugehörigen WebRequest - Unter-
Abschnitt 14.2 Internet - Ressourcen per Request/Response – Modell nutzen
403
klasse ansprechen. Aufgrund der folgenden UserAgent - Manipulation stellt sich ein C# - Programm beim Webserver als Firefox - Browser vor:
((HttpWebRequest)request).UserAgent =
"Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.0.5) "+
" Gecko/2008120122 Firefox/3.0.5 (.NET CLR 3.5.30729)";
Mit der WebRequest – Methode GetResponse() fordert man die Antwort des Servers an, z.B.
WebResponse response = request.GetResponse();
Das resultierende Objekt aus einer WebResponse – Unterklasse bietet Eigenschaften mit Metainformationen zur Serverantwort, die teilweise protokollspezifisch und daher erst nach einer Typumwandlung zugänglich sind, z.B.:
Console.WriteLine("Letzte Änderung:\t"+
((HttpWebResponse)response).LastModified);
An den eigentlichen Inhalt kommt man über ein Stream-Objekt heran, das die WebResponse –
Methode GetResponseStream() liefert, z.B.:
Stream content = response.GetResponseStream();
Zum Lesen einer Serverantwort mit bestimmtem Zeichensatz eignet sich ein entsprechend konfigurierter StreamReader:
StreamReader reader;
String cs = (((HttpWebResponse)response).CharacterSet).ToLower();
switch (cs) {
case "iso-8859-1":
case "utf-8": reader = new StreamReader(content, Encoding.GetEncoding(cs));
break;
default:
reader = new StreamReader(content, Encoding.ASCII);
break;
}
Ein Aufruf der StreamReader – Methode ReadLine() liefert die nächste Zeile der Serverantwort:
String s;
while ((s = reader.ReadLine()) != null) {
Console.WriteLine(s);
Console.ReadLine();
}
Nach dem Lesen der Serverantwort sollte man die WebResponse-Methode Close() aufrufen, um
die Ressourcen der Verbindung sofort frei zu geben:
response.Close();
Das folgende Programm zeigt die Schritte im Zusammenhang und verzichtet dabei der Einfachheit
halber auf die bei Netzverbindungen sehr empfehlenswerte Ausnahmebehandlung:
using System;
using System.IO;
using System.Net;
using System.Text;
class RequestRespone {
static void Main() {
// Request-Objekt zu URI erzeugen
WebRequest request = WebRequest.Create("http://www.uni-trier.de/");
// Protokollspezifische Request-Konfiguration
((HttpWebRequest)request).UserAgent =
"Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.0.5) " +
" Gecko/2008120122 Firefox/3.0.5 (.NET CLR 3.5.30729)";
// Response anfordern
WebResponse response = request.GetResponse();
// Zugriff auf Metainformation
Console.WriteLine("Letzte Änderung:\t"+
((HttpWebResponse)response).LastModified);
Kapitel 14: Netzwerkprogrammierung
404
Console.WriteLine("Zeichensatz:
\t" +
((HttpWebResponse)response).CharacterSet);
Console.ReadLine();
// Zugriff auf den Strom mit der Serverantwort
Stream content = response.GetResponseStream();
// StreamReader mit passender Kodierung erstellen
StreamReader reader;
String cs = (((HttpWebResponse)response).CharacterSet).ToLower();
switch (cs) {
case "iso-8859-1":
case "utf-8": reader = new StreamReader(content,Encoding.GetEncoding(cs));
break;
default: reader = new StreamReader(content);
break;
}
// Serverantwort zeilenweise ausgeben
String s;
while ((s = reader.ReadLine()) != null) {
Console.WriteLine(s);
Console.ReadLine();
}
// Datenstrom schließen
response.Close();
}
}
Ein Programmlauf (am 6.2.2009) bringt folgendes Ergebnis:
Letzte Änderung:
Zeichensatz:
06.02.2009 06:04:34
iso-8859-1
<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE html
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="de" lang="de">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"/>
. . .
. . .
Seit der .NET - Version 2.0 findet sich im Namensraum System.Windows.Forms die Steuerelementklasse WebBrowser mit der Fähigkeit, HTML zu rendern. Sie sollen als Übungsaufgabe einen
kleinen Webbrowser erstellen, der HTML-Seiten attraktiver anzeigen kann, z.B.:
Abschnitt 14.2 Internet - Ressourcen per Request/Response – Modell nutzen
405
14.2.2 Datei-Download per HTTP - Protokoll
Per Request/Response – Technologie und HTTP - Protokoll kann man nicht nur HTML-Seiten von
einem Server beziehen, sondern auch Binärdateien herunter laden. Wir nutzen zu diesem Zweck die
Klasse WebClient, welche durch Verpacken der ohnehin schon recht bequemen Klassen
WebRequest und WebResponse den Komfort auf die Spitze treibt:
using System;
using System.Net;
class FileDownload {
static void Main(string[] args) {
if (args.Length < 2) {
Console.WriteLine("Aufruf: FileDownload <URI> <Dateiname>");
return;
}
try {
WebClient client = new WebClient();
Console.WriteLine("Download startet");
client.DownloadFile(args[0], args[1]);
Console.WriteLine("Fertig!");
} catch(Exception e) {
Console.WriteLine(e);
}
}
}
Man verwendet die Methode DownloadFile() und überlässt Routinen wie das Öffnen und Schließen
von Dateien den FCL - Programmierern. Im ersten Parameter gibt man die Webadresse (den URI)
an und im zweiten Parameter den lokalen Dateinamen.
Das Beispielprogramm ermöglicht den Datei-Download per Kommandozeile, was auch im GUI Zeitalter noch gefragt ist, z.B.:
Ist mehr Flexibilität gefragt, kommt die direkte Verwendung der Klassen WebRequest und WebResponse (bzw. der entsprechenden Ableitungen) in Frage. Das folgende Download-Programm
wertet drei Kommandozeilenargumente aus:



Webadresse der Quelldatei
lokaler Dateiname
Datum der letzten Änderung
Das Datum wird für die HttpWebRequest-Eigenschaft IfModifiedSince verwendet, so dass der
Server eine Fehlermeldung liefert, wenn das Änderungsdatum der angeforderten Datei weiter zurück liegt. Auf diese und andere Server-Fehlermeldungen reagiert die HttpWebRequest - Methode
GetResponse() mit einer WebException: 1
1
Weil Fehlermeldungen des Webservers relativ wahrscheinlich sind (z.B. durch Tippfehler im URI), ist nach den
Überlegungen aus Abschnitt 10.3 die Fehlerkommunikation per Ausnahmeobjekt nicht unbedingt ideal.
Kapitel 14: Netzwerkprogrammierung
406
using System;
using System.IO;
using System.Net;
class FileDownload {
static void Main(string[] args) {
WebResponse response = null;
FileStream fs = null;
const int BUFSIZE = 4096;
DateTime date;
if (args.Length < 3) {
Console.WriteLine("Aufruf: FileDownload <URI> <Dateiname> <Datum>");
Console.ReadLine();
return;
}
try {
date = new DateTime(Convert.ToInt32(args[2].Substring(0, 4)),
Convert.ToInt32(args[2].Substring(5, 2)),
Convert.ToInt32(args[2].Substring(8, 2)));
} catch {
Console.WriteLine("Datumsformat: jjjj.mm.tt");
return;
}
try {
WebRequest request = WebRequest.Create(args[0]);
((HttpWebRequest)request).IfModifiedSince = date;
response = request.GetResponse();
} catch (Exception e) {
Console.WriteLine(e.Message);
return;
}
try {
Stream webStream = response.GetResponseStream();
byte[] buffer = new byte[BUFSIZE];
fs = new FileStream(args[1], FileMode.Create);
int bytesRead;
int sumRead = 0;
Console.WriteLine("Download beginnt ...");
while((bytesRead = webStream.Read(buffer, 0, buffer.Length)) > 0) {
fs.Write(buffer, 0, bytesRead);
sumRead += bytesRead;
}
Console.WriteLine("Fertig: "+sumRead+" Bytes übertragen");
} catch(Exception e) {
Console.WriteLine(e.Message);
} finally {
response.Close();
if(fs != null) fs.Close();
}
}
}
Seit der .NET –Version 2.0 wird auch das FTP-Protokoll (von den Klassen FtpWebRequest,
FtpWebResponse und WebClient) unterstützt, so dass ein Datei-Download von einem FTP-Server
nun ebenfalls leicht zu bewerkstelligen ist.
Abschnitt 14.2 Internet - Ressourcen per Request/Response – Modell nutzen
407
14.2.3 Dynamische erstellte Webseiten per GET oder POST anfordern
14.2.3.1 Überblick
WWW-Server halten in der Regel nicht nur statische HTML-Seiten und sonstige Dateien bereit,
sondern bieten auch verschiedene Technologien, um HTML-Seiten dynamisch nach Kundenwunsch
zu erzeugen und an Klientenprogramme (meist WWW-Browser) auszuliefern (z.B. mit den Ergebnissen eines Suchauftrags oder mit einer individuellen Produktkonfiguration). WWW-Nutzer äußern ihre Wünsche, indem sie per Browser (z.B. Mozilla-Firefox, MS Internet Explorer) eine Formularseite (mit Eingabeelementen wie Textfeldern, Kontrollkästchen usw.) ausfüllen und ihre Daten zum WWW-Server übertragen. Dieses Programm (z.B. Apache, MS Internet Information Server) analysiert und beantwortet Formulardaten aber nicht selbst, sondern überlässt diese Arbeit externen Anwendungen, die in unterschiedlichen Programmier- bzw. Skriptsprachen erstellt werden
können (z.B. ASP.NET, Java, PHP oder Perl). Traditionell geschieht dies über das so genannte
Common Gateway Interface (CGI), wobei das externe Ergänzungsprogramm bei jeder Anforderung werden neu gestartet und nach dem Erstellen der HTML-Antwortseite wieder beendet wird.
Mittlerweile werden jedoch Lösungen bevorzugt, die stärker mit dem Webserver verzahnt sind,
permanent im Speicher verbleiben und so eine bessere Leistung bieten (z.B. ASP.NET als IIS - Erweiterung, Java-Container wie Tomcat, PHP als Apache - Modul). So wird vermieden, dass bei
jeder Anforderung ein Programm (z.B. der PHP-Interpreter) gestartet und eventuell auch noch eine
Datenbankverbindung aufwändig hergestellt werden muss. Außerdem wird bei den genannten Lösungen die nicht sehr wartungsfreundliche Erstellung kompletter HTML-Antwortseiten über Ausgabeanweisungen der jeweiligen Programmiersprache vermieden. Stattdessen können in einem Dokument statische HTML-Abschnitte mit Bestandteilen der jeweiligen Programmiersprache zur dynamischen Produktion individueller Abschnitte kombiniert werden. Wir werden anschließend der
Einfachheit halber alle Verfahren zur dynamischen Produktion individueller HTML-Seiten als CGI
- Lösungen bezeichnen. Eine wichtige Gemeinsamkeit dieser Verfahren besteht darin, dass die
Browser zur Formulierung ihrer Anforderungen (Requests) die Methoden GET und POST aus dem
HTTP - Protokoll benutzen (siehe unten).
Wir beschränken uns darauf, CGI - Anwendungen durch klientenseitige C# - Programme (statt
durch einen WWW-Browser) als Informationsquelle zu nutzen. Wie zu Beginn von Abschnitt 14
verabredet, können wir uns aus Zeitgründen mit der relevanteren Realisation von CGI – Lösungen
durch serverseitige C# - Programme (im Rahmen des ASP.NET - Frameworks) nicht beschäftigen.
Es besteht eine Verwandtschaft zu den oben erwähnten Webdiensten, die allerdings keine HTMLSeiten für Browser produzieren, sondern XML - Dateien. Von dieser interessanten Technik zur Erstellung von verteiltem Anwendungen kann in diesem Kurs weder die server- noch die klientenseitige Programmierung behandelt werden.
14.2.3.2 Arbeitsablauf
Beim CGI - Einsatz sind üblicherweise folgende Programme beteiligt:



WWW-Browser
WWW-Server
CGI-Lösung
Der Browser zeigt eine vom Server erhaltene HTML-Seite mit Formular an, über die Benutzer eine
CGI-Anfrage konfigurieren und abschicken können. Wir betrachten ein einfaches Formular mit
zugehörigem CGI-Skript, um einige technische Details zu erläutern.
In diesem Browser-Fenster
Kapitel 14: Netzwerkprogrammierung
408
wird eine HTML-Datei angezeigt, die über den URL
http://urtkurs.uni-trier.de/prokur/netz/cgig.html
abrufbar ist und den folgenden HTML-Code mit Formular enthält:
<html>
<head>
<title>CGI-Demo</title>
</head>
<h1>Nenne Deinen Namen, und ich sage Dir, wie Du heißt!</h1>
<form method="get" action="cgig.php">
<table border="0" cellpadding="0" cellspacing="4">
<tr>
<td align="right">Vorname:</td>
<td><input name="vorname" type="text" size="30"></td>
</tr><tr>
<td align="right">Nachname:</td>
<td><input name="nachname" type="text" size="30"></td>
</tr>
<tr> </tr>
<tr>
<td align="right"> <input type="submit" value=" Absenden "> </td>
<td align="right"> <input type="reset" value=" Abbrechen"> </td>
</td>
</tr>
</table>
</form>
</html>
Klickt der Benutzer nach dem Ausfüllen der Textfelder auf den Schalter Absenden, werden seine
Eingaben mit der Syntax
Name=Wert
als Parameter für die CGI - Software zum WWW-Server übertragen. Zwei Felder werden jeweils
durch ein &-Zeichen getrennt, so dass im obigen Beispiel mit den Feldern vorname und
nachname (siehe HTML-Quelltext) folgende Sendung resultiert:
vorname=Kurt&nachname=M%FCller
Den Umlaut „ü“ kodiert der Browser automatisch durch ein einleitendes Prozentzeichen und seinen
Hexadezimalwert im Zeichensatz der Webseite. Wenn im HTML-Code kein Zeichensatz angegeben
ist, verwendet der Browser eine Voreinstellung (im Beispiel: ISO 8859-1). Analog werden andere
Zeichen behandelt, die nicht zum Standard - ASCII-Code gehören. Weitere Regeln dieser so genannten URL-Kodierung:
Abschnitt 14.2 Internet - Ressourcen per Request/Response – Modell nutzen


409
Leerzeichen werden durch ein „+“ ersetzt.
Die mit einer speziellen Bedeutung belasteten Zeichen (also &, +, = und %) werden durch
ihren Hexadezimalwert im Zeichensatz dargestellt.
Auf gleich noch näher zu erläuternde Weise übergibt der WWW-Server die Formulardaten an das
im action-Attribut der Formulardefinition angegebene externe Programm oder Skript. Im Beispiel
handelt es sich um folgendes PHP-Skript, das wenig kreativ aus den übergebenen Namen einen
Gruß formuliert:
<?php
$vorname = $_GET["vorname"];
$nachname = $_GET["nachname"];
echo "<html>\n<head><title>CGI-Demo</title>\n</head>\n";
echo "<body>\n<h1>Hallo, ".$vorname." ".$nachname."!</h1>\n</body>\n</html>";
?>
Im Skript wird die auszugebende HTML-Seite über echo-Kommandos an die Standardausgabe geschickt, und der WWW-Server befördert die PHP-Produktion über das HTTP-Protokoll an den
Browser, der den empfangenen HTML-Quelltext
anzeigt:
14.2.3.3 GET
Zum Versandt von CGI-Parametern an einen WWW-Server kennt das HTTP-Protokoll zwei Methoden (GET und POST), die nun vorgestellt und dabei auch gleich in C#- Programmen realisiert
werden sollen.
Bei der GET-Technik, die man im <form> - Tag einer HTML-Seite durch die Angabe
method="get"
wählt, schickt der Browser die Name-Wert - Paare als URI-Bestandteil hinter einem trennenden
Fragezeichen an den WWW-Server. Weil nach Eintreffen der Antwortseite die zugrunde liegende
Anforderung in der Adresszeile des Browsers erscheint, kann die GET-Syntax dort inspiziert werden (siehe oben).
Aus der Integration der HTTP-Parameter in die Anforderung an den WWW-Server ergibt sich eine
Längenbeschränkung, wobei die konkreten Maximalwerte vom Server und vom Browser abhängen.
Kapitel 14: Netzwerkprogrammierung
410
Man sollte vorsichtshalber eine Anforderungsgesamtlänge von 255 Zeichen einhalten und ggf. die
POST-Technik verwenden, die keine praxisrelevante Längenbeschränkung kennt.
Der WWW-Server schreibt die HTTP-Parameter in eine Umgebungsvariable namens QUERY_STRING und stellt auf analoge Weise der CGI-Software gleich noch weitere Informationen
zur Verfügung, z.B.:
QUERY_STRING="vorname=Kurt&nachname=M%3Fller"
REMOTE_PORT="1211"
REQUEST_METHOD="GET"
In obigem PHP-Skript erfolgt der der Zugriff auf die Parameter in der Umgebungsvariablen QUERY_STRING über den superglobalen Array $_GET.
Um in C# eine CGI-Software anzusprechen, die per GET mit Parametern versorgt werden möchte,
genügt ein Objekt der komfortablen Klasse WebClient, das in seiner Methode DownloadData()
den Job erledigt:
using
using
using
using
System;
System.Net;
System.Text;
System.Web;
class CgiGet {
static void Main() {
String cgiuri = "http://urtkurs.uni-trier.de/prokur/netz/cgig.php";
Console.WriteLine("Geben Sie bitte Ihren Vornamen an");
Console.Write("Vorname: ");
String vorName=HttpUtility.UrlEncode(Console.ReadLine(),Encoding.Default);
Console.WriteLine("Geben Sie bitte Ihren Nachnamen an");
Console.Write("Nachname: ");
String nachName=HttpUtility.UrlEncode(Console.ReadLine(),Encoding.Default);
Console.WriteLine("\nAnforderung wird übertragen ...");
WebClient client = new WebClient();
byte[] antwort = null;
try {
antwort = client.DownloadData(cgiuri + "?vorname=" + vorName +
"&nachname=" + nachName);
String s = HttpUtility.UrlDecode(antwort, Encoding.Default);
Console.WriteLine("\nAntwort :\n" + s);
} catch (Exception e) {
Console.WriteLine(e.Message);
}
Console.ReadLine();
}
}
Mit der statischen HttpUtility-Methode UrlEncode() wird für die korrekte URL-Kodierung der
Umlaute etc. gesorgt (siehe Abschnitt 14.2.3.2). Weil die Klasse HttpUtility (aus dem Namensraum System.Web) im GAC-Assembly System.Web implementiert ist, benötigt der Compiler einen entsprechenden Verweis. Für die korrekte Interpretation der via Konsole erhaltenen Zeichenfolgen sorgt ein Encoding-Objekt (angesprochen über die statische Eigenschaft Default der Klasse
Encoding).
Dasselbe Encoding-Objekt ist auch beteiligt, wenn der vom Webserver gelieferte Byte-Array in der
statischen HttpUtility-Methode UrlDecode() in einen String gewandelt wird, wobei für die Lieferung des Webservers eine zum lokalen System kompatible Kodierung angenommen wird. Bei einer
unbekannten CGI-Lösung sollte man die Zeichensatz - Metainformationen der Serverantwort auswerten (z.B. über die HttpWebResponse-Eigenschaft CharacterSet, siehe Abschnitt 14.2.1).
Abschnitt 14.2 Internet - Ressourcen per Request/Response – Modell nutzen
411
Weil das Programm die vom CGI-Skript gelieferte HTML-Seite nur als Text darstellt, ist sein Auftritt nicht berauschend:
Geben Sie bitte Ihren Vornamen an
Vorname: Kurt
Geben Sie bitte Ihren Nachnamen an
Nachname: Müller
Anforderung wird übertragen ...
Antwort:
<html>
<head><title>CGI-Demo</title></head>
<h1>Hallo, Kurt Müller!</h1>
</html>
14.2.3.4 POST
Bei der POST-Technik, die man im <form> - Tag einer HTML-Seite durch die Angabe
method="post"
wählt, werden die Parameter (im selben Format wie bei der GET-Methode) mit Hilfe des WWWServers zur Standardeingabe der CGI-Software übertragen. Was genau gemäß HTTP-Protokoll zu
tun ist, braucht C# - Programmierer nicht zu interessieren. Man schreibt die HTTP-Parameter über
Add()-Aufrufe in ein Objekt der Klasse NameValueCollection
NameValueCollection formData = new NameValueCollection();
formData.Add("vorname",vorName);
formData.Add("nachname",nachName);
und verwendet dieses Objekt neben dem statischen URI-Anteil als Parameter für die WebClientInstanzmethode UploadValues():
WebClient client = new WebClient();
byte[] antwort = null;
antwort = client.UploadValues(cgiuri, formData);
Die von UploadValues() als Rückgabe gelieferte Server-Antwort kann mit der HttpUtilityMethode UrlDecode() (siehe Abschnitt 14.2.3.3) unter Verwendung der lokal gültigen Kodierung
in einen String verwandelt werden:
String s = HttpUtility.UrlDecode(antwort, Encoding.Default);
Insgesamt unterscheidet sich das GET-Beispielprogramm nur wenig von der POST-Variante:
using
using
using
using
using
System;
System.Collections.Specialized;
System.Net;
System.Text;
System.Web;
class CgiPost {
static void Main() {
String cgiuri = "http://urtkurs.uni-trier.de/prokur/netz/cgip.php";
Console.WriteLine("Geben Sie bitte Ihren Vornamen an");
Console.Write("Vorname: ");
String vorName=HttpUtility.UrlEncode(Console.ReadLine(),Encoding.Default);
Console.WriteLine("Geben Sie bitte Ihren Nachnamen an");
Console.Write("Nachname: ");
String nachName=HttpUtility.UrlEncode(Console.ReadLine(),Encoding.Default);
NameValueCollection formData = new NameValueCollection();
Kapitel 14: Netzwerkprogrammierung
412
formData.Add("vorname", vorName);
formData.Add("nachname",nachName);
Console.WriteLine("\nAnforderung wird übertragen ...");
WebClient client = new WebClient();
byte[] antwort = null;
try {
antwort = client.UploadValues(cgiuri, formData);
String s = HttpUtility.UrlDecode(antwort, Encoding.Default);
Console.WriteLine("\nAntwort:\n" + s);
} catch (Exception e) {
Console.WriteLine(e.Message);
}
}
}
Das angesprochene PHP-Skript unterscheidet sich ebenfalls kaum von der GET-Variante: Anstelle
des superglobalen Arrays $_GET ist der analoge Array $_POST zu verwenden.
14.3 IP-Adressen bzw. Host-Namen ermitteln
Jeder an das Internet angeschlossen Rechner verfügt über (mindestens) eine IP-Adresse (32-bittig
in IPv4, 128-bittig in IPv6) sowie über einen Host-Namen, wobei die Zuordnung vom Domain
Name System (DNS) geleistet wird.
Die statische Methode GetHostEntry() der FCL-Klasse Dns (im Namensraum System.NET) mit
den beiden Überladungen
public static IPHostEntry GetHostEntry(IPAddress adresse)
public static IPHostEntry GetHostEntry(String nameOderAdresse)
nimmt eine Rechneridentifikation per IPAddress-Objekt oder Zeichenfolge entgegen und versucht,
per DNS-Anfrage den jeweils fehlenden Bestandteil (die IP-Adresse(n) zu einem Host-Namen bzw.
den Namen zu einer Adresse) zu ermitteln. Man erhält ein IPHostEntry-Objekt, das in seinen Eigenschaften AddressList bzw. HostName die gewünschten Daten bereit hält.
Im folgenden Programm werden beide Konvertierungsrichtungen verwendet:
Aufgrund der graphischen Oberfläche ist der Quelltext deutlich länger als bei den bisherigen Beispielprogrammen in Abschnitt 14. Daher werden nur die Ereignisbehandlungsmethoden zu den beiden Schaltflächen wiedergegeben:
Abschnitt 14.4 Socket-Programmierung
413
void CbToNumberClick(object sender, System.EventArgs e) {
try {
IPHostEntry host = Dns.GetHostEntry(tbName.Text);
tbNumber.Text = host.AddressList[0].ToString();
} catch (Exception ex) {
MessageBox.Show(ex.ToString(), "Fehler");
}
}
void CbToNameClick(object sender, System.EventArgs e) {
try {
IPHostEntry host = Dns.GetHostEntry(tbNumber.Text);
tbName.Text = host.HostName;
} catch (Exception ex) {
MessageBox.Show(ex.ToString(),"Fehler");
}
}
Mit den Dns-Methoden BeginGetHostEntry() , und EndGetHostEntry() lässt sich eine DNSAnfrage asynchron durchführen (siehe Abschnitt 13.6).
Im .NET – Framework nimmt man für jeden Rechner eine ganze Liste von IP-Adressen an, was bei
der heutigen Vielfalt von Netzwerkadaptern in Standard-PCs (z.B. LAN, VPN) und in Anbetracht
der bei Server-PCs oft mehrfach vorhandenen Ethernet-Anschlüsse durchaus realistisch ist. Das
obige Programm zeigt nur die erste IP-Adresse an:
tbNumber.Text = host.AddressList[0].ToString();
Den vollständigen (mit Visual Studio - Unterstützung erstellten) Quellcode finden Sie im Ordner
…\BspUeb\Netzwerk\DNS
14.4 Socket-Programmierung
Unsere bisherigen Beispielprogramme im Kapitel 14 haben hauptsächlich WWW-Inhalte von Servern bezogen und dazu FCL-Klassen benutzt, die ein fest „verdrahtetes“ Anwendungsprotokoll
(meist HTTP) realisieren. Im aktuellen Abschnitt gewinnen wir eine erweiterte Flexibilität durch
den direkten Einsatz des TCP-Protokolls. Daraus ergibt sich z.B. die Möglichkeit, eigene Anwendungsprotokolle zu entwickeln. Das auf der Transport- bzw. Sitzungsebene des OSI-Modells (siehe
Abschnitt 14.1.1) angesiedelte TCP-Protokoll schafft zwischen zwei (durch Portnummern identifizierten) Anwendungen, die sich meist auf verschiedenen (durch IP-Adressen identifizierten) Rechnern befinden, eine virtuelle, Datenstrom-orientierte und gesicherte Verbindung. An beiden Enden
der Verbindung steht das von praktisch allen aktuellen Programmiersprachen unterstützte SocketAPI zur Verfügung, das im .NET - Framework durch Klassen im Namensraum System.Net.Sockets
realisiert wird.
TCP-Programmierer müssen sich nicht um IP-Pakete kümmern, weder die Zustellung noch die Integrität oder die korrekte Reihenfolge überwachen, sondern (nach den Regeln eines Protokolls der
Anwendungsschicht) Bytes in einen Ausgabestrom einspeisen bzw. aus einen Eingabestrom entnehmen und interpretieren. Dabei ist der Ausgabestrom des Senders virtuell mit dem Eingabestrom
des Empfängers verbunden.
Wir beschäftigen uns in diesem Abschnitt mit der Erstellung von Klienten- und Serveranwendungen. Ein wesentlicher Unterschied zwischen beiden Rollen besteht darin, dass ein Serverprogramm
fest an einen Port gebunden ist und auf dort eingehende Verbindungswünsche wartet, während ein
Klientenprogramm nur bei Bedarf aktiv wird und dabei einen dynamisch zugewiesenen Port benutzt.
Kapitel 14: Netzwerkprogrammierung
414
14.4.1 TCP-Server
Wir erstellen einen Server, der am TCP-Port 13000 lauscht und anfragenden Klienten die aktuelle
Tageszeit mitteilt. Im Konstruktoraufruf für das zentrale Objekt der Klasse TcpListener geben wir
neben der Portnummer auch eine IP-Adresse an, z.B.
TcpListener server = null;
int svrPort = 13000;
IPAddress ip = Dns.GetHostEntry("localhost").AddressList[0];
. . .
server = new TcpListener(ip, svrPort);
Vom veralteten Konstruktoraufruf ohne IP-Adresse und der damit erforderlichen automatischen
Wahl einer IP-Adresse hält der Compiler nichts mehr:
TcpListener.cs(13,13): warning CS0618:
System.Net.Sockets.TcpListener.TcpListener(int) ist veraltet: This
method has been deprecated. Please use TcpListener(IPAddress localaddr,
int port) instead. http://go.microsoft.com/fwlink/?linkid=14202.
Nach dem Start des Servers
server.Start();
wird dieser durch Aufruf seiner Methode AcceptTcpClient() beauftragt, auf eine Verbindungsanfrage zu lauern:
tcpClient = server.AcceptTcpClient();
Während der Wartezeit ist der aktuelle Thread durch den AcceptTcpClient()-Aufruf blockiert. Dieser endet erst bei einer Verbindungsanfrage und liefert dann ein (zum Senden und Empfangen geeignetes) TcpClient-Objekt zurück, das als Verpackung für die beiden folgenden (sicher von Ihnen
erwarteten) Objekte dient:


ein Socket-Objekt, ansprechbar über die TcpClient - Eigenschaft Client
ein NetworkStream-Objekt, erreichbar über die TcpClient-Methode GetStream()
Im Beispielprogramm wird anschließend eine Zeichenfolge mit Datum und Uhrzeit unter Verwendung der ASCII-Kodierung in einen Byte-Array gewandelt und in dieser Form über das
NetworkStream-Objekt an die Gegenstelle gesendet. Das Senden kann durch einen BinaryWriter
(für elementare Datentypen) oder einen StreamWriter (für Textdaten) vereinfacht werden, wobei
ein Protokoll auf der Anwendungsebene zu verabreden oder zu beachten ist.
Am Ende der Klientenbedienung wird per ShutDown()-Befehl an das angeschlossene Socket - Objekt die sofortige Ausführung von eventuell noch anstehenden Übertragungen angefordert:
tcpClient.Client.Shutdown(SocketShutdown.Send);
Auf den Close() - Aufruf
tcpClient.Close();
reagiert das TcpClient – Objekt so:


Das interne NetworkStream - Objekt erhält einen Close() – Aufruf, und die Referenz auf
dieses Objekt wird auf null gesetzt.
Das interne Socket - Objekt erhält einen Close() – Aufruf, und die Referenz auf dieses Objekt wird auf null gesetzt.
Das Programm bedient in einer while-Schleife beliebig viele Klienten nacheinander:
Abschnitt 14.4 Socket-Programmierung
using
using
using
using
415
System;
System.Net;
System.Net.Sockets;
System.Text;
class TcpListenerDemo {
static void Main() {
TcpListener server = null;
int svrPort = 13000;
IPAddress ip = Dns.GetHostEntry("localhost").AddressList[0];
TcpClient tcpClient = null;
NetworkStream stream = null;
try {
server = new TcpListener(ip, svrPort);
server.Start();
Console.WriteLine("Zeitserver lauscht seit " + DateTime.Now + " (IP: " +
ip + ", Port: " + svrPort + ")");
while (true) {
// Achtung: Aufruf blockiert!
tcpClient = server.AcceptTcpClient();
Console.WriteLine("\n" + DateTime.Now + " Anfrage von\n IP-Nummer:\t"
+(tcpClient.Client.RemoteEndPoint as IPEndPoint).Address+"\n Port:
\t"
+(tcpClient.Client.RemoteEndPoint as IPEndPoint).Port);
stream = tcpClient.GetStream();
byte[] msg = Encoding.ASCII.GetBytes(DateTime.Now.ToString());
stream.Write(msg, 0, msg.Length);
tcpClient.Client.Shutdown(SocketShutdown.Send);
tcpClient.Close();
}
} catch (Exception e) {
Console.WriteLine(e);
Console.ReadLine();
}
}
}
Der Kürze halber verzichten wir auf ein Verfahren zum regulären Beenden des Programms, so dass
es rabiat abgebrochen werden muss.
Sobald nicht die interne IP-Adresse 127.0.0.1 (verbunden mit dem Host-Namen localhost) verwendet wird, ist unter Windows XP ab SP 2 für den Betrieb des Zeitservers eine Firewall-Ausnahme
erforderlich:
Der Zeitserver kann z.B. per Telnet-Klient angesprochen werden, z.B. mit dem folgenden Aufruf:
telnet localhost 13000
Der Server antwortet:
416
Kapitel 14: Netzwerkprogrammierung
und protokolliert seine Dienstleistungen:
So kommt das Programm an die IP-Adresse und den Port des verbundenen Klienten heran:
(tcpClient.Client.RemoteEndPoint as IPEndPoint).Address
(tcpClient.Client.RemoteEndPoint as IPEndPoint).Port
Die RemoteEndPoint-Eigenschaft des Socket-Objekts, das über die TcpClient-Eigenschaft Client
angesprochen wird, zeigt auf ein Objekt vom deklarierten Datentyp EndPoint, das tatsächlich zur
Klasse IpEndPoint gehört und daher die Eigenschaften Address und Port mit den gewünschten
Informationen besitzt.
14.4.2 TCP-Klient
Als Gegenstück zum eben präsentierten Zeit-Server wird nun ein passendes Klienten-Programm
entwickelt, wobei ein Objekt der Klasse TcpClient die zentrale Rolle spielt. Bei der gewählten
Konstruktion - Überladung sind IP-Adresse und Portnummer des Servers anzugeben, z.B.:
String svr = "localhost";
int port = 13000;
. . .
TcpClient tcpClient = new TcpClient(svr, port);
Weil die Verbindung bereits im Konstruktor aufgebaut wird, setzt man seinen Aufruf am besten in
einen try-Block.
Die empfangenen Daten stehen über ein Objekt der Klasse NetworkStream zur Verfügung, das
von der TcpClient-Methode GetStream() geliefert wird:
NetworkStream stream = client.GetStream();
byte[] bZeit = new byte[48];
stream.ReadTimeout = 1000;
int nRead = stream.Read(bZeit, 0, bZeit.Length);
Per Voreinstellung kehrt der Read()-Aufruf erst dann zurück, wenn Daten im angesprochenen
Netzwerkstrom ankommen. Seit der .NET - Version 2.0 besteht die Möglichkeit, über die
NetworkStream - Eigenschaft ReadTimeout eine Zeitspanne in Millisekunden festzulegen, nach
deren Ablauf der Leseversuch mit einer IOExecption abgebrochen werden soll.
Bei der Wandlung von Bytes in Unicode-Zeichen ist die korrekte Kodierung zu verwenden, z.B.:
string sZeit = Encoding.ASCII.GetString(bZeit, 0, nRead);
Auf den Close() - Aufruf
tcpClient.Close();
Abschnitt 14.4 Socket-Programmierung
417
reagiert das TcpClient – Objekt so:


Das interne NetworkStream - Objekt erhält einen Close() – Aufruf, und die Referenz auf
dieses Objekt wird auf null gesetzt.
Das interne Socket - Objekt erhält einen Close() – Aufruf, und die Referenz auf dieses Objekt wird auf null gesetzt. Dies beendet die Verbindung zum Server und gibt die NetzwerkRessourcen (z.B. den belegten Port) an das Betriebsystem zurück.
Die explizite Rückgabe von Ressourcen ist vor allem dann relevant, wenn ein Programm anschließend weiter aktiv bleiben soll. Bei Beendigung eines Programms werden die belegten Ressourcen
automatisch frei gegeben.
Der Quellcode im Überblick:
using System;
using System.Net.Sockets;
using System.Text;
class TcpClientDemo {
static void Main() {
String svr = "localhost";
int port = 13000;
try {
TcpClient tcpClient = new TcpClient(svr, port);
NetworkStream stream = tcpClient.GetStream();
byte[] bZeit = new byte[48];
stream.ReadTimeout = 1000;
int nRead = stream.Read(bZeit, 0, bZeit.Length);
string sZeit = Encoding.ASCII.GetString(bZeit, 0, nRead);
Console.WriteLine("Datum und Zeit von {0}: {1}", svr, sZeit);
tcpClient.Close();
} catch (Exception e) {
Console.WriteLine(e);
}
}
}
Sofern Netz und Server mitspielen, liefert das Programm Datum und Uhrzeit, z.B.:
14.4.3 Simultane Bedienung mehrerer Klienten
Bei einer ernsthaften Serverprogrammierung kommt man an einer Multithreading - Lösung nicht
vorbei, damit mehrere Klienten simultan bedient werden können. Als Beispiel erstellen wir einen
Echo-Server, der alle zugesandten Bytes unverändert zurück schickt. Den „Service“ für einen
Klienten übernimmt ein Objekt der Klasse EchoHandler, das in seiner Run()-Methode aus dem
Netzwerkstrom liest und auch dorthin schreibt:
using
using
using
using
System;
System.Net;
System.Net.Sockets;
System.Text;
public class EchoHandler {
TcpClient tcpClient;
ThreadPoolEchoServer server;
int clientID;
NetworkStream stream;
Kapitel 14: Netzwerkprogrammierung
418
public EchoHandler(ThreadPoolEchoServer server_,
TcpClient tcpClient_, int clientID_) {
server = server_;
tcpClient = tcpClient_;
clientID = clientID_;
stream = tcpClient.GetStream();
stream.ReadTimeout = 10000;
}
void Close() {
tcpClient.Client.Shutdown(SocketShutdown.Both);
tcpClient.Close();
server.CheckOut(clientID);
}
public void Run(object obj) {
int n;
byte[] buffer = new byte[80];
try {
while (true) {
n = stream.Read(buffer, 0, buffer.Length);
stream.Write(buffer, 0, n);
}
} catch {
} finally {
Close();
}
}
}
Wenn ein Klient länger als 10 Sekunden stumm bleibt, läuft die ReadTimeout - Zeitspanne des
beteiligten NetworkStream-Objekts ab:
stream.ReadTimeout = 10000;
In diesem Fall endet die Run() - Methode nach einigen Aufräumungsarbeiten in der per finallyBlock aufgerufenen Close() - Methode.
Das Dienstleistungszentrum wird durch ein Objekt der Klasse ThreadPoolEchoServer realisiert. Seine Run() - Methode wird vom primären Thread ausgeführt:
public void Run() {
try {
while (true) {
TcpClient tcpClient = tcpListener.AcceptTcpClient();
lock (this) {
nc++;
nr++;
Console.WriteLine("\n" + DateTime.Now + " Klient " +
nr + " akzeptiert. Aktiv: " + nc + "\n IP-Nummer:\t" +
(tcpClient.Client.RemoteEndPoint as IPEndPoint).Address+"\n Port:
(tcpClient.Client.RemoteEndPoint as IPEndPoint).Port);
}
EchoHandler echoHandler = new EchoHandler(this, tcpClient, nr);
ThreadPool.QueueUserWorkItem(echoHandler.Run);
}
} catch (Exception e) {
Console.WriteLine(e);
Console.ReadLine();
}
}
Sobald der Lauscher vom Typ TcpListener eine Klientenanfrage feststellt, wird ein EchoHandler - Objekt erzeugt, das im Konstruktoraufruf folgende Informationen erhält:
\t"+
Abschnitt 14.5 Übungsaufgaben zu Kapitel 1637H14
419

eine Referenz auf das Dienstleistungszentrum
So kann der EchoHandler die ThreadPoolEchoServer - Instanzmethode CheckOut() aufrufen, um das Ende einer Klientenversorgung zu melden.

eine Referenz auf das TcpClient - Objekt, das die TcpListener - Methode
AcceptTcpClient() erzeugt hat
eine Klientennummer vom Typ int

Nach dem Erstellen des EchoHandler - Objekts wird seine Run()-Methode als Arbeitsauftrag in
die Warteschlange des Threadpools eingereiht (vgl. Abschnitt 13.6)..
In der folgenden Situation bedient der ThreadPoolEchoServer drei Klienten, wobei die Klasse Dauersender zum Einsatz kommt, die Sie als Übungsaufgabe erstellen sollen (siehe Abschnitt
14.5). Dauersender-Objekte versenden pro Sekunde eine zufällige Ziffernsequenz und protokollieren die Server-Antwort.
Den vollständigen Quellcode finden Sie im Ordner
…\BspUeb\Netzwerk\MultiClientEchoServer\MultiThreadEchoServer
14.5 Übungsaufgaben zu Kapitel 14
1) Erstellen Sie mit Hilfe der Steuerelementklasse WebBrowser eine WinForms-Anwendung, die
eine TextBox zur Eingabe einer Webadresse bietet, HTML anzeigt, sinnvoll auf Änderungen der
Formulargröße reagiert und elementare Navigationsmöglichkeiten bietet (siehe Bildschirmphoto in
Abschnitt 14.2.1).
Kapitel 14: Netzwerkprogrammierung
420
2) Erstellen Sie einen TCP-Klienten, der den in Abschnitt 14.4.3 vorgestellten MultithreadEchoserver durch das regelmäßige Absenden von jeweils zehn zufällig gewählten Ziffern beschäftigt und die Server-Antworten protokolliert.
3) Erstellen Sie eine ChatRoom - Anwendung mit Server- und Klientenprogramm. Die Serveranwendung sollte …





an einem TCP-Port auf Verbindungswünsche warten
mehrere Klienten simultan bedienen (jeden Klienten in einem eigenen Thread)
Sendungen eines Klienten an alle aktiven Klienten übertragen
wichtige Ereignisse (z.B. An- und Abmeldungen von Klienten) protokollieren, z.B. mit einem mehrzeiligen TextBox-Steuerelement (Wert true für die Eigenschaft Multiline) mit
vertikalem Rollbalken (Wert Vertical für die Eigenschaft ScrollBars)
Ein Button-Steuerelement anbieten zum Beenden des Programms, wobei die aktiven Klienten über das Dienstende zu benachrichtigen sind
Ein typisches Server-Verlaufsprotokoll könnte so aussehen:
Die Klientenanwendung sollte …





TextBox-Steuerelemente anbieten für:
o den Servernamen
o den Wunschnamen des Chat-Teilnehmers
o die abzuschickenden Nachrichten
Button-Steuerelemente bieten …
o zum An- und Abmelden beim Server
o zum Abschicken der Nachricht
o zum Beenden des Programms
zum gewünschten Server eine Verbindung mit dem gewünschten Benutzernamen aufbauen
in einem speziellen Thread ständig für eingehende Nachrichten empfangsbereit sein
eingehende Nachrichten protokollieren, z.B. mit einem mehrzeiligen TextBoxSteuerelement (Wert true für die Eigenschaft Multiline) mit vertikalem Rollbalken (Wert
Vertical für die Eigenschaft ScrollBars)
Die Benutzeroberfläche der Klientenanwendung sollte ungefähr so aussehen:
Abschnitt 14.5 Übungsaufgaben zu Kapitel 1637H14
421
Im ChatRoom - Anwendungsprotokoll sollen folgende Nachrichten eine spezielle Bedeutung haben:
#Quit!
#Bye!
#UserName='name'!
#EndOfService!
Ein Klient meldet sich ab.
Serverantwort auf die Abmeldung eines Klienten
Ein Klient nennt seinen Wunschnamen.
Der Server informiert die Klienten über den bevorstehenden Dienstschluss.
Die Benutzbarkeit des Programms kann durch folgende Maßnahmen gesteigert werden:



Je nach Programmzustand sollten die gerade nicht verwendbaren Bedienelemente gesperrt
sein (z.B. die Nachrichten - TextBox bei fehlender Verbindung).
Mit der Control - Methode Select() setzt man den Eingabefokus auf die gerade benötigte
TextBox, z.B. nach dem Verbindungsaufbau auf die Nachrichten - TextBox.
Über die Form-Eigenschaft AcceptButton verbindet man die Enter-Taste mit der gerade
am ehesten benötigten Schaltfläche.
15 Grafikausgabe mit dem GDI+
Zwar bieten die aus System.Windows.Forms.Control abstammenden Klassen zahllose Optionen
zur Gestaltung von ergonomischen und ansehnlichen Programmoberflächen, doch sind nicht nur
Grafikprogramme darauf angewiesen, Fenster individueller zu gestalten, als es durch Verwendung
von vorgefundenen Steuerelementen und Modifikation der zugehörigen Eigenschaften möglich ist.
Hier ist als Beispiel das kürzlich in .NET - Technik neu entwickelte Geografische Informationssystem RegioGraph der Firma GfK Geomarketing (Version 10) zu sehen, das offenbar von anspruchsvoller Grafikprogrammierung lebt:
Mehr Gestaltungsfreiheit benötigen auch Entwickler, die eigene Steuerelemente mit individueller
Optik erstellen möchten (z.B. runde Befehlsschalter).
Windows stellt den Anwendungsprogrammen seit jeher mit dem Graphics Device Interface (GDI)
eine umfangreiche Sammlung von graphischen Ausgaberoutinen zur Verfügung. Mit dieser Softwareschicht wird die 2D-Grafik - Programmierung von den spezifischen Eigenschaften bestimmter
Ausgabegeräte (z.B. Grafikkarte, Drucker) unabhängig. Das.NET – Framework bietet für die erweiterte Fassung GDI+ mit zahlreichen Lösungen für anspruchsvolle Vektor- und Pixelgrafiken eine
objektorientierte Schnittstelle, wobei die zuständigen Klassen im Namensraum System.Drawing
und diversen Unterräumen (z.B. System.Drawing.Text) zu finden sind.
Als Zeichengrundlage werden Objekte der Klassen Form und Panel bevorzugt, doch kommen
grundsätzlich auch andere Steuerelemente in Frage.
15.1 Die Klasse Graphics
Bei der Grafikausgabe im .NET – Framework spielen Objekte der Klasse Graphics aus dem Namensraum System.Drawing eine zentrale Rolle:

Ein solches Objekt ist mit einem Windows-Gerätekontext verbunden und eröffnet damit
den Zugang zu einer Zeichenoberfläche.
Kapitel 15: Grafikausgabe mit dem GDI+
424


Die Eigenschaften eines Graphics-Objekts enthalten relevante Informationen für die Grafikausgabe, z.B. die horizontale und vertikale Auflösung des Ausgabegeräts.
Ein Graphics-Objekt beherrscht diverse Methoden zur Ausgabe von elementaren Grafikelementen (z.B. DrawLine(), FillRectangle()), Texten (z.B. DrawString()) und Bildern
(z.B. DrawImage()).
In der FormOnMouseDown() - Methode des folgenden Beispielprogramms, die beim Ereignis
MouseDown des Anwendungsfensters registriert ist, wird mit der Control-Methode CreateGraphics() ein Graphics-Objekt erzeugt, das den Klientenbereich des Anwendungsfensters als
Zeichenoberfläche erschließt:
using System;
using System.Windows.Forms;
using System.Drawing;
class GraphicsDemo : Form {
GraphicsDemo() {
Text = "Graphics-Demo";
MouseDown += new MouseEventHandler(FormOnMouseDown);
}
void FormOnMouseDown(object sender, MouseEventArgs e) {
Graphics g = this.CreateGraphics();
g.FillEllipse(Brushes.Blue, e.X - 10, e.Y - 10, 20, 20);
g.Dispose();
}
[STAThread]
static void Main() {
Application.Run(new GraphicsDemo());
}
}
Das Graphics-Objekt wird per FillEllipse()-Aufruf gebeten, mit Hilfe eines blauen Pinsels, ansprechbar über die statische Eigenschaft Blue der Klasse Brushes, um die Klickstelle (e.X, e.Y)
einen Kreis mit einem Durchmesser von 20 Pixeln zu malen. Dies ermöglicht es dem Benutzer, mit
mehreren Mausklicks ein Pünktchenmuster nach eigenem Geschmack zu entwerfen, z.B.
Neben seinen Zeichenkünsten bietet das Graphics-Objekt in etlichen Eigenschaften auch Informationen und Einstellmöglichkeiten. So lässt sich z.B. mit den Eigenschaften DpiX und DpiY die
Auflösung des angeschlossenen Gerätes in horizontaler und vertikaler Richtung ermitteln, und per
TextRenderingHint kann man die Schriftglättung beeinflussen.
Den Ausgabemethoden (wie z.B. FillEllipse()) müssen als Werkzeuge zu verwendende Objekte
übergeben werden (z.B. Pinsel und Stifte, siehe unten).
Abschnitt 15.2 GDI-Ressourcen schonen
425
15.2 GDI-Ressourcen schonen
Ein Graphics-Objekt verbraucht in erheblichem Umfang GDI-Ressourcen des Betriebssystems, die
zu den unverwalteten, nicht unter der Kontrolle der CLR stehenden, Ressourcen gehören (wie z.B.
auch Dateien oder Netzwerkverbindungen, siehe oben). Engpässe bei GDI-Ressourcen können (z.B.
in Anwendungen mit vielen Formularen) durchaus für erhebliche Probleme sorgen, z.B. für Darstellungsfehler beim Zeichen der Steuerelemente. Daher sollte man für ein per CreateGraphis() erstelltes Objekt nach getaner Arbeit die Dispose()–Methode aufrufen, um die GDI-Ressourcen sofort
frei zu geben, z.B.:
g.Dispose();
Zwar ruft auch der .NET - Garbage Collector die Dispose()-Methode auf, doch orientiert er seine
Arbeitsplanung am verfügbaren verwalteten Speicher und an der verfügbaren Prozessorkapazität.
Somit ist nicht garantiert, dass die GDI-Ressourcen der obsolet gewordenen (nicht mehr referenzierten) Graphics-Objekte rechtzeitig frei gegeben werden.
Ein voreiliges Dispose() ist natürlich strikt zu vermeiden, weil es zu einem Laufzeitfehler führt.
Vielleicht findet Microsoft eine Möglichkeit, das Verhalten des Garbage Collectors so zu optimieren, dass Anwendungsprogrammierer nicht mehr zwischen verwalteten und unverwalteten Ressourcen unterscheiden müssen.
Zu den GDI-Ressourcen, die möglichst früh zurückgegeben werden sollten, gehören neben den Gerätekontexten auch (zugehörige FCL-Klasse in Klammern):




Pinsel (Brush)
Stifte (Pen)
Pixel-Grafiken (Bitmap)
Schriftarten (Font)
Bei den vordefinierten, über statische Eigenschaften der Klassen Pens, SystemPens, Brushes,
SystemBrushes und SystemFonts (vgl. Abschnitte 15.5 und 15.7) verfügbaren Objekten (z.B.
Brushes.Blue) ist ein Dispose()-Aufruf allerdings überflüssig und schädlich (vgl. Bayer 2006, S.
874).
Für die Grafikausgaben in Paint-Ereignisbehandlungsmethoden (siehe Abschnitt 15.3) wird uns
(über eine Eigenschaft des Ereignisbeschreibungsobjekts) ein Graphics-Objekt frei Haus geliefert,
das wir nach Gebrauch einfach vergessen dürfen.
Ohne Dispose() kommt man auch bei GDI-lastigen Objekten aus, die im Konstruktor des Hauptformulars zur Verwendung im gesamten Programm erstellt werden.
Im weiteren Verlauf von Abschnitt 15 wird bei trivialen Beispielprogrammen der Einfachheit halber
auf die konsequente Schonung von GDI-Ressourcen verzichtet.
15.3 Fensterrenovierungsmethoden
Wenn ein Fenster (neu) gezeichnet werden muss (z.B. beim ersten Auftritt oder nach der Rückkehr
aus der Taskleiste), dann erhält die zugehörige Anwendung von Windows eine WM_PAINTNachricht. In den von System.Windows.Forms.Control abgeleiteten Klassen wird daraufhin die
Methode OnPaint() aufgerufen, welche ihrerseits das Control-Ereignis Paint auslöst, d.h. die registrierten Delegaten benachrichtigt. Wir haben uns in Abschnitt 9.5 ausführlich mit der Rolle der
On-Methoden bei der Ereignisbehandlung beschäftigt.
Ein Fensterobjekt ist selbst dafür verantwortlich, seinen Klientenbereich (= Gesamtfläche abzüglich
Rahmen, Titelzeile, Menüzeile, Rollbalken und ggf. Symbolleisten) in einer geeigneten Ereignisbehandlungsmethode neu zu zeichnen. Weil dem Pünktchen-Programm in Abschnitt 15.1 derartige
Kapitel 15: Grafikausgabe mit dem GDI+
426
Vorkehrungen fehlen, verliert das Fenster z.B. bei Besuchen in der Taskleiste sein Pünktchenmuster, was betroffene Künstler sicher frustrieren wird.
Im .NET - Framework stehen zwei Möglichkeiten zur Verfügung, auf die WM_PAINT-Nachricht
zu reagieren:


PaintEventHandler
Es wird eine Methode erstellt und über einen Delegaten vom Typ PaintEventHandler beim
Paint-Ereignis des betroffenen Objekts registriert. Wir haben uns in Abschnitt 9.3 ausführlich mit dem Erstellen und Registrieren von Ereignisbehandlungsmethoden beschäftigt.
OnPaint()-Überschreibung
Die von System.Windows.Forms.Control geerbte Methode OnPaint() wird überschrieben,
was natürlich nur in einer eigenen Klassendefinition möglich ist. Da in unseren GUIProgrammen das Formular (Hauptfenster) stets Objekt einer aus System.Windows.Forms.Form abgeleiteten Klasse ist, steht die OnPaint()-Technik dort zur Verfügung.
Unabhängig von der gewählten Technik muss eine Methode zum Zeichnen des Fensterinhalts ihre
Arbeit sehr flott verrichten, weil sie oft und zu beliebigen Zeitpunkten aufgerufen wird. Es wäre
z.B. keine gute Idee, Datei- oder gar Netzwerkzugriffe in einer solchen Methode vorzunehmen.
Beim Verschieben eines Fensters wird übrigens keine WM_PAINT - Ereignisbehandlungsmethode
beauftragt, um den Klientenbereich am neuen Ort zu zeichnen. Diese Routinearbeit erledigt das
Betriebssystem selbständig. Wird ein Fenster allerdings über den Bildschirmrand hinaus geschoben,
geht sein Inhalt teilweise verloren und muss ggf. per WM_PAINT - Ereignisbehandlungsmethode
wiederhergestellt werden.
Sind Steuerelemente in einem Container enthalten, werden eintreffende WM_PAINT-Nachrichten
an diese Kindfenster propagiert. Weil die Paint-Methoden der Kindfenster nach der Paint-Methode
des Containers ausgeführt werden, haben (wie in einer guten Familie) die Kinder das letzte Wort,
z.B. (Label auf einem Formular mit einer roten Diagonale):
15.3.1 PaintEventHandler
Im folgenden Programm kommt die PaintEventHandler–Technik zum Einsatz, um auf PaintEreignisse mit dem Renovieren des Fensterinhalts zu reagieren:
using
using
using
using
System;
System.Windows.Forms;
System.Drawing;
System.Collections.Generic;
class PEHDemo : Form {
List<Point> punkte = new List<Point>();
PEHDemo() {
Text = "PaintEventHandler-Demo";
MouseDown += new MouseEventHandler(FormOnMouseDown);
Paint += new PaintEventHandler(FormOnPaint);
}
Abschnitt 15.3 Fensterrenovierungsmethoden
427
void FormOnPaint(object sender, PaintEventArgs e) {
foreach (Point p in punkte)
e.Graphics.FillEllipse(Brushes.Blue, p.X, p.Y, 20, 20);
}
void FormOnMouseDown(object sender, MouseEventArgs e) {
Graphics g = this.CreateGraphics();
g.FillEllipse(Brushes.Blue, e.X - 10, e.Y - 10, 20, 20);
g.Dispose();
punkte.Add(new Point(e.X - 10, e.Y - 10));
}
[STAThread]
static void Main() {
Application.Run(new PEHDemo());
}
}
Im Vergleich zu der Punktverlustversion in Abschnitt 15.1 besitzt das neue Programm ein Objekt
der generischen Kollektionsklasse List<Point>(vgl. Abschnitt 7.2), um die vom Benutzer angeklickten Positionen als Instanzen der Struktur Point (siehe unten) zu speichern, was in der Ereignismethode FormOnMouseDown() geschieht. Dort wird außerdem weiterhin für die sofortige
Anzeige neuer Punkte per FillEllipse() -Aufruf gesorgt.
Dem registrierten PaintEventHandler FormOnPaint() wird beim Aufruf ein Graphics-Objekt
zum Bemalen des Fensters frei Haus geliefert: Es ist über die Graphics-Eigenschaft des übergebenen PaintEventArgs-Objekts ansprechbar. Zudem müssen wir uns nicht um die Freigabe der mit
diesem Graphics-Objekt verbundenen GDI-Ressourcen kümmern und insbesondere seine Dispose()-Methode nicht aufrufen (vgl. Abschnitt 15.2).
15.3.2 OnPaint() überschreiben
Im folgenden Programm wird zur Behandlung die geerbte OnPaint()-Methode überschrieben, um
auf die WM_PAINT-Nachricht zur Renovierung des Fensterinhalts zu reagieren:
using
using
using
using
System;
System.Windows.Forms;
System.Drawing;
System.Collections.Generic;
class OnPaintDemo : Form {
List<Point> punkte = new List<Point>();
OnPaintDemo() {
Text = "OnPaint-Demo";
MouseDown += new MouseEventHandler(FormOnMouseDown);
}
protected override void OnPaint(PaintEventArgs e) {
foreach (Point p in punkte)
e.Graphics.FillEllipse(Brushes.Blue, p.X, p.Y, 20, 20);
}
void FormOnMouseDown(object sender, MouseEventArgs e) {
Graphics g = this.CreateGraphics();
g.FillEllipse(Brushes.Blue, e.X - 10, e.Y - 10, 20, 20);
g.Dispose();
punkte.Add(new Point(e.X - 10, e.Y - 10));
}
Kapitel 15: Grafikausgabe mit dem GDI+
428
[STAThread]
static void Main() {
Application.Run(new OnPaintDemo());
}
}
Wie eine Paint-Behandlungsmethode erhält auch OnPaint() beim Aufruf ein Graphics-Objekt per
PaintEventArgs-Parameter, ohne sich um die Freigabe von GDI-Ressourcen kümmern zu müssen
(vgl. Abschnitt 15.2).
Kommen PaintEventHandler zusammen mit einer OnPaint()-Überschreibung zum Einsatz, muss
in der Überschreibung die OnPaint()-Methode der Basisklasse aufgerufen werden, damit die PaintEventHandler nicht abgekoppelt werden (vgl. Abschnitt 9.5).
15.3.3 Fensteraktualisierung per Programm anfordern
Bei allen bisherigen Varianten des Pünktchen-Programms wurde (auch) in einem MouseEventHandler, also außerhalb jeder WM_PAINT - Behandlungsmethode, auf die Fensteroberfläche gezeichnet. Dies geschah zunächst naiv, später in der Absicht, neben der weit blickenden Vorbereitung auf eine vom Betriebssystem angeforderte Fensterrenovierung auch für ein sofort sichtbares
Ergebnis zu sorgen.
Eine Alternative zu der (ergänzenden) Direktausgabe besteht in manchen Fällen darin, für ein Fenster mit der Control-Methode Invalidate()-Aufruf die Ausführung der WM_PAINT - Behandlungsmethode anzufordern, z.B.:
void FormOnMouseDown(object sender, MouseEventArgs e) {
punkte.Add(new Point(e.X - 10, e.Y - 10));
Invalidate();
}
Im Beispiel ist allerdings bei zunehmender Anzahl von Klecksen an einem störenden Flackern zu
bemerken, dass nun bei jedem neuen Mausklick der gesamte Klientenbereich neu gezeichnet wird.
Bei jeder Fensterrenovierung wird zunächst die gesamte Fläche grundiert, d.h. in der Hintergrundfarbe neu gestrichen. Danach werden die eigentlichen Zeichenmethoden ausgeführt, so dass an vielen Stellen in kurzer Folge unterschiedliche Farben auftauchen, was zum Eindruck des Flackerns
führt (Luis & Strasser 2008, S. 871). Alternative Invalidate()-Überladungen erlauben es, über eine
Rectangle-Instanz (siehe Abschnitt 15.4.3) bzw. ein Region-Objekt (siehe Abschnitt 15.10.3) einen
rechteckigen oder anders geformten Teil eines Fensters zu verwerfen. Weil dann (wie bei einem
Zeichenbereich, vgl. Abschnitt 15.10.2) die Ausgaben der Paint-Ereignismethode nur noch auf den
defekten Bereich des Fensters auswirken, lässt sich oft ein Flackern vermeiden. Im Beispiel wird
dieser Effekt durch eine einfache Erweiterung der Methode FormOnMouseDown() zuverlässig
erreicht:
void FormOnMouseDown(object sender, MouseEventArgs e) {
Point punkt = new Point(e.X - 10, e.Y - 10);
punkte.Add(punkt);
Invalidate(new Rectangle(punkt, new Size(20, 20)));
}
Eine andere Möglichkeit zur Vermeidung des Flackerns besteht in der Doppelpufferungstechnik
(engl.: double buffering), die im Formularkonstruktor eingeschaltet werden kann:
DoubleBuffered = true;
Nach Aktivierung der Doppelpufferung zeichnen Paint-Ereignismethoden zunächst auf eine unsichtbare Kopie der betroffenen Fläche und ersetzen erst nach Abschluss aller Arbeiten das Original
durch die neue Version. In der Regel unterscheiden sich die beiden nacheinander sichtbaren Bilder
nicht stark, und insbesondere unterbleibt das zwischenzeitliche Grundieren der Zeichenfläche in der
Abschnitt 15.3 Fensterrenovierungsmethoden
429
Hintergrundfarbe. Weil kaum schnelle Farbwechsel auftreten, unterbleibt das Flackern. Die per
Form-Eigenschaft DoubleBuffered aktivierte automatische Doppelpufferung wirkt sich nur auf
Paint-Ereignismethoden aus. Luis & Strasser (2008, S. 874) beschreiben, wie man bei beliebigen
Grafikausgaben eine eigene Doppelpufferung implementieren kann.
Vor der Reaktion auf eine per Invalidate()-Aufruf ausgelöste WM_PAINT-Nachricht, kann einige
Zeit vergehen:


Zunächst muss die aktuell ausgeführte Ereignisbehandlungsmethode (z.B. der MouseEventHandler) enden.
Befinden sich andere Nachrichten in der Warteschlange, werden diese zuerst abgearbeitet.
Man kann allerdings mit der Control-Methode Update() eine sofortige Aktualisierung der für ungültig erklärten Klientenbereiche erzwingen, z.B.:
void FormOnMouseDown(object sender, MouseEventArgs e) {
punkte.Add(new Point(e.X - 10, e.Y - 10));
Invalidate();
Update();
}
Beim gemeinsamen Einsatz der Methoden Invalidate() und Update() bestimmt erstere den Umfang
der Renovierung und letztere den Zeitpunkt. Einen Invalidate()-Aufruf ohne Angabe der Schadstelle mit anschließendem Update()-Aufruf kann man äquivalent durch einen Aufruf der ControlMethode Refresh() ersetzen, z.B.:
void FormOnMouseDown(object sender, MouseEventArgs e) {
punkte.Add(new Point(e.X - 10, e.Y - 10));
Refresh();
}
15.3.4 ResizeRedraw
In Abschnitt 15.3.3 wurde im Zusammenhang mit der Control-Methode Invalidate() von der Möglichkeit berichtet, eine Fensterrenovierung aus Kostengründen auf den beschädigten Bereich zu beschränken. Manchmal führt jedoch übertriebene Sparsamkeit zu einer unglücklichen Kombination
von alten und neuen Fensteranteilen. Das folgende Programm
using System;
using System.Windows.Forms;
using System.Drawing;
class ResizeDemo : Form {
ResizeDemo() {
Text = "Resize-Demo";
Height = 200; Width = 300;
}
protected override void OnPaint(PaintEventArgs e) {
e.Graphics.DrawLine(Pens.Black,0,0,ClientSize.Width,ClientSize.Height);
}
[STAThread]
static void Main() {
Application.Run(new ResizeDemo());
}
}
zeichnet eine Diagonale auf sein Fenster:
430
Kapitel 15: Grafikausgabe mit dem GDI+
Bei einer Verbreiterung des Fensters erscheinen „wirre“ Muster, die eventuell (bei Benutzereinstellung Fensterinhalt beim Ziehen anzeigen) vom Tempo der Mausbewegung abhängen, z.B.:
Nach der Rückkehr aus der Taskleiste zeigt sich wieder das erwartete Bild:
Per Voreinstellung beschränkt das der OnPaint()-Methode übergebene Graphics-Objekt alle Ausgaben auf das renovierungsbedürftige Rechteck, im Beispiel also auf den hinzugekommenen Bereich. Alle Ausgaben außerhalb dieses Rechtecks werden abgeschnitten, und der alte Fensterbereich
bleibt unverändert. Je nach Mausgeschwindigkeit beim Verbreitern des Fensters wird die OnPaint()-Methode unterschiedlich oft aufgerufen, so dass ein „Zufallsmuster“ entsteht. Nach der
Rückkehr aus der Taskleiste umfasst der Graphics-Arbeitsbereich das gesamte Fenster.
Hier wird offenbar an der falschen Stelle gespart, und wir setzen im Fensterkonstruktor die Control-Eigenschaft ResizeRedraw auf den Wert true, damit bei jeder Größenänderung der gesamte
Klientenbereich des Fensters neu gezeichnet wird:
ResizeRedraw = true;
15.4 Positionen und Größen
In diesem Abschnitt werden wichtige Typen zur einfachen Verwaltung von Positionen und Größen
vorgestellt.
15.4.1 Standardkoordinatensystem
Wie Sie längst wissen, befindet sich der Ursprung (0, 0) des GDI+ - Standardkoordinatensystems
per Voreinstellung in der linken oberen Ecke des Klientenbereichs. Die X-Werte wachsen nach
rechts und die Y-Werte nach unten:
431
Abschnitt 15.4 Positionen und Größen
(0, 0)
+X
Voreingestellte Maßeinheit
am Bildschirm: 1 Pixel
+Y
Wer Koordinaten in Bezug auf den gesamten Bildschirm ermitteln oder zwischen Bildschirm- und
Klientenbereichskoordinaten konvertieren möchte, kann die folgenden Control-Methoden verwenden:


Point PointToScreen(Point ptClient)
Point PointToClient(Point ptScreen)
Im Abschnitt 15.8 werden transformierte Koordinatensysteme behandelt, wobei man …


den Ursprung verschieben
und/oder die Maßeinheiten für die Achsen verändern kann.
Wir verwenden zunächst der Einfachheit halber die Maßeinheit Pixel. Um unterschiedliche Bildschirmauflösungen zu unterstützten oder eine Druckausgabe zu realisieren, benötigen wir jedoch
metrische Maßeinheiten. Über von einem Graphics-Objekt verwendete Maßeinheit lässt sich über
seine Eigenschaft PageUnit (vom Enumerationstyp GraphicsUnit) ermitteln oder verändern (siehe
Abschnitt Fehler! Verweisquelle konnte nicht gefunden werden.).
15.4.2 Point und Size
Eine Instanz der Point-Struktur repräsentiert einen Punkt der Zeichenfläche mit den Koordinaten X
und Y, die als Eigenschaften vom Typ int (mit Lese- und Schreibzugriff) implementiert sind, z.B.:
Point pkt1 = new Point();
Viele Graphics-Methoden fordern bzw. akzeptieren Ortsangaben über Point-Instanzen, z.B.:
g.DrawLine(pen, pkt1, pkt2);
Mit der Point-Methode Offset() gelingt das Verschieben eines Punktes in X- und Y-Richtung besonders bequem, z.B.:
pkt1.Offset(distX, distY);
Dieser Aufruf wirkt wie:
pkt1.X += distX;
pkt1.Y += distY;
Was die Point-Struktur für Positionsangaben leistet, bewerkstelligt die analog aufgebaute Struktur
Size für Größenangaben, die wir seit Abschnitt 9 schon oft bei der Konfiguration von Steuerelementen verwendet haben. Ihre Instanzen verfügen über die Eigenschaften Height und Width vom
Typ Int32 (mit Lese- und Schreibzugriff). Viele FCL-Klassen besitzen Eigenschaften vom Typ
Size, so dass für eine Wertzuweisung Size-Instanzen benötigt werden, z.B. bei der ClientSizeEigenschaft eines Formulars:
ClientSize = new Size(200, 200);
Kapitel 15: Grafikausgabe mit dem GDI+
432
Die Größe des Klientenbereichs festzustellen oder zu verändern ist für ein Programm oft interessanter als analoge Operationen für das gesamte Fenster (inkl. Rahmen, Titelzeile, Bildlaufleisten etc.)
über die Form-Eigenschaft Size auszuführen.
Die Verwandtschaft der Strukturen Point und Size geht so weit, dass mit Hilfe des überladenen
Additions- bzw. Subtraktions-Operators eine Size-Instanz zu einer Point-Instanz addiert bzw. von
ihr subtrahiert werden kann, z.B.:
Point pkt2 = pkt1 + ClientSize;
Alle in diesem Abschnitt vorgestellten Anweisungen entstammen dem folgenden Programm:
using System;
using System.Windows.Forms;
using System.Drawing;
class PointAndSize : Form {
Pen pen = new Pen(Color.Black, 3);
PointAndSize() {
Text = "Point and Size";
ResizeRedraw = true;
ClientSize = new Size(200, 200);
}
protected override void OnPaint(PaintEventArgs e) {
Graphics g = e.Graphics;
Point pkt1 = new Point(); // Initialisierung mit (0, 0)!
Point pkt2 = pkt1 + ClientSize;
g.DrawLine(pen, pkt1, pkt2);
int distX = (ClientSize.Width - 10) / 5;
int distY = (ClientSize.Height - 10) / 5;
pkt1.Offset(distX/2, distY/2);
for (int i = 0; i < 5; i++) {
g.DrawEllipse(pen, pkt1.X, pkt1.Y, 10, 10);
pkt1.Offset(distX, distY);
}
}
[STAThread]
static void Main() {
Application.Run(new PointAndSize());
}
}
Es zeichnet eine Diagonale vom Punkt pkt1 bis zum pkt1 + ClientSize sowie fünf Kreise um
einen per Offset() verschobenen Mittelpunkt:
Später werden wir auch mit metrischen Koordinatensystemen arbeiten, die nicht auf Pixeln basieren. Dort werden die Strukturen PointF bzw. SizeF benötigt, die sich von Point bzw. Size im Wesentlichen durch Positions- bzw. Größenangaben vom Typ float unterscheiden.
Abschnitt 15.4 Positionen und Größen
433
15.4.3 Rectangle
Viele Graphics-Methoden akzeptieren als Orts- und Erstreckungsangabe ein Rechteck, das in der
FCL durch eine Instanz der Rectangle-Struktur repräsentiert wird, z.B.:
Rectangle ra = new Rectangle(0, 0, 100, 100);
. . .
e.Graphics.DrawEllipse(rotstift, ra);
Der im Beispiel verwendete Rectangle-Konstruktor erwartet die Koordinaten der linken oberen
Ecke sowie die Breite und Höhe des Rechtecks. Ein alternativer Konstruktor akzeptiert ein Paar aus
einer Point- und einer Size-Instanz.
Von den diversen Rectangle-Methoden kommen im folgenden Programm Offset() und Inflate()
zum Einsatz:
using System;
using System.Windows.Forms;
using System.Drawing;
class RectangleDemo : Form {
Rectangle ra = new Rectangle(0, 0, 100, 100);
Pen rotstift = new Pen(Color.Red, 5);
RectangleDemo() {
Text = "Rectangle - Demo";
ClientSize = new Size(400, 400);
FormBorderStyle = FormBorderStyle.FixedSingle;
}
protected override void OnPaint(PaintEventArgs e) {
e.Graphics.DrawEllipse(rotstift, ra);
}
protected override void OnMouseDown(MouseEventArgs e) {
if (e.Button == MouseButtons.Left) {
if (e.X > Width / 2) {
if (ra.Right < Width - 20) ra.Offset(20, 20);
} else {
if (ra.X >= 20) ra.Offset(-20, -20);
}
} else {
if (e.X > Width / 2) {
if (ra.X >= 20 && ra.Right < Width - 20) ra.Inflate(20, 20);
} else {
if (ra.Width > 20) ra.Inflate(-20, -20);
}
}
Refresh();
}
[STAThread]
static void Main() {
Application.Run(new RectangleDemo());
}
}
Nach einem linken Mausklick in die linke bzw. rechte Formularhälfte wird das begrenzende Rechteck des Kreises per Offset()-Aufruf nach links oben bzw. rechts unten verschoben. Der Aufruf
ra.Offset(20, 20)
wirkt wie:
ra.X += 20;
ra.Y += 20;
Kapitel 15: Grafikausgabe mit dem GDI+
434
Mit der rechten Maustaste können Benutzer den Kreis durch Klick in die linke Fensterhälfte verkleinern bzw. durch Klick in die rechte Fensterhälfte vergrößern. Dazu wird das Begrenzungsrechteck per Inflate()–Methode aufgefordert, sich bei konstantem Mittelpunkt zu vergrößern bzw. zu
verkleinern. Der Aufruf
ra.Inflate(20, 20)
wirkt wie:
ra.X -= 20;
ra.Y -= 20;
ra.Width += 40;
ra.Height += 40;
Es kommt also auf der X- und der Y-Achse bei fixiertem Mittelpunkt in positiver und in negativer
Richtung zu einer Größenzunahme gemäß Parameterwert. Insgesamt findet der Benutzer mit wenigen Mausklicks seine bevorzugte Position und Größe für den vom Rechteck begrenzten Kreis, z.B.:
Mit Hilfe der folgenden Rectangle-Eigenschaften verhindert das Programm unerwünschte Positionen und Größen:


Width
Breite des Rechtecks
Right
Container-relative X-Koordinate des rechten Randes (= Left + Width)
Weitere Eigenschaften und Methoden der Rectangle-Struktur sind in der FCL-Dokumentation zu
finden.
Die später benötigte Struktur RectangleF steht zu Rectangle in derselben Beziehung wie Point
bzw. Size zu PointF bzw. SizeF.
15.5 Farben und Zeichenwerkzeuge
Um per Graphics-Objekt auf eine Fensterfläche zu zeichnen, benötigt man Farben und Werkzeuge
(Stifte, Pinsel).
15.5.1 Farben
Im .NET – Framework wird ein ARGB-Farbmodell mit einem Alpha- (Transparenz-) Kanal sowie
den Farbkanälen Rot, Grün und Blau verwendet. Für alle 4 Kanäle stehen Ausprägungen von 0 bis
Abschnitt 15.5 Farben und Zeichenwerkzeuge
435
255 zur Verfügung, wobei der Alpha-Wert 0 für Transparenz und der Alpha-Wert 255 für komplette Deckung steht.
Zum Verwalten von Farben dient die Struktur Color aus dem Namensraum System.Drawing. Sie
enthält 140 statische Eigenschaften zur Spezifikation von Standardfarben, z.B.
Color.Red
Die Liste der Farbnamen ist übrigens relativ kompatibel mit den von Webbrowsern beim Rendern
von HTML-Seiten unterstützten Bezeichnungen.
Eigene Farben mixt man mit der statischen Color-Methode FromArgb() zusammen. In der folgenden OnPaint()-Überschreibung wird über eine Ellipse in sattem Blau eine zweite in transparentem
Rot (Deckungsgrad 150) gemalt:
protected override void OnPaint(PaintEventArgs e) {
e.Graphics.DrawEllipse(penBlue, 50, 10, 100, 75);
e.Graphics.DrawEllipse(penRed, 50, 50, 100, 75);
if (puc) {
Pen penUs = new Pen(uc, 5);
e.Graphics.DrawEllipse(penUs, 50, 90, 100, 75);
penUs.Dispose();
}
}
Das Ergebnis:
Die Farbe einer dritten Ellipse kann der Benutzer nach einem Klick auf den Schalter Farbwahl
festlegen:
Es bedarf keiner großen Anstrengung, diesen Standarddialog zur Farbauswahl zu präsentieren und
sein Ergebnis auszuwerten, wie die Behandlungsmethode zum Click-Ereignis des Befehlsschalters
zeigt:
Kapitel 15: Grafikausgabe mit dem GDI+
436
protected void FarbwahlOnClick(object sender, EventArgs e) {
if (cd.ShowDialog() == DialogResult.OK) {
uc = cd.Color;
puc = true;
Refresh();
}
}
Es wird ein Objekt der Klasse ColorDialog aus dem Namensraum System.Windows.Forms benutzt, das nach dem Einsatz seiner ShowDialog()-Methode per Rückgabewert über die vom Benutzer verwendete Terminierungsmethode (OK oder Cancel) sowie ggf. per Color-Eigenschaft über
die gewählte Farbe zu berichten weiß. Weitere Informationen zur Klasse ColorDialog folgen später
im Kontext mit den übrigen Windows-Standarddialogen (zur Datei- und Schriftauswahl).
Die bisher im aktuellen Abschnitt vorgestellten Programmsegmente stammen aus dem Projekt:
...\BspUeb\GDI+\Color
Durch Verwendung der Windows-Systemfarben, die über statische Eigenschaften der Klasse
System.Drawing.SystemColors ansprechbar sind, kann man das vom Benutzer bevorzugte Windows-Design berücksichtigen. In der folgenden OnPaint()-Methode
Font ab20 = new Font("Arial", 20, FontStyle.Bold);
SolidBrush sbText = new SolidBrush(SystemColors.WindowText);
. . .
protected override void OnPaint(PaintEventArgs e) {
e.Graphics.DrawString("SystemColors.WindowText", ab20, sbText, 3, 40);
ab20.Dispose(); sbText.Dispose();
}
wird zum Schreiben die vom Benutzer gewählte Systemfarbe für Fenstertext verwendet:
Wie gleich im Abschnitt 15.5.3 zu erfahren ist, muss man ein SolidBrush-Objekt zum Malen (von
Text etc.) in der Systemfarbe WindowText nicht per new erzeugen, sondern kann auf ein vordefiniertes Exemplar zugreifen:
e.Graphics.DrawString("SystemColors.WindowText", ab20,
SystemBrushes.WindowText, 3, 40);
15.5.2 Pen
Beim Zeichnen von Linien (z.B. mit der Graphics-Methode DrawLine()) ist ein Objekt der Klasse
Pen (dt.: Stift) zu verwenden. Beim Herstellen dieses Werkzeugs kann man über diverse Linienattribute entscheiden. Im folgenden Pen-Konstruktor werden Farbe und Linienstärke festgelegt:
Pen stift = new Pen(Color.Firebrick, 10);
Die zahlreichen Eigenschaften der Pen-Klasse bieten weitere Gestaltungsmöglichkeiten, z.B.:

DashStyle (Linienstil), StartCap (Stil für den Linienanfang), EndCap (Stil für das Linienende) und DashCap (Stil für die Segmentgrenzen in einer gestichelten Linie)
Mit diesem Stift
Pen stift = new Pen(Color.Firebrick, 10);
zeichnet die folgende OnPaint()-Überschreibung
Abschnitt 15.5 Farben und Zeichenwerkzeuge
437
protected override void OnPaint(PaintEventArgs e) {
stift.DashStyle = DashStyle.Dash;
stift.EndCap = LineCap.ArrowAnchor;
e.Graphics.DrawLine(stift, 50, 50, 200, 100);
stift.DashCap = DashCap.Round;
stift.StartCap = LineCap.Round;
e.Graphics.DrawLine(stift, 50, 100, 200, 150);
}
einen gestrichelten Pfeil zunächst mit flachen und dann mit runden Segmentbegrenzungen:
Die zur Wahl der Stile benutzten Enumerationen LineCap, DashStyle und DashCap befinden sich im Namensraum System.Drawing.Drawing2D. Wenn keinen passenden Linienstil
findet, kann per DashPattern-Eigenschaft einen eigenen definieren und über den Wert
DashStyle.Custom der Eigenschaft DashStyle aktivieren.

Brush
Speziell bei breiteren Linien kann man zum Füllen der Fläch(en) statt einer Farbe auch ein
Brush-Objekt angeben (siehe Abschnitt 15.5.3) und so z.B. einen Farbverlauf erreichen.

Alignment
Bei geschlossenen Kurven oder Vielecken legt diese Eigenschaft das Verhalten eines PenObjekts mit Breite größer Eins an der Umrisslinie fest. Per Voreinstellung (Wert PenAlignment.Center) läuft die Mitte des Stifts auf der Umrisslinie entlang, so dass auf beiden Seiten ein gleich breiter Streifen eingefärbt wird. So entstand das linke Quadrat im folgenden
Fenster:
Es wurde unter Verwendung des Stifts
Pen dick = new Pen(Color.Yellow, 20);
von dieser OnPaint()-Überschreibung gezeichnet:
Kapitel 15: Grafikausgabe mit dem GDI+
438
protected override void OnPaint(PaintEventArgs e) {
Graphics g = e.Graphics;
g.DrawRectangle(dick, 50, 50, 100, 100);
g.DrawRectangle(Pens.Black, 50, 50, 100, 100);
dick.Alignment = PenAlignment.Inset;
g.DrawRectangle(dick, 200, 50, 100, 100);
g.DrawRectangle(Pens.Black, 200, 50, 100, 100);
g.DrawLine(dick, 200, 180, 300, 180);
g.DrawLine(Pens.Black, 200, 180, 300, 180);
dick.Alignment = PenAlignment.Center;
}
Erhält der Stift die alternative Alignment-Einstellung PenAlignment.Inset, dann landet
die gesamte Linienbreite im Innenraum (siehe rechtes Quadrat). In der Ausgabe des Beispielprogramms sind außerdem die mit einem schwarzen Stift der Stärke Eins gezeichneten
Umrisslinien der Quadrate zu sehen. Unter dem rechten Quadrat demonstriert eine breite
Linie, dass sich der Inset-Modus nur auf geschlossene Figuren auswirkt.
Eine vollständige Liste mit den Eigenschaften und sonstigen Membern der Pen-Klasse findet sich
in der FCL – Dokumentation.
Die obigen OnPaint()-Überschreibungen haben der Deutlichkeit halber die benötigten Pen-Objekte
vor Ort erzeugt und die GDI-Ressourcen vor dem Verlassen der Methode per Dispose()-Aufruf
wieder frei gegeben. In einem realen Programm sollte man es aber unbedingt vermeiden, den Erzeugungs- und Entsorgungsaufwand bei jedem Paint-Ereignis zu betreiben. Stattdessen sollte man
die wiederholt benötigten Werkzeuge im Fensterkonstruktor erstellen und dann bis zum Programmende verwenden. Dieser Hinweis zur Effizienz stammt wie der folgende aus Louis & Strasser
(2008, S. 830).
Weil die meisten Eigenschaften der Klasse Pen verändert werden können, ist es z.B. bei einem
Wechsel der Farbe (Eigenschaft Color) oder Linienstärke (Eigenschaft Width) nicht erforderlich,
einen neuen Stift zu erzeugen.
Die Klasse Pens bietet über statische Eigenschaften für jede Standardfarbe einen zugehörigen Stift
mit der Breite Eins, z.B.:
e.Graphics.DrawRectangle(Pens.Black, 200, 50, 100, 100);
Die SystemPens bietet über statische Eigenschaften für jede Windows-Systemfarbe einen zugehörigen Stift mit der Breite Eins, z.B.:
e.Graphics.DrawLine(SystemPens.ControlDark, 50, 50, 200, 100);
15.5.3 Brush
Um das Innere von Rechtecken, Ellipsen, Buchstaben etc. zu füllen, verwendet man ein BrushObjekt (dt.: einen Pinsel). Zur abstrakten Basisklasse Brush existieren diverse Konkretisierungen
für spezielle optische Effekte, z.B.:

SolidBrush (Namensraum System.Drawing)
Diese Brush-Ableitung ist für einfarbige Füllungen zuständig, z.B.:
Quellcodefragment
Ausgabe
g.DrawLine(new Pen(Color.Blue, 10),
20, 75, 260, 75);
g.FillEllipse(new SolidBrush(
Color.FromArgb(50, 0, 255, 0)), ra);
Abschnitt 15.5 Farben und Zeichenwerkzeuge

439
HatchBrush (Namensraum System.Drawing.Drawing2D)
Diese Brush-Ableitung ist zuständig für schraffierte Füllungen, die durch ein vektoriell beschriebenes Muster, eine Vordergrundfarbe und eine Hintergrundfarbe definiert sind, z.B.:
Quellcodefragment
Ausgabe
g.FillEllipse(new HatchBrush(
HatchStyle.DiagonalCross,
Color.Blue,
Color.Yellow), ra);
Über die HatchStyle-Eigenschaft lassen sich diverse Werte aus der gleichnamigen Enumeration als Muster einstellen (siehe Übungsaufgabe in Abschnitt Fehler! Verweisquelle
konnte nicht gefunden werden.).

TextureBrush (Namensraum System.Drawing)
Diese Brush-Ableitung füllt das Innere einer Figur mit einer Bitmap-Grafik, wobei das Bild
nötigenfalls bis zur Füllung der Fläche wiederholt ausgegeben wird (auf mehreren Kacheln).
Für das erste Beispiel:
Quellcodefragment
Ausgabe
g.FillEllipse(new TextureBrush(
new Bitmap("textur.bmp")), ra);
wurde mit dem Windows-Zubehör Paint eine Bitmap-Grafik mit 10  10 Punkten erstellt:
Im folgenden Beispiel ist die Bitmap-Grafik so groß, dass es nicht zu einer Mehrfachdarstellung kommt:
Quellcodefragment
Ausgabe
g.FillEllipse(new TextureBrush(
new Bitmap("land.bmp")), ra);
Eine verwendete Bitmap-Datei wird beim empfehlenswerten Verzicht auf eine Pfadangabe
im Verzeichnis mit dem Assembly erwartet (z.B. in …\Brush\bin\Debug). Später werden
Sie eine Möglichkeit kennen lernen, Bitmap-Dateien als so genannte Ressourcen in das Assembly aufzunehmen.
Mit Texturen kann man nicht nur Figuren füllen, sondern auch Schriftzüge, z.B.:
Quellcodefragment
Ausgabe
BackColor = Color.Red;
g.DrawString("Schreiben mit Textur",
new Font("Arial", 20, FontStyle.Bold),
new TextureBrush(new Bitmap("water.bmp")),
3, 40);
Kapitel 15: Grafikausgabe mit dem GDI+
440

LinearGradientBrush (Namensraum System.Drawing.Drawing2D)
Diese Brush-Ableitung erzeugte Füllungen mit Farbverlauf, z.B.:
Quellcodefragment
Ausgabe
g.FillEllipse(new LinearGradientBrush(
new Point(0, 0),
new Point(Width, Height),
Color.White,
Color.Red), ra);
Die Klasse Brushes (Namensraum System.Drawing) bietet über statische Eigenschaften für jede
Standardfarbe einen zugehörigen Pinsel, z.B.:
Quellcodefragment
Ausgabe
g.FillEllipse(Brushes.Blue, ra);
Die Klasse SystemBrushes (Namensraum System.Drawing) bietet über statische Eigenschaften für
jede Windows-Systemfarbe einen zugehörigen Pinsel, z.B. den Pinsel in der Desktop-Farbe:
Quellcodefragment
Ausgabe
g.FillEllipse(SystemBrushes.Desktop, ra);
15.6 Linien, Kurven und Flächen
15.6.1 Einfache Linien und Figuren
Graphics-Objekte beherrschen zahlreiche Methoden zum Zeichnen von einfachen Linien und Figuren:

DrawLine() und DrawLines())
Zum Zeichnen einer Linie zwischen Start- und Endpunkt mit einer der DrawLine()Überladungen, die allesamt ein Pen-Objekt als ersten Parameter erwarten, ist wenig zu berichten. Interessanter ist schon die Methode DrawLines() zum Zeichnen einer Serie von
verbundenen Liniensegmenten. Im Vergleich zu mehreren DrawLine()-Aufrufen spart man
Aufwand, weil die Verbindungspunkte nur einfach angegeben werden müssen. Außerdem
sorgt das Grafiksystem für „ansatzfreie“ Übergänge, z.B.:
Quellcodefragment
Ausgabe
Pen stift = new Pen(Color.Black, 10);
Point[] pts = {new Point(10, 10),
new Point(10, 100),
new Point(100, 100),
new Point(110, 50)};
g.DrawLines(stift, pts);
Über die Pen-Eigenschaft LineJoin lassen sich u.a. abgerundete Linienübergänge erzielen:
441
Abschnitt 15.6 Linien, Kurven und Flächen
Quellcodefragment
Ausgabe
Pen stift = new Pen(Color.Black, 10);
Point[] pts = {new Point(10, 10),
new Point(10, 100),
new Point(100, 100),
new Point(110, 50)};
stift.LineJoin = LineJoin.Round;
g.DrawLines(stift, pts);

DrawRectangle()
Weil die Ausgabe eines Rechtecks grundsätzlich unspektakulär ist, konzentrieren wir uns in
diesem Zusammenhang auf winzige Details, die aber doch stören können. Das folgende Codefragment zeichnet zwei Quadrate an der Position (5, 5) mit der angeforderten Kantenlänge
5, wobei verschiedene Linienstärken verwendet werden:
Quellcodefragment
Ausgabe
Pen stift = new Pen(Color.Black, 3);
g.DrawRectangle(stift, 5, 5, 5, 5);
g.DrawRectangle(Pens.Red, 5, 5, 5, 5);
Der stark vergrößerte Ausschnitt (mit nachträglich in Paint eingezeichneten rot-weißen Linealen) zeigt:
o Bei einer Linienstärke von einem Pixel bildet wie erwartet der Pixel (5,5) die linke
obere Ecke (siehe rotes Quadrat).
o Die Breite des roten Quadrates beträgt aber 6 Pixel! Petzold (2002, S. 176) spricht
hier vom 1-Pixel-Fehler.
o Die 3 Pixel breite schwarze Umrisslinie wird mittig gezeichnet (vgl. Abschnitt
15.5.2), so dass beim schwarzen Quadrat eine Gesamtbreite von 8 Pixeln resultiert.

DrawEllipse()
Im Zusammenhang mit dem Zeichen einer Ellipse per DrawEllipse() soll die Kantenglättung (das so genannte Antialiasing 1) demonstriert werden. Wie ein Vergleich der folgenden
Kreise zeigt, kann sich das Einschalten der Kantenglättung über die Graphics-Eigenschaft
SmoothingMode durchaus lohnen:
Der rechte Kreis entstand über folgende Syntax:
1
Vermutlich stammt der Begriff Antialiasing aus der Signalverarbeitung, wo eine Signalverfälschung durch eine zu
geringe Abtastfrequenz als Alias-Effekt bezeichnet wird. Analog kann z.B. eine Treppe nur als Ersatz (Alias) für eine schräge Gerade gelten. Versuche, die Abweichung vom Original zu mildern, bezeichnet man als Antialiasing.
Kapitel 15: Grafikausgabe mit dem GDI+
442
g.SmoothingMode = SmoothingMode.HighQuality;
Pen stift = new Pen(Color.Black, 10);
g.DrawEllipse(stift, 15, 15, 50, 50);
Gehört ein Pixel nur teilweise zum Kreis, erhält er einen passend abgeschwächten Farbton.
Ganz ohne Geschwindigkeitseinbuße sind derartige Verschönerungen natürlich nicht zu haben.

DrawArc()
Im folgenden Beispiel wird einem Quadrat mit 200 Pixeln Kantenlänge ein Bogen von 0°
bis 270° (jeweils ab X-Achse im Uhrzeigersinn gemessen) eingezeichnet:
Quellcodefragment
Ausgabe
Pen stift = new Pen(Color.Black, 5);
stift.EndCap = LineCap.ArrowAnchor;
g.DrawArc(stift, 10, 10, 200, 200, 0, 270);

DrawPie()
Im folgenden Beispiel wird einem Quadrat mit 200 Pixeln Kantenlänge ein Kreissegment
von 0° bis 270° (jeweils ab X-Achse im Uhrzeigersinn gemessen) eingezeichnet:
Quellcodefragment
Ausgabe
Pen stift = new Pen(Color.Black, 10);
stift.LineJoin = LineJoin.Round;
g.DrawPie(stift, 10, 10, 200, 200, 0, 270);
Mit der Pen-Eigenschaft LineJoin wird für glatte Übergänge zwischen dem Kreisbogen und
den beiden Radien gesorgt.

DrawPolygon()
Im Unterschied zur verwandten Methode DrawLines() liefert DrawPolygon() eine geschlossene Figur, z.B.:
Quellcodefragment
Ausgabe
Pen stift = new Pen(Color.Black, 10);
Point[] pts = {new Point(10, 10),
new Point(10, 100),
new Point(100, 100),
new Point(110, 50)};
g.DrawPolygon(stift, pts);
Abschnitt 15.6 Linien, Kurven und Flächen
443
15.6.2 Splines
Bei den mit DrawCurve() erzeugten kardinalen (kanonischen, traditionellen) Splines wird durch
vorgegebene Punkte eine geglättete Linie gelegt, z.B.:
Pen stift = new Pen(Color.Black, 10);
Point[] pts = {new Point(50, 70),
new Point(100, 100),
new Point(100, 200),
new Point(250, 50),
new Point(300, 100)};
g.DrawCurve(stift, pts);
Im Quellcodesegment fehlen die Anweisungen zum Markieren der Stützstellen:
Mit DrawClosedCurve() erhält man einen geschlossenen kardinalen Spline, z.B.:
Bei den mit DrawBezier() zu erzeugenden Bézier-Splines sind genau vier Punkte im Spiel. Ausgehend vom Punkt P0 wird die Kurve zunächst vom Kontrollpunkt P1, dann vom Kontrollpunkt P2
angezogen, um schließlich im Punkt P3 zu enden, z.B.:
Pen stift = new Pen(Color.Black, 10);
Point[] pts = {new Point(150, 250),
new Point(100, 50),
new Point(300, 50),
new Point(250, 250)};
g.DrawBezier(stift, pts[0], pts[1], pts[2], pts[3]);
Im Quellcodesegment fehlen die Anweisungen zum Zeichen der Punkte und Hilfslinien:
Kapitel 15: Grafikausgabe mit dem GDI+
444
Wenn bei einer mit DrawBeziers() gezeichneten Sequenz von mehreren Bézier-Splines die Übergänge glatt verlaufen sollen, dann muss gelten:


Jeder Spline endet dort, wo sein Nachfolger beginnt.
Folgende Punkte liegen jeweils auf einer Geraden:
o zweiter Kontrollpunkt der ersten Kurve
o Grenzpunkt
o erster Kontrollpunkt der zweiten Kurve
Im Quellcodesegment
Pen stift = new Pen(Color.Black, 10);
Point p0 = new Point(150, 250); Point p1 = new Point(100, 50);
Point p2 = new Point(300, 50); Point p3 = new Point(250, 250);
Point q1 = new Point(205, 400);
Point q2 = new Point(400, 400); Point q3 = new Point(350, 250);
Point[] pts = {p0, p1, p2, p3, q1, q2, q3};
g.DrawBeziers(stift, pts);
zum folgenden DrawBeziers()-Ergebnis:
fehlen die Anweisungen zum Zeichen der Punkte und Hilfslinien.
445
Abschnitt 15.6 Linien, Kurven und Flächen
15.6.3 Flächen
Zum Zeichnen von Flächen (gefüllten Formen) bietet das Grafiksystem folgende Methoden, wobei
ein Pinsel (Brush-Objekt, siehe Abschnitt 15.5.3) für Gestaltungsmöglichkeiten sorgt:

FillRectangle()
Mit dieser Methode erstellt man gefüllte Rechtecke, wobei es auch möglich ist, exakt ein Pixel einzufärben (vgl. Abschnitt 15.6.1 zum 1-Pixel-Fehler bei DrawRectangle()), z.B.
g.FillRectangle(Brushes.Red, 5, 5, 1, 1);

FillEllipse()
Mit dieser Methode erstellt man gefüllte Ellipsen, z.B.:
g.FillEllipse(Brushes.Blue, 10, 10, 30, 25);
Wir haben sie schon im ersten Beispiel von Abschnitt 15 dazu verwendet, um gefüllte Kreise zu malen.

FillPie()
Im folgenden Beispiel wird einem Quadrat mit 200 Pixeln Kantenlänge ein gefülltes Kreissegment von 0° bis 270° (jeweils ab X-Achse im Uhrzeigersinn gemessen) eingezeichnet:
Quellcodefragment
Ausgabe
Pen pen = new Pen(Color.Black, 5);
g.FillPie(Brushes.Aquamarine,
10, 10, 200, 200, 0, 270);
g.DrawPie(pen, 10, 10, 200, 200, 0, 270);

FillPolygon()
Mit dieser Methode erstellt man gefüllte Polygone, z.B.:
Quellcodefragment
Ausgabe
Point[] pts = {new Point(10, 10),
new Point(10, 100),
new Point(100, 100),
new Point(110, 50)};
g.FillPolygon(Brushes.MediumSeaGreen, pts);
Beim Füllen von Polygonen und geschlossenen Standard - Splines (mit FillClosedCurve()) wird es
interessant bei sich kreuzenden Linien (bzw. Kurven), weil man dabei unterschiedliche Begriffe
vom Inneren der Figur haben kann. Im folgenden Beispiel (gefunden bei Petzold 2002, S. 178) wird
ein fünfzackiger Stern (mit insgesamt zehn Ecken) geschickt über ein Polygon mit lediglich fünf
Punkten gezeichnet:
using
using
using
using
System;
System.Windows.Forms;
System.Drawing;
System.Drawing.Drawing2D;
class Stern : Form {
Point[] pts = new Point[5];
Kapitel 15: Grafikausgabe mit dem GDI+
446
Stern() {
Text = "Stern";
ClientSize = new Size(350, 350);
// Stern-Eckpunkte berechnen
int shift = 180, len = 150;
double winkel = 3 * Math.PI / 2;
for (int i = 0; i < pts.Length; i++) {
double kosinus = Math.Cos(winkel);
double sinus = Math.Sin(winkel);
int x = (int) (shift + kosinus * len);
int y = (int) (shift + sinus * len);
pts[i] = new Point(x, y);
winkel += 4 * Math.PI / 5;
}
}
protected override void OnPaint(PaintEventArgs e) {
for (int i = 0; i < pts.Length; i++) {
e.Graphics.DrawString(i.ToString(), Font, SystemBrushes.WindowText,
pts[i].X - 5, pts[i].Y - 5);
}
e.Graphics.FillPolygon(Brushes.MediumSeaGreen, pts, FillMode.Alternate);
}
[STAThread]
static void Main() {
Application.Run(new Stern());
}
}
Zur Berechnung der Koordinaten ist ein wenig Trigonometrie erforderlich:


Die Koordinaten Eckpunkte des Sterns werden über die Kosinus- bzw. Sinus-Funktion des
aktuellen Winkels ermittelt, wobei zusätzlich eine Streckung und schließlich noch eine Verschiebung erfolgt.
Weil das GDI+ Winkel im Uhrzeigersinn ab der positiven X-Achse interpretiert, wird der
3
Winkel initial auf  (= 270°) gesetzt, damit der Stern „aufrecht“ steht.
2

Beim fünfmaligen Durchlaufen der for-Schleife mit einer Winkelaktualisierung um
4

5
werden die Eckpunkte auf den Kreisbogen gesetzt.
Beim voreingestellten Füllmodus Alternate (abwechselnd) bleibt ein fünfeckiger Bereich ungefärbt:
Abschnitt 15.7 Text malen
447
Zur Klärung der Konstruktion wurden hier zusätzlich die Nummern der Eckpunkte per DrawString() (siehe unten) ausgegeben.
Der Füllmodus Winding färbt in der Regel alle umgrenzten Flächen:
15.7 Text malen
Zur Ausgabe einer individuell gestalteten Beschriftung erzeugt man zunächst ein Objekt der Klasse
System.Drawing.Font, z.B.:
Font font = new Font("Arial", 12, FontStyle.Bold);
Position und Ausdehnung einer Beschriftung lassen sich z.B. über eine Instanz der Struktur System.Drawing.Rectangle-Instanz festlegen:
Rectangle rect = new Rectangle(50, 50, 150, 70);
Der folgende DrawString()-Aufruf an ein Graphics-Objekt
g.DrawString(text, font, SystemBrushes.WindowText, rect);
liefert die Ausgabe:
Kapitel 15: Grafikausgabe mit dem GDI+
448
Als Werkzeug verwendet DrawString() keinen Stift, sondern einen Pinsel (ein Brush-Objekt).
Text wird im GDI+ also nicht per Stift geschrieben, sondern per Pinsel gemalt.
In den nächsten Abschnitten werden wir uns mit den beteiligten Klassen, Eigenschaften und Methoden näher beschäftigen. Der tiefere Sinn des Beispielsatzes liegt übrigens darin, dass er alle
Buchstaben des englischen Alphabets enthält und daher im englischen Sprachraum zum Testen von
Textein- und Ausgabeverfahren geeignet ist.
15.7.1 Schriftarten und -familien
Von den unter Windows verfügbaren Schrifttechnologien werden in WinForms-Anwendungen
„nur“ TrueType und OpenType unterstützt. Im Vergleich zu diesen modernen KonturschriftTechnologien sind die fehlenden Bitmap- und Vektortechniken sehr unattraktiv, so dass wohl kaum
jemand diese Altlasten aus den Windows-Gründerzeiten vermisst.
Seit Windows XP dominieren die 1997 von Adobe und Microsoft gemeinsam vorgestellten OpenType-Schriften. Es handelt sich im Wesentlichen um eine Kombination bzw. Integration älterer
Konturschrift-Technologien:


TrueType (gemeinsam von Apple und Microsoft für MacOS und Windows entwickelt)
Type 1 – Format der von Adobe entwickelten Seitenbeschreibungssprache Postscript
Zu einem Schriftdesign existieren in der Regel mehrere Schnitte (Regulär, Kursiv, Fett, Fett Kursiv). Jeder Schnitt stellt eine eigene Schriftart dar und erscheint folglich im Fenster des zuständigen
Applets der Windows-Systemsteuerung als eigener Eintrag, z.B.:
Die folgenden Schriftarten (und noch viele andere) sind bei praktisch jeder Windows-Installation
vorhanden:






Times New Roman, Times New Roman Fett, Times New Roman Kursiv, Times New Roman Fett Kursiv
Arial, Arial Fett, Arial Kursiv, Arial Fett Kursiv
Courier New, Courier New Fett, Courier New Kursiv, Courier New Fett Kursiv
Symbol (z.B. 
WebDings (z.B. , , )
WingDings (z.B. , , )
Wie Sie bereits aus mehrfacher Erfahrung wissen, ist im .NET – Framework ein Objekt der Klasse
Font gekennzeichnet durch:
Abschnitt 15.7 Text malen

449
ein Schriftdesign
In den verschiedenen Überladungen des Font-Konstruktors kann das Schriftdesign per
String-Objekt oder über ein FontFamily-Objekt (siehe unten) angegeben werden. Die Konstruktion:
Font font = new Font("Arial", 12, FontStyle.Bold)
ist letztlich eine Abkürzung für:
FontFamily ffar = new FontFamily("Arial");
Font font = new Font(ffar, 12, FontStyle.Bold);


eine Schriftgröße
Die so genannte Geviertgröße einer Schriftart wird in der Einheit Punkt angegeben:
1
1 Punkt 
Zoll, 1 Zoll  2,54 cm
72
einen Schriftschnitt bzw. -stil
Den Schriftschnitt bzw. stil wählt man über die Werte der Enumeration FontStyle:
Fett
Bold
Kursiv
Italic
Normal
Regular
Strikeout Durchgestrichen
Underline Unterstrichen
Die einzelnen Werte sind über den bitweisen Oder-Operator kombinierbar, z.B.:
FontStyle style = FontStyle.Italic | FontStyle.Bold;
Ein Font-Objekt kann nicht geändert werden, und seine Eigenschaften (z.B. FontStyle) bieten
dementsprechend nur einen lesenden Zugriff. Wird zu einem vorhandenen Font-Objekt ein alternativer Schnitt benötigt, so muss ein neues Font-Objekt erzeugt werden, wobei die KonstruktorÜberladung mit Font-Parameter Schreibarbeit spart, z.B.:
Font font = new Font("Arial", 12, FontStyle.Bold);
. . .
font = new Font(font, FontStyle.Italic);
Um die offensichtliche Zusammengehörigkeit der Schriftarten mit identischem Design abzubilden,
kennt das .NET-Framework die Klasse FontFamily. Zur Objekt-Konstruktion kann man u.a. einen
Familiennamen angeben, z.B.:
FontFamily ffar = new FontFamily("Arial");
Bei einem FontFamily-Objekt kann man per IsStyleAvailable()–Methode anfragen, ob ein bestimmter Schriftschnitt vorhanden ist, so dass sich Ausnahmefehler durch fehlende Schnitte vermeiden lassen.
Über die statische FontFamily-Eigenschaft Families lässt sich ein Array mit allen FontFamilyObjekten ansprechen, die dem aktuellen Graphics-Objekt zugeordnet sind, z.B.:
FontFamily[] ff = FontFamily.Families;
Im folgenden Programm wird aus jeder Familie im FontFamily-Array ff ein Font-Objekt mit
Größe 16 und Schriftstil (FontStyle.Italic | FontStyle.Bold) (falls vorhanden) in den Font-Array
fonts übernommen (siehe Formularkonstruktor):
using System.Windows.Forms;
using System.Drawing;
using System;
class FontFamilyDemo : Form {
Font[] fonts;
int nFonts;
Kapitel 15: Grafikausgabe mit dem GDI+
450
FontFamilyDemo() {
Text = "FontFamily-Demo";
BackColor = SystemColors.Window;
ClientSize = new Size(400, 300);
AutoScroll = true;
FontFamily[] ff = FontFamily.Families;
fonts = new Font[ff.Length];
FontStyle style = FontStyle.Italic | FontStyle.Bold;
int size = 16;
int vs = 0, i = 0;
foreach (FontFamily aff in ff)
if (aff.IsStyleAvailable(style)) {
fonts[i] = new Font(aff, size, style);
vs += fonts[i].Height + 5;
i++;
}
nFonts = i;
Panel pan = new Panel();
pan.Height = vs;
pan.Dock = DockStyle.Top;
pan.Paint += new PaintEventHandler(PanelOnPaint);
pan.Parent = this;
}
void PanelOnPaint(object sender, PaintEventArgs e) {
int ok = 0;
for (int i = 0; i < nFonts; i++) {
e.Graphics.DrawString(fonts[i].Name, fonts[i],
SystemBrushes.WindowText, 0, ok);
ok += fonts[i].Height + 5;
}
}
[STAThread]
static void Main() {
Application.Run(new FontFamilyDemo());
}
}
Zweck des Programms ist die Anzeige einer rollbaren Liste mit allen in einem bestimmten Stil verfügbaren Schriftarten:
Zu jeder Schriftart soll in einer eigenen Zeile ihr Name (verfügbar in der Font-Eigenschaft Name)
per DrawString()-Aufruf mit dem passenden Font-Objekt geschrieben werden:
e.Graphics.DrawString(fonts[i].Name,fonts[i],SystemBrushes.WindowText,0,ok);
Weil mit einer längeren Liste zu rechnen ist, benötigt das Programm eine vertikale Bildlaufleiste,
die auf folgende Weise mit geringem Aufwand realisiert wird:
451
Abschnitt 15.7 Text malen

Auf dem Formular wird ein Steuerelement der Klasse Panel untergebracht, das als Zeichenfläche für die DrawString()-Aufrufe dient. Sein Paint-Ereignis wird in der Methode
PanelOnPaint() behandelt (siehe oben).

Besitzt die AutoScroll-Eigenschaft eines ScrollableControl-Objekts (z.B. eines Formulars)
den Wert true, dann ergänzt das Laufzeitsystem automatisch Bildlaufleisten, wenn die untergeordneten Steuerelemente nicht vollständig in den Klientenbereich passen.
Um die vertikale Ausdehnung der Liste mit allen Schriftzügen zu berechnen, werden die
Height-Eigenschaftswerte der beteiligten Font-Objekte plus jeweils 5 Pixel Abstand aufaddiert (siehe Formularkonstruktor).

Sind zahlreiche Schriftarten vorhanden, zeigt die Rollbalkenlösung ein zähes Verhalten. Bei der
systematischen Beschäftigung mit dem Thema Rollbalken werden wir eine deutlich performantere
Lösung entwickeln (siehe Abschnitt 16.4).
Font-Objekte belegen GDI-Ressourcen (vgl. Abschnitt 15.2), so dass für selbst erzeugte und später
obsolet gewordene Exemplare die Dispose()-Methode aufgerufen werden sollte.
Über schreibgeschützte statische Eigenschaften der Klasse System.Drawing.SystemFonts sind
vordefinierte Schriften im aktuellen Windows-Design verfügbar, z.B.:
Eigenschaft
CaptionFont
DefaultFont
MenuFont
StatusFont
Verwendung
Text auf den Titelleisten von Fenstern
Standardschriftart für Dialogfelder und Formulare
Menüs
Text auf der Statusleiste
Bei ihrer Verwendung braucht man sich um GDI-Ressourcen und um Konsistenz mit anderen Teilen der Benutzeroberfläche keine Gedanken zu machen.
15.7.2 Standarddialog zur Schriftauswahl
Analog zum Einsatz des Farbauswahl-Standarddialogs (siehe Abschnitt 15.5.1) kann man dem Benutzer über ein Objekt der Klasse FontDialog (aus dem Namensraum System.Windows.Forms)
Gelegenheit bieten, auf vertraute Weise seine Lieblingsschrift zu wählen:
Es bedarf keiner großen Anstrengung, diesen Standarddialog zur Schriftauswahl zu präsentieren
und sein Ergebnis auszuwerten, wie die Behandlungsmethode zum Click-Ereignis des Befehlsschalters im Beispielprogramm
Kapitel 15: Grafikausgabe mit dem GDI+
452
zeigt:
protected void ButtonOnClick(object sender, EventArgs e) {
if (fd.ShowDialog() == DialogResult.OK) {
Font oldFont = lbProbe.Font;
lbProbe.Font = fd.Font;
lbProbe.Text = fd.Font.Name;
oldFont.Dispose();
}
}
Nach dem Einsatz seiner ShowDialog()-Methode informiert das FontDialog-Objekt per Rückgabewert über die vom Benutzer verwendete Terminierungsmethode (OK oder Cancel) sowie ggf.
per Font-Eigenschaft über die gewählte Schriftart. Zur Schonung der GDI-Ressourcen (vgl. Abschnitzt 15.2) wird für das obsolete Font-Objekt die Dispose()-Methode aufgerufen.
Wenn man das gelieferte Font-Objekt nach seiner (in Punkten zu 1/72 Zoll) gemessenen Größe
befragt,
MessageBox.Show(fd.Font.SizeInPoints.ToString());
stellt man aber Abweichungen zum Schriftgrad fest, den der Benutzer im Standarddialog zu einer
beliebigen Schriftart (Open Type oder True Type) gewählt hat, z.B. 1
Vom Benutzer gewählter
Schriftgrad
10
12
14
SizeInPoints
des gelieferten Font-Objekts
9,75
12
14,25
Diese Ergebnisse entstanden bei einer angenommenen Bildschirmauflösung von 96 DPI unter Windows XP (SP 3) oder Windows Vista Business (SP 2) mit installiertem .NET - Framework 3.5. Bei
höherer Auflösung fallen die Abweichungen etwas kleiner aus. Offenbar wird der vom Benutzer
gewünschte Schriftgrad (Abk.: t) nach folgender Formel
x
t
96
72
von der Maßeinheit Punkt unter Berücksichtigung der Bildschirmauflösung in Pixel (Abk.: x) umgerechnet, z.B.:
13, 3 
10
96
72
Jetzt findet bedauerlicherweise eine Rundung statt:
13, 3  13
Das fehlerhafte Pixel-Zwischenergebnis wird vor dem Erstellen des Font-Objekts nach der Formel
1
Auf dieses Problem hat dankenswerterweise Herr Lei Yu, ein besonders aufmerksamer und engagierter Student,
hingewiesen. Zur Aufklärung der Hintergründe hat die befragte MSDN-Hotline wertvolle Beiträge geleistet.
453
Abschnitt 15.7 Text malen
t
x
72
96
wieder in einen Punktwert umgerechnet, z.B.
9,75 
13
72
96
Weil der vom Benutzer gewählte Schriftgrad bei der ersten Transformation durch 3 dividiert wird,
96 4

72 3
tritt kein Fehler auf, wenn der Schriftgrad ein Vielfaches von 3 ist. Ferner ergibt sich, dass der vom
FontDialog-Objekt gelieferte SizeInPoints-Wert nur einen Fehler von  0,25 enthalten kann, so
dass eine erneute Rundung den vom Benutzer gewünschten Schriftgrad liefert:
lbProbe.Font = new Font(fd.Font.FontFamily,
(float) Math.Round(fd.Font.SizeInPoints), fd.Font.Style);
Zunächst ist die FCL - Klasse FontDialog für das Problem verantwortlich. Diese verlässt sich aber
wohl auf das Betriebssystem (also auf das GDI+), und der Fehler dürfte auch bei Verwendung anderer Windows-APIs auftreten (z.B. bei MFC-Programmen in C++).
Unter Verwendung der später zu behandelnden Druckfunktionalität unseres Editors (siehe Abschnitt
21) lässt sich (dank Druckvorschau bequem und ohne Papierverschwendung) nachweisen, dass aus
dem diskutierten FontDialog-Problem Unterschiede bei der Druckausgabe resultieren. In der folgenden Tabelle ist für die Schriftgrade 10 und 14 angegeben, wie viele Zeilen (bei bestimmten, hier
irrelevanten Randbreiten) mit bzw. ohne Korrektur des FontDialog-Fehlers auf eine Seite passen:
Vom Benutzer gewählter Schriftgrad
10
14
ohne Korrektur
81
58
mit Korrektur
79
59
Aufgrund des Fehlers kann die Zahl der Zeilen pro Seite steigen oder fallen.
Speichert man den Text im RTF-Format in eine Datei werden die Font-Abweichungen automatisch
wieder durch Runden beseitigt. Im RTF-Format wird die Schriftgröße in Halbpunkten angegeben,
und bei beim Speichern resultiert z.B. aus 9,75 oder 10 Punkten jeweils die Größenangabe fs20, wie
ein Blick auf den Dateianfang bestätigt:
{\rtf1\ansi\deff0{\fonttbl{\f0\fnil\fcharset0 Lucida Console;}}
\viewkind4\uc1\pard\lang1031\f0\fs20 1\par
. . .
Weitere Informationen zur Klasse FontDialog folgen in Abschnitt 18.3 im Kontext mit den übrigen
Windows-Standarddialogen (zur Farb- und Dateiauswahl).
Den vollständigen Quellcode des Beispielprogramms finden Sie im Ordner
…\BspUeb\GDI+\Textausgabe\FontDialog
15.7.3 Zeichen setzen
Die DrawString()-Methode zur Ausgabe einer Zeichenfolge unter Verwendung einer bestimmten
Schriftart mit einem bestimmten Pinsel an einer bestimmten Stelle auf der Oberfläche eines Steuerelementes kennt gleich sechs Überladungen:
Kapitel 15: Grafikausgabe mit dem GDI+
454






public void DrawString(String, Font, Brush, PointF);
public void DrawString(String, Font, Brush, RectangleF);
public void DrawString(String, Font, Brush, PointF, StringFormat);
public void DrawString(String, Font, Brush, RectangleF, StringFormat);
public void DrawString(String, Font, Brush, float, float);
public void DrawString(String, Font, Brush, float, float, StringFormat);
Sie unterscheiden sich durch die Technik zur Spezifikation der Ausgabestelle (per PointF-Objekt,
RectangleF-Objekt oder über Koordinaten) sowie durch die An- bzw. Abwesenheit eines StringFormat-Objekts mit Formatierungsattributen.
Über die verfügbaren Schriftarten haben wir uns bereits ausreichend informiert. Als Pinsel wird
man in der Regel ein SolidBrush-Objekt verwenden (vgl. Abschnitt 15.5.3), wobei die Wahl der
Farbe eine kurze Überlegung wert ist. Sinnvoller als Schwarz kann die vom Benutzer zu beeinflussende Farbe SystemColors.WindowText sein. Dann verwendet man am besten gleich den Pinsel
SystemBrushes.WindowText (siehe Abschnitt 15.5.3) und eine passende Hintergrundfarbe, z.B.
SystemColors.Window.
Bei der Textpositionierung über ein PointF-Objekt oder ein Koordinatenpaar aus zwei float-Werten
resultiert eine einzige Ausgabezeile, wenn die Zeichenfolge keinen Zeilenumbruch enthält. Mit dem
Text
string text = "The quick brown fox jumps over the lazy dog.";
liefert der Aufruf
g.DrawString(text, font, SystemBrushes.WindowText, 10, 10);
folgendes Ergebnis:
Der Text
string text = "The quick brown fox\n jumps over the lazy dog.";
wird vom selben Aufruf so ausgegeben:
Definiert man den Ausgabeort per RectangleF-Objekt, dann bietet DrawString() sogar einen automatischen Zeilenumbruch. Der Text
string text = "»Ich habe ja gesagt, dass er Karlsson heißt und oben "
+ "auf dem Dach wohnt«, sagte Lillebror. »Was ist denn da "
+ "Komisches dran? Die Leute dürfen doch wohl wohnen, "
+ "wo sie wollen!«";
wird vom DrawString()-Aufruf
g.DrawString(text, font, SystemBrushes.WindowText, rect);
unter Verwendung des Rechtecks
Rectangle rect = new Rectangle(50, 50, 200, 150);
Abschnitt 15.7 Text malen
455
so umgebrochen:
Mit dem Rechteck
Rectangle rect = new Rectangle(50, 50, 350, 100);
erhält man:
Mit den Eigenschaften eines StringFormat-Objekt lässt sich das Verhalten eines DrawString()Aufrufs auf vielfältige Weise modifizieren. Das folgende Exemplar
StringFormat sf = new StringFormat();
soll für eine horizontal und vertikal zentrierte Textausgabe sorgen:
sf.Alignment = StringAlignment.Center;
sf.LineAlignment = StringAlignment.Center;
Von der StringFormat-Eigenschaft Alignment hängt die horizontale Textausrichtung innerhalb
einer Zeile ab. Die StringFormat-Eigenschaft LineAlignment entscheidet über die vertikale Ausrichtung des gesamten Textblocks innerhalb eines begrenzenden Rechtecks. Der Aufruf
g.DrawString(text, font, SystemBrushes.WindowText, rect, sf);
liefert mit dem letzten Textbeispiel folgende Ausgabe:
Kapitel 15: Grafikausgabe mit dem GDI+
456
Über die StringFormat-Eigenschaft Trimming legt man fest, wie ein Text gekürzt werden soll,
wenn er nicht vollständig in das Ausgaberechteck passt. Mit der folgenden Anweisung
sf.Trimming = StringTrimming.EllipsisWord;
sorgt man dafür, dass der Text gekürzt und hinter dem letzten vollständig passenden Wort ein Auslassungszeichen (…) eingefügt wird:
15.7.4 Maß halten
Bei der Textausgabe werden gelegentlich genaue Angaben zum horizontalen und vertikalen Platzbedarf einer bestimmten Zeichenfolge bei Verwendung einer bestimmten Schriftart benötigt.
15.7.4.1 Schrifthöhe und Zeilenabstand
In diesem Abschnitt geht es um den erforderlichen vertikalen Pixel-Abstand bei der Bildschirmausgabe von aufeinander folgender Textzeilen, wobei die Größe der beteiligten Schriftarten in der Einheit Punkt (= 1/72 Zoll) angegeben wird. Zwar kann DrawString() Text mit automatischem Zeilenumbruch und automatischer Zeilenabstandsberechnung in ein Rechteck schreiben, doch oft erzwingt z.B. eine tabellarische Layout-Vorgabe oder ein Schriftartenwechsel die Verwendung mehrerer DrawString()-Aufrufe mit zugehörigen Positionsberechnungen.
Bei den folgenden Überlegungen ist auch die Annahme des Betriebssystems zur Bildschirmauflösung (gemessen in Dots Per Inch, DPI) relevant, die vom Benutzer unter Windows XP nach
Systemsteuerung > Anzeige > Einstellungen > Erweitert
bzw. unter Windows Vista nach
Systemsteuerung > Anpassung > Schriftgrad anpassen (DPI)
beeinflusst werden kann:
457
Abschnitt 15.7 Text malen
Die aktive Ein- (bzw. Unter-) stellung lässt sich über die DpiY-Eigenschaft eines zur Bildschirmausgabe befähigten Graphics-Objektes ermitteln (siehe unten). In Abschnitt 15.8.1 über metrische
Maßeinheiten (an Stelle von Pixeln) werden wir uns mit der potentiellen Ungenauigkeit dieser Unterstellung beschäftigen.
Über die Instanzmethoden

GetEmHeight(FontStyle)

GetCellAscent(FontStyle)

GetCellDescent(FontStyle)

GetLineSpacing(FontStyle)
der Klasse FontFamily erhält man zum angegebenen Schnitt einer Schriftfamilie vier Höhenangaben in schriftgradunabhängigen Entwurfseinheiten, z.B. bei Times New Roman mit
FontStyle.Regular:
Grundlinie
Lücke
Grundlinie
Kappe
Äpfel
unter der Grundlinie
(CellDescent = 443)
über der Grundlinie
(CellAscent = 1825)
Zeilenabstand
(LineSpacing =
2355)
In der Abbildung ist die EmHeight des Schriftschnitts von 2048, die als Referenzgröße für die anderen Angaben fungiert, nicht darstellbar.
Aus dem Quotienten aus LineSpacing und EmHeight, dem Schriftgrad in Punkten sowie der vertikalen Auflösung des Ausgabegerätes lässt sich der empfohlene Zeilenabstand in Pixeln berechnen.
Beim Schriftgrad 10 (Zeichenhöhe 10/72 Zoll) und einem Bildschirm mit 96 Pixeln pro Zoll ergibt
sich als empfohlener Zeilenabstand:
2355 10
96  15,33203
2048 72
Über die Font-Methode GetHeight() wird bei den beschriebenen Voraussetzungen genau dieser
Zeilenabstand ermittelt, der als Empfehlung der Schriftdesigner gelten kann:
Die Font-Eigenschaft Height liefert eine korrespondierende, auf die nächst größere Ganzzahl gerundete und ausschließlich für den Bildschirm geeignete Zeilenabstandsempfehlung.
Kapitel 15: Grafikausgabe mit dem GDI+
458
Von der Graphics-Methode MeasureString() wird (als Height-Eigenschaft des SizeFRückgabewerts) eine Angabe zur maximalen vertikalen Ausdehnung einer Schrift beigesteuert, die
oft über dem empfohlenen Zeilenabstand liegt und nicht genutzt werden sollte (im Beispiel: 16,43).
Im Projektordner
… \BspUeb\GDI+\Textausgabe\FontMetrik
finden Sie ein Programm, das die eben diskutierten Größen per DrawString() ausgibt.
15.7.4.2 Textbreite
Das horizontale Positionieren einzelner Wörter in einer auszugebenden Textzeile erledigt meist die
Methode DrawString(), wobei für den gesamten Text dieselbe Schriftart verwendet wird, z.B.:
Sollen aber z.B. in einer Zeile verschiedene Schriftstile auftauchen, sind mehrere DrawString()Aufrufe und entsprechende Positionsberechnungen erforderlich.
Im folgenden Programm wird die Graphics-Methode MeasureString() dazu verwendet, um die
Breite einer Textausgabe und damit die sinnvolle Startposition der nächsten Textausgabe zu berechnen:
using System.Windows.Forms;
using System.Drawing;
using System;
class MeasureStringDemo : Form {
Brush brush = SystemBrushes.WindowText;
Font[] fonts;
string[] texte;
MeasureStringDemo() {
Text = "MeasureString-Demo";
BackColor = SystemColors.Window;
ClientSize = new Size(440, 50);
FontFamily ffar = new FontFamily("Arial");
Font fontRegular = new Font(ffar, 16, FontStyle.Regular);
Font fontItalic = new Font(ffar, 16, FontStyle.Italic);
fonts = new Font[] {fontRegular, fontItalic, fontRegular};
texte = new string[] {"Oh, welch ein schrecklich"," schräges"," Wort!"};
}
protected override void OnPaint(PaintEventArgs e) {
Graphics g = e.Graphics;
PointF pkt = new PointF(10, 10);
SizeF size;
for (int i = 0; i < 3; i++) {
g.DrawString(texte[i], fonts[i], brush, pkt);
size = g.MeasureString(texte[i], fonts[i]);
g.DrawRectangle(Pens.Red, pkt.X, pkt.Y, size.Width, size.Height);
pkt.X += size.Width;
}
}
[STAThread]
static void Main() {
Application.Run(new MeasureStringDemo());
}
}
Abschnitt 15.7 Text malen
459
Man erhält als Rückgabewert ein SizeF-Objekt mit Größenangaben in der aktuellen Maßeinheit.
Allerdings ist nicht nur die Höhe (siehe Abschnitt 15.7.4.1), sondern auch die Breite leicht übertrieben, so dass zwischen horizontal aufeinander folgenden DrawString()-Ausgaben unschöne Lücken
entstehen, wenn man sich bei der Positionierung an diesem SizeF-Objekt orientiert, z.B.:
Das Ergebnis wird präziser, wenn bei DrawString() und bei MeasureString() ein spezielles
StringFormat-Objekt assistiert, das von der statischen StringFormat-Eigenschaft GenericTypographic geliefert wird.
StringFormat sf = StringFormat.GenericTypographic;
Man muss seine FormatFlags-Eigenschaft um den Wert MeasureTrailingSpaces aus der Enumeration StringFormatFlags erweitern, damit Leerzeichen am Ende einer Zeichenfolge bei der Breitenberechnung Berücksichtigung finden, z.B.:
protected override void OnPaint(PaintEventArgs e) {
Graphics g = e.Graphics;
PointF pkt = new PointF(10, 10);
SizeF size;
StringFormat sf = StringFormat.GenericTypographic;
sf.FormatFlags |= StringFormatFlags.MeasureTrailingSpaces;
for (int i = 0; i < 3; i++) {
g.DrawString(texte[i], fonts[i], brush, pkt, sf);
size = g.MeasureString(texte[i], fonts[i], pkt, sf);
g.DrawRectangle(Pens.Red, pkt.X, pkt.Y, size.Width, size.Height);
pkt.X += size.Width;
}
}
Trotz aller Mühen überschätzt MeasureString() nach wie vor die Breite, wobei der (absolute) Fehler offenbar mit der Zeichenfolgenlänge wächst, z.B.:
Eine weitere Verbesserung stellt sich ein, wenn man über die Graphics-Eigenschaft TextRenderingHint das Antialiasing einschaltet, z.B.
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
Nun kommen DrawString() und MeasureString() bei der horizontalen Ausdehnung fast zusammen, wobei allerdings das Antialiasing wegen des damit verbundenen Unschärfeeindrucks nicht
unbedingt gefallen muss:
Bei einer LCD-Anzeige sollte statt des gewöhnlichen Antialiasing über den TextRenderingHintWert ClearTypeGridFit die hochwertigere ClearType-Technik verwendet werden, die auch bei
der Breitenmessung nochmals besser abschneidet:
460
Kapitel 15: Grafikausgabe mit dem GDI+
Im Internet wird das Genauigkeitsproblem der MeasureString()-Methode intensiv diskutiert. Wer
kein Antialiasing nicht verwenden möchte, findet auf der Webseite
http://www.codeproject.com/cs/media/measurestring.asp
eine von Pierre Arnaud vorgeschlagene Lösung zur Berechnung der Textbreite, welche auf der
Graphics-Methode MeasureCharacterRanges() basiert und im Beispiel folgendes Ergebnis produziert:.
Als Alternative zu der problematischen Graphics-Methode MeasureString() wird für Bildschirmausgaben vielfach die statische Methode MeasureText() der seit .NET 2.0 verfügbaren Klasse
TextRenderer im Namensraum System.Windows.Forms empfohlen. Dann sollte man aber die
Ausgabe mit der zugehörigen TextRenderer-Methode DrawText() bewerkstelligen, die leider
nicht zum Drucken taugt.
Während die Klasse Graphics das GDI+ - Textrendering benutzt, verwendet die Klasse TextRenderer für diesen Zweck das ältere GDI (ohne Plus).
Seit .NET 2.0 können viele FCL-Steuerelemente zum Rendern ihrer Texte alternativ die Klase Graphics oder die Klasse TextRenderer verwenden. Die vom Visual Studio 2008 bei neuen WinForms-Projekten per Voreinstellung in die Main()-Methode der Startklasse aufgenommene Anweisung
Application.SetCompatibleTextRenderingDefault(false);
(vgl. Abschnitt 9.6.4) sorgt übrigens dafür, dass per Voreinstellung auf die Kompatibilität mit .NET
1.x verzichtet und die neue .NET 2 - Technik verwendet wird (, die eigentlich eine Rückkehr zum
Altbewährten ist).
15.8 Transformationen
Bisher haben wir uns auf Bildschirmausgaben beschränkt und dabei das Standardkoordinatensystem
mit der Maßeinheit Pixel verwendet. Soll ein Programm verschiedene Bildschirmauflösungen berücksichtigen, alternative Ausgabegeräte (z.B. Drucker) unterstützen und/oder besonderer Ansprüche an maßhaltige Ausgaben erfüllen (z.B. in CAD-Anwendungen), dann sind metrische Maßeinheiten unvermeidlich (siehe Abschnitt 15.8.2). Bei ihrem Einsatz werden die in Zeichenmethoden
angegebenen Seitenkoordinaten bei der Ausgabe vom .NET -Framework umgerechnet in Gerätekoordinaten. Es werden z.B. hinreichend viele Pixel eingeschwärzt, damit die von Ihnen gewünschte schwarze Linie mit einer Länge von 5 cm auf dem Bildschirm erscheint. In Abschnitt
15.8.1 geht es um kleine Fehler, die durch unvollständiges Wissen über die tatsächliche Größe eines
Geräte-Pixels entstehen können.
Oft ist ein Koordinatensystem wünschenswert, das sich vom System der Seitenkoordinaten der zu
bemalenden Steuerelementoberfläche (mit dem Ursprung links oben und einer waagerecht verlaufenden X-Achse) unterscheidet, z.B. durch einen verschobenen Ursprung und/oder durch eine Drehung. In Abschnitt 15.8.3 beschäftigen wir uns damit, wie man eine solche Welttransformation vereinbart, damit man anschließend in Zeichenmethoden die Koordinaten der eigenen Welt verwenden
und dem .NET - Framework die Übersetzung in die Seitenkoordinaten überlassen kann.
Wir müssen also insgesamt drei Koordinatensysteme unterscheiden:
461
Abschnitt 15.8 Transformationen



Weltkoordinaten
In den Zeichenmethoden werden Weltkoordinaten verwendet. Sie unterscheiden sich von
den Seitenkoordinaten der zu bemalenden Steuerelementoberfläche, sobald eine Welttransformation aktiv ist.
Seitenkoordinaten
Dies sind die logischen Koordinaten der Normalwelt. Sie unterscheiden sich von den Bildschirmkoordinaten, sobald eine andere Maßeinheit als Pixel verwendet wird.
Gerätekoordinaten
Diese beziehen sich auf die (mehr oder weniger fixierte) Pixelmatrix des Ausgabegeräts.
Ist weder eine Welt- noch eine Seitentransformation im Spiel, sind die Koordinatensysteme identisch.
15.8.1 Auflösungserscheinungen
Während ein Druckertreiber sehr genau weiß, wie groß seine Zeichenfläche (also der bedruckbare
Bereich eines Blattes) ist, hat das Betriebssystem nur eine unsichere Vermutung über die Größe der
Bildschirmfläche. Windows kennt die folgenden, vom Benutzer beeinflussbaren Einstellungen (vgl.
Abschnitt 15.7.4.1):


Anzahl der Bildpunkte pro Zeile bzw. Spalte
Bildschirmauflösung in der Einheit DPI (Dots Per Inch)
Daraus ist die Größe des Bildschirms leicht zu errechnen.
Beim LCD-Monitor eines 15,4 Zoll - Laptops wurden folgende Werte festgestellt:


Bildpunkte:
DPI-Einstellung:
1280800
96
Windows geht also von einer Bildschirmdiagonale mit ca. 15,72 Zoll aus:
1280
 13, 3 Zoll
96
(13, 3 ) 2  (8, 3 ) 2  15,72 Zoll
800
 8, 3 Zoll
96
Die tatsächliche Bildschirmdiagonale des Monitors beträgt (wie vom Hersteller angegeben und
durch Messung bestätigt) 15,4 Zoll, was einer Auflösung von 98 DPI entspricht. In diesem Fall
stimmt die Annnahme des Betriebssystems relativ gut mit der Realität überein, und eine per DrawLine() mit der metrischen Längenangabe (siehe unten) von 5 cm ausgegebene Linie erreicht auf
dem Monitor mit ca. 4,9 cm fast die korrekte Länge. Stellt der Laptop-Benutzer aber den DPI-Wert
Kapitel 15: Grafikausgabe mit dem GDI+
462
120 ein (vgl. Abschnitt 15.7.4.1), dann produziert derselbe DrawLine()-Aufruf eine Linie mit 6,25
cm Länge.
angenommener DPI-Wert
96
120
Verfälschungsfaktor
120/98
= 1,25
96/98  0,98
Länge einer 5 cm -Linie
6,25 cm
 4,9 cm
Im folgenden Quellcodesegment wird demonstriert, wie man die Anzahl der horizontalen bzw. vertikalen Pixel auf dem primären Bildschirm ermitteln kann. Von welchen Auflösungen ein Graphics-Objekt (mit einem Bildschirm oder auch mit einem Drucker verbunden) ausgeht, erfährt man
über seine Eigenschaften DpiX und DpiY:
Quellcodefragment
Ausgabe
protected override void OnPaint(PaintEventArgs e) {
Graphics g = e.Graphics;
g.DrawString("Pixel pro Zeile:\t" +
Screen.PrimaryScreen.Bounds.Width.ToString() +
"\nPixel pro Spalte:\t" +
Screen.PrimaryScreen.Bounds.Height +
"\nHoriz. Aufl.:\t" + g.DpiX.ToString() +
"\nVertik. Aufl.:\t" + g.DpiY.ToString(),
Font, SystemBrushes.WindowText, 10, 10);
}
Bei der Druckausgabe, mit der wir uns bald beschäftigen, werden Positions- und Größenangaben
per Voreinstellung in Einheiten von 1/100 Zoll (also metrisch) interpretiert, so dass z.B. die Anweisung
g.DrawLine(Pens.Black, 0, 10, 100, 10);
auf jedem Drucker eine Linie von exakt 2,54 cm (= 1 Zoll) Länge erzeugt.
Weil ein Drucker per Voreinstellung als 100-DPI-Gerät behandelt wird, und Monitore häufig mit
ca. 100 DPI arbeiten, genügt für einfache Zwecke die Orientierung an dieser Standardauflösung.
15.8.2 Metrische Maßeinheiten (Seitentransformation)
Wir haben bisher ausschließlich den Bildschirm als Ausgabegerät verwendet, wobei für Positionsund Größenangaben die voreingestellte Maßeinheit Pixel akzeptiert wurde. Das .NET - Framework
unterstützt aber auch diverse metrische Maßeinheiten (z.B. Millimeter). Sollen Graphikproduktionen mit exakten Positionen und Größen in der Druckausgabe landen, dann sind metrischen Maßeinheiten unvermeidlich. In vielen Fällen sind diese auch bei der (reinen) Bildschirmausgabe von Vorteil.
Für eine Änderung der Maßeinheit über die Graphics-Eigenschaft PageUnit stehen folgende Werte
der Enumeration GraphicsUnit zur Verfügung:
463
Abschnitt 15.8 Transformationen
Wert
Display
Bedeutung
Bildschirm: identisch mit Pixel
Drucker:
1 Einheit = 1/100 Zoll (Voreinstellung)
1 Einheit = 1 Pixel (Voreinstellung beim Bildschirm)
Pixel
1 Einheit = 1/72 Zoll
Point
1 Einheit = 1 Zoll
Inch
Document 1 Einheit = 1/300 Zoll
Millimeter 1 Einheit = 1 Millimeter
Über die Graphics-Eigenschaft PageScale lässt sich die Schrittweite verfeinern oder vergröbern.
Nach den folgenden Anweisungen versteht das Graphics-Objekt alle Positionen und Größen in der
Einheit 1/10 Millimeter:
g.PageUnit = GraphicsUnit.Millimeter;
g.PageScale = 0.1f;
Mit den Graphics-Eigenschaften PageUnit und PageScale wird definiert, wie die in Ausgabeanweisungen enthaltenen Seitenkoordinaten in Gerätekoordinaten umzurechnen sind. Bei obigen
Einstellungen und (DpiX = DpiY = 96) resultiert z.B. die folgende Seitentransformation:
Ausgabeanweisung
g.DrawLine(Pens.Black,
100, 100, 600, 100);
Seitenkoordinaten
der Punkte
(100, 100)
(600, 100)
Gerätekoordinaten
der Punkte
(38, 38)
(227, 38)
Lässt man Graphics-Eigenschaften PageUnit und PageScale unverändert, stimmen Seiten- und
Gerätekoordinaten überein (keine Seitentransformation).
Weil die per PageUnit und PageScale definierte Seitentransformation mit einem Graphics-Objekt
vereinbart wird, hat sie z.B. keinen Einfluss auf die Formulareigenschaft ClientSize. Im folgenden
Paint-Handler wird u.a. die ClientSize-Eigenschaft vor und nach einer Seitentransformation durch
einen DrawString()-Aufruf mit der horizontalen X-Koordinate 50 protokolliert:
protected override void OnPaint(PaintEventArgs e) {
Graphics g = e.Graphics;
g.DrawString("PageUnit:\t\t" + g.PageUnit.ToString() +
"\nPageScale:\t\t" + g.PageScale.ToString() +
"\nClientSize:\t\t"+ClientSize.ToString(),
new Font("Arial", 10), SystemBrushes.WindowText, 50, 10);
g.PageUnit = GraphicsUnit.Millimeter;
g.PageScale = 0.1f;
g.DrawString("PageUnit:\t\t" + g.PageUnit.ToString() +
"\nPageScale:\t\t" + g.PageScale.ToString() +
"\nClientSize:\t\t"+ClientSize.ToString(),
new Font("Arial", 10), SystemBrushes.WindowText, 50, 200);
}
Die Seitentransformation verändert zwar die horizontale Position der DrawString()-Ausgabe, aber
nicht den ClientSize-Wert:
Kapitel 15: Grafikausgabe mit dem GDI+
464
Das Beispielprogramm in Abschnitt 15.8.1 demonstriert, wie die ungefähre metrische Größe des
Klientenbereichs ermittelt werden kann.
15.8.3 Welttransformationen
Über eine so genannte Welttransformation legt man einen alternativen Bezugsrahmen für die Koordinaten in den Ausgabeanweisungen fest. Man kann …



den Nullpunkt verschieben,
für die X- und oder die Y-Achse eine Streckung bzw. Dehnung vereinbaren,
oder die Zeichenebene um den Nullpunkt rotieren.
Mit der Graphics-Methode TranslateTransform() lässt sich der Ursprung des Koordinatensystems aus seiner linken oberen Ecke locken, z.B.:
Quellcodefragment
Ausgabe
protected override void OnPaint(PaintEventArgs e) {
Graphics g = e.Graphics;
for (int i = 0; i < 6; i++) {
g.DrawEllipse(Pens.Black, 0, 0, 30, 30);
g.TranslateTransform(40, 40);
}
}
Die mit der Graphics-Methode ScaleTransform() möglichen Streckungen und Stauchungen des
Koordinatensystems wirken ähnlich wie eine Änderung der Maßeinheit, wobei jedoch beide Richtungen der Ebene unterschiedlich behandelt werden können. Um einen runden Kopf mit elliptischem Hut zu zeichnen, wird im folgenden Paint-Handler zweimal derselbe DrawEllipse()-Aufruf
verwendet, wobei zwischenzeitlich eine Skalierungstransformation stattfindet:
Quellcodefragment
Ausgabe
protected override void OnPaint(PaintEventArgs e) {
Graphics g = e.Graphics;
g.DrawEllipse(Pens.Black, 20, 20, 300, 300);
g.ScaleTransform(1, 0.5f);
g.DrawEllipse(Pens.Black, 20, 20, 300, 300);
}
Mit der Graphics-Methode RotateTransform() kann man eine Figur drehen, ohne Koordinatenneuberechnungen durchführen zu müssen, z.B.:
465
Abschnitt 15.9 Rastergrafik
Quellcodefragment
Ausgabe
protected override void OnPaint(PaintEventArgs e) {
Graphics g = e.Graphics;
g.PageUnit = GraphicsUnit.Millimeter;
g.TranslateTransform(50, 15);
g.DrawRectangle(Pens.Blue, 0, 0, 50, 20);
g.RotateTransform(120);
g.DrawRectangle(Pens.Red, 0, 0, 50, 20);
}
Nach Ausgabe des blauen Rechtecks per DrawRectangle() wird das Koordinatensystem um 120
Grad im Uhrzeigersinn gedreht. Anschließend wird das rote Rechteck gezeichnet, wobei sich im
DrawRectangle()-Aufruf nur die Farbe des Pen-Objekts ändert. Als Maßeinheit werden Millimeter
verwendet, und der Ursprung des Koordinatensystems wird mit TranslateTransform() verschoben.
Im letzten Beispiel ist sowohl eine Seitentransformation vorhanden (Verwendung einer metrischen
Maßeinheit), als auch eine Welttransformation (Verschiebung und Rotation). Die in GraphicsZeichenbefehlen enthaltenen Positionen und Größen werden auf dem Weg zu Pixelwerten für den
Bildschirm also zweimal transformiert (vgl. Petzold 2002, S. 247):


Welttransformation zur Berücksichtigung der Verschiebung und der Rotation
Seitentransformation zur Berücksichtigung der Maßeinheit
Mit Grundkenntnissen in linearer Algebra lassen sich unter Verwendung von Objekten der Klasse
Matrix aus dem Namensraum System.Drawing.Drawing2D) sehr flexible Transformationen konstruieren (z.B. Scherungen).
Mit der Graphics-Methode ResetTransform() lassen sich alle aktiven Welttransformationen abschalten.
15.9 Rastergrafik
Mit der Klasse Bitmap unterstützt das .NET – Framework die Darstellung und Bearbeitung von
Rastergrafiken, die z.B. aus einer Datei importiert werden können:
image = new Bitmap("land.jpg");
Das folgende Fenster zeigt links das per DrawImage()
g.DrawImage(image, 10, 10);
gemalte Original und rechts die per
ra = new RectangleF(280, 10, 1.5f * image.Width, image.Height);
g.DrawImage(image, ra);
horizontal gedehnte Variante:
Kapitel 15: Grafikausgabe mit dem GDI+
466
Zur Definition der gewünschte Position und Größe kommt bei der zweiten Ausgabe eine RectangleF-Instanz zum Einsatz.
Wie der komplette Quellcode zum Beispiel demonstriert, sollte man das Laden des Bildes keinesfalls in der Paint-Ereignisbehandlungsmethode vornehmen, sondern z.B. im Formularkonstruktor:
using System;
using System.Windows.Forms;
using System.Drawing;
class BitmapDemo : Form {
Bitmap image;
RectangleF ra;
BitmapDemo() {
Text = "Bitmap";
ClientSize = new Size(680, 280);
image = new Bitmap("land.jpg");
ra = new RectangleF(280, 10, 1.5f * image.Width, image.Height);
}
protected override void OnPaint(PaintEventArgs e) {
Graphics g = e.Graphics;
g.DrawImage(image, 10, 10);
g.DrawImage(image, ra);
}
[STAThread]
static void Main() {
Application.Run(new BitmapDemo());
}
}
Beim empfehlenswerten Verzicht auf eine Pfadangabe wird die im Bitmap-Konstruktor angegebene Datei im Verzeichnis mit dem Assembly erwartet (z.B. in …\bin\Debug). Später werden Sie
eine Möglichkeit kennen lernen, Bitmap-Dateien als Ressourcen in ein Assembly aufzunehmen.
Im .NET - Framework werden folgende Rastergrafikdateiformate unterstützt:






BMP (Bitmap)
GIF (Graphics Interchange Format)
JPEG (Joint Photographic Experts Group)
EXIF (Exchangeable Image File)
PNG (Portable Network Graphics)
TIFF (Tag Image File Format)
467
Abschnitt 15.9 Rastergrafik
Während das obige Bild (mit 256  256 - Pixelmatrix) vom manipulierten Seitenverhältnis sogar
profitiert hat, ist die Verzerrung bei Einpassung eines (z.B. vom Benutzer via Dateistandarddialog
gelieferten) Bildes in ein Rechteck mit festgelegter Größe in der Regel zu vermeiden. Im folgenden
Beispiel (Rechteck wie oben, 2166  1141 Pixelmatrix) ist mit Beschwerden der Hauptdarsteller 1
zu rechnen:
Im folgenden Quellcodesegment
int bmh = image.Width;
int bmv = image.Height;
float faktor = Math.Min(((float)ClientSize.Width) / bmh,
((float)ClientSize.Height) / bmv);
if (faktor < 1.0)
ra = new RectangleF(0, 0, faktor*bmh, faktor*bmv);
else
ra = new RectangleF(0, 0, bmh, bmv);
wird zur Einpassung auf beide Achsen derselbe Faktor angewendet, der sich als minimaler Quotient
aus den korrespondierenden Pixelzahlen von Bild und Zielrechteck ergibt:
 Zeilenpixel im Zielrechteck
Min 
,
Zeilenpixel im Bild

Spaltenpixel im Zielrechteck 

Spaltenpixel im Bild

Nun unterbleibt die unschmeichelhafte Dehnung:
Auf ein Vergrößern von Bildern wird im Beispiel explizit verzichtet (siehe if-Anweisung):
1
Zu sehen sind ein Berner Sennenhund (Bodo) und eine braune Sennenhündin (Emma).
Kapitel 15: Grafikausgabe mit dem GDI+
468
Zwei Methoden der Klasse Bitmap haben wir schon in Abschnitt 9.4.2.3 zum Bemalen von Button-Objekten verwendet:


public Color GetPixel(int x, int y)
ermittelt die Farbe eines Pixels
public void MakeTransparent(Color Transparentfarbe)
setzt eine Farbe des Bitmap-Objekts transparent
Zur Bearbeitung eines Bitmap-Objekts stehen u.a. die folgenden Methoden zur Verfügung:


public Color SetPixel(int x, int y)
setzt die Farbe eines Pixels
public void RotateFlip(RotateFlipType Rotations-Kipp-Typ)
erlaubt u.a. das horizontale Spiegeln eines Bildes, z.B.:
Man kann sich mit der statischen Graphics-Methode FromImage() ein Graphics-Objekt zu einem
Bild besorgen und hat dann alle in Abschnitt 15 beschriebenen Zeichenmethoden zur Verfügung.
Mit dem folgenden Quellcodefragment
Pen pen = new Pen(Color.Red, 20);
Graphics g = Graphics.FromImage(image);
g.DrawRectangle(pen, 1450, 50, 700, 650);
wird die braune Sennenhündin hervorgehoben:
Abschnitt 15.10 Pfade, Zeichen- und Anzeigebereiche
469
Über die Bitmap-Methode Save() lässt sich ein Bild in eine Datei sichern.
Über die Klasse System.Drawing.Imaging.Metafile, die wie Bitmap von der abstrakten Klasse
Image abstammt, unterstützt das .NET -Framework auch die Bearbeitung von vektorbasierten Grafiken (siehe Luis & Strasser 2008, S. 859).
15.10 Pfade, Zeichen- und Anzeigebereiche
15.10.1 Grafikpfade
In der OnPaint-Methode des folgenden Programms
using System.Windows.Forms;
using System.Drawing;
using System;
class Schluesselloch : Form {
Pen stift = new Pen(Color.Black, 10);
Point ul, ur, p0, p1, p2, p3;
Point[] lz;
Schluesselloch() {
Text = "Schlüsselloch";
ClientSize = new Size(500, 400);
ul = new Point(150, 350);
ur = new Point(350, 350);
p0 = new Point(200, 200);
p1 = new Point(70, 10);
p2 = new Point(430, 10);
p3 = new Point(300, 200);
lz = new Point[] { p0, ul, ur, p3 };
}
protected override void OnPaint(PaintEventArgs e) {
e.Graphics.DrawLines(stift, lz);
e.Graphics.DrawBezier(stift, p0, p1, p2, p3);
}
[STAThread]
static void Main() {
Application.Run(new Schluesselloch());
}
}
Kapitel 15: Grafikausgabe mit dem GDI+
470
wird mit den Graphics-Methoden DrawLines() und DrawBezier() (siehe Abschnitt 15.6) ein
Schlüsselloch gezeichnet:
Der DrawLines()-Aufruf ist nicht nur bequemer als drei einzelne DrawLine()-Aufrufe, sondern
sorgt zusätzlich für „ansatzfreies“ Zeichen an den Verbindungspunkten. An den Übergängen zwischen dem Linienzug und der Bézier-Kurve sind jedoch störende Lücken zu erkennen. Zur eleganten Lösung derartiger Probleme enthält das .NET-Grafiksystem die Klasse GraphicsPath (Namensraum System.Drawing.Drawing2D). Man übergibt einem GraphicsPath-Objekt eine Sequenz von Linien und Kurven und verwendet es dann als Parameter der Graphics-Methode
DrawPath(), welche den gesamten Pfad mit perfekten Übergängen zeichnet.
Um im Beispielprogramm das folgende Ergebnis zu erzielen,
kann man so vorgehen:

Aus OnPaint() zu streichen:
e.Graphics.DrawLines(stift, lz);
e.Graphics.DrawBezier(stift, p0, p1, p2, p3);
471
Abschnitt 15.10 Pfade, Zeichen- und Anzeigebereiche

Instanzvariable vom Typ GraphicsPath deklarieren und Objekt erstellen:
using System.Drawing.Drawing2D;
. .
GraphicsPath gp = new GraphicsPath();

Im Formularkonstruktor werden die GraphicsPath-Methoden AddLines() und AddBezier() verwendet, um die benötigten Segmente in den Pfad aufzunehmen:
gp.AddLines(lz);
gp.AddBezier(p3, p2, p1, p0);
gp.CloseFigure();

Wozu der CloseFigure()-Aufruf dient, und warum die Bézier-Punkte im Vergleich zur Methode DrawBezier() in umgekehrter Reihenfolge stehen, ist gleich zu erfahren.
Jetzt fehlt nur noch die Graphics-Zeichenmethode DrawPath() in OnPaint():
e.Graphics.DrawPath(stift, gp);
Ein Pfad kann aus mehreren Figuren bestehen, die sich wiederum aus beliebig vielen Segmenten
zusammensetzen, welche jeweils mehrere Punkte und die (abhängig vom Typ) dadurch definierten
Linien bzw. Kurven enthalten. Wir haben eben eine Schlüssellochfigur aus zwei Segmenten aufgebaut (ein Linienzug mit vier Punkten und ein Bézier-Spline) und dabei den Startpunkt des zweiten
Segments identisch mit dem Endpunkt des Vorgängers gewählt. Generell werden zwei nacheinander in eine Figur eingefügte Segmente automatisch durch Linien zwischen dem Endpunkt des früheren und Startpunkt des späteren Segments verbunden. Daraus folgt, dass eine Figur trotz Verwendung derselben Segmente in Abhängigkeit von der Einfügereihenfolge sehr verschieden aussehen
kann, z.B.:
Quellcodefragment
Ausgabe
gp.AddLine(50, 50, 200, 50);
gp.AddLine(200, 50, 200, 200);
gp.AddLine(200, 200, 100, 200);;
gp.AddLine(200, 50, 200, 200);
gp.AddLine(200, 200, 100, 200);
gp.AddLine(50, 50, 200, 50);
Mit der Methode CloseFigure() beauftragt man ein GraphicsPath-Objekt, die aktuelle Figur abzuschließen. Wie der kleine Defekt in der oberen rechten Ecke der letzten Figur zeigt, ist das Schließen auch dann ratsam, wenn das letzte Segment einer Figur dort endet, wo das erste startet:
Kapitel 15: Grafikausgabe mit dem GDI+
472
Quellcodefragment
Ausgabe
gp.AddLine(200, 50, 200, 200);
gp.AddLine(200, 200, 100, 200);
gp.AddLine(50, 50, 200, 50);
gp.CloseFigure();
Schreibfaule können sich am Ende einer Figur ein Liniensegment sparen, weil dieses von
CloseFigure() ohnehin ergänzt wird. Außerdem bietet das automatische Füllen von Lücken (siehe
oben) noch Einsparmöglichkeiten:
Quellcodefragment
Ausgabe
gp.AddLine(50, 50, 200, 50);
gp.AddLine(200, 200, 100, 200);
gp.CloseFigure();
Mit StartFigure() kann man eine neue Figur beginnen, ohne die aktuelle zu schließen. Während
CloseFigure() die aktuell im Aufbau befindliche Figur schließt, bearbeitet CloseAllFigures() alle
offenen Figuren.
Bisher bestanden unsere GraphicsPath-Beispiele einer einzigen Figur. Im folgenden Programm
using
using
using
using
System;
System.Windows.Forms;
System.Drawing;
System.Drawing.Drawing2D;
class Figuren : Form {
Pen pen = new Pen(Color.Black, 5);
GraphicsPath path = new GraphicsPath();
Figuren() {
Text = "Figuren";
ClientSize = new Size(400, 250);
path.AddBezier(100, 150, 150, 250, 400, 200, 250, 50);
path.CloseFigure();
path.AddEllipse(250, 100, 15, 15);
path.AddLine(200, 200, 180, 150);
}
Abschnitt 15.10 Pfade, Zeichen- und Anzeigebereiche
473
protected override void OnPaint(PaintEventArgs e) {
e.Graphics.FillPath(Brushes.BurlyWood, path);
e.Graphics.DrawPath(pen, path);
}
[STAThread]
static void Main() {
Application.Run(new Figuren());
}
}
wird ein Exemplar aus drei Figuren erstellt:



Die erste Figur startet mit einem Bézier-Spline und wird per CloseFigure() durch eine Linie
komplettiert, so dass sie aus zwei Segmenten besteht.
Durch die Methode AddEllipse() wird eine zweite Figur begonnen und automatisch abgeschlossen. Analog verhalten sich die anderen GraphicsPath-Methoden zum Erstellen geschlossener Figuren, z.B. AddRectangle(), AddPolygon().
Mit AddLine() startet also im Beispiel die dritte Figur, die als einziges Segment eine Linie
enthält und ohne Abschluss bleibt.
Verwendet in den Graphics-Methoden DrawPath() und FillPath() liefert der Pfad folgendes Ergebnis:
Weitere GraphicsPath-Methoden zum Bestücken eines Pfads sind:



AddArc()
Fügt einen Bogen ein
AddBeziers()
Fügt eine Serie von Bézier-Splines ein
AddCurve()
Fügt einen kardinalen Spline hinzu
Bögen und kardinale Splines werden bei der Aufnahme in einen Pfad automatisch in Bézier-Splines
gewandelt. In Petzold (2002, S. 597ff) findet sich eine zahlreiche weitere Optionen zum Erzeugen
und Transformieren von Grafikpfaden.
15.10.2 Zeichenbereiche
Einen Pfad kann man nicht nur zeichnen oder füllen, sondern über die Graphics-Methode SetClip()
auch zur Definition eines Zeichenbereichs (engl. clip area) verwenden, auf den die Grafikausgabe
anschließend beschränkt werden soll. Als Beispiel verwenden wir den Schlüssellochpfad aus Abschnitt 15.10.1:
474
Kapitel 15: Grafikausgabe mit dem GDI+
g.SetClip(gp);
g.Clear(Color.White);
Mit der Graphics-Methode Clear() setzt man die gesamte zugängliche Zeichenfläche auf eine bestimmte Farbe. Im Beispiel wirkt sich die Methode nur auf das Innere des Schlüssellochs aus:
Mit der Clipping-Technik lassen auch sich beliebig geformte (per Pfad definierte) Bereiche von
Bitmaps anzeigen, z.B.:
Bitmap image = new Bitmap("land.jpg");
. . .
g.SetClip(gp);
g.DrawImage(image, new RectangleF(100, 50,
image.Width, 1.5f * image.Height));
Bei dieser tollen Aussicht, kann man sich den Blick durch das Schlüsselloch nicht verkneifen:
Eine mit den Zeichenbereichen verwandte Technik ist uns schon in Abschnitt 15.3.3 über die programminitiierte Fensterrenovierung durch Aufruf der Control-Methode Invalidate() begegnet.
Dieser Methode kann man aus Performanzgründen als Parameter einen Bereich übergeben, auf den
sich die Renovierung beschränken soll. Dabei ist zur Bereichsdefinition neben der selbstverständlichen Rectangle-Option auch ein Objekt ein gleich zu behandelnden Klasse Region erlaubt, deren
Konstruktor u.a. ein GraphicsPath-Objekt akzeptiert.
Abschnitt 15.10 Pfade, Zeichen- und Anzeigebereiche
475
15.10.3 Anzeigebereiche
Mit der eben vorgestellten Graphics-Methode SetClip() legt man einen Clipping-Bereich fest, auf
den sich das angesprochene Graphics-Objekt anschließend bei seinen Ausgaben beschränkt. Mit
der Control-Eigenschaft Region, die auf ein Objekt der gleichnamigen Klasse zeigt, lässt sich ein
Fensterbereich festlegen, den das Betriebsystem ausschließlich anzeigen soll. Oft definiert man die
Region-Fläche durch eine Rectangle-Instanz, doch über ein GraphicsPath-Objekt sind auch andere Gestalten möglich. Im folgenden Beispiel erhält ein Formular über seine Region-Eigenschaft ein
sehr individuelles Erscheinungsbild:
Im Formularkonstruktor wird ein Region-Objekt unter wesentlicher Verwendung eines GraphicsPath-Objekts in Schlüssellochform (vgl. Abschnitt 15.10.1) erstellt und dann der RegionEigenschaft des Formulars zugewiesen:
RegionDemo() {
Size = new Size(500, 400);
Point ul = new Point(170, 300);
Point ur = new Point(300, 300);
Point p0 = new Point(190, 200);
Point p1 = new Point(70, 10);
Point p2 = new Point(400, 10);
Point p3 = new Point(280, 200);
Point[] lz = new Point[] {p0, ul, ur, p3};
gp = new GraphicsPath();
gp.AddLines(lz);
gp.AddBezier(p3, p2, p1, p0);
gp.CloseFigure();
Region = new Region(gp);
image = new Bitmap("land.jpg");
Button cbClose = new Button();
cbClose.Top = 230;
cbClose.Left = 194;
cbClose.Text = "Beenden";
cbClose.Parent = this;
cbClose.Click += new EventHandler(ButtonOnClick);
stift = new Pen(SystemColors.ButtonFace, 10);
shiftX = -((Width - ClientSize.Width)/2);
shiftY = -(Height - ClientSize.Height + shiftX);
}
Weil das Formular durch die Region-Definition seine Titelzeile samt Standardschaltflächen verloren hat, muss über Ereignismethoden für Beweglichkeit und eine Terminierungsmöglichkeit gesorgt
werden. Während die Click-Behandlungsmethode zum Befehlsschalter
476
Kapitel 15: Grafikausgabe mit dem GDI+
void ButtonOnClick(object sender, EventArgs e) {
Close();
}
keiner weiteren Erläuterung bedarf, sind zur Bewegungstechnik einige Hinweise angebracht. Bei
einem Mausklick werden die Bildschirmkoordinaten der Klickposition gespeichert:
protected override void OnMouseDown(MouseEventArgs e) {
lastMousePos = PointToScreen(new Point(e.X, e.Y));
}
Eine Mausbewegung wird der Anwendung von Windows nur dann gemeldet, wenn sie über dem
Anzeigebereich stattgefunden hat. Die zuständige Ereignisbehandlungsmethode prüft, ob die linke
Maustaste gedrückt ist, und verschiebt ggf. die linke obere Ecke des Formulars. Dabei ergibt sich
der Bewegungsvektor aus der Differenz zwischen der aktuellen Mausposition und der zuletzt gespeicherten:
protected override void OnMouseMove(MouseEventArgs e) {
if (e.Button == MouseButtons.Left) {
Point actMousePos = PointToScreen(new Point(e.X, e.Y));
Left += actMousePos.X - lastMousePos.X;
Top += actMousePos.Y - lastMousePos.Y;
lastMousePos = actMousePos;
}
}
Schließlich ist noch ein Detailproblem zu lösen. Bei der Region - Definition werden die Koordinaten (im GraphicsPath-Objekt) relativ zur linken oberen Ecke des Formulars interpretiert. In
Graphics-Zeichenmethoden beziehen sich die Koordinaten hingegen auf die linke obere Ecke des
Klientenbereichs. Dies wird in OnPaint() durch eine Verschiebung des Ursprungs per
TranslateTransform() kompensiert, damit der per DrawPath() gezeichnete Rand des Schlüssellochs an der richtigen Stelle erscheint:
protected override void OnPaint(PaintEventArgs e) {
Graphics g = e.Graphics;
g.SmoothingMode = SmoothingMode.HighQuality;
g.TranslateTransform(shiftX, shiftY);
g.DrawImage(image, new RectangleF(120, 60, image.Width, image.Height));
g.DrawPath(stift, gp);
}
Bei der Verschiebung müssen die Breiten von Fensterrand und Titelzeile berücksichtigt werden.
Weile diese von der Windows-Version abhängen könnten, werden sie durch Größenvergleich von
Formular und Klientenbereich ermittelt (siehe Fensterkonstruktor).
Den vollständigen Quellcode des Beispielprogramms finden Sie im Ordner
…\BspUeb\GDI+\Pfade, Zeichen- und Anzeigebereiche\Region
15.11 Übungsaufgaben zu Kapitel 15
1) Erstellen Sie ein Programm, das die für HatchBrush-Objekte verfügbaren Muster demonstriert,
z.B. mit folgender Bedienoberfläche:
Abschnitt 15.11 Übungsaufgaben zu Kapitel 1687H15
2) Erstellen Sie ein Programm, das die Kochsche Schneeflocke in jeder beliebigen Komplexität
zeichnet, z.B. mit folgender Ausgabe:
Man startet mit einem gleichseitigen Dreieck
477
Kapitel 15: Grafikausgabe mit dem GDI+
478
und ersetzt bei jedem Komplexitätsschritt alle geraden Linien durch eine Polylinie nach folgendem
Schema:
Das mittlere Drittel der Linie wird zu einem gleichseitigen Dreieck ausgeformt.
3) Realisieren Sie mit Hilfe der Klassen GraphicsPath und Region eine eigene Button-Ableitung
(vgl. Abschnitt 9.4.2) mit einem runden Design, z.B.:
Normalzustand
gedrückt
16 Weitere Standardkomponenten für WinForms-Anwendungen
In diesem Abschnitt werden weitere Komponenten bzw. Steuerelemente aus dem Namensraum System.Windows.Forms vorgestellt. Es handelt sich meist um simple Bedienelemente, die in jedem
größeren Programm zum Einsatz kommen (z.B. Kontrollkästchen, Optionsfelder). Eine Ausnahme
bildet das mächtige RichTextBox-Steuerelement, das sich als Basis für einen relativ kompletten
Editor nach dem Vorbild der Windows-Zugabe WordPad eignet. Wir führen das RichTextBoxSteuerelement in Abschnitt 16.5 ein und vertiefen seine Behandlung mehrfach in späteren Abschnitten. Neben Steuerelementen mit der Kompetenz zu selbständiger Benutzerinteraktion wird mit dem
Timer auch eine Komponente vorgestellt, die im Hintergrund ihren Dienst verrichtet.
Wer sich beim Einsatz von Windows-Bedienelementen streng an die Vorgaben der Forma Microsoft halten möchte an, kann das kostenlos auf einem Webserver angebotenen Dokument Official
Guidelines for User Interface Developers and Designers (Microsoft 2007b) konsultieren.
16.1 Kontrollkästchen und Optionsfelder
In diesem Abschnitt werden zwei Umschalter vorgestellt:


Für Kontrollkästchen steht die Klasse CheckBox zur Verfügung.
Für ein Optionsfeld verwendet man Objekte der Klasse RadioButton.
Beide Klassen stammen gemeinsam mit der schon in Abschnitt 9.4.2 vorgestellten Klasse Button
von der abstrakten Basisklasse System.Windows.Forms.ButtonBase ab:
ButtonBase
Button
CheckBox
RadioButton
In folgendem Programm kann für den Text einer Label-Komponente über zwei Kontrollkästchen
der Schriftschnitt und über ein Optionsfeld die Schriftart gewählt werden:
Die beiden Kontrollkästchen (cbBold und cbItalic genannt) erlauben das separate Ein- bzw.
Ausschalten der Schriftattribute fett und kursiv. Sie werden im Formularkonstruktor folgendermaßen definiert: 1
1
Im Beispielprogramm wird die so genannte Ungarische Notation zur Bezeichnung der Instanzvariablennamen verwendet, wobei ein Präfix den Datentyp angibt (z.B. lblTextbeispiel). Die in früheren Zeiten der WindowsProgrammierung sehr verbreitete Konvention gilt mittlerweile als obsolet und veraltet. Wir verwenden sie im aktuellen Beispiel trotzdem.
Kapitel 16: Weitere Standardkomponenten für WinForms-Anwendungen
480
Umschalter() {
ClientSize = new Size(460, 125);
Text = "Umschalter";
lblTextbeispiel = new Label();
lblTextbeispiel.Text = "Beispieltext";
lblTextbeispiel.Font = new Font("Arial", 16);
lblTextbeispiel.Location = new Point(250, 50);
lblTextbeispiel.Width = 200;
Controls.Add(lblTextbeispiel);
chkBold = new CheckBox();
chkBold.Text = "fett";
chkBold.Location = new Point(20, 30);
chkBold.Width = 60;
Controls.Add(chkBold);
chkItalic = new CheckBox();
chkItalic.Text = "kursiv";
chkItalic.Location = new Point(20, 70);
chkItalic.Width = 60;
Controls.Add(chkItalic);
EventHandler eh = new EventHandler(CheckBoxOnClick);
chkBold.Click += eh;
chkItalic.Click += eh;
. . .
}
Als EventHandler wird für beide Kontrollkästchen dasselbe Delegatenobjekt verwendet, das auf
der Methode CheckBoxOnClick() basiert:
protected void CheckBoxOnClick(object sender, EventArgs e) {
if (chkBold.Checked)
lblTextbeispiel.Font = new Font(lblTextbeispiel.Font,
lblTextbeispiel.Font.Style | FontStyle.Bold);
else
lblTextbeispiel.Font = new Font(lblTextbeispiel.Font,
lblTextbeispiel.Font.Style & ~FontStyle.Bold);
if (chkItalic.Checked)
lblTextbeispiel.Font = new Font(lblTextbeispiel.Font,
lblTextbeispiel.Font.Style | FontStyle.Italic);
else
lblTextbeispiel.Font = new Font(lblTextbeispiel.Font,
lblTextbeispiel.Font.Style & ~FontStyle.Italic);
}
Die Schriftattribute werden durch eine bitweise Verknüpfung ihrer FontStyle-Ausprägung mit dem
aktuellen Schriftstil ein- bzw. ausgeschaltet:


Durch die bitweise Per OR-Operation werden die zum Attribut gehörigen Bits eingeschaltet.
Durch die bitweise Negation (~) und die anschließende bitweise AND-Operation werden die
zum Attribut gehörigen Bits abgeschaltet.
Der Bequemlichkeit halber werden GDI-Ressourcen verschwendend (vgl. Abschnitt 15.2) bei jedem Aufruf der Methode CheckBoxOnClick()zwei neue Font-Objekte erzeugt, jeweils ausgehend von der aktuellen Schriftart der Label-Komponente.
Hat die AutoCheck-Eigenschaft eines CheckBox- oder RadioButton-Objekts den voreingestellten
Wert true, dann wechselt das Steuerelement bei einem Mausklick oder beim äquivalenten Tastaturkommando automatisch (ohne Beteiligung einer Click-Ereignisbehandlungsmethode) seinen Mar-
Abschnitt 16.1 Kontrollkästchen und Optionsfelder
481
kierungszustand und den Wert seiner Checked-Eigenschaft. Im Beispiel wird dieser Komfort genutzt, und die Click-Behandlungsmethoden greifen nur lesend auf die Checked-Eigenschaft zu.
Alle unmittelbar zu einem Container gehörenden RadioButton-Objekte werden von der CLR als
Gruppe behandelt, wobei nur ein Mitglied eingerastet sein kann (Wert true bei der Eigenschaft
Checked). Im Umschalter-Beispielprogramm fungiert das Hauptfenster als gruppierender Container:
Umschalter() {
. . .
rbArial = new RadioButton();
rbArial.Text = "Arial";
rbArial.Location = new Point(100, 10);
rbArial.Checked = true;
Controls.Add(rbArial);
rbTimesNewRoman = new RadioButton();
rbTimesNewRoman.Text = "Times New Roman";
rbTimesNewRoman.Location = new Point(100, 50);
rbTimesNewRoman.Width = 190;
Controls.Add(rbTimesNewRoman);
rbCourierNew = new RadioButton();
rbCourierNew.Text = "Courier New";
rbCourierNew.Location = new Point(100, 90);
Controls.Add(rbCourierNew);
eh = new EventHandler(RadioButtonOnlick);
rbArial.Click += eh;
rbTimesNewRoman.Click += eh;
rbCourierNew.Click += eh;
}
Sollen auf einem Formular mehrere RadioButton-Gruppen erscheinen, ist jeweils ein eigener SubContainer erforderlich, der z.B. per GroupBox- oder Panel-Steuerelement realisiert werden kann
(siehe FCL – Dokumentation).
Dass initial genau ein Objekt einer RadioButton-Gruppe markiert ist, muss vom Programmierer
durch passendes Vorbesetzen einer Checked-Eigenschaft sichergestellt werden, z.B.:
rbArial.Checked = true;
Hat bei keinem Gruppenmitglied die Checked-Eigenschaft den Wert true, resultiert ein gewohnt
startendes, aber doch nutzbares Programm:
Haben mehrere Gruppenmitglieder beim Programmstart den Checked-Wert true, dann resultiert
ein erratisches Verhalten:
Kapitel 16: Weitere Standardkomponenten für WinForms-Anwendungen
482
Im Beispielprogramm wird für alle RadioButton-Objekte ein gemeinsamer EventHandler verwendet:
protected void RadioButtonOnlick(object sender, EventArgs e) {
if (rbArial.Checked)
lbTextbeispiel.Font = new Font("Arial",lbTextbeispiel.Font.Size,
lbTextbeispiel.Font.Style);
else
if (rbTimesNewRoman.Checked)
lbTextbeispiel.Font = new Font("Times New Roman",lbTextbeispiel.Font.Size,
lbTextbeispiel.Font.Style);
else
lbTextbeispiel.Font = new Font("Courier New",lbTextbeispiel.Font.Size,
lbTextbeispiel.Font.Style);
}
Der Bequemlichkeit halber wird GDI-Ressourcen verschwendend (vgl. Abschnitt 15.2) bei jedem
Aufruf der Methode ein neues Font-Objekte erzeugt, wobei Größe und Stil von der aktuellen
Schriftart der Label-Komponente übernommen werden.
Den vollständigen Quellcode des Beispielprogramms finden Sie im Ordner
…\BspUeb\WinForms\Steuerelemente\Umschalter
16.2 Listen- und Kombinationsfelder
In diesem Abschnitt werden die Steuerelementklassen ListBox und ComboBox vorgestellt, die von
der abstrakten Klasse System.Windows.Forms.ListControl abstammen:
ListControl
ListBox
ComboBox
Ein ListBox-Steuerelement präsentiert eine Liste von Elementen, die per Mausklick oder geschickte
Tastaturbedienung ausgewählt werden können.
Das ComboBox-Steuerelement bietet eine Kombination aus einem einzeiligen Textfeld und einer
Liste. Um seine Wahl zu treffen, hat der Benutzer zwei Möglichkeiten:


den Text der gewünschten Option eintragen
die versteckte Liste aufklappen und die gewünschte Option wählen
In folgendem Programm wird die Angabe des Nachnamens durch eine Liste mit den häufigsten
Namen erleichtert:
Abschnitt 16.2 Listen- und Kombinationsfelder
483
In diesem einfachen Beispielprogramm spielen die Unterschiede zwischen den Klassen ListBox
und ComboBox keine Rolle:
using System;
using System.Windows.Forms;
using System.Drawing;
class ComboList : Form {
ListBox listBoxAnrede;
ComboBox listBoxNachname;
ComboList() {
. . .
listBoxAnrede = new ListBox();
listBoxAnrede.Location = new Point(20, 30);
listBoxAnrede.Size = new Size(50, 30);
listBoxAnrede.Items.AddRange(new String[2] {"Frau", "Herr"});
listBoxAnrede.SelectedIndex = 0;
Controls.Add(listBoxAnrede);
comboBoxNachname = new ComboBox();
comboBoxNachname.Location = new Point(100, 30);
comboBoxNachname.Width = 100;
comboBoxNachname.Items.AddRange(
new String[4] {"Müller", "Maier", "Schulz", "Schmitt"});
comboBoxNachname.Sorted = true;
Controls.Add(comboBoxNachname);
. . .
}
. . .
}
Die Eigenschaft Items zeigt auf ein Objekt der inneren Klasse ListBox.ObjectCollection bzw.
ComboBox.ObjectCollection das die Liste der Elemente enthält und diverse Verwaltungsmethoden bietet, z.B.:


public int Add(Objekt element)
Die Liste wird um ein Element erweitert. Zwar ist syntaktisch ein beliebiges Objekt
zugelassen, doch kommt letztlich nur eine Zeichenfolge in Frage. Hat die SortedEigenschaft des Steuerelements den Wert voreingestellten false, wird das neue Element am
Ende der Liste angehängt, anderenfalls wird es gemäß Sortierreihenfolge eingefügt. Über
den Rückgabewert erfährt man die Position des Neulings.
public void AddRange(Object[] elemente)
Mit der AddRange()-Methode fügt man einem Array von Zeichenfolgen in eine Liste ein,
z.B.:
listBoxAnrede.Items.AddRange(new String[2] {"Frau", "Herr"});

Hat die Sorted-Eigenschaft des Steuerelements den Wert voreingestellten false, werden die
neuen Elemente am Ende der Liste angehängt, anderenfalls werden sie gemäß Sortierreihenfolge eingefügt.
public void Remove(Object element)
Das angegebene Element wird aus der Liste entfernt.
Kapitel 16: Weitere Standardkomponenten für WinForms-Anwendungen
484

public void Clear()
Es werden alle Elemente entfernt.
Über die Eigenschaft SelectedIndex kann man dafür sorgen, dass der Anwenderbequemlichkeit
halber schon beim Öffnen des Fensters ein Listenelement markiert ist, z.B.:
listBoxAnrede.SelectedIndex = 0;
Über dieselbe Eigenschaft lässt sich auch der Indexwert zur aktuellen Auswahl feststellen. Bei einem ComboBox-Steuerelement ist allerdings die String-Eigenschaft Text weit relevanter, z.B.:
comboBoxNachname.Text
Sie steht auch bei einem ListBox-Steuerelement zur Verfügung.
Über den Sorted-Eigenschaftswert true beauftragen wir ein ListBox- bzw. ComboBox-Objekt,
seine Elemente stets sortiert zu halten, z.B.:
comboBoxNachname.Sorted = true;
Dies wirkt sich nicht nur auf die Anzeige aus, sondern auch auf die Vergabe von Indexpositionen
beim Einfügen von Elementen (siehe oben).
Im Beispielprogramm sind die durchaus zahlreich vorhandenen ListBox- bzw. ComboBoxEreignisse (z.B. TextChanged) nicht vor Interesse. Allerdings wird in der Click-Ereignismethode
zum Button-Objekt
protected void FertigOnClick(object sender, EventArgs e) {
String nachname;
if (comboBoxNachname.Text.Length > 0)
nachname = comboBoxNachname.Text;
else
nachname = " ... Äh";
MessageBox.Show("Guten Tag, " + listBoxAnrede.Text + " " +
nachname, Text, MessageBoxButtons.OK);
}
auf die Text-Eigenschaft der Listen-Steuerelemente zugegriffen, um einen freundlichen Gruß formulieren zu können:
Den vollständigen Quellcode des Beispielprogramms finden Sie im Ordner
…\BspUeb\WinForms\Steuerelemente\Listen\ComboList
In einem weiteren Beispielprogramm sollen folgende Techniken demonstriert werden:


Mehrfachauswahl (nur bei ListBox-Objekten verfügbar)
Dynamische Veränderung von Listen
Wir erstellen und befüllen wie eben ein ListBox-Objekt mit Zeichenfolgen:
listeAnwaerter = new ListBox();
listeAnwaerter.Parent = this;
listeAnwaerter.Location = new Point(16, 30);
listeAnwaerter.Size = new Size(180, 120);
listeAnwaerter.SelectionMode = SelectionMode.MultiExtended;
listeAnwaerter.Items.AddRange(new String[10] {"Rott, Felix", "Konz, Susanne",
"Roth, Ruth", "Schorn, Kurt", "Orth, Peter", "Antwerpen, Udo",
"Thor, Theo","Nickel, Nicole", "Sand, Sandra", "Mock, Mirko"});
listeAnwaerter.Sorted = true;
Abschnitt 16.3 UpDown-Regler
485
Ein zweites ListBox-Objekt wird ohne Inhalt angelegt:
listeTeilnehmer = new ListBox();
listeTeilnehmer.Parent = this;
listeTeilnehmer.Location = new Point(280, 30);
listeTeilnehmer.Size = new Size(180, 120);
listeTeilnehmer.SelectionMode = SelectionMode.MultiExtended;
Im Beispielprogramm können aus der linken Liste mit Anwärtern einzelne Kandidaten flexibel ausgewählt und in die rechte Liste mit Teilnehmern transportiert werden:
Aus der Teilnehmerliste können Personen auch wieder entfernt werden.
Weil die ListBox-Eigenschaft SelectionMode bei beiden Listen den Wert MultiExtended erhält,
können auf Windows-üblich Weise (z.B. mit Hilfe der Umschalt- oder Strg-Taste) bequem aufeinander folgende oder auch separierte Elemente gewählt werden.
In der Ereignisroutine zu den beiden Schaltflächen (cmdRein, cmdRaus)
void ButtonOnClick(object sender, EventArgs e) {
if (sender == cmdRein) {
bool found;
foreach (object anw in listeAnwaerter.SelectedItems) {
found = false;
foreach (object teiln in listeTeilnehmer.Items)
if (anw == teiln)
found = true;
if (!found)
listeTeilnehmer.Items.Add(anw);
}
} else {
while (listeTeilnehmer.SelectedItems.Count > 0)
listeTeilnehmer.Items.Remove(listeTeilnehmer.SelectedItems[0]);
}
}
werden die markierten Elemente über die ListBox-Eigenschaft SelectedItems angesprochen, die
auf ein Objekt der inneren Klasse ListBox.SelectedObjectCollection zeigt. Offenbar zeigt
SelectedItems große Ähnlichkeiten mit der bereits bekannten Eigenschaft Items, die auf eine Liste
mit allen Elemente zeigt.
Beim Transport von links nach rechts achtet die Methode ButtonOnClick() darauf, dass in der
Teilnehmerliste keine Dubletten entstehen.
Den vollständigen Quellcode des Beispielprogramms finden Sie im Ordner
…\BspUeb\WinForms\Steuerelemente\Listen\AddRemoveItems
16.3 UpDown-Regler
Die auf modernen Programmoberflächen häufig anzutreffenden UpDown-Steuerelemente (alias:
Drehregler, engl. spin control) erlauben dem Benutzer wie ComboBox-Objekte alternativ die
Auswahl aus einer Liste oder die Eingabe von Werten, z.B.:
Kapitel 16: Weitere Standardkomponenten für WinForms-Anwendungen
486
Es ist aber grundsätzlich nur eine Listenoption zu sehen, wobei man über kleine Aufwärts- und
Abwärtsschalter wie bei einem vertikalen Rollbalken in Minimalausführung zwischen den Optionen
wechseln kann.
In der WinForms-Bibliothek finden sich eine für numerische Werte geeignete und eine für Zeichenfolgenwerte konzipierte UpDown-Variante, die von der gemeinsamen (abstrakten) Basisklasse System.Windows.Forms.UpDownBase abstammen:
UpDownBase
NumericUpDown
DomainUpDown
Im Beispielprogramm kann man über vier Steuerelemente der Klasse NumericUpDown eine Farbe
definieren. Dabei sind über NumericUpDown–Eigenschaften festgelegt:



Minimum
Als minimal erlaubter Wert bleibt die Voreinstellung 0 in Kraft.
Maximum
Maximal wird der Wert 255 erlaubt.
Value
Initial wird der Wert 127 angezeigt.
Der Benutzer hat folgende Möglichkeiten, das ValueChanged-Ereignis eines NumericUpDownReglers auszulösen:


Er kann der Wert in- oder dekrementieren …
o per Mausklick auf den Aufwärts- oder Abwärtsschalter
o mit den vertikalen Pfeiltasten bei einem UpDown-Regler mit Eingabefokus
Hat die ReadOnly-Eigenschaft den (voreingestellten) Wert false hat, kann eine Zahl im zulässigen Bereich eingegeben und per Enter-Taste quittiert werden.
Im Beispielprogramm veranlasst die ValueChanged-Ereignisbehandlungsmethode per Refresh()Aufruf eine sofortige Renovierung des Panel-Steuerelements mit der Farbprobe, so dass die registrierte Paint-Behandlungsmethode eine neue Farbe gemäß den Reglerständen mixt und aufträgt:
Abschnitt 16.3 UpDown-Regler
487
using System;
using System.Drawing;
using System.Windows.Forms;
class UpDown : Form {
Panel panel;
NumericUpDown numUpDownRot, numUpDownBlau, numUpDownGruen, numUpDownAlpha;
DomainUpDown domainUpDown;
UpDown() {
. . .
numUpDownRot = new NumericUpDown();
numUpDownRot.Location = new Point(80, 64);
numUpDownRot.Size = new Size(70, 20);
numUpDownRot.Maximum = 255;
numUpDownRot.Value = 127;
Controls.Add(numUpDownRot);
numUpDownRot.ValueChanged +=
new System.EventHandler(NumericUpDownOnValueChanged);
. . .
domainUpDown = new DomainUpDown();
domainUpDown.Items.Add("Ellipse");
domainUpDown.Items.Add("Rechteck");
domainUpDown.Location = new Point(80, 272);
domainUpDown.Size = new Size(70, 20);
domainUpDown.ReadOnly = true;
domainUpDown.SelectedIndex = 0;
Controls.Add(domainUpDown);
domainUpDown.SelectedItemChanged +=
new System.EventHandler(DomainUpDownOnSelectedItemChanged);
}
void PanelOnPaint(object sender, PaintEventArgs e) {
Graphics g = e.Graphics;
Panel pan = (Panel)sender;
Brush brush = new SolidBrush(Color.FromArgb((int)numUpDownAlpha.Value,
(int)numUpDownRot.Value, (int)numUpDownGruen.Value,
(int)numUpDownBlau.Value));
if (domainUpDown.SelectedIndex == 0) {
g.FillEllipse(brush, 0, 0, pan.ClientRectangle.Width,
pan.ClientRectangle.Height);
} else
g.FillRectangle(brush, 0, 0, pan.ClientRectangle.Width,
pan.ClientRectangle.Height);
brush.Dispose();
}
void NumericUpDownOnValueChanged(object sender,System.EventArgs e) {
panel.Refresh();
}
void DomainUpDownOnSelectedItemChanged(object sender, System.EventArgs e) {
panel.Refresh();
}
[STAThread]
static void Main() {
Application.EnableVisualStyles();
Application.Run(new UpDown());
}
}
Kapitel 16: Weitere Standardkomponenten für WinForms-Anwendungen
488
Über ein DomainUpDown-Steuerelement erlaubt das Beispielprogramm, die Form der gefärbten
Fläche zu beeinflussen. Bei der Steuerelement-Konfiguration werden folgende Eigenschaften angesprochen:



Wie bei einem ListBox- oder ComboBox-Objekt zeigt die Eigenschaft Items auf eine Liste
der enthaltenen Elemente. Diese Liste wird über ein Objekt der internen Klasse DomainUpDown.DomainUpDownItemCollection realisiert, die von ArrayList abstammt und die üblichen Methoden zum Verwalten der Listenelemente bietet (z.B. Add(), AddRange(),
Remove()).
Der ReadOnly-Eigenschaftswert true hindert die Benutzer daran, Eigenkreationen als Wert
einzutippen. In anderen Einsatzfällen ist die Eingabemöglichkeit sicher sinnvoll, wobei die
Text-Eigenschaft die eingegebene oder ausgewählte Zeichenfolge enthält.
Über die SelectedIndex-Eigenschaft wird für die Anzeige eines initialen Werts gesorgt. Außerdem ist diese Eigenschaft relevant für die Paint-Ereignismethode des Panel-Steuerelements.
Um über Wertveränderungen zu informieren, bietet ein DomainUpDown-Objekt die Ereignisse
SelectedItemChanged und TextChanged an.
Den vollständigen Quellcode des Beispielprogramms finden Sie im Ordner
…\BspUeb\WinForms\Steuerelemente\UpDown
16.4 Rollbalken
Viele Steuerelemente werden automatisch mit Rollbalken ausgestattet (z.B. ListBox-Objekte) oder
können leicht über eine Eigenschaft (z.B. ScrollBars bei der Klasse TextBox) mit diesen Bedienelementen versorgt werden. Solche Lösungen erfüllen ihren Zweck und ersparen uns lästigen Aufwand.
Besitzt die AutoScroll-Eigenschaft eines ScrollableControl-Objekts (z.B. eines Formulars oder
Panel-Objekts) den Wert true, dann ergänzt das Laufzeitsystem automatisch Bildlaufleisten, wenn
die untergeordneten Steuerelemente nicht vollständig angezeigt werden können. Bei einem Beispielprogramm zur Klasse FontFamily haben wir uns vom AutoScroll-Angebot allerdings zu einer
wenig performanten Lösung verleiten lassen (siehe Abschnitt 15.7.1). Dort werden die Namen aller
auf dem lokalen PC installierten Schriftarten im Originallayout jeweils in einer eigenen Zeile auf
ein Panel-Steuerelement geschrieben. Über den automatischen Bildlauf kann das gewünschte Segment des überlangen Panel-Steuerelements in den sichtbaren Bereich befördert werden. Dass bei
jeder Rollaktion das Panel-Element komplett neu zu erstellen ist, erschwert allerdings bei zahlreich
vorhandenen Schriftarten die Verwendung des Rollbalkens.
Wir ersetzen nun das überlange Panel-Objekt und die AutoScroll-Funktion des Formulars durch
eine neue Lösung:


Das Formular erhält ein explizites VScrollBar-Steuerelement.
In der OnPaint-Methode des Formulars wird nur das sichtbare Segment der Schriftartenliste
geschrieben.
Die Klassen VScrollBar und HScrollBar bieten über ihre Eigenschaften und Ereignisse erheblich
mehr Flexibilität als eine AutoScroll-Lösung. Sie stammen von der gemeinsamen abstrakten Basisklasse System.Windows.Forms.ScrollBar ab:
ScrollBar
HScrollBar
VScrollBar
489
Abschnitt 16.4 Rollbalken
Zwar kann man die ScrollBar-Abkömmlinge frei positionieren, dimensionieren und färben, doch
wird man sie in der Regel mit dem voreingestellten Erscheinungsbild per DockStyle-Wert am passenden Fensterrand anbringen. Im Fensterkonstruktor des performanzoptimierten FontFamiliy-Demonstrationsprogramms wird eine vertikale Bildlaufleiste am rechten Fensterrand angedockt:
scrollBar = new VScrollBar();
scrollBar.Parent = this;
scrollBar.Dock = DockStyle.Right;
scrollBar.Minimum = 0;
scrollBar.Maximum = vSpace;
scrollBar.SmallChange = vStep;
scrollBar.LargeChange = vStep * 5;
scrollBar.Scroll += new ScrollEventHandler(VScrollBarOnScroll);
Über die ScrollBar-Eigenschaften Minimum und Maximum ordnet man den extremen Bildlaufpositionen jeweils einen Wert zu. Im Beispiel wird das Minimum auf Null und das Maximum auf die
Gesamthöhe der untereinander geschriebenen Textproben gesetzt. Die aktuelle Position des Bildlauffelds auf dem Rollbalken ist per Value-Eigenschaft zu ermitteln und auch zu modifizieren.
Welche Wertveränderungen der Benutzer bei einem Klick auf einen der Pfeile am Ende des Rollbalkens bzw. auf eine Balkenzone zwischen Bildlauffeld und Pfeil bewirkt, legt man über die
SmallChange- bzw. LargeChange-Eigenschaft fest.
Mausklick bewirkt eine LargeChangeVeränderung der Value-Eigenschaft
Bildlauffeld
Mausklick bewirkt eine SmallChangeVeränderung der Value-Eigenschaft
Die maximale Value-Ausprägung beträgt:
Maximum - LargeChange + 1
Von einer Verschiebung des Bildlauffelds kann man sich über die Ereignisse ValueChanged und
Scroll informieren lassen. Das ValueChanged-Ereignis tritt ein, wenn die Value-Eigenschaft entweder durch den Benutzer oder per Programm geändert wird. Eine mögliche Behandlung besteht im
FontFamily-Beispiel darin, per Refresh()-Aufruf die OnPaint-Methode des Formulars dazu zu
bringen, die sichtbaren Schriftproben neu zu malen:
scrollBar.ValueChanged += new EventHandler(VScrollBarOnValueChanged);
. . .
void VScrollBarOnValueChanged(object sender, EventArgs e) {
Refresh();
}
Das Scroll-Ereignis tritt ein, wenn das Bildlauffeld durch eine Maus- oder Tastaturaktion verschoben wurde, jedoch nicht bei einer Value-Veränderung durch das Programm. Der Scroll-Ereignisbehandlungsmethode wird beim Aufruf ein informatives ScrollEventArgs-Objekt übergeben. Seiner Type-Eigenschaft ist zu entnehmen, ob die Value-Veränderung im Rahmen einer noch nicht
abgeschlossenen Verschiebungsaktion geschah. In diesem Fall kann man aus Performanzgründen
auf eine Ereignisbehandlung verzichten, z.B.:
scrollBar.Scroll += new ScrollEventHandler(VScrollBarOnScroll);
. . .
void VScrollBarOnScroll(object sender, ScrollEventArgs e) {
if (e.Type == ScrollEventType.EndScroll)
Refresh();
}
Kapitel 16: Weitere Standardkomponenten für WinForms-Anwendungen
490
Es empfiehlt sich, auch den Bildlauf über das Scroll-Rad der Maus zu unterstützen. Dazu ist lediglich eine simple Ereignismethode zu erstellen
void FormOnMouseWheel(object sender, MouseEventArgs e) {
if (e.Delta > 0)
scrollBar.Value = Math.Max(scrollBar.Minimum,
scrollBar.Value - scrollBar.SmallChange);
else
scrollBar.Value = Math.Min(scrollBar.Maximum - scrollBar.LargeChange + 1,
scrollBar.Value + scrollBar.SmallChange);
Refresh();
}
und beim MouseWheel-Ereignis des Formulars zu registrieren:
MouseWheel += new MouseEventHandler(FormOnMouseWheel);
Über die MouseEventArgs-Eigenschaft Delta erfahren wir, ob der Benutzer das Mausrad vor (Delta = 120) oder zurück gedreht hat (Delta = -120).
Den vollständigen Quellcode des Beispielprogramms finden Sie im Ordner
…\BspUeb\WinForms\Steuerelemente\ScrollBar
16.5 Ein RTF-Editor auf RichTextBox-Basis
Das RichTextBox–Steuerelement implementiert einen brauchbaren Texteditor, der sogar das RTFFormat (Rich Text Format) beherrscht:
Es stammt wie das bereits in Abschnitt 9.4.3 vorgestellte TextBox-Steuerelement von der Klasse
TextBoxBase ab:
TextBoxBase
TextBox
RichTextBox
Für eine Rohversion des RichTextBox-Editors, die immerhin z.B. schon den Datenaustausch via
Zwischenablage und eine mehrstufige Un(Re)do-Funktion beherrscht, muss man erstaunlich wenig
Aufwand betreiben:
using System;
using System.Windows.Forms;
class RichTextBoxEinfach : Form {
RichTextBoxEinfach() {
Text = "Ritchis Texteditor (einfach)";
RichTextBox editor = new RichTextBox();
editor.Dock = DockStyle.Fill;
editor.AcceptsTab = true
Controls.Add(editor);
}
Abschnitt 16.6 Timer-Komponente
491
[STAThread]
static void Main() {
Application.Run(new RichTextBoxEinfach());
}
}
Mit dem Wert DockStyle.Fill für die Dock-Eigenschaft wird dafür gesorgt, dass die RichTextBoxKomponente den gesamten Klientenbereich des Formulars belegt.
Mit dem AcceptsTab-Wert true sorgen wir dafür, dass die RichTextBox-Komponente Tabulatorzeichen per Tastatur entgegen nehmen. Dies klappt per Voreinstellung nicht, weil die Tabulatorteste
von der übergeordneten ContainerControl-Komponente zur Fokusverwaltung verwendet wird
(vgl. Abschnitt 9.2.1).
Das Einfüllen des obigen Beispieltextes (mit Auszeichnungen) gelingt vorläufig nur über die Zwischenablage. Mit etwas Detailarbeit lässt sich auf der RichTextBox-Grundlage jedoch eine zur
Windows-Zugabe WordPad äquivalente Anwendung erstellen. Wir werden die Funktionalität des
Editors in späteren Abschnitten erheblich ausbauen.
16.6 Timer-Komponente
Über die in Abschnitt 13.7 vorgestellte Klasse Timer im Namensraum System.Threading kann
man die CLR beauftragen, eine Methode regelmäßig in einem eigenen Thread auszuführen. Es ist
aber ein gewisser Aufwand zu treiben, wenn Arbeitsergebnisse eines solchen Threads sich auf die
Steuerelemente der Benutzeroberfläche auswirken sollen.
Im Namensraum System.Windows.Forms ist ebenfalls eine Klasse namens Timer vorhanden, die
als (nicht-visuelle) Komponente ereignisorientiert arbeitet und sehr bequem in eine GUIAnwendung einzubinden ist. Die per Timer-Komponente angestoßenen Methodenaufrufe laufen
jedoch im GUI-Thread ab, so dass nur Methoden mit geringem Rechenzeitbedarf in Frage kommen,
damit die Pogrammoberfläche bedienbar bleibt (vgl. Abschnitt 13.7).
Die Timer-Komponente produziert mit einstellbarer Regelmäßigkeit Tick-Ereignisse und bringt
somit die bei diesem Ereignis registrierten Methoden ins Spiel. Allerdings kann man sich auf die
exakte Taktung nicht verlassen, weil eine per Timer ausgelöste Ereignisbearbeitung eine bereits
laufende Ereignisverarbeitung nicht unterbricht. Mit einem int-Wert für die Timer-Eigenschaft
Intervall legt man den gewünschten Abstand zwischen zwei Tick-Ereignissen in Millisekunden
fest. Die CLR verhindert, dass mehrere unerledigte Tick-Ereignisse in der Warteschlange auflaufen.
Im folgenden Beispielprogramm mit Editor (vgl. Abschnitt 16.5) wird für eine per Standardkonstruktor erzeugte Timer-Komponente das Auslöseintervall auf 1000 Millisekunden gesetzt und
eine Behandlungsmethode registriert, welche die aktuelle Zeit in der Titelzeile anzeigt:
using System;
using System.Windows.Forms;
class TimerKomponente : Form {
TimerKomponente() {
RichTextBox editor = new RichTextBox();
editor.Dock = DockStyle.Fill;
Controls.Add(editor);
Timer tim = new Timer();
tim.Interval = 1000;
tim.Tick += new EventHandler(TimerOnTick);
tim.Start();
}
492
Kapitel 16: Weitere Standardkomponenten für WinForms-Anwendungen
void TimerOnTick(object sender, EventArgs e) {
Text = DateTime.Now.ToLongTimeString();
}
[STAThread]
static void Main() {
Application.Run(new TimerKomponente());
}
}
Damit ein Timer tatsächlich damit beginnt, Ereignisse zu feuern, muss seine Start()-Methode aufgerufen oder seine Enabled-Eigenschaft auf den Wert true gesetzt werden. Soll er eine Pause einlegen, ruft man seine Stop()-Methode auf oder setzt seine Enabled-Eigenschaft auf den Wert false.
Die aktuelle Zeit liefert im Beispiel eine Instanz der Struktur DateTime, welche über die statische
Eigenschaft Now dieser Struktur angesprochen wird. Zur benutzerfreundlichen Zeitanzeige wird im
Beispielprogramm die DateTime-Instanzmethode ToLongTimeString() verwendet:
Wir haben die Now-Instanz der DateTime-Struktur schon mehrfach zur Zeitmessung verwendet
und dabei per Ticks-Eigenschaft die Anzahl der 100-Nanosekunden-Intervalle seit dem 1. Januar 1,
00:00:00 Uhr, abgerufen (109 Nanosekunden = 1 Sekunde).
17 Menüs
Wir erweitern das in Abschnitt 16.5 begonnene Editor-Projekt um ein Menü:
Dabei werden die mit .NET 2.0 hinzu gekommenen WinForms-Klassen MenuStrip,
ContextMenuStrip und ToolStripMenuItem verwendet, die im Vergleich zur Vorgängerlösung
aus .NET 1.x
eine erweiterte Funktionalität bieten (insbesondere bei der Positionierung). Wer die traditionelle
Lösung bevorzugt, findet eine Beschreibung in Abschnitt 0.
17.1 Wichtige Klassen und Begriffe
Das Hauptmenü (die Menüleiste) eines Formulars realisiert man durch ein Objekt der Klasse
MenuStrip, das in der Regel am oberen Containerrand angedockt wird:
MenuStrip mainMenu = new MenuStrip();
mainMenu.Dock = DockStyle.Top;
Controls.Add(mainMenu);
Mit den folgenden Anweisungen werden die ToolStripMenuItem-Komponenenten mitFile,
mitEit, mitTools und mitHelp erzeugt und in das Hauptmenü eingefügt:
ToolStripMenuItem mitFile, mitTools, mitHelp;
. . .
mitFile = new ToolStripMenuItem("&Datei");
mitEdit = new ToolStripMenuItem("&Bearbeiten");
mitTools = new ToolStripMenuItem("&Extras");
mitHelp = new ToolStripMenuItem("&Hilfe");
mainMenu.Items.AddRange(new ToolStripItem[]
{mitFile , mitEdit, mitTools, mitHelp});
Vor der weiteren Beschäftigung mit dem Quellcode des Beispiels sollten wir uns um einige Erbschaftsangelegenheiten kümmern und wichtige Begriffe klären. Die Klasse MenuStrip stammt wie
die später zur Realisation eines Kontextmenüs (siehe Abschnitt 17.5) verwendete Klasse ContextMenuStrip von der für Symbolleisten zuständigen Klasse ToolStrip ab, so dass wir eigentlich die
Symbolleisten vor den Menüs behandeln sollten:
Kapitel 17: Menüs
494
Control
ScrollableControl
ToolStrip
MenuStrip
ContextMenuStrip
Bei einem klassischen Windows-Programm sind aber Menüs elementarer und für die Bedienung
wichtiger als die (in Kapitel 20 behandelten) Symbolleisten. 1
Von ihrer gemeinsamen Basisklasse ToolStrip erben MenuStrip und ContextMenuStrip u.a. die
Eigenschaft Items, die auf eine Liste mit ToolStripItem-Objekten zeigt. Diese Liste (ein Objekt
aus der Klasse ToolStripItemCollection) verfügt u.a. über die Methoden Add() und AddRange()
zur Aufnahme von Items. Offenbar ist die Items-Eigenschaft der Klasse ToolStrip ähnlich einzusetzen wie die Controls-Eigenschaft der Klasse Control.
Die Klasse ToolStripMenuItem stammt nicht von Control ab, verfügt aber über eine ähnliche
Ausstattung mit Eigenschaften (z.B. BackColor, ForeColor, Font) und Ereignissen (z.B. Click,
Paint, MouseDown):
Component
ToolStripItem
ToolStripDropDownItem
ToolStripMenuItem
Ein Objekt der Klasse ToolStripMenuItem kann eine Liste mit untergeordneten Objekten derselben Klasse enthalten, wobei …



in der Klasse ToolStripMenuItem eine Eigenschaft DropDownItems vorhanden ist,
die auf ein Objekt der Klasse ToolStripItemCollection zeigt,
das eine Liste mit ToolStripItem-Objekten enthält.
Also kann sowohl MenuStrip als auch ToolStripMenuItem eine Kollektion von untergeordneten
ToolStripMenuItem-Objekten enthalten, wobei die zur Ansprache verfügbaren Eigenschaften unterschiedliche Namen tragen (Items bzw. DropDownItems).
Wird ein ToolStripMenuItem mit gefüllter Liste vom Benutzer ausgewählt (z.B. per Maus), dann
erscheint ein rechteckiges Fenster mit einem Untermenü, z.B.:
1
Neuerdings zeigt die Firma Microsoft allerdings (z.B. beim Internet-Explorer und bei Windows-Vista) die Tendenz,
das Hauptmenü nur noch als Zusatzoption für traditionsbewusste Anwender zu betrachten.
495
Abschnitt 17.2 Menüitems erzeugen und konfigurieren
ToolStripMenuItem-Objekte
Datei
Bearbeiten
Extras
MenuStrip-Objekt
Hilfe
Schriftart
Untermenü
ToolStripMenuItem-Objekte
Große Schrift
Hintergrundfarbe
Man kann das Untermenü aufgrund seines Verhaltens beim Auftritt als DropDown-Menü bezeichnen, was ja auch in Typ- und Eigenschaftsnamen zum Ausdruck kommt (siehe oben). Es existiert
keine Klasse für Untermenüs, weil jedes ToolStripMenuItem-Objekt eine Kollektion untergeordneter Objekte desselben Typs (und damit ein Untermenü) aufnehmen kann.
Neben gewöhnlichen Items (ToolStripMenuItem-Objekten) kann ein Menü (MenuStrip-Objekt)
auch Objekte aus den Klassen ToolStripTextBox und ToolStripComboBox aufnehmen. Bei diesen selten genutzten Klassen handelt es sich um Varianten der Klassen TextBox und ComboBox
(siehe Abschnitt 9.4.3 bzw. 16.2) mit Optimierungen für den Einsatz in einem Menü.
17.2 Menüitems erzeugen und konfigurieren
Soll ein ToolStripMenuItem-Objekt ein Untermenü aufnehmen, eignet sich ein Konstruktoraufruf
mit String-Parameter für den anzuzeigenden Text, z.B.:
mitFile = new ToolStripMenuItem("&Datei");
Allmählich wird es Zeit, die Bedeutung des (optionalen) &-Zeichens in den ToolStripMenuItemBeschriftungen zu erklären. Es legt eine Zugriffstaste fest, die im laufenden Programm …

(spätestens nach Betätigen der Alt-Taste) durch Unterstreichen hervorgehoben wird,

das Click-Ereignis zum Menüitem auslöst, wenn sie nach der Alt-Vorschalttaste gedrückt
wird, wobei ggf. das zugehörige Untermenü erscheint.
Um das „&“-Zeichen als gewöhnlichen Bestandteil der Itembeschriftung zu verwenden, schreibt
man es doppelt.
Steht ein ToolStripMenuItem-Objekt für einen Menübefehl, dann ist ein alternativer Konstruktor
zu verwenden und neben der Beschriftung auch eine Click-Ereignisbehandlungsmethode anzugeben, z.B.:
mitInfo = new ToolStripMenuItem("&Info", null,
new EventHandler(MitInfoOnClick));
mitHelp.DropDownItems.Add(mitInfo);
Im zweiten Parameter der gewählten Konstruktor-Überladung kann man ein Symbol zum Menüitem
angegeben (siehe unten).
Kapitel 17: Menüs
496
Über die Eigenschaft ShortcutKeys lässt sich eine Tastenkombination zum Auslösen des Menübefehls vereinbaren, wobei die Enumeration Keys eine reichhaltige Auswahl an Bezeichnungen für
Tasten(kombinationen) bietet, z.B.:
mitInfo.ShortcutKeys = Keys.F1;
In Beispiel hat der Benutzer also zwei Möglichkeiten, die Methode MitInfoOnClick() per Tastatur aufzurufen:


Alt, H, I
Alt aktiviert die Tastatursteuerung des Menüs, H ruft das Untermenü zum Hauptmenüitem
Hilfe auf und I startet den Ereignishandler zum Untermenüitem Info.
F1
Die Taste(nkombination) zu einem Item erscheint im aufgeklappten Untermenü, sofern die
ToolStripMenuItem-Eigenschaft ShowShortcutKeys den voreingestellten Wert true besitzt, z.B.
Um eine Taste(nkombination) „ohne“ zugehöriges Menüitem mit einer Behandlungsmethode zu
verbinden, setzt man die Visible-Eigenschaft des Menüitems auf den Wert false, z.B.:
mitInfo.Visible = false;
Weil ein Menüitem wiederum Menüitems aufnehmen kann, erstellt man leicht ein mehrstufiges
Menü. Im folgenden Segment des Fensterkonstruktors zum Beispielprogramm wird zunächst ein
Menüitem namens mitFont erzeugt und dem Hauptmenüitem mitTools untergeordnet:
ToolStripMenuItem mitTools,
mitFont, mitCourierNew, mitTimesNewRoman, mitArial, mitCurrentFont;
. . .
mitFont = new ToolStripMenuItem("&Schriftart");
mitTools.DropDownItems.Add(mitFont);
Dann entstehen die Menüitems mitCourierNew, mitTimesNewRoman und mitArial, die in
der DropDownItems-Kollektion von mitFont landen:
EventHandler fontHandler = new EventHandler(MitFontOnClick);
mitCourierNew = new ToolStripMenuItem("&Courier New", null, fontHandler);
mitFont.DropDownItems.Add(mitCourierNew);
mitTimesNewRoman = new ToolStripMenuItem("&Times New Roman", null,
fontHandler);
mitTimesNewRoman.Checked = true;
mitFont.DropDownItems.Add(mitTimesNewRoman);
mitArial = new ToolStripMenuItem("&Arial", null, fontHandler);
mitFont.DropDownItems.Add(mitArial);
Ein vorhandenes Untermenü wird im laufenden Programm automatisch durch einen nach rechts
gerichteten Pfeil angezeigt:
Abschnitt 17.2 Menüitems erzeugen und konfigurieren
497
Mit der Eigenschaft Checked (Voreinstellung: false) wird die Aktivierung eines Items ermittelt
oder festgelegt, z.B. bei mitTimesNewRoman (siehe unten).
Über ein ToolStripSeparator-Objekt fügt man eine horizontale Trennlinie in ein Untermenü ein,
z.B.:
mitTools.DropDownItems.Add(new ToolStripSeparator());
Im Beispielprogramm stehen die drei Items im Untermenü Extras > Schriftart für eine Gruppe
sich gegenseitig ausschließender Optionen und verwenden denselben Click-Handler:
protected void MitFontOnClick(object sender, EventArgs e) {
mitCurrentFont.Checked = false;
mitCurrentFont = (ToolStripMenuItem)sender;
mitCurrentFont.Checked = true;
if (sender == mitCourierNew) {
editor.Font = (mitSize.Checked ? fontCN16 : fontCN12);
} else
if (sender == mitTimesNewRoman) {
editor.Font = (mitSize.Checked ? fontTNR16 : fontTNR12);
} else {
editor.Font = (mitSize.Checked ? fontA16 : fontA12);
}
}
Das RichTextBox-Steuerelement erhält die gewünschte Schriftart, wobei ein eventueller Benutzerwunsch nach großen Schriften zu berücksichtigen ist, was unter Verwendung des Konditionaloperators geschieht.
Mit den ersten Anweisungen der Methode MitFontOnClick() wird für eine korrekte Aktivierung des Menüitems zur aktuell gewählten Schrift über die Eigenschaft Checked gesorgt. Leider
existiert für Items mit Optionsfeld-Logik keine Gruppenbildung mit dem Effekt, dass genau ein
Item aktiviert ist. Im Beispielprogramm sorgt folgende Technik (übernommen von Petzold 2002, S.
572ff) für stets korrekte Markierungen:

Die ToolStripMenuItem-Referenz mitCurrentFont zeigt stets auf das aktuell aktivierte Schriftarten-Menüitem.

In der Methode MitFontOnClick() wird zunächst die aktuelle Aktivierung aufgehoben.
Dann erhält mitCurrentFont die Adresse des vom Benutzer gewählten Items, und
schließlich wird dieses Item aktiviert.
Über die in Abschnitt 17.3 vorzustellenden Symbole zu Menüitems könnte man auch die Optionsfeld-Optik herstellen. Dieser Aufwand lohnt sich aber kaum, weil die Benutzer mittlerweile an Häkchen als Ersatz für Radioknöpfe gewöhnt sind.
Bei unzureichender Formularbreite verschwinden per Voreinstellung Items, die in der Menüleiste
keinen Platz mehr finden, z.B.:
Kapitel 17: Menüs
498
Grundlage für dieses Verhalten ist die Ausprägung ToolStripLayoutStyle.HorizontalStackWithOverflow der ToolStrip-Eigenschaft LayoutStyle. Mit der alternativen Ausprägung Flow
mainMenu.LayoutStyle = ToolStripLayoutStyle.Flow;
erhält man bei horizontalem Platzmangel einen „Zeilenumbruch“:
Über die ToolStrip-Eigenschaft AllowItemReorder
mainMenu.AllowItemReorder = true;
kann man dem Benutzer erlauben, bei gedrückter Alt-Taste per Maus die Reihenfolge der Menüitems zu verändern, z.B.:
Allerdings sollte man die individuelle Anordnung mit den in Abschnitt 19 behandelten Techniken
auch zwischen den Sitzungen speichern
17.3 Symbole für Menüitems
Beim Bearbeiten-Untermenü mit den Items
ToolStripMenuItem
mitEdit, mitCut, mitCopy, mitPaste, mitDel;
sind die Anwender daran gewöhnt, dass in dem senkrechten Streifen am linken Rand des Untermenüs Symbole zu den Items erscheinen:
Man kann diese Symbole folgendermaßen realisieren:

Bitmap-Dateien mit einer (16  16)-Pixelmatrix erstellen
Wer sich nicht selbst künstlerisch betätigen will, findet geeignete Bitmap-Dateien im Zubehör vieler Entwicklungsumgebungen oder im Internet. Anwender der Microsoft Visual Stu-
Abschnitt 17.3 Symbole für Menüitems

499
dio C# 2008 Express Edition haben nach der Registrierung (z.B. über das Item Produkt
registrieren im Hilfemenü der Entwicklungsumgebung) Zugang zum Visual Studio 2005 1
Registration Benefits Portal. Von dort kann mit der IconBuffet Studio Edition Icon Suite eine Sammlung mit zahlreichen professionellen und lizenzfreien Symbolen bezogen werden.
Bitmap-Objekte erstellen und eine Transparentfarbe festlegen, z.B.:
Bitmap bmp = new Bitmap("cut.bmp");
bmp.MakeTransparent(bmp.GetPixel(0, 0));

In den ToolStripMenuItem-Konstruktoren das Bitmap-Objekt als Parameter übergeben,
z.B.:
mitCut = new ToolStripMenuItem("&Ausschneiden", bmp, MitCutOnClick);
Dem als Beispiel verwendeten Menüitem Bearbeiten > Ausschneiden wird noch die übliche
Tastenkombination zugeordnet:
mitCut.ShortcutKeys = Keys.Control | Keys.X;
Analog werden auch die restlichen Items im Bearbeiten-Untermenü des Beispielprogramms erzeugt:
bmp = new Bitmap("copy.bmp");
bmp.MakeTransparent(bmp.GetPixel(0, 0));
mitCopy = new ToolStripMenuItem("&Kopieren", bmp, MitCopyOnClick);
mitCopy.ShortcutKeys = Keys.Control | Keys.C;
bmp = new Bitmap("paste.bmp");
bmp.MakeTransparent(bmp.GetPixel(0, 0));
mitPaste = new ToolStripMenuItem("&Einfügen", bmp, MitPasteOnClick);
mitPaste.ShortcutKeys = Keys.Control | Keys.V;
bmp = new Bitmap("del.bmp");
bmp.MakeTransparent(bmp.GetPixel(0, 0));
mitDel = new ToolStripMenuItem("&Löschen", bmp, MitDeleteOnClick);
mitDel.ShortcutKeys = Keys.Delete;
Schließlich wird das per DropDownItems-Eigenschaft ansprechbare ToolStripItemCollectionObjekt im Menüitem mitEdit über die Methode AddRange() beauftragt, die neu erstellten Items
aufzunehmen:
mitEdit.DropDownItems.AddRange(new ToolStripItem[]
{mitCut, mitCopy, mitPaste, mitDel});
In den Click-Ereignisbehandlungsmethoden zu den Bearbeiten-Menüitems kann man auf die
RichTextBox-Funktionalität zurückgreifen, z.B.:
protected void MitCutOnClick(object sender, EventArgs e) {
editor.Cut();
}
protected void MitDeleteOnClick(object sender, EventArgs e) {
if (editor.SelectionLength==0 && editor.SelectionStart < editor.TextLength)
editor.Select(editor.SelectionStart, 1);
editor.SelectedText = "";
}
Indem man die RichTextBox-Eigenschaft SelectedText auf die leere Zeichenfolge setzt,
editor.SelectedText = "";
löscht man den ausgewählten Text.
Im Rahmen einer Übungsaufgabe (siehe Abschnitt 17.8) sollen Sie das Bearbeiten-Menü unseres
Editors noch um einige Funktionen erweitern (Rückgängig, Wiederholen, Alles markieren).
1
Dies ist kein Tippfehler. Ein Visual Studio 2008 Registration Benefits Portal scheint es nicht zu geben.
500
Kapitel 17: Menüs
17.4 Verfügbarkeit von Menüitems dynamisch anpassen
Ist ein Menüitem momentan nicht nutzbar (z.B. Bearbeiten > Kopieren ohne markierten Text),
dann sollte seine Enabled-Eigenschaft auf false gesetzt werden. Es wird dann abgeblendet dargestellt, und sein Click-Ereignis ist nicht auslösbar, z.B.:
Über das ToolStripMenuItem-Ereignis DropDownOpening, das vor dem Öffnen eines Untermenüs eintritt, kann man für eine rechtzeitige Enabled-Aktualisierung sorgen, z.B. bei unserem Bearbeiten-Untermenü:
protected void MitEditOnDropDownOpening(object sender, EventArgs e) {
if (editor.SelectionLength > 0) {
mitCut.Enabled = true;
mitCopy.Enabled = true;
mitDel.Enabled = true;
} else {
mitCut.Enabled = false;
mitCopy.Enabled = false;
mitDel.Enabled = false;
}
if (Clipboard.ContainsText()) {
mitPaste.Enabled = true;
} else {
mitPaste.Enabled = false;
}
}
Über die Methode ContainsText() der Klasse Clipboard sorgen wir dafür, dass unser Editor ausschließlich Text (also z.B. keine Bilder) aus der Windows-Zwischenablage übernimmt. Bei der Kooperation mit der Zwischenablage kommt unser Programm mit der COM-Technologie von Windows in Kontakt, so dass nach Abschnitt 11.1die Main()-Methode das Attribut STAThread benötigt:
[STAThread]
static void Main() {
Application.EnableVisualStyles();
Application.Run(new RitchisTexteditor());
}
Wir dekorieren seit Abschnitt 11.1 einer Anweisung von Microsoft folgend in allen WinFormsProgrammen die Main()-Methode mit diesem Attribut, obwohl bei vielen Programmen eine Unterlassung keine erkennbaren Folgen hat. Beim Einsatz der Klasse Clipboard ist das Attribut aber
unverzichtbar.
Die Methode zur Verfügbarkeits-Aktualisierung der Bearbeiten-Menüitems wird beim DropDownOpening-Ereignis des Hauptmenü-Items mitEdit registriert:
mitEdit.DropDownOpening += MitEditOnDropDownOpening;
Mit Hilfe der ToolStripItemCollection-Methoden zum Einfügen oder Entfernen von Menüitems
(Add(), AddRange(), Clear(), Remove() etc.) kann man in einem DropDownOpening-Handler
die Liste der Menüitems kontextspezifisch anpassen.
Abschnitt 17.5 Kontextmenü
501
Über das DropDownOpened-Ereignis kann man z.B. dafür sorgen, dass zum gerade aktivierten
Menüitem eine Erläuterung in der Statuszeile erscheint (siehe unten).
Den vollständigen Quellcode zum aktuellen Entwicklungsstand des Beispielprogramms finden Sie
im Ordner
…\BspUeb\WinForms\Menüs\DotNet 2\Hauptmenü
17.5 Kontextmenü
Unser Editor (genauer: seine RichTextBox-Komponente) sollte noch ein Kontextmenü mit den
Bedienoptionen des Bearbeiten-Menüs erhalten, z.B.
Bei der Erstellung des Kontextmenüs zu einem Steuerelement kann man genauso vorgehen wie bei
der Erstellung eines Hauptmenüs, wobei aber ein Container aus der Klasse ContextMenuStrip zu
verwenden ist, z.B.:
ContextMenuStrip contextMenu = new ContextMenuStrip();
Weil ein ToolStripMenuItem nicht zu zwei Containern gehören kann, benötigen wir im Beispiel
zu jeder Bearbeiten-Funktion zwei ToolStripMenuItem-Objekte, eines für das Hauptmenü und
eines für das Kontextmenü:
ToolStripMenuItem
mitCut, mitCopy, mitPaste, mitDel,
cmitCut, cmitCopy, cmitPaste, cmitDel;
Das mit ToolStripMenuItem-Objekten ausgestattete Kontextmenü
contextMenu.Items.AddRange(
new ToolStripMenuItem[] {cmitCut, cmitCopy, cmitPaste, cmitDel});
wird der ContextMenuStrip-Eigenschaft des Steuerelements zugewiesen, z.B.:
editor.ContextMenuStrip = contextMenu;
Über das ContextMenuStrip-Ereignis Opening, das vor dem Öffnen des Kontextmenüs eintritt,
kann man die Enabled-Eigenschaften der enthaltenen Menüitems rechtzeitig aktualisieren oder das
Kontextmenü passend zusammenstellen, z.B.:
contextMenu.Opening += ContextMenuOnOpening;
. . .
protected void ContextMenuOnOpening(object sender, CancelEventArgs e) {
if (editor.SelectionLength > 0) {
cmitCut.Enabled = true;
cmitCopy.Enabled = true;
cmitDel.Enabled = true;
} else {
cmitCut.Enabled = false;
cmitCopy.Enabled = false;
cmitDel.Enabled = false;
}
}
Kapitel 17: Menüs
502
Über die Cancel-Eigenschaft des als Aktualparameter übergebenen CancelEventArgs-Objekts
lässt sich der Auftritt des Kontextmenüs blockieren. Wenn das Kontextmenü unseres Editors nur bei
vorhandener Textmarkierung sinnvoll wäre, könnten wir einen nutzlosen Auftritt so verhindern:
protected void ContextMenuOnOpening(object sender, CancelEventArgs e) {
if (editor.SelectionLength == 0)
e.Cancel = true;
}
Mit Hilfe der ContextMenuStrip-Methode Show() kann man ein Kontextmenü, das mit keinem
Steuerelement verbunden sein muss, programmgesteuert an einer frei wählbaren Bildschirmposition
auftauchen zu lassen.
Den vollständigen Quellcode zum aktuellen Entwicklungsstand des Beispielprogramms finden Sie
im Ordner
…\BspUeb\WinForms\Menüs\DotNet 2\Kontextmenü
17.6 Unterstützung durch die Entwicklungsumgebungen
Um die exzellente Unterstützung von Microsofts Entwicklungsumgebungen (Visual C# Express
Edition bzw. Visual Studio 2008) bei der Erstellung von Menüs kennen zu lernen, starten wir ein
WinForms-Projekt und erstellen die Bedienoberfläche eines einfachen Editors im RAD-Modus
(Rapid Application Development).
Befördern Sie im Formulardesigner aus der Toolbox ein ToolStrip - Objekt auf das Formular:
Platzierungshinweis
Ergebnis (Menüleiste markiert)
Ergänzen Sie eine RichTextBox - Komponente,
und wählen Sie im Eigenschaftsfenster zur Dock-Eigenschaft der Editorkomponente den Wert
DockStyle.Fill:
Abschnitt 17.6 Unterstützung durch die Entwicklungsumgebungen
503
Der entstehende Editor verhält sich wunschgemäß:
Eine Inspektion des automatisch vom Formulardesigner in der Datei Form1.Designer.cs erstellten
Quellcodes zeigt, dass die RichTextBox- und die ToolStrip-Komponente in der gemäß Abschnitt
9.4.4.3 korrekten Reihenfolge als Top-Dock - Konkurrenten in die Steuerelementliste des Formulars
aufgenommen worden sind:
this.Controls.Add(this.richTextBox1);
this.Controls.Add(this.menuStrip1);
Wird die notwendige Controls-Reihenfolge von Dock-Konkurrenten beim Einfügen per Formulardesigner anwendet, überlagert die Symbolleiste (DockStyle.Top) den Editor (DockStyle.Fill), und
die ersten Textzeilen bleiben hinter der Symbolleiste verborgen. Sachwissen und konzentriertes
Arbeiten führen leider zur falschen Reihenfolge der Controls-Aufrufe. Man muss also entweder
naiv das oberste Steuerelement zuerst einfügen oder wissen, dass im Designer in der „falschen“
Reihenfolge vorzugehen ist.
Der Formulardesigner unterstützt die Menübestückung (ohne weitere Überraschungseffekte) durch
ein Kombiwerkzeug:


Ein neues Menüitem entsteht, sobald man seine Beschriftung an Stelle des Platzhaltertextes
Hier eingeben schreibt, z.B.:
Neben einem vorhandenen Hauptmenüitem oder auch in ein Untermenü lassen sich weitere
Items einfügen.
Mit einem Bindstrich an Stelle einer Itembeschriftung erreicht man eine Horizontale Trennlinie.
Kapitel 17: Menüs
504

Über den Pfeil am rechten eines Platzhalters ist ein DropDown-Menü verfügbar, mit dem
sich neben Menüitems auch Objekte aus den Klassen ToolStripTextBox und ToolStripComboBox einfügen lassen:

Über den kleinen Pfeil am oberen rechten Rand der Menüleiste erhält man ein lokales Eigenschaftsfenster mit MenuStrip-Aufgaben:
Hier lässt sich z.B. das Einbetten der Menüleiste in einen ToolStripContainer anfordern
(siehe Abschnitt 20.1.2). Über Elemente bearbeiten erreicht man den Elementauflistungs-Editor (siehe unten). Über Standardelemente einfügen erhält man mit einem
Mausklick eine komplette Standardmenüleiste (siehe unten).
Eine eingefügte Komponente kann man per Eigenschaftsfenster bequem konfigurieren.
Über den Erweiterungsschalter in der markierten Items-Zeile des Eigenschaftsfensters zur Menüleiste
ist mit dem Elementauflistungs-Editor ein weiteres Hilfsmittel zur Symbolleistungsgestaltung
erreichbar:
Abschnitt 17.6 Unterstützung durch die Entwicklungsumgebungen
505
Hier lassen sich Steuerelemente aus allen zulässigen Klassen bequem einfügen, positionieren und
konfigurieren.
Als Clou bietet das Eigenschaftsfenster zur Menüleiste in der Befehlszone am unteren Rand, die
eventuell zunächst per Kontextmenü
aktiviert werden muss, über den Link Standardelemente einfügen die Möglichkeit, mit einem
einzigen Mausklick alle Standardkomponenten auf die Menüleiste zu setzen:
Dieselbe Funktion ist auch über das oben beschriebene lokale Eigenschaftsfenster mit MenuStripAufgaben zu erreichen. Das Ergebnis ist beeindruckend:
Kapitel 17: Menüs
506
können unvorsichtige Zielprogramme bei der Übernahme von Zwischenablageninhalten in Schwierigkeiten
17.7 Die Menütechnik aus .NET 1
In diesem Abschnitt wird die aus Kompatibilitätsgründen weiterhin unterstützte Menütechnik aus
.NET 1 vorgestellt. Wer auf frei eine bewegliche und dynamisch auf horizontalen Platzmangel reagierende Menüleiste verzichten kann, spart im Vergleich zur neuen, ToolStrip-basierten Lösung
einige Zeilen Quellcode. Ein weiterer Vorteil besteht darin, dass eine .NET 1 - Menüleiste nicht
zum Klientenbereich des Fensters gehört, also nicht mit dortigen Anzeigen kollidieren kann. Dies
wird vom folgenden Beispielprogramm mit Grafikausgabe
using System;
using System.Windows.Forms;
using System.Drawing;
class Kollision : Form {
public Kollision() {
Text = "Kollision";
Size = new Size(220, 100);
ResizeRedraw = true;
this.Menu = new MainMenu();
MenuItem mitFile, mitEdit, mitTools, mitHelp;
mitFile = Menu.MenuItems.Add("&Datei");
mitEdit = Menu.MenuItems.Add("&Bearbeiten");
mitTools = Menu.MenuItems.Add("&Extras");
mitHelp = Menu.MenuItems.Add("&Hilfe");
}
protected override void OnPaint(PaintEventArgs e) {
Pen stift = new Pen(Color.Black, 2);
e.Graphics.DrawLine(stift, 0, 0, ClientSize.Width, ClientSize.Height);
}
[STAThread]
static void Main() {
Application.EnableVisualStyles();
Application.Run(new Kollision());
}
}
demonstriert:
Die .NET 2 - Lösung für dasselbe Hauptmenü ist etwas aufwändiger
MenuStrip mainMenu = new MenuStrip();
Controls.Add(mainMenu);
ToolStripMenuItem mitFile, mitEdit, mitTools, mitHelp;
mitFile = new ToolStripMenuItem("&Datei");
mitEdit = new ToolStripMenuItem("&Bearbeiten");
mitTools = new ToolStripMenuItem("&Extras");
507
Abschnitt 17.7 Die Menütechnik aus .NET 1
mitHelp = new ToolStripMenuItem("&Hilfe");
mainMenu.Items.AddRange(
new ToolStripItem[] { mitFile, mitEdit, mitTools, mitHelp });
und führt zu einer Kollision im Klientenbereich:
Natürlich ist dieses Problem leicht zu beheben, z.B. durch die Grafikausgabe auf der Oberfläche
eines Steuerelements aus der Klasse Panel statt auf der Oberfläche des Formulars.
17.7.1 Wichtige Klasen und Begriffe
Bei der .NET 1 - Lösung wird das Hauptmenü (die Menüzeile) eines Formulars durch ein Objekt
der Klasse MainMenu realisiert, das der Form-Eigenschaft Menu zugewiesen wird:
this.Menu = new MainMenu();
Mit den folgenden Anweisungen werden die MenuItem-Komponenenten mitDatei,
mitExtras und mitHilfe erzeugt und in das Hauptmenü des Formulars eingefügt:
MenuItem mitFile, mitEdit, mitTools, mitHelp;
. . .
mitFile = Menu.MenuItems.Add("&Datei");
mitEdit = Menu.MenuItems.Add("&Bearbeiten");
mitTools = Menu.MenuItems.Add("&Extras");
mitHelp = Menu.MenuItems.Add("&Hilfe");
Vor der weiteren Beschäftigung mit dem Quellcode des Beispiels werden Erbschaftsangelegenheiten erläutert und wichtige Begriffe erklärt. Die Klassen MainMenu, ContextMenu (siehe Abschnitt 17.7.3) und MenuItem stammen von der gemeinsamen (abstrakten) Basisklasse Menu ab:
Component
Menu
MainMenu
MenuItem
ContextMenu
Menu stammt nicht von Control stammt, so dass die Eigenschaften dieser Klasse wie BackColor,
ForeColor, Font nicht zur Verfügung stehen. Um die Farbe oder Schriftart eines Menüs per Programm zu ändern, muss einiger Aufwand betrieben und die Owner Draw - Technik verwendet werden (siehe z.B. Petzold 2002, S.587ff). Benutzer können hingegen diese Merkmale relativ leicht per
Windows-Systemsteuerung über die Eigenschaften der Anzeige modifizieren.
Von ihrer gemeinsamen Basisklasse erben MainMenu und MenuItem u.a. die Eigenschaft MenuItems, die auf eine Liste mit MenuItem-Objekten zeigt. Diese Liste (ein Objekt aus der Menuinternen Klasse MenuItemCollection) verfügt u.a. über die mehrfach überladene und komfortable
Add()-Methode zum Erzeugen von Menüitems. Offenbar ist die MenuItems-Eigenschaft der Klasse Menu ähnlich einzusetzen wie die Controls-Eigenschaft der Klasse Control.
Wird ein MenuItem-Objekt mit vorhandener Kollektion von untergeordneten MenuItem-Objekten
vom Benutzer ausgewählt (z.B. per Maus), dann erscheint ein rechteckiges Fenster mit einem Untermenü:
Kapitel 17: Menüs
508
MenuItem-Objekte
Datei
Bearbeiten
Extras
Schriftart
MenuItem-Objekte
MainMenu-Objekt
Hilfe
Untermenü
Große Schrift
Es existiert keine spezielle Klasse für Untermenüs, weil jedes Menu-Objekt eine Kollektion untergeordneter MenuItem-Objekte (und damit ein Untermenü) aufnehmen kann.
17.7.2 Menüitems erzeugen
Statt wie in obigen Beispielen MenuItem-Objekte implizit durch einen Aufruf der Menu-Methode
Add() zu erzeugen, kann man sie mit erweiterter Gestaltungsmöglichkeit auch über einen expliziten
Konstruktor-Aufruf erstellen, z.B.:
MenuItem mitInfo;
. . .
mitInfo = new MenuItem("&Info",
new EventHandler(MitInfoOnClick),
Shortcut.F1);
mitHelp.MenuItems.Add(mitInfo);
Hier wird neben der Text-Eigenschaft des Menüitems (mit &-Kennzeichnung der Zugriffstaste, vgl.
Abschnitt 17.2) auch ein EventHandler für sein Click-Ereignis und eine auslösende Taste(nkombination) zum Click-Ereignis vereinbart, wobei die Enumeration Shortcut eine reichhaltige Auswahl
an Bezeichnungen für Tasten(kombinationen) bietet.
Weil ein Menüitem wiederum Menüitems aufnehmen kann, erstellt man leicht ein mehrstufiges
Menü. Im folgenden Segment des Fensterkonstruktors zum Beispielprogramm wird zunächst per
Add()-Aufruf ein Menüitem namens mitFont erzeugt und dem Hauptmenüitem Extras untergeordnet:
MenuItem mitFont,mitCourierNew,mitTimesNewRoman,mitArial,mitCurrentFont;
. . .
mitFont = mitTools.MenuItems.Add("&Schriftart");
Dann entstehen die Menüitems mitCourierNew, mitTimesNewRoman und mitArial, die in
der Item-Kollektion von mitFont landen:
EventHandler fontHandler = new EventHandler(MitFontOnClick);
mitCourierNew = new MenuItem("&Courier New", fontHandler);
mitCourierNew.RadioCheck = true;
mitFont.MenuItems.Add(mitCourierNew);
mitTimesNewRoman = new MenuItem("&Times New Roman", fontHandler);
mitTimesNewRoman.Checked = true;
mitTimesNewRoman.RadioCheck = true;
mitCurrentFont = mitTimesNewRoman;
mitFont.MenuItems.Add(mitTimesNewRoman);
mitArial = new MenuItem("&Arial", fontHandler);
mitArial.RadioCheck = true;
mitFont.MenuItems.Add(mitArial);
Die im Quellcode auftauchenden Menu-Eigenschaften Checked und RadioCheck werden gleich
behandelt. Mit der Referenzvariablen mitCurrentFont wird der Quellcode zum Umschalten
zwischen den Schriftarten vereinfacht (siehe unten).
Abschnitt 17.7 Die Menütechnik aus .NET 1
509
Das Untermenü zum Hauptmenüitem Bearbeiten:
mitUndo = mitEdit.MenuItems.Add("&Rückgängig", new
EventHandler(MitUnRedoOnClick));
mitUndo.Shortcut = Shortcut.CtrlZ;
mitRedo = mitEdit.MenuItems.Add("&Wiederholen", new
EventHandler(MitUnRedoOnClick));
mitRedo.Shortcut = Shortcut.CtrlY;
mitEdit.MenuItems.Add("-");
mitCut = mitEdit.MenuItems.Add("&Ausschneiden", new
EventHandler(MitCutOnClick));
mitCut.Shortcut = Shortcut.CtrlX;
mitCopy = mitEdit.MenuItems.Add("&Kopieren", new
EventHandler(MitCopyOnClick));
mitCopy.Shortcut = Shortcut.CtrlC;
mitPaste = mitEdit.MenuItems.Add("&Einfügen", new
EventHandler(MitPasteOnClick));
mitPaste.Shortcut = Shortcut.CtrlV;
mitDel = mitEdit.MenuItems.Add("&Löschen", new
EventHandler(MitDeleteOnClick));
mitDel.Shortcut = Shortcut.Del;
mitEdit.MenuItems.Add("-");
mitSelectAll = mitEdit.MenuItems.Add("Alles &markieren", new
EventHandler(MitSelectAllOnClick));
mitSelectAll.Shortcut = Shortcut.CtrlA;
Weil keine Add()-Überladung mit Shortcut-Parameter existiert, wird die MenuItem-Eigenschaft
Shortcut in einer separaten Anweisung versorgt. In den Click-Ereignisbehandlungsmethoden zu
den Bearbeiten-Menüitems kann man sich meist auf den Aufruf von RichTextBox-Methoden
beschränken, z.B.:
protected void MitCutOnClick(object sender, EventArgs e) {
editor.Cut();
}
Durch ein Menüitem mit Bindestrich als Wert der Text-Eigenschaft fügt man eine horizontale
Trennlinie in ein Untermenü ein, z.B.:
mitEdit.MenuItems.Add("-");
Die drei Items im Menü Extras > Schriftart stehen für eine Gruppe sich gegenseitig ausschließender Optionen und verwenden denselben Click-Handler:
protected void MitFontOnClick(object sender, EventArgs e) {
mitCurrentFont.Checked = false;
mitCurrentFont = (MenuItem)sender;
mitCurrentFont.Checked = true;
if (sender == mitCourierNew) {
editor.Font = (mitSize.Checked ? fontCN16 : fontCN12);
} else
if (sender == mitTimesNewRoman) {
editor.Font = (mitSize.Checked ? fontTNR16 : fontTNR12);
} else {
editor.Font = (mitSize.Checked ? fontA16 : fontA12);
}
}
Das RichTextBox-Steuerelement erhält die gewünschte Schriftart, wobei ein eventueller Benutzerwunsch nach großen Schriften zu berücksichtigen ist, was unter Verwendung des Konditionaloperators geschieht.
Mit den ersten Anweisungen der Methode wird für eine korrekte Markierung des Menüitems zur
aktuell gewählten Schrift gesorgt. Über seine Checked-Eigenschaft lässt sich ein Menüitem als
Kapitel 17: Menüs
510
gewählt markieren. Ein benutzerfreundliches Programm wählt dabei einen adäquaten Markierungsstil über die Eigenschaft RadioCheck:
 Menüitems zum Ein- bzw. Ausschalten eines Merkmals
Bei einem Menüitem zum Ein- bzw. Ausschalten eines Merkmals eignet sich der voreingestellte RadioCheck-Wert false, wobei der eingeschaltete Zustand durch ein Häkchen markiert wird. Im Beispielprogramm zeigt die Menüoption Extras > Große Schrift dieses
Verhalten.
 Menüitems zur Wahl aus einer Menge sich gegenseitig ausschließender Alternativen
Dient eine Gruppe von Menüitems zur Wahl aus einer Menge sich gegenseitig ausschließender Alternativen, sollten alle RadioCheck–Eigenschaften auf true gesetzt werden. Dann
führt der Checked-Wert true zu einem Markierungsstil wie bei Optionsfeldern. Im Beispielprogramm zeigen die Items im Untermenü zu Extras > Schriftart dieses Verhalten.
Leider existiert für Items mit Optionsfeld-Logik keine Gruppenbildung mit dem Effekt, dass genau
ein Item markiert ist. Im Beispielprogramm sorgt folgende Technik (übernommen von Petzold
2002, S. 572ff) für stets korrekte Markierungen:

Die MenuItem-Referenz mitCurrentFont zeigt stets auf das aktuell markierte Schriftartenitem.

In MitFontOnClick() wird zunächst die aktuelle (alte) Markierung aufgehoben. Dann
erhält mitCurrentFont die Adresse des vom Benutzer gewählten Items, und schließlich
wird dieses Item markiert.
Ist ein Menüitem momentan nicht nutzbar (z.B. Bearbeiten > Kopieren ohne markierten Text),
dann sollte seine Enabled-Eigenschaft auf false gesetzt werden. Es wird dann abgeblendet dargestellt, und sein Click-Ereignis ist nicht auslösbar. Über das MenuItem-Ereignis Popup, das vor
dem Öffnen eines Untermenüs eintritt, kann man für eine rechtzeitige Enabled-Aktualisierung sorgen, z.B.:
mitEdit.Popup += new EventHandler(MitEditOnPopup);
. . .
protected void MitEditOnPopup(object sender, EventArgs e) {
if (editor.CanUndo) {
mitUndo.Enabled = true;
} else {
mitUndo.Enabled = false;
}
if (editor.CanRedo) {
mitRedo.Enabled = true;
} else {
mitRedo.Enabled = false;
}
if (editor.SelectionLength > 0) {
mitCut.Enabled = true;
mitCopy.Enabled = true;
mitDel.Enabled = true;
} else {
mitCut.Enabled = false;
mitCopy.Enabled = false;
mitDel.Enabled = false;
}
if (Clipboard.ContainsText()) {
mitPaste.Enabled = true;
} else {
mitPaste.Enabled = false;
}
if (editor.TextLength > 0) {
mitSelectAll.Enabled = true;
} else {
mitSelectAll.Enabled = false;
}
}
Abschnitt 17.7 Die Menütechnik aus .NET 1
511
Über die Methode ContainsText() der Klasse Clipboard sorgen wir dafür, dass unser Editor ausschließlich Text (also z.B. keine Bilder) aus der Windows-Zwischenablage übernimmt.
Mit Hilfe der MenuItemCollection-Methoden zum Einfügen oder Entfernen von Menüitems
(Add(), Clear(), Remove() etc.) gelingt es, per Popup-Handler ein Untermenü komplett neu aufzubauen, um eine Liste von Menüitems stets aktuell zu halten
Über das Select-Ereignis kann man z.B. dafür sorgen, dass zum gerade markierten Menüitem eine
Erläuterung in der Statuszeile erscheint (siehe unten).
Um eine Taste(nkombination) „ohne“ zugehöriges Menüitem mit einer Behandlungsmethode zu
verbinden, setzt man die Visible-Eigenschaft des Menüitems auf den Wert false.
17.7.3 Kontextmenü
Das Kontextmenü zu einem Steuerelement wird in .NET 1 - Technik über ein Objekt der Klasse
ContextMenu realisiert (siehe Abstammungsdiagramm in Abschnitt 17.7.1). Dem Konstruktor
übergibt man ein Array mit MenuItem-Objekten. So lässt sich im Editorprojekt zur RichTextBoxKomponente ein Kontextmenü
mit den Bedienoptionen des Bearbeiten-Menüs realisieren:
MenuItem mitUndo, mitRedo, mitCut, mitCopy, mitPaste, mitDel, mitSelectAll,
cmitUndo, cmitRedo, cmitCut, cmitCopy, cmitPaste, cmitDel, cmitSelectAll;
. . .
cmitUndo = new MenuItem("&Rückgängig",new EventHandler(MitUnRedoOnClick));
cmitRedo = new MenuItem("&Wiederholen", new EventHandler(MitUnRedoOnClick));
cmitCut = new MenuItem("&Ausschneiden", new EventHandler(MitCutOnClick));
cmitCopy = new MenuItem("&Kopieren", new EventHandler(MitCopyOnClick));
cmitPaste = new MenuItem("&Einfügen", new EventHandler(MitPasteOnClick));
cmitDel = new MenuItem("&Löschen", new EventHandler(MitDeleteOnClick));
cmitSelectAll = new MenuItem("Alles &markieren",
new EventHandler(MitSelectAllOnClick));
MenuItem[] cmi = {cmitUndo, cmitRedo, new MenuItem("-"),
cmitCut, cmitCopy, cmitPaste, new MenuItem("-"), cmitSelectAll};
Weil ein MenuItem nicht zu zwei Containern gehören kann, benötigen wir im Beispielprogramm
zu jeder Bearbeiten-Funktion zwei MenuItem-Objekte, eines für das Hauptmenü und eines für
das Kontextmenü.
Das ContextMenu-Objekt wird der Control-Eigenschaft des RichTextBox-Steuerelements zugewiesen:
editor.ContextMenu = new ContextMenu(cmi);
Über das ContextMenu-Ereignis Popup, das vor dem Öffnen des Kontextmenüs eintritt, kann man
die Enabled-Eigenschaften der enthaltenen Menüitems rechtzeitig aktualisieren oder das Kontextmenü passend zusammenstellen, z.B.:
editor.ContextMenu.Popup += MitEditOnPopup;
Kapitel 17: Menüs
512
Den vollständigen Quellcode mit der .NET 1 - Menülösung zum Editorprojekt finden Sie im Ordner
…\BspUeb\WinForms\Menüs\DotNet 1
17.8 Übungsaufgaben zu Kapitel 17
1) Nehmen Sie an unserem Editorprojekt ausgehend vom Entwicklungsstand in
…\BspUeb\WinForms\Menüs\DotNet 2\Kontextmenü
folgende Änderungen vor:

Erweitern Sie das Bearbeiten-Untermenü:
Die zusätzlich benötigten Click-Ereignishandler sind mit Hilfe der RichTextBox-Methoden
Undo(), ReDo() und SelectAll() leicht zu realisieren. Setzen Sie die Enabled-Eigenschaft
der Menüitems je nach Verfügbarkeit auf true oder false. Definieren Sie Tastenkombinationen zum Starten der Click-Ereignismethoden. Verwenden Sie bei Bedarf bitweise OderVerknüpfungen von zwei Keys-Werten, z.B.:
mitUndo.ShortcutKeys = Keys.Control | Keys.Z;

Verbessern Sie analog auch das Kontextmenü (allerdings ohne ShortcutKeys):
18 Dialogfenster
Nur die wenigsten Windows-Anwendungen beschränken sich auf ein einziges Formular. Auch in
unseren (möglichst einfach gestrickten) Beispielprogrammen kamen gelegentlich zusätzliche Formulare zum Einsatz, wobei wir uns aber auf MessageBox.Show()-Aufrufe und Standarddialoge
beschränkt haben. Wir beschäftigen uns nun mit der Verwendung von selbst entworfenen Dialogfenstern und erweitern anschließend unser Wissen über die Standarddialoge.
Bei WinForms-Programmen werden auch Dialogfenster durch Objekte einer aus System.Windows.Forms.Form abgeleiteten Klasse realisiert; es gibt also keine spezielle Basisklasse für Dialogfenster. Der Begriff Dialogfenster steht für die Verwendung von (zusätzlichen) Form-Objekten
zur Kommunikation mit dem Benutzer (zur Präsentation oder Erfassung von Informationen). In den
Official Guidelines for User Interface Developers and Designers ordnet Microsoft die Dialogfenster
(zusammen mit den Nachrichtenfenstern und Werkzeugpaletten) den sekundären Fenstern zu (Microsoft 2007b, S. 217). Typische Kennzeichen von Dialogfenstern sind die unveränderliche Formulargröße und die sparsame Ausstattung der Titelzeile mit Symbolschaltern, z.B. beim folgenden
Dialog aus MS-Word 2003:
Die Initiative für den Auftritt eines Dialogfensters für eine spezielle Kommunikationsaufgabe kann
vom Benutzer ausgehen (z.B. durch Wahl eines Menüitems) oder vom Programm (z.B. Nachfrage
vor dem Löschen einer Datei).
18.1 Modale Dialogfenster
Die meisten Dialogfenster sind vom modalen Typ, so dass die restlichen Fenster der Anwendung
bis zum Schließen des Dialogs keine Benutzereingaben entgegen nehmen. Einem modalen Dialog
ist also die ungeteilte Aufmerksamkeit der Benutzer gewiss. Dies war z.B. bei den Standarddialogen
zur Schriftart- oder Farbauswahl zu beobachten. In der Regel kann der Benutzer zwar zu anderen
Programmen wechseln, doch die Anwendung „hinter“ dem modalen Dialog ist von allen Mausoder Tastaturereignissen abgeschottet. Einige Ereignisse werden aber auch bei abgeschotteten Fenstern bzw. Steuerelementen ausgelöst:


Paint-Ereignisse, die zum Neuaufbau des Klientenbereichs auffordern
Tick-Ereignisse von Timer-Objekten
Wie gleich zu sehen sein wird, resultiert das modale Verhalten eines Dialogs nicht aus irgendwelchen Form-Eigenschaftsausprägungen, sondern aus dem Auftritt über einen ShowDialog()-Aufruft
(im Unterschied zum Show()-Aufruf bei nicht-modalen Dialogen). Weil sich die geplante modale
Verwendung eines Dialogs in der Regel auf seine Ausstattung mit Bedienelementen auswirkt (z.B.
OK-Schalter), ist die Rede von modalen Dialogen aber doch gerechtfertigt.
Kapitel 18: Dialogfenster
514
18.1.1 Konfiguration
In der Regel definiert man zu einem Dialogfenster eine eigene Klasse, welche direkt oder indirekt
von Form abstammt. Wir erstellen als Beispiel die Klasse OptionsDialog, die ein OptionenDialogfenster für unseren seit Abschnitt 16.5 sukzessive aufgebauten Editor realisiert:
Folgende Einstellungen des Editors können im Optionen-Dialog modifiziert werden:



Zeilenumbruch
Mit diesem Kontrollkästchen wird die RichTextBox-Eigenschaft WordWrap gesetzt. Beim
Wert true wird der Text im Steuerelement automatisch umgebrochen.
Bildlaufleisten
Mit den beiden Kontrollkästchen legt der Benutzer den Wert der RichTextBox-Eigenschaft
ScrollBars (vom Enumerationstyp RichTextBoxScrollBars) fest:
Vertikale Bildlaufleiste
an
aus
an
Horizontale
Both
Horizontal
Bildlaufleiste
aus
Vertical
None
Die Bildlaufleisten erscheinen nur bei Bedarf, so dass die horizontale Leiste bei eingeschaltetem Zeilenumbruch nie erscheint.
Zoom-Faktor
Dieser UpDown-Regler wirkt auf die RichTextBox-Eigenschaft ZoomFactor, die (bei einem erlaubten Wert zwischen 1/64 und 64) die angezeigte Größe der Schrift verändert. Wir
arbeiten im Optionen-Dialog mit Prozentangaben, so dass sich erlaubte Werte von 64 bis
640 ergeben,
Im Konstruktor eines modalen Dialogs finden sich in der Regel einige Besonderheiten im Vergleich
zu den bisher gewohnten Fensterkonstruktoren:

Über die booleschen Eigenschaften HelpBox (Voreinstellung false), MaximizeBox und
MinimizeBox (Voreinstellung jeweils true) sorgt man für eine bescheidene Ausstattung der
Titelzeile mit Standardschaltflächen, z.B.
MaximizeBox = false;
MinimizeBox = false;

Über folgenden Wert für die Eigenschaft FormBorderStyle erreicht man einen fixierten
Rahmen ohne Systemmenü-Symbolschalter (am linken Rand der Titelzeile):
FormBorderStyle = FormBorderStyle.FixedDialog;

Meist sind Schaltflächen mit der Beschriftung (und Bedeutung) OK bzw. Abbrechen vorhanden, und man sorgt über die Form-Eigenschaften AcceptButton bzw. CancelButton
dafür, dass die Click-Ereignisse dieser Schalter von der Eingabe- bzw. Esc-Taste ausgelöst werden (vgl. Abschnitt 9.4.2.2):
AcceptButton = cmdOK;
CancelButton = cmdCancel;
Abschnitt 18.1 Modale Dialogfenster
515
In der Regel wird ein Dialogfenster auf gleich näher zu beschreibende Weise über diese
Schalter beendet, wobei die aufrufende Methode die DialogResult-Eigenschaftsausprägung
des auslösenden Schalters als Rückgabewert erhält:
cmdOK.DialogResult = DialogResult.OK;
cmdCancel.DialogResult = DialogResult.Cancel;
Man setzt die Standardschaltflächen oft in die untere rechte Ecke eines Dialogfensters.
Der Optionsdialog unseres Editors soll …

beim ersten Auftritt eine Position in Relation zum Hauptfenster wählen

und bei späteren Auftritten an der zuletzt vom Benutzer gewählten Position erscheinen.
Dieses Verhalten wird folgendermaßen realisiert:

Während einer Editor-Sitzung kommt stets dasselbe OptionsDialog-Objekt zum Einsatz. Zwischen zwei Einsätzen des Fensters bleibt seine Desktop-Position in der FormEigenschaft Location erhalten.

Im OptionsDialog-Konstruktor wird eine Initialposition in Relation zum Anwendungsfenster gewählt:
Location = Owner.DesktopLocation +
new Size(Owner.ClientSize.Width / 5, Owner.ClientSize.Height / 2);
Soll ein Formular eine individuelle Startposition an Stelle der Windows-Voreinstellung erhalten, dann muss seine Eigenschaft StartPosition auf den Wert
FormStartPosition.Manual gesetzt werden. Im OptionsDialog-Konstruktor findet sich
daher die Zeile:
StartPosition = FormStartPosition.Manual;
Wer damit einverstanden ist, dass der Dialog stets an derselben Position in sinnvoller Orientierung
zum elterlichen Fenster erscheint, kommt mit folgender Ortsangabe aus:
StartPosition = FormStartPosition.CenterParent;
Ansonsten bietet der OptionsDialog-Konstruktor die mittlerweile vertrauten SteuerelementGestaltungsarbeiten:
public OptionsDialog(Form owner) {
Text = "Optionen";
MaximizeBox = false;
MinimizeBox = false;
FormBorderStyle = FormBorderStyle.FixedDialog;
ClientSize = new Size(310, 170);
StartPosition = FormStartPosition.Manual;
Owner = owner;
Kapitel 18: Dialogfenster
516
Location = Owner.DesktopLocation +
new Size(Owner.ClientSize.Width/5, Owner.ClientSize.Height/2);
Button cmdOK = new Button();
cmdOK.Text = "OK";
cmdOK.Parent = this;
cmdOK.Location = new Point(138, 130);
AcceptButton = cmdOK;
cmdOK.DialogResult = DialogResult.OK;
Button cmdCancel = new Button();
cmdCancel.Text = "Abbrechen";
cmdCancel.Parent = this;
cmdCancel.Location = new Point(218, 130);
CancelButton = cmdCancel;
cmdCancel.DialogResult = DialogResult.Cancel;
chkWordWrap = new CheckBox();
chkWordWrap.Text = "Zeilenumbruch";
chkWordWrap.Location = new Point(20, 20);
chkWordWrap.Parent = this;
chkHorBar = new CheckBox();
chkHorBar.Text = "Horizontale Bildlaufleiste";
chkHorBar.Location = new Point(20, 50);
chkHorBar.Width = 100;
chkHorBar.Parent = this;
chkVertBar = new CheckBox();
chkVertBar.Text = "Vertikale Bildlaufleiste";
chkVertBar.Location = new Point(20, 80);
chkVertBar.Parent = this;
updwnZoomFactor = new NumericUpDown();
updwnZoomFactor.Location = new Point(130, 50);
updwnZoomFactor.Size = new Size(50, 50);
updwnZoomFactor.Minimum = 64;
updwnZoomFactor.Increment = 5;
updwnZoomFactor.Maximum = 640;
updwnZoomFactor.Value = 100;
updwnZoomFactor.Parent = this;
Label lbZoomFactor = new Label();
lbZoomFactor.Location = new Point(190, 50);
lbZoomFactor.Text = "Zoom-Faktor in %";
lbZoomFactor.Parent = this;
}
18.1.2 Datenaustausch mit dem aufrufenden Formular
Für den Datenaustausch mit einer nutzenden Klasse erhält unsere OptionsDialog–Klasse zu
jeder Einstellmöglichkeit eine öffentliche Eigenschaft mit Lese- und Schreibzugriff, z.B.:
public bool WordWrap {
get {return chkWordWrap.Checked;}
set {chkWordWrap.Checked = value;}
}
Zu Beginn des nächsten Abschnitts ist zu sehen, wie diese Eigenschaften vor dem Öffnen des Dialogs initialisiert und nach Beenden des Dialogs ausgelesen werden.
Durch den Einsatz von öffentlichen Eigenschaften ist unser Dialogfenster analog zu den Standarddialogen (siehe Abschnitt 18.3) zu verwenden. Eine gegen das Datenkapselungsprinzip der objekt-
Abschnitt 18.1 Modale Dialogfenster
517
orientierten Programmierung (vgl. Abschnitt 4.1.1.1) verstoßende Alternative zur Definition öffentlicher Eigenschaften bestünde darin, für die informationshaltigen Steuerelemente des Dialogfensters
öffentliche Instanzvariablen zu verwenden (siehe z.B. Luis & Strasser 2008, S. 629).
Um den Optionsdialog im Hauptfenstermenü anzubieten, wird das Extras-Untermenü um ein Item
erweitert (vgl. Abschnitt 17):
mitOptions = new ToolStripMenuItem("&Optionen...", null, MitOptionenOnClick);
mitTools.DropDownItems.Add(mitOptions);
Mit drei Punkten am Ende einer Menüitem-Beschriftung kündigt man den Auftritt eines Dialogfensters an. Die zugehörige Click-Behandlungsmethode mit dem Aufruf des Optionsdialogs wird im
nächsten Abschnitt vorgestellt.
18.1.3 Auftritt und Abgang
Ein modaler Dialog wird über die Form-Methode ShowDialog() angezeigt, z.B. in der ClickBehandlungsmethode des zuständigen Menüitems in unserem Editor:
protected void MitOptionenOnClick(object sender, EventArgs e) {
if (optionsDialog == null) {
optionsDialog = new OptionsDialog();
}
optionsDialog.ZoomFactor = editor.ZoomFactor;
optionsDialog.ScrollBars = editor.ScrollBars;
optionsDialog.WordWrap = editor.WordWrap;
if (optionsDialog.ShowDialog() == DialogResult.OK) {
editor.WordWrap = optionsDialog.WordWrap;
editor.ScrollBars = optionsDialog.ScrollBars;
editor.ZoomFactor = optionsDialog.ZoomFactor;
}
}
Die Methode ShowDialog() gibt die Kontrolle über den aktuellen Thread erst beim Beenden des
Dialogfensters an den Aufrufer zurück und berichtet dann per Rückgabewert vom Enumerationstyp
DialogResult, welches Verhalten des Benutzers zum Schließen des Fensters geführt hat (siehe unten).
In der Methode MitOptionenOnClick() des Beispielprogramms werden nur nach Beendigung
des Optionen-Dialogfensters mit dem Rückgabewert DialogResult.OK die dortigen Eigenschaften
ins Anwendungsfenster übernommen.
Über den ShowDialog()-Rückgabewert erfahren wir den Wert der DialogResult–Eigenschaft des
Dialogfenster-Objekts mit der gleichnamigen Enumeration als Datentyp. Wird dieser FormEigenschaft ein von DialogResult.None verschiedener Wert zugewiesen, resultieren bei einem modal ausgeführten Dialogfenster folgende Konsequenzen:


Das Dialogfenster verschwindet von der Bildfläche, wobei das zugehörige Objekt samt
Fenster-Ressourcen aber weiterhin existiert.
Die Methode ShowDialog() kehrt zurück.
Dank diverser Automatisierungen ist es selten nötig, die terminierende Wertzuweisung explizit vorzunehmen:


Klickt der Benutzer auf die Schließen-Schaltfläche der Dialogfenster-Titelzeile, erhält
DialogResult den Wert Cancel.
Mit der Button-Eigenschaft DialogResult legt man einen Wert fest, den die DialogResultEigenschaft des elterlichen Formulars bei einem Click auf einen Schalter erhalten soll. Somit kann man sich den Click-Handler für eine Schaltfläche sparen, wenn lediglich ein Be-
Kapitel 18: Dialogfenster
518
enden des Dialogs mit bestimmtem DialogResult-Wert bezweckt ist. Folgende Werte der
Enumeration DialogResult bewirken eine Rückkehr des ShowDialog()-Aufrufs:
DialogResultRückgabewert
OK
Cancel
Abort
Retry
Ignore
Yes
No
Übliche Beschriftung
der auslösenden Schaltfläche
OK
Abbrechen
Abbrechen
Wiederholen
Ignorieren
Ja
Nein
Im Beispiel setzt der Schalter cmdOK den Wert DialogResult.OK und der Schalter
cmdCancel den Wert DialogResult.Cancel.
Unser Editor verwendet nur ein OptionsDialog-Objekt und erstellt es nur bei Bedarf in der
Click-Behandlungsmethode zum Menüitem Extras > Optionen. Im Vergleich zur Objektkreation
bei jedem Aufruf des Menübefehls sparen wir Speicher und Rechenzeit. Außerdem merkt sich das
Dialogfeld ohne unser Zutun seine letzte Position.
Beim beschriebenen Abgang eines Dialogfelds wird vom .NET - Framework die Control-Methode
Hide()) aufgerufen. Sollte das Dialogfeld irgendwann obsolet geworden sein, kann über einen expliziten Close()-Aufruf für die frühzeitige Freigabe seiner Windows-Ressourcen gesorgt werden.
Den vollständigen Quellcode zum aktuellen Entwicklungsstand des Editors finden Sie im Ordner
…\BspUeb\WinForms\Dialogfenster\Modal\Optionendialog
18.1.4 Übernehmen Sie keinen Aberglauben
Auch bei modalen Dialogen kann man den Benutzern den Luxus bieten, Einstellungsänderungen
auf das Hauptfenster anzuwenden, ohne den Dialog beenden zu müssen. Somit erspart man dem
Benutzer, bei Feinjustierungen den Dialog wiederholt aufrufen zu müssen. In der Regel bietet man
diese Direktaktivierung über eine mit Übernehmen (engl. Apply) beschriftete Schaltfläche an. Bei
Benutzern ist der Aberglaube verbreitet, dass ein vorhandener Übernehmen-Schalter auf jeden
Fall vor dem Quittieren eines Dialogfensters mit OK betätigt werden muss, damit die vorgenommenen Einstellungen wirksam werden. Weil die abergläubische Sequenz Übernehmen > OK stets
erfolgreich ist, wird sie lernpsychologisch zementiert. Hoffentlich kommen möglichst wenige Programmierer auf die Idee, Dialogfenster-Effekte tatsächlich vom Schalter Übernehmen abhängig
zu machen.
Technisch gesehen stellt sich die interessante Frage, wie aus dem modalen, noch nicht beendeten
Dialog Informationen an das aufrufende Fenster übertragen und dort verarbeitet werden sollen. Wie
Sie sich vermutlich bereits gedacht haben, wird dieses Kommunikationsproblem per EreignisTechnik gelöst:

Man definiert in der Dialogfenster-Klasse ein öffentliches Ereignis, z.B. mit dem Namen
Apply:
public event EventHandler Apply;

Auf dem Dialogfenster wird der zusätzliche Schalter Übernehmen eingebaut, z.B.:
Abschnitt 18.1 Modale Dialogfenster
519
Button btnApply = new Button();
btnApply.Text = "Übernehmen";
btnApply.Width = 90;
btnApply.Parent = this;
btnApply.Location = new Point(200, 130);
btnApply.Click += new EventHandler(ButtonApplyOnClick);

Im Click-Handler zum Schalter Übernehmen löst man das Ereignis Apply aus, d.h. man
ruft alle bei diesem Ereignis registrierten Behandlungsmethoden (falls vorhanden) auf, z.B.:
protected void ButtonApplyOnClick(object sender, EventArgs ea) {
if (Apply != null)
Apply(this, EventArgs.Empty);
}

In der Hauptfensterklasse definiert man eine Methode, welche den Apply-Delegatentyp
EventHandler erfüllt und nach einem Klick auf den Dialogfenster-Schalter Übernehmen
ausgeführt werden soll, z.B.:
protected void OptionsDialogOnApply(object sender, EventArgs e) {
editor.WordWrap = optionsDialog.WordWrap;
editor.ScrollBars = optionsDialog.ScrollBars;
editor.ZoomFactor = optionsDialog.ZoomFactor;
}
Hier übernimmt man aus den öffentlichen Eigenschaften des Dialogfelds alle Einstellungen,
die sich auf die Anzeige des Hauptfensters auswirken.

Diese Methode wird beim Apply-Ereignis des Dialogfenster-Objekts registriert, z.B.:
optionsDialog.Apply += new EventHandler(OptionsDialogOnApply);

Sind die vom Dialog übernommenen Informationen relevant für Grafikausgaben des Hauptfensters, dann sollte das Hauptfenster am Ende der Methode OptionsDialogOnApply
per Invalidate()-Aufruf für ungültig erklärt werden, damit der Paint-Handler aktiv wird
(vgl. Abschnitt 15.3.3). Im Beispiel ist dies nicht erforderlich.
Unser Editor erlaubt nun z.B. eine bequeme Auswahl des Zoom-Faktors:
Beim Verlassen des Dialogs mit Abbrechen sollen nach den Design-Empfehlungen der Firma
Microsoft (2007b, S. 213) alle noch nicht übernommenen Einstellungsänderungen verworfen werden. Die bereits per Übernehmen in Kraft gesetzten Einstellungsänderungen sollen aber erhalten
bleiben.
Den vollständigen Quellcode zum aktuellen Entwicklungsstand des Editors finden Sie im Ordner
…\BspUeb\WinForms\Dialogfenster\Modal\Übernehmen
Kapitel 18: Dialogfenster
520
18.2 Nicht-modale Dialogfenster
Viele Dialogfenster sollen (oder müssen) längere Zeit oder gar ständig offen bleiben und dürfen
dabei die Funktionalität des Hauptfensters nicht behindern. Häufig anzutreffende Beispiele sind die
Suchdialoge von Editoren und vergleichbaren Programmen (z.B. Entwicklungsumgebungen, EMail-Klienten). Man kann einem solchen Fenster fast beliebige Funktionalitäten zuordnen, so dass
man eventuell nicht mehr von einem Dialogfenster sondern allgemeiner von einem sekundären
Fenster reden sollte (Microsoft 2007b, S. 217). Im aktuellen Abschnitt geht es also um die generelle
(nicht durch Modalität eingeschränkte) Kommunikation zwischen den Fensterobjekten einer Anwendung.
18.2.1 Konfiguration
Unser seit Abschnitt 16.5 im Aufbau befindlicher Editor soll nun mit einem einfachen Suchdialog
ausgestattet werden:
Wir definieren dazu die von Form abstammende Klasse SearchDialog:
using
using
using
using
System;
System.Windows.Forms;
System.Drawing;
System.ComponentModel;
class SearchDialog : Form {
TextBox tbSearchText;
public event EventHandler FindNext;
public SearchDialog(Form owner) {
Text = "Suchen";
MaximizeBox = false;
MinimizeBox = false;
ShowInTaskbar = false;
FormBorderStyle = FormBorderStyle.FixedDialog;
ClientSize = new Size(430, 100);
StartPosition = FormStartPosition.Manual;
Location = ActiveForm.DesktopLocation +
new Size(ActiveForm.ClientSize.Width/5,ActiveForm.ClientSize.Height/2);
Owner = owner;
Label lblSearch = new Label();
lblSearch.Text = "Suchen nach:";
lblSearch.Parent = this;
lblSearch.Location = new Point(10, 20);
lblSearch.Width = 80;
tbSearchText = new TextBox();
tbSearchText.Parent = this;
tbSearchText.Location = new Point(100, 20);
tbSearchText.Size = new Size(200, Font.Height);
Abschnitt 18.2 Nicht-modale Dialogfenster
521
Button cmdNext = new Button();
cmdNext.Text = "Weitersuchen";
cmdNext.Parent = this;
cmdNext.Location = new Point(320, 20);
cmdNext.Width = 90;
AcceptButton = cmdNext;
cmdNext.Click += new EventHandler(ButtonNextOnClick);
Button cmdCancel = new Button();
cmdCancel.Text = "Abbrechen";
cmdCancel.Parent = this;
cmdCancel.Location = new Point(320, 60);
cmdCancel.Width = 90;
CancelButton = cmdCancel;
cmdCancel.Click += new EventHandler(ButtonCancelOnClick);
}
. . .
}
Wie bei den meisten nicht-modalen Dialogen fehlt auch in unserem Beispiel ein OK-Schalter.
Stattdessen ist ein Schalter zum Starten der Suche vorhanden. Zum Beenden des Dialogs wird der
Schalter Abbrechen angeboten, der aber (anders als beim modalen Dialog) mit einer Click-Behandlungsmethode ausgestattet werden muss (siehe unten).
Weil in der Windows-Taskleiste nur Hauptfenstern erscheinen sollten, wird die Eigenschaft ShowInTaskbar auf den Wert false gesetzt:
ShowInTaskbar = false;
Bei einem nicht-modalen Dialogfenster ist es in der Regel erwünscht, dass es sich stets über dem
Anwendungsfenster befindet, also von diesem nicht abgedeckt werden kann. Außerdem sollte das
Dialogfenster beim Minimieren des Anwendungsfensters ebenfalls vom Desktop verschwinden. Um
diese Verhaltensweisen anzufordern, setzt man die Owner-Eigenschaft des Dialogs auf das Hauptfenster der Anwendung, z.B. über einen Konstruktor-Parameter:
Owner = owner;
Wie der in Abschnitt 18.1 entwickelte Optionsdialog soll der Suchdialog …


beim ersten Auftritt eine Position in Relation zum Hauptfenster wählen,
bei späteren Auftritten die vorherige Position verwenden.
Dieses Verhalten wird folgendermaßen realisiert:

Während einer Editor-Sitzung kommt stets dasselbe SearchDialog-Objekt zum Einsatz.
Zwischen zwei Einsätzen wird das Fenster nicht geschlossen, sondern lediglich versteckt,
wobei es seine Position natürlich nicht vergisst. Was genau zu tun ist, erfahren Sie in Abschnitt 18.2.2.

Um die Position beim ersten Auftritt festzulegen, wird im SearchDialog-Konstruktor
dieselbe Technik gewählt wie im OptionsDialog-Konstruktor (siehe Abschnitt 18.1.1).
Um den Suchdialog im Hauptfenstermenü anzubieten, wird im Bearbeiten-Untermenü das Item
Suchen ergänzt (vgl. Abschnitt 17):
ToolStripMenuItem mitEdit, mitFind;
. . .
bmp = new Bitmap("find.bmp");
bmp.MakeTransparent(bmp.GetPixel(0, 0));
mitFind = new ToolStripMenuItem("&Suchen", bmp, MitFindOnClick);
mitFind.ShortcutKeys = Keys.Control | Keys.F;
522
Kapitel 18: Dialogfenster
In der zugehörigen Click-Behandlungsmethode wird nötigenfalls ein Objekt der Klasse SearchDialog erzeugt:
protected void MitFindOnClick(object sender, EventArgs e) {
if (editor.Text.Length == 0)
return;
if (searchDialog == null) {
searchDialog = new SearchDialog();
searchDialog.FindNext += new EventHandler(SearchDialogOnFindNext);
}
searchDialog.Show();
}
Trotz Item-Desaktivierung in der DropDownOpening-Behandlungsmethode zum BearbeitenUntermenü
if (editor.Text.Length > 0) {
mitFind.Enabled = true;
} else {
mitFind.Enabled = false;
}
kann MitFindOnClick() auch bei leerer Editor-Komponente über die Tastenkombination
Strg+F aufgerufen werden. Eine entsprechende Abfrage verhindert aber den Auftritt des Suchdialogs. In MitFindOnClick() enthaltene Details zum Auftritt des Suchdialogs (siehe Abschnitt
18.2.2) und zu seiner Kommunikation mit dem Hauptfenster (siehe Abschnitt 18.2.3) werden später
erläutert.
Im Konstruktor des Hauptfensters wird mit folgender Anweisung
editor.HideSelection = false;
dafür gesorgt, dass eine Textmarkierung auch dann sichtbar bleibt, wenn ein anderes Fenster (speziell: der Suchdialog) aktiv ist. Beim Durchsuchen des Textes soll nämlich eine Trefferstelle durch
Markieren gekennzeichnet werden (siehe Screenshot am Anfang des Abschnitts).
18.2.2 Auftritt und Abgang
Anstelle der zur Anzeige von modalen Dialogen erforderlichen Form-Methode ShowDialog(),
welche die Kontrolle erst beim Beenden des Dialogs zurückgibt, wird zur Anzeige eines nichtmodalen Dialogfensters die Methode Show() verwendet, z.B. in der Click-Behandlungsmethode
zum Editor-Menüitem Suchen:
suchDialog.Show();
Sie macht das Dialogfenster sichtbar und kehrt dann sofort zurück.
Die zum Beenden von modalen Dialogen wichtige Form-Eigenschaft DialogResult (siehe Abschnitt 18.1.3) spielt bei nicht-modalen Dialogen keine Rolle. Letztere werden häufig über die
Form-Methode Close() im Rahmen einer Ereignisbehandlungsmethode beendet, z.B.:
protected void ButtonCancelOnClick(object sender, EventArgs ea) {
Close();
}
Auch bei einem Mausklick auf die Schließen-Schaltfläche in der Titelzeile eines nicht-modalen
Dialogfensters wird per Voreinstellung die Close()-Methode ausgeführt. Es ist zu beachten, dass
diese Methode gründlich aufräumt und alle mit einem Dialogfenster verbundenen Ressourcen freigibt, sodass dieses Fenster anschließend nicht mehr mit Show() angezeigt werden kann.
In unserem Editor soll aber nur ein Objekt der Klasse SearchDialog (samt zugehörigem Fenster) verwendet werden. Neben einem kleinen (vermutlich nicht spürbaren) Spareffekt gewinnen wir
den Vorteil, dass die Fensterposition des Dialogfensters ohne Aufwand zwischen zwei Auftritten
Abschnitt 18.2 Nicht-modale Dialogfenster
523
erhalten bleibt. Daher verstecken wir das Suchfenster per Hide()-Aufruf, wenn es vorübergehend
nicht mehr benötigt wird, statt es zu schließen:
protected void ButtonCancelOnClick(object sender, EventArgs ea) {
HideSearchDialog();
}
protected void HideSearchDialog() {
tbSearchText.Focus();
tbSearchText.SelectAll();
Owner.Activate();
Hide();
}
Mit
Owner.Activate();
wird sichergestellt, dass nach dem Verschwinden des Dialogfensters dessen Besitzer (also das Editor-Hauptfenster) das aktive (im Vordergrund liegende und den Eingabefokus besitzende) Fenster
ist. Außerdem sorgen wir im Hinblick auf
Herunterladen