Sandini Bib
Visual C# 2005
Sandini Bib
Sandini Bib
Frank Eller
Visual C# 2005 Grundlagen, Programmiertechniken, Datenbanken
An imprint of Pearson Education München • Boston • San Francisco • Harlow, England Don Mills, Ontario • Sydney • Mexico City Madrid • Amsterdam
Sandini Bib
Bibliografische Information Der Deutschen Bibliothek Die Deutsche Bibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über
abrufbar.
Die Informationen in diesem Buch werden ohne Rücksicht auf einen eventuellen Patentschutz veröffentlicht. Warennamen werden ohne Gewährleistung der freien Verwendbarkeit benutzt. Bei der Zusammenstellung von Texten und Abbildungen wurde mit größter Sorgfalt vorgegangen. Trotzdem können Fehler nicht vollständig ausgeschlossen werden. Verlag, Herausgeber und Autor können für fehlerhafte Angaben und deren Folgen weder juristische Verantwortung noch irgendeine Haftung übernehmen. Für Verbesserungsvorschläge und Hinweise auf Fehler sind Verlag, Herausgeber und Autor dankbar. Alle Rechte vorbehalten, auch die der fotomechanischen Wiedergabe und Speicherung in elektronischen Medien. Die gewerbliche Nutzung der in diesem Produkt gezeigten Modelle und Arbeiten ist nicht zulässig. Fast alle Hardware- und Softwarebezeichnungen, die in diesem Buch erwähnt werden, sind gleichzeitig eingetragene Warenzeichen oder sollten als solche betrachtet werden. Umwelthinweis: Dieses Buch wurde auf chlorfrei gebleichtem Papier gedruckt.
10 9 8 7 6 5 4 3 2 1 08 07 06 ISBN-13: 978-3-8273-2288-3 ISBN-10: 3-8273-2288-X © 2006 by Addison-Wesley Verlag, ein Imprint der Pearson Education Deutschland GmbH, Martin-Kollar-Straße 10–12, D-81829 München/Germany – Alle Rechte vorbehalten Einbandgestaltung: Marco Lindenbeck, www.webwo.de Lektorat: Sylvia Hasselbach, [email protected] Korrektorat: Bianca Schiener, Burghausen Herstellung: Andreas Fleck, [email protected] Satz: Frank Eller, Emmerting, http://www.frankeller.de Druck und Verarbeitung: Bercker, Kevelaer Printed in Germany
Sandini Bib
Inhalt Vorwort
19
1
Einführung
23
1
Das .NET Framework
25
1.1 1.1.1 1.1.2 1.2 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.4 1.4.1 1.4.2 1.4.3
Warum .NET? Die .NET-Strategie .NET und Java Vorteile von .NET Der Aufbau des .NET Frameworks Übersicht Die Common Language Runtime Die Klassenbibliothek Die Benutzerschnittstelle Common Language Specification und Common Type System Der Intermediate Language Code (IL-Code) Der Global Assembly Cache (GAC) Strong Names Neuerungen in .NET 2.0 Neues im Compiler Neues in C# bzw. dem .NET Framework Neuerungen in Windows.Forms
25 25 26 28 31 32 32 34 35 36 37 38 39 40 40 41 42
2
Erste Schritte
45
2.1 2.1.1 2.1.2 2.2 2.2.1 2.2.2 2.2.3 2.2.4
Hello World (Konsole) Das erste Programm Erweiterung des Programms Hello World (Windows-Version) Projektauswahl Entwurf der Oberfläche Einfügen von Code Quelltext-Dateien
45 45 49 52 52 54 56 58
3
Das Visual Studio 2005
61
3.1 3.1.1 3.1.2 3.2 3.2.1 3.2.2 3.2.3 3.2.4
Einführung Übersicht Systemvoraussetzungen und Versionen Wichtige Fenster der Entwicklungsumgebung Der Projektmappen-Explorer Die Toolbox Das Eigenschafts-/Ereignisfenster Die Projekteigenschaften
61 62 63 64 66 69 69 70
Sandini Bib
6
Inhalt
3.3 3.4 3.4.1 3.4.2 3.4.3 3.4.4 3.4.5 3.5 3.5.1 3.5.2 3.5.3 3.6
Der visuelle Designer Der Editor Anpassung des Editors IntelliSense Smarttags Änderungen innerhalb einer Sitzung Refactoring Tools und Hilfsmittel Das Klassendiagramm Das Objekttestcenter Code Snippets (Codeausschnitte) Fazit
II
Grundlagen
79
4
Datentypen
81
4.1 4.1.1 4.2 4.2.1 4.2.2 4.2.3 4.2.4 4.2.5 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.4 4.4.1 4.4.2 4.4.3 4.5 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5
Werte- und Referenztypen Unterschiede zwischen Werte- und Referenztypen Integrierte Datentypen Der Datentyp bool Der Datentyp char Numerische Datentypen Der Datentyp string Nullable Types Variablen Deklaration und Initialisierung Bezeichner Gültigkeitsbereich Konstanten Konvertierungen und Boxing Implizite und explizite Konvertierung Boxing und Unboxing Typumwandlung Arrays Eindimensionale Arrays Mehrdimensionale Arrays Ungleichförmige Arrays Arbeiten mit Arrays Syntaxzusammenfassung Aufzählungstypen (enum) Grundlagen Deklaration und Anwendung Bitfelder Arbeiten mit Aufzählungen Syntaxzusammenfassung
71 72 72 74 74 74 75 75 76 76 78 78
81 81 83 84 85 87 88 88 91 91 92 93 94 94 95 96 97 98 99 100 101 102 105 106 106 107 109 111 113
Sandini Bib
7
Inhalt
5
Ablaufsteuerung
115
5.1 5.1.1 5.1.2 5.1.3 5.1.4 5.2 5.2.1 5.2.2 5.2.3 5.2.4 5.3 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6
Verzweigungen Die if-Anweisung Die switch-Anweisung Die bedingte Zuweisung (tenärer Operator) Die goto-Anweisung Schleifen Die for-Schleife Die while-Schleife Die do-Schleife Die foreach-Schleife Operatoren Arithmetische Operatoren Vergleichsoperatoren Logische Operatoren Bitweise Operatoren Zusammengesetzte Operatoren Sonstige Operatoren
115 115 117 118 119 120 120 121 122 123 123 124 125 125 126 126 127
6
Klassen und Objekte
129
6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.4 6.4.1 6.4.2 6.4.3 6.5 6.6 6.6.1 6.6.2 6.7
Grundlagen der Objektorientierung Abstraktion Kapselung Vererbung Polymorphie Aggregation Gliederung einer Anwendung: Namespaces Deklaration Aufteilung der Deklaration Klassen Klassendeklaration Felder (Instanzvariablen) Methoden Konstruktoren und Destruktoren Eigenschaften (Properties) Statische Klassenelemente Private Konstruktoren Statische Konstruktoren Statische Klassen Modifizierer Operatorenüberladung Überladen mathematischer Operatoren Überladen der Konvertierungsoperatoren Partielle Klassen
129 129 130 131 132 132 133 134 135 136 136 137 139 147 150 155 156 157 158 158 161 161 162 163
Sandini Bib
8
Inhalt
6.8 6.8.1 6.8.2
Strukturen (struct) Deklaration Nullable Structs
164 164 166
7
Vererbung und Polymorphie
167
7.1 7.1.1 7.1.2 7.1.3 7.2 7.3 7.3.1 7.3.2 7.3.3
Vererbung Ableiten von Klassen Gemeinsame Methoden aller Klassen Virtuelle Methoden Polymorphie Abstrakte, versiegelte und verschachtelte Klassen Versiegelte Klassen Abstrakte Klassen Verschachtelte Klassen
167 167 170 171 173 177 177 178 178
8
Delegates und Events
181
8.1 8.2 8.2.1 8.2.2 8.2.3 8.3 8.3.1 8.3.2
Grundlagen zu Delegates Verwenden von Delegates Eine Sortierroutine Ein Delegate – mehrere Methoden Anonyme Methoden Ereignisse (Events) Deklaration Ereignisse implementieren und verwenden
181 182 182 188 189 192 192 194
9
Interfaces
199
9.1 9.1.1 9.1.2 9.1.3 9.1.4 9.2 9.2.1 9.2.2 9.3
Grundlagen Deklaration Implementierung Interface explizit verwenden Nicht implementierte Methoden Die Interfaces IComparer und IComparable Deklaration Verwendung von IComparer und IComparable Das Interface IDisposable
199 200 201 206 207 208 208 209 212
10
Attribute
215
10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3
Grundlagen Verwendung Parameter Eigene Attribute erstellen Verwendung festlegen Attributparameter Ermitteln des Attributs
215 216 216 217 218 218 219
Sandini Bib
9
Inhalt
10.3 10.3.1 10.3.2
Beispiel: Ein Todo-Attribut Deklaration der Attributklasse Auswertung der Attribute
219 219 221
11
Generics
225
11.1 11.1.1 11.1.2 11.1.3 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.4
Grundlagen zu Generics Deklaration Beispiel: ein generischer Stack Der Standardwert eines generischen Typs Constraints Mögliche Bedingungen Erweitern des Beispiels Vererbung mit Generics Konkrete Klassen mit generischer Basisklasse Generische Klassen mit generischer Basisklasse Generische Methoden
225 225 226 227 229 230 231 233 234 234 235
III
Grundlegende Programmiertechniken
12
Arbeiten mit Datentypen
239
12.1 12.1.1 12.1.2 12.1.3 12.1.4 12.1.5 12.1.6 12.2 12.2.1 12.2.2 12.2.3 12.2.4 12.2.5 12.2.6 12.2.7 12.3 12.3.1 12.3.2 12.3.3 12.3.4 12.3.5 12.4 12.4.1 12.4.2 12.4.3
Zahlen Notation Rundungsfehler Division durch Null und der Wert unendlich Arithmetische Funktionen Zahlen runden und andere Funktionen Zufallszahlen Strings Grundlagen Verketten von Strings Zugriff auf Zeichenketten Vergleiche von Zeichenketten Die Klasse StringBuilder Unicode Syntaxzusammenfassung Datum und Zeit Die Struktur DateTime Die Struktur TimeSpan Arbeiten mit Datum und Zeit Zeitmessungen - die Klasse Stopwatch Syntaxzusammenfassung Formatierungsmethoden in .NET Grundlagen Zahlen formatieren Datum und Zeit formatieren
239 239 239 241 241 242 243 244 244 246 247 251 252 255 257 262 262 263 265 266 267 271 271 272 278
237
Sandini Bib
10
Inhalt
13
Collections
281
13.1 13.2 13.2.1 13.2.2 13.3 13.3.1 13.3.2 13.3.3 13.3.4 13.3.5 13.4 13.4.1 13.4.2 13.4.3 13.5 13.5.1 13.5.2 13.6 13.6.1 13.6.2
Grundlagen Die Listenklassen aus System.Collections Übersicht Übersicht über die verwendeten Interfaces Grundlegende Programmiertechniken Listenelemente löschen Sortieren von Listen Suchen in einer ArrayList Queue und Stack verwenden Datenaustausch zwischen Listen Eigene Listenklassen erstellen Eine neue Art von Eigenschaft: der Indexer Implementierung der foreach-Schleife Beispielprogramm Bücherliste Syntaxzusammenfassung Interfaces Klassen Generische Listenklassen Verwendung generischer Listenklassen Geschwindigkeitsvergleich Generics zu normal
281 282 282 285 286 286 289 294 295 298 300 300 302 303 308 308 310 312 313 315
14
Dateien und Verzeichnisse
317
14.1 14.1.1 14.1.2 14.2 14.2.1 14.2.2 14.2.3 14.2.4 14.2.5 14.2.6 14.2.7 14.2.8 14.3 14.3.1 14.3.2 14.3.3 14.3.4 14.4 14.4.1 14.4.2 14.4.3 14.4.4
Grundlagen Streams Klassen von System.IO Verzeichnisse und Dateien Datei- und Verzeichnisinformationen Ermittlung von Dateien in einem Verzeichnis Manipulation von Dateien und Verzeichnissen Verzeichnisse, Dateien und Laufwerke ermitteln Datei- und Verzeichnisnamen bearbeiten Beispielanwendung Backup Beispielanwendung Synchronisieren Syntaxzusammenfassung Dialoge für Verzeichnisse und Dateien Der Dialog zum Öffnen einer Datei Der Dialog zum Speichern einer Datei Der Dialog zur Verzeichnisauswahl Syntaxzusammenfassung Textdateien Kodierung von Textdateien Textdateien lesen (mit StreamReader) Textdateien schreiben (StreamWriter) Beispielprogramm – Textdatei erstellen und lesen
317 317 318 320 320 323 327 332 335 336 343 346 354 354 355 355 356 357 357 358 365 367
Sandini Bib
11
Inhalt
14.4.5 14.4.6 14.4.7 14.5 14.5.1 14.5.2 14.5.3 14.5.4 14.5.5 14.5.6 14.5.7 14.5.8 14.5.9 14.6 14.6.1 14.6.2 14.7 14.7.1 14.7.2 14.8 14.8.1 14.8.2 14.8.3 14.8.4
Beispielprogramm – Textkodierung ändern Zeichenketten lesen und schreiben (StringReader und StringWriter) Syntaxzusammenfassung Binäre Dateien Die Klasse FileStream Beispielprogramm – Dateien zerteilen Gleichzeitiger Zugriff auf eine Datei Die Klasse BufferedStream MemoryStream (Streams im Arbeitsspeicher) Dateien komprimieren BinaryReader und -Writer (Variablen binär speichern) Beispielprogramm – unterschiedliche Daten schreiben und lesen Syntaxzusammenfassung Asynchroner Dateizugriff Verwendung eines asynchronen Streams Asynchrones Schreiben mit Callback Verzeichnisse überwachen FileSystemWatcher verwenden Verzeichnisüberwachung mit Logdatei Serialisierung Grundlagen Serialisieren mit BinaryFormatter und SoapFormatter Angepasste Serialisierung Die Klasse XmlSerializer
369 370 371 375 376 378 385 388 392 393 395 397 402 405 406 409 411 411 413 416 416 416 422 424
15
Multithreading
429
15.1 15.1.1 15.1.2 15.1.3 15.2 15.2.1 15.2.2 15.2.3 15.3 15.3.1 15.3.2 15.3.3 15.4 15.4.1 15.4.2
Grundlagen Preemptives Multitasking Multithreading-Modelle Wozu Multithreading? Arbeiten mit Threads Die Klasse Thread Beispielanwendung Syntaxzusammenfassung Synchronisation Wozu synchronisieren? Die Klasse Monitor Die Anweisung lock() Die Komponente BackgroundWorker Methoden und Ereignisse Beispielapplikation: Fibonacci
429 429 430 430 431 431 436 439 440 440 441 448 449 449 450
16
Fehlersuche und Fehlerabsicherung
455
16.1 16.1.1 16.1.2
Fehlerabsicherung Abfangen von Exceptions try-catch-Syntax
455 456 457
Sandini Bib
12
Inhalt
16.1.3 16.1.4 16.2 16.2.1 16.2.2 16.2.3 16.2.4
Eigenschaften und Methoden der Klasse Exception Eigene Exception-Klassen Fehlersuche (Debugging) Grundlagen Fehlersuche in der Entwicklungsumgebung Ausgaben der Klasse Debug Syntaxzusammenfassung
463 463 466 466 468 473 475
IV
Windows-Programmierung
17
Einführung in Windows.Forms
479
17.1 17.1.1 17.1.2 17.2 17.2.1 17.2.2
Einführung Interaktion zwischen Code und Designer Arbeiten mit dem Visual Studio Arbeiten mit Formularen Eigenschaften von Formularen Grundlegende Vorgehensweisen
479 480 483 486 486 490
18
Standard-Steuerelemente
501
18.1 18.1.1 18.1.2 18.1.3 18.2 18.2.1 18.2.2 18.2.3 18.2.4 18.2.5 18.3 18.3.1 18.3.2 18.3.3 18.4 18.4.1 18.4.2 18.4.3 18.4.4 18.4.5 18.5 18.5.1 18.5.2 18.6 18.6.1 18.6.2
Überblick .NET-Steuerelemente COM und .NET Steuerelemente in Toolbox einfügen Gemeinsame Member der Steuerelemente Aussehen Größe, Position und Layout Eingabefokus, Validierung Sonstiges Syntaxzusammenfassung Buttons Das Steuerelement Button Das Steuerelement CheckBox Das Steuerelement RadioButton Steuerelemente für Text Das Steuerelement Label LinkLabel Das Steuerelement TextBox Das Steuerelement MaskedTextBox Das Steuerelement RichTextBox Steuerelemente für Grafik Das Steuerelement PictureBox Das Steuerelement ImageList Listen ListBox CheckedListBox
501 501 502 503 504 505 508 512 514 516 520 520 522 524 525 525 526 528 535 537 540 540 541 542 542 557
477
Sandini Bib
13
Inhalt
18.6.3 18.6.4 18.6.5 18.6.6 18.6.7 18.7 18.7.1 18.7.2 18.8 18.8.1 18.8.2 18.8.3 18.8.4 18.8.5 18.9 18.9.1 18.9.2 18.9.3 18.10 18.10.1 18.10.2 18.10.3 18.10.4 18.10.5 18.10.6 18.10.7 18.10.8
ComboBox ListView Beispielprogramm TreeView Beispielprogramm: Festplatteninhalt ermitteln Datum und Zeit MonthCalendar DateTimePicker Schiebe- und Zustandsbalken, Drehfelder HScrollBar, VScrollBar TrackBar ProgressBar NumericUpDown DomainUpDown Gruppieren von Steuerelementen Panel GroupBox TabControl (Dialogblätter) Weitere Steuerelemente SplitContainer TableLayoutPanel FlowLayoutPanel Timer ToolTip HelpProvider ErrorProvider NotifyIcon
558 559 568 579 587 593 593 596 597 597 599 601 601 601 602 602 604 604 606 606 607 607 607 608 611 614 617
19
Eigene Steuerelemente erstellen
621
19.1 19.1.1 19.1.2 19.2 19.2.1 19.2.2 19.2.3 19.2.4 19.3 19.3.1 19.3.2
Grundlagen zu Steuerelementen Arten von Steuerelementen Vorbereitung Zusammengesetzte Steuerelemente Eine IP-Textbox als Steuerelement Funktionalität hinzufügen Die Bitmap für die Toolbox Testen des Steuerelements Abgeleitete Steuerelemente Ein erweitertes Panel Funktionalität hinzufügen
621 621 622 624 624 626 634 635 636 636 638
20
Benutzeroberfläche
647
20.1 20.1.1 20.1.2 20.1.3
Bedienelemente Der Menüeditor Menüeigenschaften einstellen Kontextmenüs
647 648 649 653
Sandini Bib
14
Inhalt
20.1.4 20.1.5 20.1.6 20.2 20.2.1 20.2.2 20.2.3 20.3 20.3.1 20.3.2 20.3.3 20.4 20.4.1 20.4.2 20.4.3 20.4.4
Symbolleisten (ToolStrip-Steuerelement) Die Statusleiste (StatusStrip-Steuerelement) Eigene Elemente für den ToolStrip Standarddialoge Dateien öffnen und speichern Farbauswahl Schriftart auswählen MDI-Anwendungen Grundlagen MDI-Fenster verwalten Beispiel: Mergen von Menüs und ToolStrip-Komponenten Programmiertechniken Anzeige eines Splashscreens Arbeiten mit der Zwischenablage Drag&Drop Konfigurationsdateien
657 660 661 667 667 669 669 670 670 671 673 676 676 677 678 686
21
Grafikprogrammierung (GDI+)
691
21.1 21.1.1 21.1.2 21.1.3 21.1.4 21.2 21.2.1 21.2.2 21.2.3 21.2.4 21.2.5 21.2.6 21.3 21.3.1 21.3.2 21.3.3 21.3.4 21.3.5 21.3.6 21.3.7 21.4 21.4.1 21.4.2 21.4.3 21.4.4 21.4.5 21.4.6 21.5
Einführung Ein erstes Beispiel Grafik-Container (Form, PictureBox) Dispose für Grafikobjekte Fazit Elementare Grafikoperationen Linien, Rechtecke, Vielecke, Ellipsen, Kurven (Graphics-Klasse) Farben (Color-Struktur) Linienformen (Pen-Klasse) Füllmuster (Brush-Klassen) Koordinatensysteme und -transformationen Syntaxzusammenfassung Text ausgeben (Font-Klassen) Einführung TrueType-, OpenType- und Type-1-Schriftformate Schriftarten und -familien Schriftgröße Schriftauszeichnung und Textformatierung Font-Auswahldialog Syntaxzusammenfassung Bitmaps, Icons und Metafiles Die Klassen Graphics, Image und Bitmap Bitmaps in Formularen darstellen Bitmaps manipulieren Transparente Bitmaps Metafile-Dateien Syntaxzusammenfassung Fortgeschrittene Programmiertechniken
691 692 695 697 698 699 699 712 714 718 723 727 731 731 733 734 737 748 760 762 764 764 767 770 778 783 786 787
Sandini Bib
15
Inhalt
21.5.1 21.5.2 21.5.3 21.5.4 21.5.5 21.5.6 21.5.7 21.5.8 21.5.9
Zeichen- und Textqualität Grafikobjekte zusammensetzen (GraphicsPath) Umgang mit Regionen und Clipping Rechteck-Auswahl mit der Maus (Rubberbox) Bitmap-Grafik zwischenspeichern (AutoRedraw) Flimmerfreie Grafik (Double-Buffer-Technik) Scrollbereich für Grafik Einfache Animationseffekte Bitmaps direkt manipulieren
787 792 794 801 805 817 818 820 825
22
Drucken
831
22.1 22.1.1 22.1.2 22.2 22.2.1 22.2.2 22.2.3 22.2.4 22.2.5 22.3 22.4 22.5 22.5.1 22.5.2 22.6
Überblick Limitationen und weitere Werkzeuge zum Drucken Die wichtigsten Klassen und Steuerelemente Grundlagen Die Komponente PrintDocument Die Dialoge PrintDialog und PageSetupDialog Der Dialog PrintPreviewDialog Druckereigenschaften und Seitenlayout Syntaxzusammenfassung Beispiel – Mehrseitiger Druck Eine Textbox zum Drucken Weitere Programmiertechniken Unterschiedliches Seitenlayout Drucken ohne Status- bzw. Abbruch-Dialog Eigene Seitenvorschau
831 831 832 833 833 836 840 841 844 847 851 856 856 856 857
V
Programmiertechniken
23
Lokalisierung von Anwendungen
865
23.1 23.1.1 23.1.2 23.1.3 23.1.4 23.2
Eigenschaften von Steuerelementen und Formularen lokalisieren Ressourcendateien Auswertung der Lokalisierungseinstellungen Auswahl der aktuellen Kultur Zusätzliche Zeichenketten in den Lokalisierungsdateien speichern Beispielprogramm
865 865 866 867 867 868
24
Externe Programme steuern (Automation)
873
24.1 24.1.1 24.1.2 24.2 24.2.1 24.2.2 24.2.3
Automation mittels COM-Komponenten Verwendung der Klassenbibliothek Beispiel – RichTextBox mit Word ausdrucken API-Aufrufe (P/Invoke) Grundlagen zu P/Invoke Konvertierungen Aufruf von DLL-Funktionen
873 873 874 878 878 880 881
863
Sandini Bib
16
Inhalt
25
Reflection
885
25.1 25.1.1 25.1.2 25.2 25.2.1 25.2.2 25.2.3 25.2.4 25.2.5 25.3 25.3.1 25.3.2
Grundlagen zu Reflection Grundlegende Eigenschaften und Methoden von Type Relevante Klassen für Reflection Beispielapplikation: Informationen über die BCL ermitteln Das Hauptformular der Anwendung – der Aufbau Die Klasse AssemblyReflector Die Klasse TypeReflector Das Hauptformular – die Funktionalität Programmstart mit Splashscreen Beispielprogramm: Daten mittels Reflection ändern Die Klasse zum Instanzieren Das Hauptformular
885 885 886 887 888 889 896 901 903 906 907 907
26
Weitergabe von Windows-Programmen (Setup.exe)
911
26.1 26.2 26.3 26.4 26.4.1 26.4.2 26.4.3 26.4.4 26.4.5 26.5 26.5.1 26.5.2 26.6 26.6.1 26.6.2 26.6.3
Einführung Installationsprogramm erstellen (Entwicklersicht) Installation ausführen (Kundensicht) Installationsprogramm für Spezialaufgaben Grundeinstellungen eines Setup-Projekts Startmenü, Desktop-Icons Benutzeroberfläche des Installationsprogramms Start- und Weitergabebedingungen Dateityp registrieren ClickOnce ClickOnce Einstellungen ClickOnce-Installation aus Anwendersicht Signieren eines Projekts Bestandteile einer Signatur Der Signiervorgang Signierte DLL in den GAC einfügen
911 912 914 916 916 919 920 923 925 926 927 932 933 933 935 936
VI
Datenbanken (ADO.NET)
27
Grundlagen
941
27.1 27.1.1 27.2 27.2.1 27.2.2 27.3 27.3.1 27.3.2 27.4 27.4.1
Datenbanksysteme Übersicht über gebräuchliche Datenbanksysteme Einrichten der Datenbankumgebung Installation des SQL Server 2005 (Standard/Express) Die Beispieldatenbank des Buchs Datenbankwerkzeuge SQL Server Management Studio SQL Management Studio Express SQL Grundlagen Data Definition Language
941 942 944 944 947 948 948 953 955 955
939
Sandini Bib
17
Inhalt
27.4.2 27.4.3 27.4.4 27.5 27.5.1 27.5.2
Data Manipulation Language Data Query Language Stored Procedures Datenbankaufbau Global Unique Identifiers (GUID) Normalformen
957 959 962 965 966 967
28
Überblick über ADO.NET
969
28.1 28.1.1 28.1.2 28.1.3 28.1.4 28.1.5 28.2 28.2.1 28.2.2 28.2.3 28.2.4 28.2.5 28.2.6 28.3 28.3.1 28.3.2 28.3.3 28.3.4 28.3.5
Grundlegender Datenbankzugriff Namespaces für die Datenbankprovider Die Klasse SqlConnection Datenbankkommandos absetzen (SqlCommand) Daten ermitteln (SqlDataReader) Stored Procedures verwenden In-Memory-Datenbank: Das DataSet Klassen für das DataSet Laden der Daten: SqlDataAdapter Erzeugen von Kommandos: Der SqlCommandBuilder Speichern der Daten mit SqlDataAdapter Datenspeicher: Die Klasse DataTable Verknüpfen von Tabellen (DataRelation) Visual Data Tools Steuerelemente für den Datenzugriff Die Hilfsmittel des Visual Studio Das DataGridView im Detail Weitere Steuerelemente für die Datenbindung Typisierte DataSets
969 969 970 974 975 983 986 987 988 990 993 995 998 1001 1002 1002 1009 1015 1017
29
Fortgeschrittene Programmiertechniken
29.1 29.1.1 29.1.2 29.1.3 29.1.4 29.2 29.2.1 29.2.2 29.2.3 29.3 29.3.1 29.3.2
Metadaten des SQL Server ermitteln Arten von Metadaten Ermitteln des Datenbankschemas Parameter für GetSchema() Ermitteln der Datenbankinformationen Automatische Erstellung von Business-Objekten Das Hauptformular Tabellen auswählen »Konvertieren« der Tabellen in Klassen Datenbindung mit Objekten Grundlagen der Datenbindung an Objekte Beispielprogramm mit Objekt-Datenbindung
A
Glossar
1059
Index
1065
1023 1023 1023 1026 1026 1027 1032 1033 1037 1041 1049 1049 1051
Sandini Bib
Sandini Bib
Vorwort Als Microsoft zur PDC 2000 das .NET Framework (damals noch als .NET-Strategie) vorstellte, sorgte dies für eine nicht unerhebliche Unsicherheit unter vielen Programmierern. Schuld daran war zugegebenermaßen auch Microsoft selbst, denn die Vermarktung von .NET als »Zukunftsstrategie« gelang nicht wirklich – die Marketingexperten waren schlicht nicht in der Lage, die einfache Frage »Was ist .NET?« schlüssig zu beantworten. Die Programmierer stellten sich die Frage, ob .NET wirklich das bringt, was Microsoft sich erhofft. Immerhin waren die bisherigen Technologien ausgereift und funktionierten; Nun brachte Microsoft nicht nur ein Framework im Stile von Java auf den Markt, sondern dazu noch eine neue Programmiersprache. Ganz zu schweigen von dem vollständigen Umstieg auf Objektorientierung, die das .NET Framework mit sich bringt. Es war ein großer Schritt für Microsoft, und damit auch ein großer Schritt für die Programmierer. Vor allem VB6Entwickler hatten große Probleme, denn Objektorientierung war ihnen fremd, und viele verstanden die neuen Vorgehensweisen schlicht nicht (im Internet gibt es unter http://www.classicvb.org sogar eine Petition, VB6 unter dem Namen VB.COM weiterzuentwickeln. Sie können diese Petition unterschreiben, aber machen Sie sich keine Hoffnungen – VB6 ist für Microsoft kein Thema mehr). Inzwischen ist das .NET Framework in der Version 2.0 erschienen, und damit auch dieses Buch als eines der ersten zur neuen Version. C# ist mittlerweile weltweit akzeptiert und die meistgenutzte Sprache für .NET. Die Akzeptanz geht sogar so weit, dass .NET unter dem Namen Mono nahezu komplett auf Linux portiert wurde (Der Kopf dahinter ist Gnome-Entwickler Miguel de Icaza, mittlerweile bei Novell und nach wie vor verantwortlich für Mono). Die nächste Version von Fedora Linux wird Mono sogar als Systembestandteil integrieren, eigentlich schon ein Schlag ins Gesicht der Java-Leute. Und die Zukunft wird noch mehr bringen – Windows Vista, die nächste Windows-Version, wird .NET nicht nur integrieren, sondern teilweise auch darauf basieren (die neuen Oberflächen von Vista funktionieren nur mit .NET). Das vorliegende Buch ist eines der ersten zu C# 2005 bzw. dem .NET Framework 2.0. Die Überarbeitung bezog sich nicht nur darauf, die neuen Features der Sprache zu beschreiben, es kam vielmehr ein komplett neuer Teil hinzu, der von den Lesern der Vorgängerversion gewünscht wurde. In dieser Auflage wird auch die Datenbankprogrammierung berücksichtigt; das Buch enthält eine Einführung in ADO.NET und die Datenbanksprache SQL. Insgesamt sind dabei ca. 1100 Seiten detaillierter Informationen über C#, die Programmierung unter .NET sowie die wichtigsten Klassen des .NET Frameworks herausgekommen. Das Konzept wurde nicht geändert – am Anfang steht nach wie vor die Sprache C# sowie die Konzepte objektorientierter Programmierung, bevor es an Windows.Forms und die fortgeschrittenen Technologien, wie beispielsweise Reflection, geht.
Sandini Bib
20
Vorwort
Für wen ist dieses Buch geeignet Wenn man ein Buch schreibt, macht man sich zunächst Gedanken über die Zielgruppe. Das Buch sollte die Bezeichnung »Umfassende Einführung« verdienen, womit der Bereich, der abgedeckt werden muss, relativ groß ist. Die Zielgruppe sind alle Programmierer, die sich mit .NET und C# beschäftigen wollen und bereits Erfahrung in einer anderen Programmiersprache gesammelt haben. Sollten Sie in Sachen Programmierung völlig jungfräulich sein, werden Sie mit Sicherheit auf einige Hürden stoßen, die sich zum Teil als unüberwindbar darstellen. In diesem Fall sei Ihnen zunächst ein einführendes Buch empfohlen. Geeignet ist das Buch allerdings auch für Profis, die genauer wissen wollen, wie etwas in C# funktioniert. Mit fortschreitender Seitenzahl werden die verwendeten Techniken (zwangsläufig) etwas anspruchsvoller, ohne jedoch die Detailinformationen außen vor zu lassen. Der Aufbau des Buchs macht es sowohl möglich, es von vorne nach hinten durchzuarbeiten, als auch, einzelne Kapitel als Nachschlagewerk zu benutzen.
Was Sie in diesem Buch nicht finden Der Umfang der Klassenbibliothek macht es unmöglich, wirklich alle ihre Bestandteile in einem einzigen Buch zu behandeln. Schon gar nicht, wenn man auch die Detail- und Hintergrundinformationen liefern will. Aus diesem Grund erheben wir hier keinen Anspruch auf absolute Vollständigkeit. Die folgende Liste zeigt auf, was Sie von diesem Buch nicht erwarten dürfen. f Programmierung von Internetanwendungen/ASP.NET. Dieses Thema ist so umfangreich, dass auch die leichteste Andeutung dieser Technologie nicht möglich ist. Hierzu erscheinen bei Addison-Wesley weitere Bücher, die sich speziell der ASP.NETTechnologie widmen. f Die Datenbankprogrammierung mit ADO.NET und C# wird in diesem Buch nur einführend angesprochen. Auch dieses Thema ist zu umfangreich, um es in ein paar Seiten zu packen. Der Autor plant jedoch ein eigenes Buch zu diesem Thema, zumal die Datenbankprogrammierung immer wichtiger wird. f Eine Übersicht über alle Klassen des .NET Frameworks. Dabei handelt es sich um eine ziemlich große Anzahl, die ebenfalls in einem einzigen Buch nicht unterzubringen ist. Einen Überblick über die wichtigsten Klassen und Vorgehensweisen finden Sie in dem folgenden Buch: Programmierung mit der .NET Klassenbibliothek, Schwichtenberg/Eller, Addison-Wesley, ISBN 3-8273-2128-X
Sandini Bib
Vorwort
21
Zu guter Letzt … danke Kein Vorwort ohne Danksagung. Immerhin arbeiten an einem Buch sehr viel mehr Leute, als nur der Autor. Zu nennen wären da zunächst die Lektor(inn)en und Hersteller von Addison-Wesley. Namentlich an diesem Buch beteiligt waren Sylvia Hasselbach, Elisabeth Prümm und Andreas Fleck. Herzlichen Dank für die gewohnt angenehme Zusammenarbeit. Ebenfalls danken möchte ich meiner Korrektorin Bianca Schiener, die dafür gesorgt hat, dass die Rechtschreibfehler aus dem Buch verschwinden und manche allzu verworrenen Sätze doch noch verständlich wurden. Letztlich geht mein Dank natürlich auch an die Personen, denen ich aufgrund der Arbeit am Buch etwas weniger Zeit widmen konnte, als ich eigentlich gewollt hätte. Falls Sie noch Fragen zum Buch haben, Kritik anbringen möchten oder auch trotz aller Sorgfalt noch einen Fehler gefunden haben, besuchen Sie einfach die Website http://www.frankeller.de. Dort finden Sie eine Errata-Liste zum Buch, in die Sie Ihre Anmerkungen eintragen können. Möglicherweise finden Sie dort bereits eine Korrektur. Ich hoffe, Ihnen mit diesem Buch einen guten Einstieg in eine faszinierende Technologie und eine ebenso faszinierende Programmiersprache geben zu können und wünsche Ihnen viel Erfolg.
Frank Eller, im Januar 2006 http://www.frankeller.de
Sandini Bib
Sandini Bib
Teil 1 Einführung
Sandini Bib
Sandini Bib
1
Das .NET Framework
Die Grundlage der Programmierung mit C# ist das .NET Framework. Dabei handelt es sich nicht nur um eine besondere Laufzeitumgebung, die die Ausführung der Programme ermöglicht. Es ist außerdem eine enorm umfangreiche Klassenbibliothek, die eine zukunftssichere Basis für die Programmierung unter Windows-Betriebssystemen darstellt. Verständlicherweise ist die erste Frage die sich stellt, warum Microsoft von seinen bestehenden etablierten Technologien abrückt und etwas vollkommen Neues zur Verfügung stellt. Dieses Kapitel versucht, diese Frage zu beantworten und auch ein wenig Hintergrundinformationen zu .NET zu liefern.
1.1
Warum .NET?
1.1.1
Die .NET-Strategie
Als Microsoft im Jahre 2000 zum ersten Mal die .NET-Strategie vorstellte, waren viele Leute skeptisch. Das hatte nichts mit der Tatsache zu tun, dass Microsoft eine neue Strategie vorstellte, sondern vielmehr damit, dass niemand (auch keiner bei Microsoft) so richtig erklären konnte, worum es sich bei .NET eigentlich handelt. Plötzlich sollte alles den Zusatz ».NET« tragen – Windows Server .NET, Windows .NET, Biztalk Server .NET, Exchange Server .NET … nun, mittlerweile ist Microsoft von diesem Weg glücklicherweise wieder abgekommen und geht nun eher den Weg, .NET aus den Namen der Produkte verschwinden zu lassen. Es steht außerdem zu vermuten, dass diese Version des .NET Frameworks die letzte ist, die unter diesem Namen firmiert. Der interessanteste Bestandteil der .NET-Strategie war und ist sicherlich jenes .NET Framework. Dabei handelt es sich um eine neu gestaltete Grundlage für die WindowsProgrammierung, die über kurz oder lang alle bestehenden Technologien ersetzen wird. Hinzu kam eine neue Programmiersprache, die alle Möglichkeiten des .NET Frameworks optimal ausnutzen sollte: C#. Aber die Frage stand im Raum, wozu Microsoft das alles tat? Letztlich lief es ja darauf hinaus, die Entwicklungen der letzten 10 Jahre ad absurdum zu führen und alles neu zu machen – ganz zu schweigen von der Tatsache, dass alle Programmierer nun gezwungen waren, wieder eine neue Klassenbibliothek, neue Funktionalitäten und Vorgehensweisen zu erlernen (und möglicherweise auch noch eine neue Sprache). Eine derartige Vorgehensweise kann durchaus abschreckende Wirkung haben. Der Grund, warum .NET eingeführt wurde, ist relativ einfach zu verstehen. Die bisherigen Technologien sind über die Jahre gewachsen und ständig erweitert worden, mitunter zu Dingen, für die sie ursprünglich gar nicht vorgesehen waren. Visual Basic 6 ist eines der besten Beispiele dafür – die besten und meistgenutzten Tipps und Tricks für diese Programmiersprache bestehen aus Workarounds, die Dinge möglich machen, zu denen beispielsweise VB6 eigentlich gar nicht in der Lage ist. Die Notwendigkeit dafür ergab sich aus den Anforderungen, die an moderne Softwareprodukte gestellt werden, und auch diese Anforderungen wachsen.
Sandini Bib
26
1 Das .NET Framework
Ein Problem bei wachsenden Systemen ist grundsätzlich die mitwachsende Entropie, also prinzipiell die Unordnung eines Systems. Umgesetzt auf die Kerntechnologien der Programmierung bedeutet das schlicht, wenn ein System nicht von Anfang an ausgereift und dafür konzipiert ist zu wachsen, nimmt mit zunehmenden Möglichkeiten auch die Komplexität zu. Die Windows-Programmierung basierte vor .NET in der Hauptsache auf zwei großen Bausteinen: Dem Windows API und COM, dem Component Object Model. Das Windows API besteht aus einer unüberschaubaren Anzahl von C-Funktionen, COM ist (bzw. war) Microsofts Technologie für die komponentenorientierte Programmierung. COMKomponenten sind zwar in der Lage, unabhängig von der Programmiersprache verwendet zu werden, bringen aber auch zahlreiche Probleme und eine gewisse Komplexität mit, auf die mancher Programmierer gerne verzichten würde. Für beide Technologien gilt, dass sie keineswegs dafür ausgelegt sind, zu wachsen. Zu ihrer Zeit sicherlich zukunftsweisend haben sie jetzt den Zenith der Leistungsfähigkeit erreicht. Microsoft hat dies erkannt und nach einer Lösung gesucht, und das schon länger, als Sie vielleicht vermuten. Als das .NET Framework zur PDC 2000 vorgestellt wurde, war es bereits eine recht ausgereifte Klassenbibliothek mit einer Unmenge an Funktionalität, die über Jahre entstanden sein muss. In Wirklichkeit begann Microsoft bereits 1996/1997 mit der Arbeit an .NET (und C#). Die Vorgaben waren klar: Einerseits eine Grundlage für die Programmierung schaffen, die zukunftssicher sowie erweiterbar ist und andererseits dafür zu sorgen, dass bestehende Probleme der Vergangenheit angehören. Für die Entwicklung dieser neuen objektorientierten Grundlage wurde Anders Hejlsberg engagiert, der bei Borland für Delphi und dessen Programmbibliothek zuständig gewesen war. Viele Vorgehensweisen in .NET erinnern daher auch an Borlands Delphi, mit dem Unterschied, dass .NET konsequent objektorientiert ist. Delphi konnte diesen Anspruch nie erheben, war stets eine Mischung zwischen strukturierter und objektorientierter Programmierung.
1.1.2
.NET und Java
Microsoft hat sich bei der Entwicklung von .NET und C# von vielen anderen Technologien leiten lassen. Das Ergebnis war eine Technologie, die der von Suns Java ähnelt. Mit einem Unterschied: Während es Sun mit Java eher darum geht, auf allen Plattformen präsent zu sein, ging es Microsoft mehr darum, unterschiedlichen Sprachen eine gemeinsame Plattform zu bieten. Beide Technologien arbeiten mit einem »Zwischencode«, der von den Compilern erzeugt und erst zur Laufzeit in nativen Code übersetzt wird. Bei Java heißt dieser Bytecode und ist unabhängig von der zugrunde liegenden Plattform. Die MicrosoftVariante nennt sich Intermediate Language und ist unabhängig von der verwendeten Programmiersprache. Kurz nach dem ersten Erscheinen von C# wurden dann auch Stimmen laut, dass Microsoft alles von Java geklaut hätte. Immerhin waren sowohl Plattform, Vorgehensweise als auch die Sprache recht ähnlich. Natürlich war die Programmiersprache Java eine der Sprachen gewesen, auf die Microsofts Entwickler bei der Kreation von C# geschaut hatten. Es flossen aber noch zahlreiche andere Sprachen mit ein: Unter anderem C++, Modula2, Pascal und viele andere.
Sandini Bib
Warum .NET?
27
Bereits die erste Version von C# zeigte dann auch bereits Features, die in Java eben nicht vorhanden waren (und zum Teil mittlerweile nachgerüstet worden sind). So hatte C# eine eigene Syntax für Eigenschaften, die die get- und set-Methode zu einem einzigen Sprachkonstrukt zusammenfasste und einen wesentlich einfacheren Zugriff auf Eigenschaften darstellt als Javas Methodensyntax. C# bzw. .NET machen einen Unterschied zwischen Werte- und Referenztypen, erlauben sogar, eigene Wertetypen zu erstellen. In Java ist das nicht möglich. Die foreach-Schleife von C# erlaubt eine schnelle Iteration durch Listen oder Arrays, ebenfalls ein Feature, das erst im JDK 5 als »erweiterte for-Schleife« Einzug hielt. Die Liste der Unterschiede ließe sich noch lange fortführen. Gelernt hat Microsoft sicherlich von der Java-Technologie, dass sie geklaut hätten ist allerdings eine unhaltbare Aussage. Ebenso könnte man behaupten, Java hätte seine Syntax von C geklaut, und das tut ja auch keiner. Der größte Unterschied zwischen den beiden Technologien besteht allerdings im Verhalten der Laufzeitumgebung. Unter .NET wird der erzeugte Zwischencode grundsätzlich kompiliert, wenn es zur Ausführung kommt. Das bedeutet, er wird in nativen Code übersetzt und dann ausgeführt. Der Vorteil dieser Vorgehensweise ist, dass bei dieser so genannten Just-in-time-Kompilierung diverse Kontrollen und Optimierungen vorgenommen werden können (beispielsweise auf den bestehenden Prozessor). Die Jitter (Just-In-TimeCompiler, es sind in .NET deren drei) übersetzen dabei nur den Teil der Anwendung, der gerade gebraucht wird und speichern ihn in einem so genannten Cache zwischen. Nur bei einer Änderung des Codes wird danach neu kompiliert, ansonsten nimmt die Laufzeitumgebung das bereits kompilierte Codestück her. Im Falle von Java war es lange Zeit so, dass der Bytecode zur Laufzeit interpretiert wurde. Es wurde also keineswegs nativer und damit schnell ablaufender Code erzeugt, sondern der Code wurde immer wieder Stückchen für Stückchen übersetzt. Mittlerweile ist es zwar auch mit Java möglich, just-in-time zu kompilieren, der Interpreter ist aber nach wie vor vorhanden und sogar als Standardverhalten eingestellt. Ist .NET also insgesamt besser als Java? Ist C# die bessere Programmiersprache? Pauschal lässt sich diese Frage nicht beantworten. Wenn als Kriterium die verwendete Plattform angenommen wird, dann ist es ganz klar, dass .NET auf einer Windows-Plattform die bessere Alternative ist, da die Möglichkeiten des Betriebssystems komplett ausgereizt werden können (was mit Java aufgrund der gewünschten Plattformunabhängigkeit nicht der Fall sein kann). Geht es darum, auf unterschiedlichen Betriebssystemen lauffähig zu sein, hat Java (noch) die Nase vorn. Aber auch hier droht Konkurrenz durch das Mono-Projekt, das nahezu alle Features von .NET auf verschiedene Plattformen bringt (darunter in der Hauptsache Linux und MacOS).
Sandini Bib
VERWEIS
28
1 Das .NET Framework
Das Mono-Projekt ist eine Initiative von Gnome-Erfinder Miguel de Icaza. Mittlerweile ist dessen Firma Ximian ebenso wie SuSe von Novell aufgekauft worden. Für .NET-Programmierer ist Mono aus zwei Gründen interessant. Zum einen können Applikationen, die keinen intensiven Gebrauch spezieller Windows-Features machen, leicht auf Linux oder MacOS portiert werden. Zum anderen ist der Quellcode von Mono frei verfügbar. Sie finden Mono unter folgender URL: http://www.go-mono.com
1.2
Vorteile von .NET
In diesem Abschnitt erfahren Sie einiges über die Vorteile, die die Programmierung mit .NET mit sich bringt.
Einheitliche Klassenbibliothek Die erste Entscheidung, die ein Programmierer vor .NET treffen musste, wenn es ein Problem zu lösen oder eine Applikation zu erstellen gab, war die der am besten geeigneten Programmiersprache. Diese Entscheidung war von mehreren Faktoren abhängig. Zum Einen von der Problemstellung, zum Zweiten vom Kenntnisstand des Programmierers. Jede Programmiersprache besaß ihre Vor- und Nachteile, aber vor allem auch ihre eigene Bibliothek an Funktionalität, eigene Vorgehensweisen, eigene unterschiedliche Datentypen usw. Wichtig war auch noch die Art der Applikation. Für Webapplikationen gab es ASP, PHP, Perl und viele andere mehr, im Falle einer Windows-Desktop-Applikation musste in der Regel Visual Basic herhalten, C++ war Sprache der Wahl für Windows-Dienste oder Treiber ebenso wie für zeitkritische Anwendungen. .NET nimmt dem Programmierer diese Entscheidung ab. Letztlich ist es vollkommen egal, mit welcher Programmiersprache programmiert wird – alle können das gleiche und unterscheiden sich lediglich in der Syntax (und marginal in den Vorgehensweisen). Das gilt sowohl für Windows- als auch für Webapplikationen. Die gesamte Funktionalität kommt ausschließlich aus dem .NET Framework, das eine umfangreiche Klassenbibliothek zur Verfügung stellt, die von allen .NET-fähigen Sprachen genutzt werden kann. Damit kann eine DLL, die unter Windows geschrieben wurde und beispielsweise die Business-Logik einer Applikation enthält, problemlos auch im Web verwendet werden – sie muss nur dorthin kopiert und in der Webapplikation referenziert werden.
Einheitliche Datentypen Aus einer einheitlichen Klassenbibliothek ergibt sich noch ein weiterer Vorteil. Datentypen waren schon immer eine komplizierte Sache. Ein Integer-Datentyp (also eine Ganzzahl) hat in C++ die Größe 32 Bit, in Visual Basic (6) 16 Bit. Visual Basic (6) kennt den Datentyp
Sandini Bib
Vorteile von .NET
29
String für Zeichenketten, C bzw. C++ stellen diese als Array aus Zeichen dar. Derartige Unterschiede können schnell zu Problemen führen. So ist das Windows API in C geschrieben, und zum Aufruf einer derartigen Funktion (was sehr häufig vorkommt, vor allem in VB6) ist es notwendig, einen entsprechenden kompatiblen Datentyp zur Verfügung zu stellen. Auch dies hat mit .NET ein Ende. Unter .NET existieren nur noch die Datentypen, die das .NET Framework vorgibt, die einzelnen Programmiersprachen beinhalten keine eigenen Datentypen mehr. Damit ist auch die Kommunikation bzw. der Datenaustausch zwischen Anwendungen kein Problem mehr. Der (immer noch zum Teil notwendige) Aufruf nativer Win32-API-Funktionen wird zukünftig immer mehr in den Hintergrund treten, weil die darin enthaltene Funktionalität ins .NET Framework eingegliedert wird.
Einheitliches Programmiermodell
HINWEIS
.NET bietet ein einheitliches Programmiermodell sowohl für die Webentwicklung als auch für den Windows-Developer. Der Wechsel zwischen beiden Modellen ist unkompliziert. Zwar ist es notwendig, für die Webentwicklung ein wenig umzudenken, was das Verhalten einer Applikation angeht (anders als unter Windows reagieren Steuerelemente dort beispielsweise nicht direkt auf Ereignisse, sondern vorher muss die Seite erst neu geladen werden, damit der Server das Ereignis mitbekommt), die Programmiersprache bleibt aber die gleiche und auch die Funktionalität – denn die kommt ausschließlich aus dem .NET Framework. Geändert werden muss lediglich die Präsentationsschicht. Mitunter wird auch in der Literatur behauptet, ein mehrschichtiger Aufbau sei unter ASP.NET dank der neuen Features nicht mehr notwendig. Das ist natürlich vollkommen falsch. Gerade die Möglichkeit, DLLs sowohl unter Windows als auch im Web zu verwenden ist einer der größten Vorteile der .NET-Programmierung. Teilen Sie also auch in Zukunft Ihre Anwendungen sauber und mehrschichtig auf.
Ende der DLL-Hölle Als DLL-Hölle bezeichnet man den Umstand, dass eine DLL durch eine ältere Version ersetzt wurde und somit manche Programme den Dienst verweigern. Das ist besonders bei der Verwendung der COM-Technologie ein Problem. Dort kommt noch hinzu, dass eine COM-Komponente auf dem Zielsystem registriert werden muss und nur einmal existieren kann. Diese Registrierung erfolgt in der Registry über eine eindeutige ID (die berühmte Class-ID oder CLSID). Und eben diese Class-ID ist eindeutig für eine COM-Komponente, und zwar unabhängig von der Version. .NET-Komponenten müssen nicht nur nicht registriert werden, sie können auch mehrfach auf einem System existieren, was sowohl die Weitergabe einer Anwendung erleichtert als auch das Problem der DLL-Hölle beseitigt. Das Ganze geht sogar so weit, dass das gesamte .NET Framework in unterschiedlichen Versionen auf dem Rechner existieren kann (und auf den meisten auch existiert). Dabei gibt es zwei grundlegende Szenarien. Entweder die benötigten DLLs werden mit einem Programm gemeinsam installiert (und einfach ins Pro-
Sandini Bib
30
1 Das .NET Framework
grammverzeichnis kopiert) oder Sie installieren eine .NET-Komponente in den Global Assembly Cache, das Verzeichnis, in dem sich auch die DLLs des .NET Frameworks selbst befinden. Der Vorteil des Global Assembly Cache (auch GAC genannt) liegt in der automatischen Versionsverwaltung. Die gleiche DLL mit dem gleichen Namen kann mehrfach darin existieren, weil zur Unterscheidung auch die Versionsnummer herangezogen wird. Das ist ein eklatanter Vorteil gegenüber bisherigen Vorgehensweisen. Noch dazu, weil die Registry endlich entrümpelt werden kann und so auch Ihr System schneller startet.
Ende der Registry Die Registry war ohnehin ein Hort ständigen Ärgers. Alle Programme legen dort ihre Einstellungen ab, mitunter auch Daten, für die die Registry eigentlich gar nicht vorgesehen war. Mit .NET kehrt Microsoft zu den guten alten *.ini-Dateien zurück. Diese nennen sich allerdings jetzt Konfigurationsdateien und sind im XML-Format gespeichert. Der Vorteil dieser Vorgehensweise ist, dass einerseits die Registry nicht mehr unnötig aufgebläht wird und andererseits die Konfigurationsdateien endlich wieder von menschlichen Augen lesbar sind. Ohnehin baut Microsoft mit .NET auch sehr stark auf das XML-Format. Mit .NET 2.0 ist es möglich, die Einstellungen für eine Applikation sowohl im so genannten Application Scope oder auch für jeden Benutzer getrennt zu speichern. In jedem Fall bekommt ein anderer Nutzer keinen Zugriff auf die entsprechende Konfigurationsdatei (es sei denn, er ist der Administrator und weiß, wo sich diese Dateien befinden). Das Laden und Speichern von Einstellungen ist mit .NET 2.0 ein Kinderspiel; Alle Einstellungen sind in einer Klasse namens Settings als Eigenschaften verfügbar, was einen einfachen Zugriff erlaubt. Details zum Laden und Speichern von Einstellungen sowie zu Konfigurationsdateien finden Sie im Abschnitt 20.4.4 ab Seite 686.
Automatische Speicherverwaltung Vor allem in C++ ist die Verwaltung des Speichers ständig Ursache vieler Programmfehler. Die Implementierung einer funktionierenden und performanten Speicherverwaltung ist keineswegs trivial und in jedem Fall zeitaufwändig. Sollte sich darin ein Fehler verbergen, ist dieser nur sehr schwer aufzufinden. Die Begriffe »zirkuläre Referenz« oder »wilder Zeiger« sind Synonyme für Albträume, schlaflose Nächte oder Kopfschmerzen eines C++Entwicklers. Das .NET Framework ist vollständig objektorientiert und beinhaltet eine automatische Speicherverwaltung, bekannt als Garbage Collection. Sie räumt nicht mehr referenzierte Objekte automatisch aus dem Speicher und sorgt dafür, dass dieser stets aufgeräumt wird. Die Verquickung zahlreicher zum Teil intelligenter Algorithmen stellt die Performance der Garbage Collection sicher. In der Tat wird der Speicher so schnell aufgeräumt, dass der durch die Garbage Collection angestoßene Hintergrundtask überhaupt nicht auffällt.
Sandini Bib
Der Aufbau des .NET Frameworks
31
Sicherheit Ein Programm, das auf einem Rechner gestartet wird, hat auch die Rechte, die der angemeldete Benutzer besitzt. Nun ist es unter Windows (leider) so, dass der erste angelegte Benutzer Administrationsrechte hat, was viele Anwender nicht wissen. Damit sind schadhaften Programmen natürlich alle Türen und Tore geöffnet. .NET führt die so genannte Code Access Security ein, bei der es in der Hauptsache darum geht, dass das .NET Framework selbst kontrolliert, welche Rechte ein Programm bekommt und welche nicht. Festgelegt wird dies durch den Administrator des Systems. Programme, die Rechte anfordern, die der Administrator nicht gewähren will, werden schlicht nicht ausgeführt. Code Access Security ist allerdings ein sehr umfangreiches Thema und wird in diesem Buch (in dem es primär um die Programmiersprache C# und die Erstellung von Windows-Anwendungen geht) nicht detailliert behandelt.
Sprachenunabhängigkeit Die Unabhängigkeit von der Programmiersprache wurde bereits erwähnt, sie geht aber noch viel weiter. .NET arbeitet wie Java mit einem Zwischencode, dem so genannten Intermediate Language Code oder kurz IL-Code. Diese Vorgehensweise bringt mehrere Vorteile mit sich. Zum Einen wird der Zwischencode erst zur Laufzeit in nativen, ausführbaren Code übersetzt, was es ermöglicht, Optimierungen auf den aktuell im Zielrechner befindlichen Prozessor vorzunehmen. Zum Zweiten beinhaltet der Zwischencode Metadaten und wird von jeder .NET-Sprache verstanden. Damit ist es auch möglich, dass in C# geschriebene Klassen in Visual Basic erweitert und später in C++ verwendet werden können.
Erweiterbarkeit Durch die konsequente Objektorientierung innerhalb des .NET Frameworks ist dieses sehr leicht erweiterbar. Aber das ist noch nicht alles. Auch die verwendeten Sprachen sind keineswegs endgültig festgelegt. Prinzipiell kann jede Sprache .NET-fähig gestaltet werden. Populärstes Beispiel ist hierbei Borlands Delphi, das in der Version 2005 sowohl native Programme als auch .NET-Programme erstellen kann (aber inzwischen immer weniger Beachtung findet). Das .NET Framework definiert hierzu einen Satz von Regeln und einige grundlegende Datentypen, die jede Sprache implementieren muss, will sie .NET-konform sein. Dadurch wird auch die Sprachunabhängigkeit sichergestellt, denn nur durch Einhaltung dieser Regeln ist sicher, dass Code einer Sprache auch in einer anderen Sprache verwendet werden kann.
1.3
Der Aufbau des .NET Frameworks
Das Konzept des .NET Frameworks ähnelt dem Konzept, das bereits von Java her bekannt ist. Auch im Falle von .NET gibt es eine Runtime und ein dazu gehöriges SDK. Die Laufzeitumgebung muss auf jedem Rechner installiert sein, auf dem ein .NET-Programm ausgeführt werden soll, das SDK wird (als Minimum) benötigt, wenn sie unter .NET programmieren wollen.
Sandini Bib
32
1 Das .NET Framework
Vor allem in den einschlägigen Newsgroups taucht von Zeit zu Zeit die Frage auf, ob es die Möglichkeit gibt, .NET-Applikationen auch ohne das .NET Framework laufen zu lassen. Das ist prinzipbedingt nicht möglich. Anderslautende Aussagen können Sie getrost ignorieren.
1.3.1
Übersicht
Der grundlegende Aufbau des .NET Frameworks ist in Abbildung 1.1 dargestellt. Die Basis stellt das Betriebssystem dar. Anders als Java baut Microsoft nicht auf Betriebssystemunabhängigkeit, .NET läuft nur auf Windows-Betriebssystemen. Das ist keineswegs ein Nachteil. Durch die Festlegung auf eine bestimmte Art von Betriebssystem (denn natürlich läuft .NET auf unterschiedlichen Windows-Versionen, die auch architekturelle Unterschiede aufweisen) ist es möglich, das Framework weit tiefer in das System zu integrieren. Was in Java beispielsweise nicht möglich ist, nämlich die Nutzung bereits vorhandener Funktionalität mithilfe des Windows API oder mittels COM, ist in .NET ein integraler Bestandteil des Frameworks und aller darauf basierenden Sprachen.
Abbildung 1.1: Der Aufbau des .NET Frameworks in der Übersicht
1.3.2
Die Common Language Runtime
Die Common Language Runtime (CLR), ist die eigentliche Ausführungsschicht von .NET, sie führt die Programme aus. Der Lademechanismus von Windows startet die CLR, sobald erkannt wird, dass es sich bei einer ausführbaren Datei um ein .NET-Programm handelt. Das Dateiformat von .NET-Programmen entspricht übrigens dem auch bisher verwendeten Portable Executable-Format, das lediglich um Bereiche für IL-Code und Daten erweitert wurde.
Sandini Bib
Der Aufbau des .NET Frameworks
33
Der Aufbau der CLR ist ähnlich umfangreich wie der des gesamten .NET Frameworks. Sie besteht aus verschiedenen Bestandteilen, die für die Ausführung eines Programms nötig sind. Den Aufbau sehen Sie in Abbildung 1.2.
Abbildung 1.2: Der Aufbau der Laufzeitumgebung, Common Language Runtime
In der CLR geschieht zur Laufzeit eines Programms relativ viel. Zentraler Bestandteil ist der Code Manager, der mehrere Aufgaben hat. Unter anderem sorgt er dafür, dass der Code aus dem IL-Format in das native Format überführt wird, indem einer der Just-In-TimeCompiler (kurz JITter) aufgerufen wird. Kompiliert wird aber nur der Bestandteil, der gerade benötigt wird. Der Code-Manager kontrolliert, ob das benötigte Codestück (das kann auch eine einzige Methode sein) bereits in kompilierter Form vorliegt oder nicht. Falls nicht, wird es kompiliert und in einen In-Memory-Cache abgelegt. Existiert es bereits, sorgt der Code-Manager dafür, dass die Jitter eben nicht in Aktion treten und der bereits kompilierte Bestandteil wird einfach ausgeführt. Bei Änderungen am Code, die sich vor allem in der Debug-Phase einer Anwendung ergeben, wird neu kompiliert. Damit das funktioniert, versieht der Code-Manager alle kompilierten Bestandteile mit einem Kontrollcode, der es ihm erlaubt, eine Änderung des Quellcodes festzustellen. Ist dies der Fall, wird der kompilierte Bestandteil weggeworfen und der geänderte Code kompiliert. Weiterhin wird vom Code-Manager aus zu gewissen Zeiten die Garbage Collection aufgerufen, die nicht mehr benötigten Speicherplatz freigibt. Aber der Code-Manager tut noch mehr. Bevor ein Programm abläuft, wird zunächst geprüft, ob auch alle Berechtigungen vorhanden sind, die das Programm angefordert hat bzw. benötigt. Ist das nicht der Fall, stoppt die Ausführung. Zu diesem Zweck dient die Security Engine, die ebenfalls in der CLR untergebracht ist. Der COM-Marshaler tritt in Aktion, wenn der Code Manager bemerkt, dass eine Methode aus dem Windows API aufgerufen werden soll. Da die .NET-Datentypen zwar für alle
Sandini Bib
34
1 Das .NET Framework
.NET-Sprachen einheitlich sind, nicht jedoch den in C verwendeten Datentypen entsprechen (das Windows API ist in C geschrieben), müssen sie entsprechend überführt werden. Mitunter geschieht das automatisch, zum Teil muss der Programmierer selbst Hand anlegen und der CLR »sagen«, in welchen Datentyp ein Wert überführt werden soll. Der Exception Manager ist für das Abfangen so genannter Exceptions oder Ausnahmen zuständig. Dabei handelt es sich um das .NET-Konzept für das Abfangen von Fehlern. Die strukturierte Fehlerbehandlung, wie sie aus C++ oder aus VB6 bekannt ist, ist Vergangenheit. .NET verwendet ein objektorientiertes Konzept, das manchen schon aus Borlands Delphi bekannt sein könnte. Exceptions sind nichts anderes als Objekte, die einen ungültigen Zustand darstellen. Der Vorteil ist, dass diese Objekte Fehlermeldungen im Klartext enthalten und sauber über objektorientierte Strukturen abgefangen werden können. Es ist außerdem leicht möglich, eigene Fehlerklassen für applikationsspezifische Fehler zu erstellen. Mehr über Exceptions und Fehlerbehandlung finden sie in Kapitel 16 auf Seite 455. Die CLR unterstützt weiterhin auch Multithreading sowie (sinnvollerweise) die Klassen der BCL.
1.3.3
Die Klassenbibliothek
Die Klassenbibliothek enthält die gesamte Funktionalität des .NET Frameworks und damit auch die gesamte Funktionalität von C#. C# selbst bringt lediglich die Syntax mit, keinerlei Funktionalität – diese kommt ausschließlich aus dem .NET Framework. Sie besteht in der Version 2.0 aus über 4600 Klassen und ist aufgeteilt in so genannte Namespaces, eine Kategorisierung, die es leicht möglich macht, die benötigten Klassen aufzufinden. Diese Kategorisierung ist rein virtuell, d.h. es existiert keine Klasse mit Namen »Namespace«, die es ermöglichen würde, auf alle darin enthaltenen Klassen zuzugreifen. Stattdessen kennt vielmehr jede Klasse den Namespace, in dem sie sich befindet. Die .NET-Klassenbibliothek ist das zentrale Element der Programmierung unter .NET, ohne sie geht nichts, denn die Sprachen selbst bringen keinerlei Funktionalität mit. Die Größe der Klassenbibliothek zeigt schon den darin enthaltenen Leistungsumfang. Einige der wichtigsten Namespaces finden Sie in der folgenden Liste: f Namespace System: Er enthält die Basisfunktionalität für sämtliche Programme. Darin enthalten sind unter anderem Klassen wie Console für die Ausgabe auf die Eingabekonsole von Windows, Application für grundlegende Anwendungsdaten, Environment für umgebungsspezifische Daten sowie alle integrierten Datentypen wie int, double, float oder auch Arrays. f Namespace System.Windows.Forms: In diesem Namespace finden Sie sämtliche Klassen, Steuerelemente und Komponenten, die für die grafische Benutzeroberfläche unter Windows wichtig sind. In diesem Buch geht es in der Hauptsache um alle die Klassen, die in diesem Namespace zusammengefasst sind. Ab Kapitel 17 ab Seite 479 erfahren Sie mehr darüber. f Namespace System.IO: Dieser Namespace enthält sämtliche Klassen, die dem Zugriff auf das Dateisystem von Windows dienen. Da diese Art Zugriff essenziell für nahezu
Sandini Bib
Der Aufbau des .NET Frameworks
35
jedes Programm ist, wurde ihm ein eigenes Kapitel gewidmet. Sie finden weitere Informationen in Kapitel 14 ab Seite 317. f Namespace System.Data: Dieser Namespace und seine Unternamespaces stellen ADO.NET dar, also die Datenbankschnittstelle von .NET. Sämtliche dafür nötigen Klassen sind darin enthalten. ADO.NET wird ab Kapitel 27 ab Seite 941 beschrieben. f Namespace System.Drawing: Dieser Namespace und seine Unternamespaces enthalten wie der Name schon sagt, die Klassen, die für das Zeichnen wichtig sind. Zum Zeichnen gehört auch das Drucken, das nichts anderes ist als ein Zeichenvorgang auf eine vom Drucker zur Verfügung gestellte Zeichenoberfläche (diese entspricht natürlich dem Papier im Drucker). Mehr über das Zeichnen bzw. das Drucken finden Sie in den Kapiteln 21 ab Seite 691 sowie 22 ab Seite 831.
1.3.4
Die Benutzerschnittstelle
In der Programmierung vor .NET gab es immer einen großen Schnitt zwischen der Erstellung von Anwendungen unter Windows und von Anwendungen für das Internet. Mit .NET hatte dies ein Ende. Der Name für die Webentwicklung ist ASP.NET, und diese Technologie basiert ebenfalls auf dem .NET Framework. Das bedeutet, dass Sie sämtliche Klassen des .NET Frameworks sowohl für Windows- als auch für Webapplikationen nutzen können. Dazu gehören natürlich auch die Sprachen; war der Webentwickler bisher darauf angewiesen, mit JScript oder VBScript zu arbeiten, kann er sich jetzt zwischen C#, Visual Basic oder anderen .NET-fähigen Sprachen entscheiden. Da die Benutzerschnittstelle im Web anders aussieht und andere Anforderungen erfüllen muss, als das für eine Desktop-Applikation nötig ist, existieren hierfür auch andere Steuerelemente. Damit bietet ASP.NET das, was Java-Entwickler erst hinzuinstallieren mussten, nämlich ein Framework für die Erstellung von Oberflächen. In .NET ist das bereits integriert. Ein weiterer Vorteil von ASP.NET über Java ist, dass kein Application-Server benötigt wird (und die sind ja doch mitunter recht teuer, schaut man sich die Preise von BEA Weblogic oder IBM WebSphere an). Windows selbst bringt nämlich schon einen Application-Server mit, was allerdings viele nicht wissen. Und dabei handelt es sich um nichts anderes als COM+, eine Technologie, die in jedem Windows-System integriert ist. Dieses Buch behandelt ASP.NET allerdings nicht, dazu ist das Thema zu umfangreich. Die Benutzerschnittstelle um die es in diesem Buch geht heißt Windows.Forms, und es handelt sich dabei um die Benutzerschnittstelle für Windows-Applikationen. Die BCL stellt auch hierfür zahlreiche Steuerelemente und Komponenten zur Verfügung, mit denen moderne Programmoberflächen gestaltet werden können. In der Version 1.1 von .NET konnte man hier den Entwicklern noch vorwerfen, etwas lax gearbeitet zu haben (die Steuerelemente entsprachen ungefähr dem, was man von Windows 3.1 her kannte). Die Version 2.0 setzt hier neue Maßstäbe, u.a. mit Theme-Unterstützung, modernem Aussehen und erweiterten Funktionalitäten.
Sandini Bib
36
1.3.5
1 Das .NET Framework
Common Language Specification und Common Type System
Die Basis von .NET ist Sprachenunabhängigkeit, d.h. jede Sprache muss bestimmten Regeln folgen, die sicherstellen, dass die Sprache unter .NET verwendet werden kann und dass Code, der mit dieser Sprache erstellt wurde, auch von anderen Sprachen konsumiert werden kann. Welche Probleme sich hieraus ergeben können, wird durch ein kleines Beispiel deutlich. Die Programmiersprache C# ist case-sensitive, d.h. sie unterscheidet zwischen Groß- und Kleinschreibung. Damit ist es kein Problem, eine Klasse bzw. ein Objekt zu erzeugen, bei dem die öffentlich zugänglichen Funktionen sich ausschließlich durch Groß-/Kleinschreibung unterscheiden. Visual Basic hingegen trifft keine derartige Unterscheidung. Diese Sprache hätte nun ein Problem mit dem Objekt, das der C#-Programmierer zur Verfügung gestellt hat, denn da sich die öffentliche Funktionalität dieses Objekts nur durch Groß-/Kleinschreibung unterscheidet, besteht kein Unterscheidungsmerkmal mehr für Visual Basic und die Verwendung ist nicht möglich. Ähnliche Probleme ergeben sich bei den Datentypen; auch hier muss Einheit herrschen unter allen Sprachen und es muss sichergestellt sein, dass nur ein gewisser Satz an Datentypen verwendet wird – alles andere fällt weg. Um dies sicherzustellen gibt es in .NET das Common Type System (CTS) und die Common Language Specification (CLS). Das Common Type System beschreibt sämtliche in .NET verfügbaren Datentypen und ihr Verhalten. Dazu gehören unter anderem sämtliche Wertetypen sowie die Klassen (und ihr allgemeines Verhalten) selbst definierte Wertetypen (und ihr allgemeines Verhalten), Arrays und Delegates. Zu Arrays finden Sie nähere Informationen in Abschnitt 4.5 ab Seite 98, zu Delegates in Kapitel 8 ab Seite 181. Nun kann aber nicht jede Sprache dazu gezwungen werden, sämtliche Datentypen von .NET zu implementieren – möglicherweise sind verschiedene in .NET befindliche Datentypen gar nicht Bestandteil der Sprache und auch nicht vorgesehen oder benötigt. Deshalb existiert mit der CLS eine Untermenge der im CTS definierten Datentypen und Regeln, die jede Programmiersprache einhalten muss, will sie .NET-kompatibel sein. Darunter sind auch die Regeln, um sicherzustellen, dass eine mit einer beliebigen Sprache erstellte DLL auch für andere Sprachen zur Verfügung steht. Das ist nur dann der Fall, wenn f die Regeln der CLS eingehalten werden und
HINWEIS
f die öffentlichen Teile der DLL, auf die von anderen Sprachen zugegriffen werden kann, nur solche Datentypen verwenden, die in der CLS definiert sind. Eine Liste der Regeln finden Sie in der MSDN unter http://msdn.microsoft.com/library default.asp?url=/library/en-us/cpguide/html/cpconwhatiscommonlanguagespecification.asp.
Sandini Bib
Der Aufbau des .NET Frameworks
1.3.6
37
Der Intermediate Language Code (IL-Code)
Auch der IL-Code ist ein Bestandteil von .NET, sogar ein sehr wichtiger, wenn nicht der wichtigste. Um die Sprachenunabhängigkeit zu erreichen trennt Microsoft den Code, in dem ein Programm geschrieben wird, von dem Code, den die Laufzeitumgebung letztendlich zu Gesicht bekommt. Alle Compiler der .NET-fähigen Sprachen kompilieren ihren Code in eine Zwischensprache, genannt Microsoft Intermediate Language (oder auch nur Intermediate Language). Diese Sprache ist vergleichbar mit dem Bytecode von Java. Grundsätzlich ist IL-Code lesbar, theoretisch wäre es auch möglich, direkt in IL-Code zu programmieren. In der Tat bietet das .NET Framework über eine Technologie namens Reflection die Möglichkeit, ausführbare Dateien zur Laufzeit zu erzeugen – unter Verwendung so genannter IL-OpCodes. Damit der IL-Code nun wirklich sprachenunabhängig ist, muss es möglich sein, von anderen Sprachen darauf zuzugreifen. Dazu muss diese zweite Sprache aber das auswerten können, was im IL-Code vorhanden ist, z.B. Abhängigkeiten von Klassen untereinander, Datentypen usw.
HINWEIS
Eine ausführbare .NET-Datei, genannt Assembly, beinhaltet daher zwei Arten von Daten: Einmal den bereits genannten IL-Code, der den eigentlichen ausführbaren Code darstellt. Weiterhin sind Metadaten enthalten, deren Format für jede Sprache gleich ist und die die Datei beschreiben. In den Metadaten sind alle Informationen enthalten, die benötigt werden, um die Datentypen aus der IL-Datei zu ermitteln bzw. die Abhängigkeiten und übrigen Informationen der enthaltenen Klassen. Dadurch ist es mit jeder .NET-fähigen Sprache möglich, auf eine Assembly zuzugreifen und die enthaltenen Klassen zu verwenden oder gar zu erweitern. An dieser Stelle ein Hinweis in eigener Sache. Tatsächlich ist es so, dass die Mehrzahl des Begriffs »Assembly« hierzulande als »Assemblys« geschrieben werden müsste, in England hingegen (wie ich finde, korrekt) als »Assemblies«. Das gleiche gilt übrigens auch für den Begriff »Hobby«. Dennoch sieht die Schreibweise »Assemblys« für mich schlichtweg falsch aus. Ich nutze daher im Buch ausschließlich die englische (und für mich korrekte) Schreibweise.
Der IL-Code wird durch die bereits angesprochenen Jitter in nativen Code umgesetzt. Dabei geschieht allerdings sehr viel mehr als das pure Kompilieren. Vielmehr wird der Code nochmals auf Typsicherheit geprüft und auf den Prozessor des aktuellen Zielrechners optimiert. Das .NET Framework bietet eine Möglichkeit, den IL-Code und die Metadaten einer Assembly, einer ausführbaren Datei, anzusehen. Dazu dient das Tool ILDASM.exe, das Sie bei installiertem Visual Studio im Verzeichnis C:\Programme\Microsoft Visual Studio 8\SDK\v2.0\Bin finden. Abbildung 1.3 zeigt eine Abbildung des Tools.
Sandini Bib
38
1 Das .NET Framework
Abbildung 1.3: ILDASM im Einsatz
Für den Entwickler bietet die Tatsache, dass ein solcher lesbarer Zwischencode produziert wird, allerdings auch den Nachteil, dass dieser auch leicht zu dekompilieren ist. Verwendete Algorithmen können mit diversen Tools (beispielsweise mit dem Roeder Reflector) im Klartext sichtbar gemacht werden. Um das zu verhindern, gibt es so genannte Obfuscatoren, von denen beim Visual Studio sogar einer mitgeliefert wird.
1.3.7
Der Global Assembly Cache (GAC)
C# bringt keine eigene Klassenbibliothek mit, sondern verwendet ausschließlich Klassen und Datentypen, die das .NET Framework zur Verfügung stellt. Die Klassen von .NET sind im so genannten Global Assembly Cache zusammengefasst. Sie befinden sich in vorkompilierten DLLs und liegen in zwei Versionen vor – kompiliert und als Assembly im ILFormat. Der Grund dafür ist einfach. Würden nur die fertig kompilierten DLLs vorliegen, gäbe es keine Möglichkeit, auf die Informationen zuzugreifen, die beispielsweise von der IntelliSense-Hilfe zur Verfügung gestellt werden. Andererseits sind vorkompilierte DLLs schneller, als wenn der gesamte Code beim ersten Programmstart zunächst noch durch den Jitter müsste. Der GAC befindet sich im Verzeichnis C:\<Windows>\assembly. Wenn Sie mit dem Windows Explorer auf dieses Verzeichnis zugreifen, zeigt sich Ihnen ein Bild wie in Abbildung 1.4.
Sandini Bib
Der Aufbau des .NET Frameworks
39
Abbildung 1.4: Der Global Assembly Cache im Windows Explorer
Damit wird der Eindruck erweckt, dass es sich um ein Verzeichnis handelt, in dem alle DLLs, die die Klassen des .NET Frameworks enthalten, abgelegt sind. Dem ist allerdings nicht so. Die Anzeige wird gesteuert über die Datei shfusion.dll. In Wirklichkeit enthält der Global Assembly Cache eine große Anzahl an Unterverzeichnissen, für jede DLL und darin nochmals für jede Version der DLL. Daher ist es auch möglich, dass alle Dateien in mehrfacher Ausführung angezeigt werden – sie unterscheiden sich lediglich in der Versionsnummer, oder anders ausgedrückt, auf diesem System existieren mehrere Versionen des .NET Frameworks.
1.3.8
Strong Names
Falls Sie in der Situation sind, eine Komponentenbibliothek geschrieben zu haben, die ihren Platz im Global Assembly Cache finden und damit systemweit verfügbar ist sein soll, können Sie Ihre DLL auch darin abspeichern. Voraussetzung hierfür ist dann aber, dass diese DLL einen eindeutigen Namen besitzt. Ein solcher eindeutiger Name wird unter .NET als Strong Name bezeichnet. Er setzt sich zusammen aus dem Namen der Datei, der Versionsnummer, der Kulturinformation (Land, Sprache, usw.) und einer digitalen Signatur durch ein Schlüsselpaar aus öffentlichem und privatem Schlüssel. Das erfordert natürlich etwas Aufwand. Mit .NET 1.1 bzw. dem Visual Studio .NET 2003 war dies noch sehr umständlich; das Visual Studio 2005 aber hilft bei diesem Vorgang, sodass das Signieren einer Assembly sehr leicht von der Hand geht. Detailliert wird das Signieren einer Anwendung (bzw. einer DLL) in Abschnitt 26.6 ab Seite 933 beschrieben.
Sandini Bib
40
1.4
1 Das .NET Framework
Neuerungen in .NET 2.0
Nicht nur das .NET Framework selbst bietet mit der Version 2.0 etliche neue Möglichkeiten, die den Programmierer mit Freude erfüllen, auch das Visual Studio hat enorm zugelegt. War es schon in den Vorgängerversionen so, dass die Entwicklungsumgebung Maßstäbe gesetzt hat, so gilt dies mit der aktuellen Version noch mehr. Um den Platz für die Neuerungen nicht über Gebühr zu beanspruchen, werden an dieser Stelle nur die Dinge angesprochen, die im Falle von .NET bzw. C# neu sind; das Visual Studio, das als Basis für sämtliche in diesem Buch erstellten Beispiele verwendet wurde, wird in Kapitel 3 ab Seite 61 vorgestellt.
1.4.1
Neues im Compiler
Der C#-Compiler wurde um einige Compilerschalter erweitert. Sämtliche Einstellungen können Sie auch im Visual Studio vornehmen: f C# ist eine standardisierte Sprache, und einige der neuen Features gehen über den Standard hinaus. Falls Sie sicherstellen möchten, dass nur die Features berücksichtigt werden, die dem Standard entsprechen, können Sie den Compilerschalter /langversion verwenden. Die Option /langVersion:ISO-1 erlaubt nur die Features, die im ISOStandard festgeschrieben sind. Generics beispielsweise gehören nicht zu diesem Standard. f Die neue Option /keyfile dient dem Signieren einer Assembly, die einen Strong Name erhalten soll. Mit der Option /delaysign können Sie zusätzlich angeben, dass lediglich Platz für die digitale Signatur in der Datei freigehalten werden soll. Falls Sie sich fragen, wie diese Signatur aussieht: Es wird ein Hashcode der Datei gebildet, die signiert werden soll, dieser Hash wird mit dem privaten Key verschlüsselt und in der Assembly abgelegt. Da Microsoft Sicherheit groß schreibt und zukünftig möglicherweise nur noch signierte Anwendungen als sicher einstuft, sollten Sie es sich zur Gewohnheit machen, Anwendungen nach Fertigstellung grundsätzlich zu signieren. Einfacher als mit den Compilerschaltern geht das mit dem Visual Studio selbst; die Vorgehensweise ist in Abschnitt 26.6 ab Seite 933 beschrieben. f Die Option /linkresource dient dazu, zu einer Ressourcendatei zu verlinken statt sie in die ausgegebene Assembly mit einzubetten. Dabei kann auch eingegeben werden, ob es sich um eine private Ressource handeln soll oder ob diese öffentlich sein soll (über einen der Modifizierer private oder public). Der Standard ist ein öffentlicher Zugriff. f Mittels /errorreport kann im Falle eines internen Compilerfehlers ein so genannter BugReport an Microsoft gesendet werden. Das Standardverhalten ist, nichts zu senden. Die Angabe prompt zeigt vor dem Senden noch einen Dialog an, die Angabe send sendet den Fehlerbericht sofort bei Auftreten eines Fehlers.
Sandini Bib
Neuerungen in .NET 2.0
1.4.2
41
Neues in C# bzw. dem .NET Framework
Mitunter verschwimmen die Grenzen. Beispielsweise werden Generics als ein neues Feature von C# gepriesen, in der Tat sind Generics aber ein integraler Bestandteil des .NET Frameworks und stehen auch anderen Sprachen zur Verfügung, beispielsweise Visual Basic. Die folgenden Features sind neu in C# bzw. in .NET: f Partial Classes: Partielle Klassen erlauben es, eine Klasse auf mehrere Dateien aufzuteilen. Ein solches Feature wurde vor allem gefordert, nachdem der Überblick im Quellcode von Formularen gerne verloren ging - immerhin erzeugte das Visual Studio eine Menge Code automatisch. In der neuen Version ist der Quellcode eines WindowsFormulars auf mehrere Dateien aufgeteilt. Mehr über Partial Classes erfahren Sie in Abschnitt 6.7 ab Seite 163. f Nullable Types: Wertetypen besitzen anders als Referenztypen keine Möglichkeit, darzustellen, dass ihnen aktuell kein Wert zugewiesen ist. Zu Problemen oder Verwirrung führte das beispielsweise beim Datentyp DateTime - es war einfach nicht möglich, festzulegen, dass dieser eben kein Datum beinhaltet. Mittels Nullable Types ist dies nun möglich. Nullable Types werden in Abschnitt 4.2.5 ab Seite 88 behandelt. f Static Classes: Statische Klassen waren bereits in .NET 1.1 möglich. Dabei handelt es sich um Klassen, die ausschließlich statische Member beinhalten und nicht instanziiert werden können. Dazu musste bisher immer der Konstruktor als private gekennzeichnet werden. Mit .NET 2.0 wird einfach die gesamte Klasse als static gekennzeichnet, darf dann allerdings auch nur noch statische Member enthalten. Statische Klassen finden Sie in Abschnitt 6.4.3 ab Seite 158. f Zugriff auf Eigenschaften: Mit .NET 2.0 ist es nun möglich, Getter und Setter einer Klasse eine unterschiedliche Sichtbarkeit zu geben, beispielsweise public für den Getter und internal für den Setter. Eigenschaften werden in Abschnitt 6.3.5 ab Seite 150 behandelt. f Anonyme Methoden: Anonyme Methoden sind dem einen oder anderen auch schon aus Java bekannt. Mithilfe dieses Features können Sie überall dort, wo ein Delegate erwartet wird, einfach die eigentliche Methodendeklaration hinschreiben. Das spart zwar in manchen Fällen ein klein wenig Zeit (ein Methodenkopf weniger zu deklarieren), kann aber sehr schnell zu sehr unübersichtlichem Code führen. Daher ist dieses Feature mit Bedacht anzuwenden. Anonyme Methoden finden Sie in Abschnitt 8.2.3 ab Seite 189. f extern: Die Verwendungsmöglichkeiten des Schlüsselworts extern wurden erweitert. Sollte es wirklich innerhalb einer Applikation notwendig sein, zwei unterschiedliche Assemblies zu referenzieren, die die gleichen Datentypen und Namespaces beinhalten, kann dies über extern realisiert werden. f Der Operator :: verhindert Namenskonflikte, beispielsweise wenn Sie einen Namespace oder eine Klasse mit einem Namen deklariert haben, der im Framework bereits auftritt. f Covariante Delegates: Covarianz bedeutet, dass ein Rückgabewert auch dann gültig ist, wenn sein Datentyp spezifischer als der ursprüngliche Datentyp ist und somit implizit konvertiert werden kann. Delegates in .NET unterstützen dieses Verhalten in der Version 2.0. Delegates finden Sie in Kapitel 8 ab Seite 181.
Sandini Bib
42
1 Das .NET Framework
f Generics: Generics sind wohl das Feature, über das in den letzten Monaten am meisten geschrieben wurde und das auch in diesem Buch ausführlich behandelt wird. Im Prinzip handelt es sich bei Generics nur um »Platzhalter« für Datentypen, ebenso wie Variablen Platzhalter für Werte sind. Mehr über Generics finden Sie in Kapitel 11 ab Seite 225.
1.4.3
Neuerungen in Windows.Forms
Die Komponenten von Windows.Forms entsprachen in .NET 1.x nicht wirklich dem, was man von einem modernen Framework erwartet. Eigentlich entsprachen sie dem, was unter Windows 3.1 mal aktuell war. Aber gerade hier hat Microsoft sich ins Zeug gelegt und sowohl neue Komponenten entwickelt als auch bestehende mit mehr Möglichkeiten ausgestattet.
Menüs und Toolbars Das Hauptmenü, Kontextmenüs und die Toolbars waren der häufigste Grund für Ärger (und auch ein Grund für das plötzliche Erscheinen zahlreicher Third-Party-Anbieter). Die Standardkomponenten von .NET unterstützten nicht einmal Bitmaps in Menüs. Doch mit .NET 2.0 wird das alles anders. Die bisherigen Komponenten werden komplett ausgetauscht, stehen aus Gründen der Abwärtskompatibilität zwar noch zur Verfügung, werden allerdings nicht mehr in der Toolbox angezeigt. Als Ersatz kommen vollkommen neue Komponenten zum Einsatz, die Bitmaps unterstützen, auch Windows Themes und zahlreiche neue Features bieten. f Die Komponente MenuStrip steht für ein Hauptmenü. Eingefügt werden können nicht mehr nur Menüpunkte und Trenner, sondern beispielsweise auch eine TextBox oder eine ComboBox - direkt als MenuItem. f Das Äquivalent zum MenuStrip für Kontextmenüs ist die Komponente ContextMenuStrip. Sie verhält sich ebenso wie ihr großer Bruder. f Die ToolStrip-Komponente steht für die neue Toolbar. Auch hier ist es möglich, mehr einzufügen als nur Buttons und Trenner. Zur Verfügung stehen TextBox, ComboBox oder auch eine Fortschrittsanzeige. f Die StatusStrip-Komponente ersetzt die alte StatusBar. Ebenso wie bei den vorangegangenen ist es auch bei einer StatusStrip-Komponente möglich, verschiedene UnterSteuerelemente einzufügen. f Der ToolStripContainer bietet eine einfache Möglichkeit, Toolbars verschiebbar zu gestalten und sie an verschiedenen Seiten des Formulars anzudocken. Beim Abreißen wird allerdings noch nicht automatisch ein Fenster darum gelegt, dieses Verhalten muss manuell programmiert werden.
Sandini Bib
Neuerungen in .NET 2.0
43
Texteingabe Die TextBox und die ComboBox sind nach wie vor enthalten, wurden aber im Vergleich zum Vorgänger um eine History-Funktionalität erweitert. Als History-Liste können z.B. die zuletzt aufgerufenen Webseiten des Internet-Explorers, das Dateisystem oder eine selbst definierte Liste dienen. Mit der MaskedTextBox steht in .NET 2.0 auch die Möglichkeit zur Verfügung, dem Anwender eine feste Eingabemaske vorzugeben, die er einhalten muss.
Containerelemente Das Splitter-Steuerelement ist Vergangenheit, an seine Stelle tritt der SplitContainer, letztlich auch nur ein Splitter, allerdings mit zwei angehängten Panel-Controls. Für das Layout wurden zwei neue Steuerelemente hinzugefügt, das FlowLayoutPanel und das TableLayoutPanel. Das FlowLayoutPanel ermöglicht eine automatische Anordnung der Elemente nebeneinander mit automatischem Umbruch (wie im Web), das TableLayoutPanel eine automatische tabellarische Anordnung.
Datenbanken Das Steuerelement DataGridView ersetzt das bisher verwendete DataGrid. Es ist flexibler, kann aber nicht mehrere Tabellen in einem Grid anzeigen. Der BindingNavigator ist ebenfalls neu und ermöglicht ein Navigieren in gebundenen Datenquellen. Sein Aussehen ähnelt der Navigationsleiste von Access. Neu hinzugekommen ist die Komponente BindingSource, die die Basis für die Datenbindung darstellt. Genaueres über die Arbeit mit Datenbanken (insbesondere mit dem SQL Server 2005) finden Sie ab Kapitel 27 ab Seite 941.
Weitere neue Steuerelemente Mit .NET 2.0 hält ein WebBrowser-Steuerelement Einzug in die Toolbox. Es ist leichter zu bedienen als das entsprechende ActiveX-Control, das vorher eingebunden werden musste. Dahinter steht natürlich die Engine des Internet Explorers. Neu ist auch der Zugriff auf den seriellen Port mittels der SerialPort-Komponente, ein Feature, das sehr häufig verlangt wurde. Der ReportViewer ist eine Möglichkeit, auf die ReportingServices des SQL Servers zuzugreifen und Listen auszugeben. Diese können auch nach Excel exportiert werden. Außerdem ist die Verwendung des SQL Servers nicht zwingend notwendig, das Steuerelement funktioniert auch mit anderen Datenquellen, beispielsweise Access. Leider ist es nicht sehr flexibel, hier bieten die ebenfalls mitgelieferten Crystal Reports weit mehr Möglichkeiten. Als Erleichterung für all diejenigen, die noch wenig Erfahrung mit MultithreadingAnwendungen haben, dient die BackgroundWorker-Komponente. Sie ermöglicht es auf einfache Art und Weise, eine zeitaufwändige Operation im Hintergrund auszuführen und deren Fertigstellung zurückzumelden. Den BackgroundWorker im Einsatz sehen Sie in Abschnitt 15.4 ab Seite 449.
Sandini Bib
Sandini Bib
2
Erste Schritte
Erfahrungsgemäß sind die ersten Schritte in einer neuen Entwicklungsumgebung zumindest langwierig, wenn nicht sogar schwierig. In diesem Kapitel erhalten Sie daher einige grundlegende Informationen, die Ihnen den Einstieg erleichtern sollen. Zwei kleine Programme bieten einen ersten Überblick darüber, wie .NET Programme aussehen. Die Entwicklung erfolgt mittels eines Texteditors bzw. mithilfe des Visual Studios. Die verwendete .NET-Version ist an dieser Stelle ohne Belang, die Beispiele im Buch wurden allerdings ausnahmslos mit der Version 2.0 kompiliert.
2.1
Hello World (Konsole)
2.1.1
Das erste Programm
Hello World ist das wohl berühmteste Programm, das jemals erstellt wurde – und das am häufigsten kompilierte. Es existiert für jede nur denkbare Programmiersprache und wird (in verschiedenen Abwandlungen) auch in jedem Fachbuch verwendet, um einen ersten Einstieg zu ermöglichen. Dieses Buch folgt natürlich einer solchen Tradition, somit ist auch hier das erste Programm ein Programm mit dem Namen Hello World. Voraussetzung ist ein installiertes .NET Framework SDK. Für dieses erste Programm ist jede beliebige Version ausreichend, für spätere Applikationen sollte es die Version 2.0 sein. Zwar enthält das .NET SDK sowohl die gesamte Klassenbibliothek als auch sämtliche benötigten Compiler, sobald es aber daran geht, Windows.Forms-Applikationen zu entwerfen, empfiehlt sich der Einsatz einer Entwicklungsumgebung.
Der Pfad zum .NET Framework Die Compiler des .NET Frameworks befinden sich im Installationsverzeichnis des SDK. Um die Compiler aufrufen zu können (für das erste Programm wird der C#-Compiler von der Kommandozeile aus aufgerufen) müssen Sie sicherstellen, dass der Pfad zum Compiler korrekt eingestellt ist. Diese Einstellungen sollten Sie als Administrator festlegen, damit sie für jeden Anwender gültig sind. Grundsätzlich ist die einfachste Alternative die Verwendung der Visual-Studio-Konsole, die Sie im Startmenü unter den Visual Studio Tools finden. Dieser Eintrag öffnet eine Konsole, in der alle Werte bereits korrekt eingestellt sind, sodass Sie sofort loslegen können. Sollten Sie jedoch der Meinung sein, aus der Standard-Konsole heraus Programme kompilieren zu wollen, müssen Sie die Pfadeinstellungen anpassen. Unter Windows XP finden Sie die Pfade in der Systemsteuerung. Öffnen Sie dort das Programm System. Im Dialog wechseln Sie in den Bereich ERWEITERT und klicken Sie auf den Button UMGEBUNGSVARIABLEN. Es erscheint der Dialog aus Abbildung 2.1.
Sandini Bib
46
2 Erste Schritte
Wählen Sie im unteren Bereich die Umgebungsvariable PATH aus und klicken Sie auf BEARBEITEN. Im Eingabedialog können Sie nun den Pfad zu den .NET-Compilern hinzufügen. Für .NET Version 2.0 ist dieser Pfad (eine Standardinstallation vorausgesetzt) C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727
Bestätigen Sie die Eingabe durch einen Klick auf OK und schließen Sie alle anderen Fenster ebenfalls durch einen Klick auf OK. Jetzt können Sie den Compiler aus jedem Verzeichnis heraus aufrufen. Es ist durchaus möglich, dass sich in diesem Verzeichnis noch weitere Versionen des .NET Frameworks befinden, vor allem, wenn Sie das Service Pack 2 unter Windows XP installiert haben. Lassen Sie sich dadurch nicht beirren – Programme, die mit .NET 2.0 erstellt wurden, werden auch genau dieses Framework verwenden und keine der Vorgängerversionen.
Abbildung 2.1: Der Dialog zum Einstellen der Umgebungsvariablen
Der Programmtext Bei dem folgenden Programm handelt es sich um ein wirklich minimales Konsolenprogramm in C#. Starten Sie einen Texteditor Ihrer Wahl (in diesem Fall wurde Notepad verwendet) und geben Sie die folgenden Zeilen ein:
Sandini Bib
Hello World (Konsole)
47
public class HelloConsole { public static void Main( string[] args ) { System.Console.WriteLine( "Hello Console C#" ); System.Console.ReadLine(); } }
Speichern Sie die Datei unter dem Namen HelloConsole.cs ab. Dieses Programm ist nun bereits eine vollständige C#-Anwendung, die kompiliert werden kann. Achten Sie aber darauf, dass Sie die Datei als reinen Text abspeichern, falls Sie z.B. Word zur Eingabe verwenden. Mit den Formatierungsanweisungen, die die handelsüblichen Textverarbeitungsprogramme in die gespeicherten Dateien einfügen, kann der C#-Compiler nichts anfangen.
ACHTUNG
Falls Sie nicht abtippen wollen finden Sie alle in diesem Kapitel vorgestellten Konsolenprogramme auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_02\ HelloConsole. Achten Sie bei der Eingabe auf Groß- und Kleinschreibung. Tippen Sie das Programm exakt so ab, wie es im Buch abgedruckt ist. C# ist eine Sprache, die in der Tradition aller von C abstammenden Sprachen die Groß-/Kleinschreibung berücksichtigt, d.h. der Methodenaufruf heißt WriteLine(), nicht writeline(). Wenn Sie Notepad verwenden, müssen Sie darauf achten, wirklich die richtige Endung anzugeben. Notepad neigt dazu, dem Benutzer vorschreiben zu wollen, dass er eine Datei mit der Endung .txt erstellen will. Um die Endung .cs anzugeben wechseln Sie den Filter des SPEICHERN-Dialogs auf »Alle Dateien« und geben die Endung .cs mit an.
Kompilieren und Starten Öffnen Sie jetzt die Eingabeaufforderung und wechseln Sie in das Verzeichnis, in dem Sie die Datei erstellt haben. Der Name des C#-Compilers ist csc.exe. Kompilieren Sie das Programm durch die Eingabe von csc HelloConsole.cs
Zum Starten müssen Sie jetzt nur noch die Datei HelloConsole.exe aufrufen. Die Ausgabe in der Eingabeaufforderung sehen Sie in Abbildung 2.2.
Sandini Bib
2 Erste Schritte
48
HINWEIS
Abbildung 2.2: Die Ausgabe des Minimalprogramms
Zur besseren Lesbarkeit innerhalb des Buchs wurden die Farben für die Ausgabe in der Eingabeaufforderung geändert. Weiße Schrift auf schwarzem Grund lässt sich zwar auf dem Bildschirm recht gut lesen, beim Buchdruck allerdings ist diese Kombination nicht besonders vorteilhaft. Falls Sie die Änderungen ebenfalls vornehmen wollen, können Sie das im Eigenschaften-Dialog der Eingabeaufforderung (SYSTEMMENÜ|EIGENSCHAFTEN|FARBEN bzw. SYSTEMMENÜ|EIGENSCHAFTEN|STANDARDWERTE) tun.
Analyse des Programms Das Programm besteht eigentlich nur aus einer so genannten Klasse mit Namen HelloConsole und einer einzigen Methode namens Main(). Klassen sind die Basis aller Programme unter .NET, also auch aller C#-Programme. Die gesamte Funktionalität einer Applikation befindet sich in den darin deklarierten Klassen. Der Umstieg auf ein vollständig objektorientiertes System erfordert mitunter einen etwas erhöhten Lernaufwand, da ein grundlegendes Verständnis der Vorgänge innerhalb eines solchen Systems und auch der Zusammenhänge wichtig ist. In diesem Buch werden Sie in der Folge noch detaillierte Informationen über objektorientierte Programmierung erhalten. Mit der Zeit werden Sie bemerken, dass Sie sich schnell daran gewöhnen, nicht zuletzt, weil diese Art der Programmierung sowohl komfortabel als auch übersichtlich ist. Die Methode Main() ist die wichtigste Methode eines C#-Programms. Sie stellt den Einsprungpunkt des Programms dar und ist in jedem C#-Programm enthalten. Das Programm startet mit der ersten Anweisung innerhalb dieser Methode und endet, wenn die Methode komplett abgearbeitet ist. Das gilt nicht nur für Konsolenanwendungen, sondern für alle .NET-Programme.
Sandini Bib
Hello World (Konsole)
49
C# arbeitet außerdem mit Anweisungsblöcken. Ein Block wird durch eine öffnende geschweifte Klammer eingeleitet und mit der schließenden geschweiften Klammer beendet. Damit wird auch der Gültigkeitsbereich für Variablen festgelegt. Wird eine Variable deklariert, ist sie in dem Block gültig in dem sie deklariert ist, und auch in allen untergeordneten Blöcken. Das Programm beinhaltet zwei Aufrufe so genannter Methoden, nämlich System.Console. WriteLine() und System.Console.ReadLine(). Hierbei handelt es sich um die vollständige Qualifizierung der Methodenaufrufe. System ist ein so genannter Namespace, ein Namensraum, mit dessen Hilfe sich thematisch zusammengehörige Klassen oder Programmteile in Kategorien einteilen lassen. Console ist eine Klasse, in der die Methoden WriteLine() und ReadLine() deklariert sind. Es handelt sich dabei um so genannte statische Methoden, die Bestandteil der Klassendeklaration selbst sind und dadurch unmittelbar aufgerufen werden können. Anweisungen werden in C# immer mit einem Semikolon abgeschlossen. Anders als beispielsweise in Visual Basic gilt hier nicht das Zeilenende automatisch auch als Anweisungsende, d.h. ist eine Anweisung auf mehrere Zeilen verteilt (z.B. im Falle eines Methodenaufrufs mit einer großen Anzahl an Parametern), wird kein Verbindungszeichen benötigt. Es steht lediglich das Semikolon am Ende der gesamten Anweisung.
2.1.2
Erweiterung des Programms
Namespaces einbinden Wenn ein Programm länger wird, ist es natürlich müßig, für einen Methodenaufruf immer wieder die gesamte Deklaration zu verwenden. Beispielsweise beinhaltet das .NET Framework auch einen Namespace namens System.Security.Cryptography, in dem sich eine Klasse namens DESCryptoServiceProvider befindet. Wenn Sie da immer den gesamten Namespace davor schreiben müssten, wäre das schon ein gewaltiger Aufwand. Aus diesem Grund können Sie Namespaces einbinden. Alle in dem eingebundenen Namespace verfügbaren Klassen stehen dann ohne weitere Qualifizierung zur Verfügung. Das entsprechende Schlüsselwort heißt using. Das HelloConsole-Programm könnten Sie also auch folgendermaßen schreiben: using System; public class HelloConsole { public static void Main( string[] args ) { Console.WriteLine( "Hello Console C# Version 2" ); Console.ReadLine(); } }
Sandini Bib
2 Erste Schritte
HINWEIS
Namespaces können immer nur für die aktuelle Datei eingebunden werden. Wird ein Namespace in einer anderen Datei erneut benötigt, muss auch dort eine usingAnweisung verwendet werden.
ACHTUNG
50
Achten Sie bei der Namensgebung Ihrer Klassen darauf, dass es keine Konflikte gibt. Der Compiler sucht hierarchisch nach Klassen, d.h. zuerst im aktuellen Namespace, dann kontinuierlich in den durch using eingebundenen Namespaces. Wenn Sie also selbst eine Klasse Console deklariert hätten, würde der Compiler sich beschweren, dass er die Methode WriteLine() nicht finden kann. Im Falle eines solchen Namenskonflikts müssen Sie entweder Ihre eigene Klasse umbenennen oder wirklich die vollständig qualifizierte Bezeichnung einer Klasse verwenden (also z.B. System.Console). Diese Art Konflikt gilt auch für Namespaces, die Sie selbst deklarieren. Auch hier sollten Sie darauf achten, keine Bezeichnung zu verwenden, die im .NET Framework bereits deklariert ist.
Kommandozeilenargumente Die Methode Main() im Beispielprogramm besitzt einen Parameter mit Namen args, der als Array aus Strings deklariert ist. Ein Array ist prinzipiell eine Liste fester Größe mit mehreren Werten gleichen Typs. Dieser Parameter beinhaltet die Kommandozeilenargumente, die dem Programm übergeben wurden. Arrays in C# (bzw. in .NET, denn das gilt auch für andere Programmiersprachen) sind immer nullbasiert, d.h. das erste Argument erhalten Sie durch Abfrage von args[0]. In vielen Programmiersprachen gibt es diese Möglichkeit ebenfalls, wobei es häufig der Fall ist, dass das erste Argument der Name der ausführbaren Datei selbst ist. In C# ist das nicht der Fall, hier werden nur die Argumente übergeben. Sie können das Programm sehr leicht so umbauen, dass es auf ein evtl. übergebenes Kommandozeilenargument reagiert. Das folgende Beispielprogramm gibt, wenn Argumente übergeben wurden, die Liste der Argumente aus: using System; public class HelloConsole { public static void Main(string[] args) { Console.WriteLine( "Hello Console C# Version 3" ); Console.WriteLine(); if ( args.Length>0 ) foreach ( string s in args ) Console.WriteLine( "Argument: " + s );
Sandini Bib
Hello World (Konsole)
51
Console.ReadLine(); } }
Die Anweisungen if und foreach werden detaillierter in den Abschnitten 5.1.1 ab Seite 115 und 5.2.4 ab Seite 123 behandelt und sollen hier nicht näher beleuchtet werden. Mehr über Arrays und ihre Verwendung erfahren sie in Abschnitt 4.5 ab Seite 98. Den Quellcode dieses Programms finden Sie auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_02\HelloConsole.
Platzhalter Die Methode WriteLine() ermöglicht es, mit so genannten Platzhaltern zu arbeiten. Diese Möglichkeit ist vor allem dann sinnvoll, wenn eine Zeichenkette vor der Ausgabe erst zusammengesetzt werden muss. Im Hintergrund steht hierbei die Methode String.Format(), die die eigentliche Formatierung durchführt. Platzhalter werden innerhalb einer Zeichenkette in geschweiften Klammern angegeben und durchnummeriert. Damit steht {0} für den ersten Platzhalter, {1} für den zweiten, usw. Die Werte, die an der Stelle der Platzhalter eingefügt werden sollen, werden nach der Zeichenkette getrennt durch Kommas übergeben. Das folgende kleine Programm ermittelt den Namen des aktuell angemeldeten Benutzers und die Zeit und gibt beide aus. Dabei werden Platzhalter verwendet. Zur Ermittlung des Benutzernamens dient die Klasse Environment, in der einige für die Betriebssystemumgebung relevante Informationen abgelegt sind (beispielsweise auch Umgebungsvariablen). Zur Ermittlung der Zeit dient der Datentyp DateTime, eine Struktur (struct), die hierfür statische Methoden zur Verfügung stellt. using System; public class HelloConsole { public static void Main(string[] args) { Console.WriteLine("Hello Console C# Version 4"); Console.WriteLine(); Console.WriteLine("Hallo {0}, es ist {1} Uhr", Environment.UserName, DateTime.Now.ToShortTimeString()); Console.ReadLine(); } }
Abbildung 2.3 zeigt einen Screenshot der Ausgabe.
Sandini Bib
2 Erste Schritte
52
VERWEIS
Abbildung 2.3: Ausgabe des Programms HelloConsole4.exe
Mehr über die Bestandteile eines C#-Programms erfahren Sie in den folgenden Kapiteln: Datentypen: Klassen: Strukturen: Namespaces:
CD
2.2
2.2.1
Kapitel 4 ab Seite 81 Kapitel 6 ab Seite 129 Abschnitt 6.8 ab Seite 164 Abschnitt 6.2 ab Seite 133
Hello World (Windows-Version) Das Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_02\HelloWindows.
Projektauswahl
Für die Windows-Version des Hello-World-Programms kommt nun erstmals die Entwicklungsumgebung zum Einsatz. Grundsätzlich könnte dieses Programm genauso wie die Konsolenversion auch mit einem herkömmlichen Editor geschrieben werden. Aufgrund der Menge an Informationen, die ein Windows-Programm mit sich bringt und der großen Anzahl notwendiger Deklarationen empfiehlt sich diese Vorgehensweise jedoch nicht. Wie in allen folgenden Programmen wird hier das Visual Studio verwendet. Beim ersten Start der Entwicklungsumgebung werden Sie zunächst gefragt, welche Art Entwickler Sie sind (es sei denn, Sie arbeiten mit einer Express-Version, bei der die Programmiersprache fest vorgegeben ist). Die Entscheidung, die Sie an dieser Stelle treffen, können Sie später jederzeit revidieren. Nach dem Start der Entwicklungsumgebung können Sie über den Menüpunkt DATEI|NEU bzw., falls Sie sich auf der Startseite befinden, direkt über den Button NEUES PROJEKT den Dialog zur Projektauswahl aufrufen (siehe Abbildung 2.4). In diesem Fall soll es eine Windows-Anwendung sein.
Sandini Bib
Hello World (Windows-Version)
53
Abbildung 2.4: Der Dialog zur Projektauswahl
Das Visual Studio .NET verwaltet Projekte innerhalb so genannter Projektmappen. Sie sollten sich angewöhnen, für jede Projektmappe ein eigenes Verzeichnis anzulegen. Eine Projektmappe muss nämlich nicht zwangsläufig nur ein einziges Projekt enthalten, es können auch mehrere sein. Diese finden sich dann in jeweiligen Projektverzeichnissen, die Unterverzeichnisse des Projektmappenverzeichnisses sind. Hinzufügen können Sie ein Projekt jederzeit, indem Sie entweder den Menüpunkt DATEI|HINZUFÜGEN|NEUES PROJEKT auswählen oder den gleichnamigen Menüpunkt aus dem Kontextmenü der Projektmappe auswählen. Standardmäßig entspricht der Projektmappenname dem Namen des ersten darin erstellten Projekts, Sie können diesen aber auch ändern. Der Name der Projektmappe entspricht auch dem Verzeichnis, in dem die darin befindlichen Projekte abgelegt werden. Der Projektname erfüllt hingegen einen weiteren Zweck. Zum Einen wird unterhalb des Projektmappenverzeichnisses ein Projektverzeichnis dieses Namens angelegt. Zum Anderen entspricht der Name eines Projekts auch dem so genannten Root-Namespace des Projekts. Ebenso wie das .NET Framework komplett in Namespaces aufgeteilt ist, ist auch jede .NET-Applikation auf die Verwendung dieser Untergliederungsmöglichkeit vorbereitet, und das zahlt sich aus, vor allem in umfangreichen Projekten. Der Projektmappen-Explorer, der im Visual Studio auf der rechten Seite eingeblendeet wird, dient als Übersicht über das aktuelle Projekt, reflektiert aber gleichzeitig die Verzeichnisstruktur im Projektverzeichnis. Dabei gilt außerdem per Konvention, dass ein im Projektmappenexplorer angelegtes Verzeichnis gleichzeitig auch als Namespace dient. Um einen weiteren Unternamespace zu erzeugen müssen Sie also lediglich ein Verzeichnis anlegen. Alle in diesem Verzeichnis angelegten Klassen befinden sich dann in diesem Namespace.
Sandini Bib
2 Erste Schritte
54
Nach der Eingabe der gewünschten Vorgaben bringt ein Klick auf OK Sie in die Entwicklungsumgebung. Im Falle einer Windows-Anwendung wird ein erstes Formular zunächst in der Entwurfsansicht angezeigt. Diese Ansichtsform dient der Platzierung von Steuerelementen und dem Einstellen verschiedener Eigenschaften sowohl des Formulars als auch der darauf enthaltenen Steuerelemente.
2.2.2
Entwurf der Oberfläche
Steuerelemente einfügen Der erste Schritt zum Beispielprogramm besteht darin, in das angezeigte Formular die erforderlichen Steuerelemente einzufügen. (Steuerelemente sind Bedienungs- bzw. Anzeigeelemente eines Windows-Programms, z.B. Buttons, Listenfelder etc.). Das Beispielprogramm benötigt nur drei Steuerelemente, zwei Label-Elemente zur Darstellung kurzer Texte sowie ein Button-Steuerelement, um das Programm zu beenden.
TIPP
Die Steuerelemente erreichen Sie über das Toolbox-Fenster. Standardmäßig ist dieses Fenster an der linken Seite der Entwicklungsumgebung als Button angedockt. Sobald Sie die Maus über diesen Button ziehen, wird die Toolbox eingeblendet. Wenn Sie möchten, dass die Toolbox ständig eingeblendet bleibt, klicken Sie auf den Pin in der Kopfzeile des Fensters. Das sollten Sie allerdings nur dann tun, wenn Ihr Bildschirm wirklich groß genug ist, denn der Arbeitsbereich verkleinert sich dann um die Breite der Toolbox. Grundsätzlich lassen sich alle angedockten Fenster der Entwicklungsumgebung auf Buttongröße verkleinern. Jedes Fenster besitzt einen Pin in der Kopfzeile. Durch einen Klick darauf können Sie auch standardmäßig ständig sichtbare Fenster auf Buttongröße verkleinern. Das empfiehlt sich nicht immer, kann aber bei manchen Fenstern durchaus nützlich sein.
Sollte die Toolbox nicht sichtbar sein, können Sie sie über den Menüpunkt ANSICHT| TOOLBOX anzeigen. Steuerelemente werden allerdings nur dann angezeigt, wenn Sie sich in der Entwurfsansicht befinden. Es gibt drei Möglichkeiten, ein Steuerelement in ein Formular einzufügen. f Ein Doppelklick auf das Steuerelement bewirkt, dass dieses links oben im Formular eingefügt wird und eine Standardgröße zugewiesen bekommt. Dabei wird jedes neu eingefügte Steuerelement um ein paar Pixel nach rechts unten verschoben. Diese Vorgehensweise eignet sich, wenn Sie viele Steuerelemente auf einen Schlag einfügen wollen. f Sie können auch das gewünschte Steuerelement anklicken und dann auf die Stelle im Formular klicken, an der es eingefügt werden soll. Auch hierbei erhält das Steuerelement eine Standardgröße.
Sandini Bib
Hello World (Windows-Version)
55
f Die dritte Möglichkeit besteht darin, das Steuerelement in der Toolbox mittels Klick zu markieren und dann auf dem Formular einen Rahmen in der gewünschten Größe zu ziehen. Diese Vorgehensweise ist vor allem bei Listboxen o.ä. nützlich.
Ausrichten Leser, die bereits die Vorgängerversion des Visual Studio verwendet haben, werden auf den ersten Blick das Ausrichtungsgitter vermissen. Dieses lässt sich zwar über die Optionen wieder einblenden, wird aber eigentlich nicht mehr benötigt. Der Designer des Visual Studio wurde enorm erweitert. Die Ausrichtung von Steuerelementen geschieht in der neuen Version mittels so genannter Snap Lines, die ein pixelgenaues Ausrichten der Steuerelemente aneinander oder am Rand eines Formulars ermöglichen. Damit ist auch das Problem gelöst, das sich immer dann ergab, wenn ein Label vor einer TextBox platziert wurde und in der Höhe genau so ausgerichtet werden sollte, dass die Grundlinie der Texte zueinander passt. So richtig hat das nie funktioniert (ein Grid ist dafür kein passendes Ausrichtungsmittel), mit .NET 2.0 ist es kein Problem – der Designer macht das automatisch.
Abbildung 2.5: Ein Formular mit SnapLines. Das Label wird genau auf die Grundlinie des Textes ausgerichtet.
Eigenschaften einstellen Für das Beispiel benötigen Sie lediglich zwei Label-Steuerelemente, die untereinander ausgerichtet werden, sowie eine Schaltfläche um das Programm wieder zu beenden. Dazu dient ein Button-Steuerelement. Ziehen sie die Steuerelemente auf das Formular und richten Sie sie ungefähr so aus, wie in Abbildung 2.6 gezeigt.
Abbildung 2.6: Das Beispielprogramm mit zwei Labels und einem Button
Sandini Bib
2 Erste Schritte
56
Jetzt müssen noch einige Eigenschaften eingestellt werden, denn nach dem Programmstart sollen ja auch die richtigen Bezeichnungen auf der Schaltfläche und auf dem Formular zu sehen sein. Die Eigenschaften finden Sie im Eigenschaftsfenster auf der rechten Seite der Entwicklungsumgebung. Wenn ein Steuerelement angeklickt wird, werden immer die jeweiligen Eigenschaften dieses Steuerelements angezeigt. Sollte das Fenster nicht sichtbar sein, können Sie es über das Menü ANSICHT | EIGENSCHAFTENFENSTER oder über die Taste (F4) einblenden. Für das Beispiel müssen Sie noch folgende Einstellungen vornehmen: f Stellen Sie die Eigenschaft Text des Formulars, die den Text in der Kopfzeile darstellt, auf »Hello Windows« ein. f Stellen Sie die Eigenschaft Text des Buttons auf »Beenden« ein. Wenn Sie das Programm jetzt starten, werden Sie feststellen, dass es sich bereits wie eine richtige Windows-Anwendung verhält (obwohl noch keinerlei Programmcode eingefügt wurde). Es ist auch eigentlich schon ein richtiges Windows-Programm. Die Größe des Fensters kann eingestellt werden, das Fenster besitzt auch ein SYSTEM-Menü, es kann minimiert oder maximiert werden und auch beim Klick auf den Schließen-Button in der Kopfzeile des Fensters verhält sich das Programm erwartungsgemäß – es wird beendet. Der Klick auf den Button BEENDEN bringt natürlich noch nicht das gewünschte Ergebnis, denn diese Funktionalität müssen Sie selbst einfügen.
2.2.3
Einfügen von Code
Der Programmfluss von Windows-Programmen wird durch Ereignisse bestimmt. Ereignisse sind z.B. eine Mausbewegung, das Anklicken eines Buttons oder das Starten des Programms. Windows selbst funktioniert komplett ereignisgesteuert.
Ereignisprozeduren einfügen Auf Ereignisse, die innerhalb des Programms passieren, können Sie mit eigenem Programmcode reagieren. Beispielsweise auf das Doppelklicken eines Buttons mit der Maus. Das entsprechende Ereignis trägt den treffenden Namen Click. Hello Windows soll auf zwei Ereignisse reagieren: Beim Laden des Programms sollen in den beiden Labelfeldern der Benutzername und das aktuelle Datum angezeigt werden. Beim Anklicken von BEENDEN soll das Programm beendet werden. Um diese Ereignisse einzufügen, führen Sie zunächst einen Doppelklick auf den Button aus. Die Entwicklungsumgebung fügt nun eine leere Ereignisbehandlungsroutine namens button1_Click() ein und wechselt in den Code-Editor. Die einzige Anweisung, die Sie hier eingeben müssen, ist Close();
Wechseln Sie zurück in die Entwurfsansicht und führen Sie nun einen Doppelklick im Innenbereich des Formulars aus. Die Entwicklungsumgebung fügt damit eine weitere Methode namens Form1_Load() ein, in die Sie die beiden folgenden Zeilen einfügen: label1.Text = "Benutzer: " + Environment.UserName; label2.Text = "Datum: " + DateTime.Now.ToLongDateString();
Sandini Bib
Hello World (Windows-Version)
57
HINWEIS
Damit erreichen Sie, dass beim Programmstart die beiden Label-Texte mit dem aktuellen Benutzernamen und dem Datum initialisiert werden. Bereits bei diesen Eingaben haben Sie sicherlich bemerkt, dass das Visual Studio schon nach dem ersten Tastendruck versucht, Sie bei der Eingabe zu unterstützen. Dazu wird eine Liste der auf die Eingabe passenden Ausdrücke und Typennamen angezeigt. Diese IntelliSense-Hilfe ist einer der großen Vorteile des Visual Studio.
HINWEIS
Einzelne Objekte eines Windows-Programms kennen meist mehrere Ereignisse. Load und Click waren die so genannten Standardereignisse des Formulars bzw. des Buttons und konnten deswegen komfortabel per Doppelklick auf das jeweilige Element in den Code eingefügt werden. Das Einfügen weiterer Ereignisse gestaltet sich ähnlich komfortabel. Markieren Sie einfach das entsprechende Steuerelement und wechseln Sie im Eigenschaftsfenster in die Ereignisansicht (klicken Sie den Button mit dem Blitz oben im Eigenschaftsfenster an). Sie sehen eine Liste aller verfügbaren Ereignisse. Durch Doppelklick auf einen Ereignisnamen können Sie einen entsprechenden Eventhandler in den Code einfügen. Alternativ können Sie in das Feld neben dem Ereignisnamen auch eine eigene Bezeichnung für den EventHandler eingeben.
Automatisch generierter Programmcode Der gesamte Programmcode für das Hello-Windows-Projekt besteht aus weit mehr als den beiden Ereignisprozeduren, die Sie selbst eingegeben haben. Sobald Sie einem Formular ein neues Steuerelement hinzufügen oder eine Eigenschaft ändern, wird diese Änderung in C# auch durch entsprechenden Programmcode reflektiert. Es gibt also kein »Magic Behind« in C#, alles was geschieht können Sie auch im Quellcode erkennen. Der Code, der für das Hinzufügen von Steuerelementen, ihre Initialisierung, die Änderung von Eigenschaften oder die Verknüpfung von Ereignissen mit den entsprechenden Ereignisroutinen notwendig ist, wird vom Visual Studio Designer automatisch generiert. In der Regel müssen Sie hier nicht eingreifen, gerade aber beim Einstieg ist es manchmal nützlich, sich den generierten Code zumindest anzuschauen. In der Vorgängerversion des Visual Studio wurde sämtlicher zu einem Formular gehörender Code in eine einzige Datei geschrieben. Mit .NET 2.0 führt Microsoft das Konzept der Partial Classes ein, das es erlaubt, die Definition einer Klasse (und ein Formular ist nichts anderes als eine Klasse) auf mehrere Dateien zu verteilen. Vor allem bei Formularen ergibt sich hierdurch eine größere Übersicht, weil Sie in einer Datei ausschließlich selbst geschriebene Codezeilen und in einer anderen Datei den vom Visual Studio generierten Code finden. In diesen müssen und sollten Sie ohnehin nie eingreifen. Sie finden ihn in der Datei .Designer.cs, während Sie Ihren Code in die Datei .cs eingeben. Zugriff auf die Designer-Datei erhalten Sie, wenn Sie auf das +-Symbol vor dem Formularnamen im Projektmappen-Explorer klicken.
Sandini Bib
2 Erste Schritte
58
Die Methode Main()
VERWEIS
Auch bei einem Windows-Programm ist die Methode Main() der Einsprungpunkt des Programms. In .NET 1.1 befand sich diese Methode im Hauptformular der Anwendung. Auf die Ausführung hatte das keinen Einfluss (die Methode Main() existiert nur einmal, weshalb es egal ist, wo sie definiert ist), aber eigentlich gehört sie in eine eigene Klasse und nicht in ein Formular. Viele Programmierer (auch der Autor dieses Buchs) gingen deshalb dazu über, diese Methode aus der Form-Klasse zu entfernen und in eine separate Klasse auszulagern. Das neue Visual Studio macht das nun automatisch – in Windows.FormsProgrammen finden Sie die Methode Main() in der Datei Program.cs. Eine ausführlichere Einführung in die Windows-Programmierung folgt ab Kapitel 17 ab Seite 479.
Ausführen des Programms Zum Ausführen des Programms wählen Sie entweder den Menüpunkt DEBUGGEN|STARTEN oder klicken den grünen Pfeil in der Symbolleiste des Visual Studios an. Die schnellste Möglichkeit ist allerdings, einfach (F5) zu drücken (bzw. (Strg)+(F5), falls Sie nicht debuggen wollen). Das Ergebnis Ihrer Bemühungen sehen Sie in Abbildung 2.7.
Abbildung 2.7: HelloWindows zur Laufzeit
2.2.4
Quelltext-Dateien
Bis jetzt kamen Sie in der Entwicklungsumgebung nur mit der Codedatei Form1.cs in Kontakt. Tatsächlich hat die Entwicklungsumgebung aber eine ganze Reihe weiterer Dateien erzeugt, die hier kurz beschrieben werden: Von der Entwicklungsumgebung erzeugte Dateien und Verzeichnisse Program.cs
Die Datei mit der Klasse Program, die ausschließlich für den Start der Applikation zuständig ist
Form1.cs
Der Quellcode für das Formular Form1
Form1.Designer.cs
Die Datei mit dem vom Visual Studio erzeugten Quelltext
Sandini Bib
Hello World (Windows-Version)
59
Von der Entwicklungsumgebung erzeugte Dateien und Verzeichnisse Form1.resx
Die Ressourcendatei des Formulars
App.ico
Das Applikationssymbol
AssemblyInfo.cs
C#-Quellcode mit Informationen über die Programmversion, den Entwickler, die Firma, das Copyright etc.; die Angabe dieser Informationen ist optional und kann über die Eigenschaften des Projekts vorgenommen werden.
.csproj
C#-Projektdatei mit Informationen darüber, aus welchen Dateien das Projekt besteht, welche Einstellungen gelten, welche Verweise auf zusätzliche Bibliotheken eingerichtet wurden etc.; viele Einstellungen dieser Datei können durch die Projekteigenschaften eingestellt werden.
.csproj.user
Ergänzung zur C#-Projektdatei, enthält benutzerspezifische Einstellungen
.sln
Projektmappe mit Informationen darüber, welche Projekte zur Mappe gehören. Der Name muss nicht dem Projektnamen entsprechen. Bei einfachen Anwendungen enthält die Datei nur einen Verweis auf .csproj, bei mehreren Projekten innerhalb der Mappe entsprechend mehrere Verweise.
.suo
Ergänzung zu .sln, enthält benutzerspezifische Einstellungen
bin\*
Dieses Verzeichnis enthält das zur Ausführung geeignete Kompilat des Programms.
obj\*
Das Verzeichnis für temporäre Dateien, die während des Kompilierens erzeugt werden
Settings.settings
Eine Datei für Ihre eigenen Programmeinstellungen. Sie können eigene Einstellungen komfortabel speichern und dynamisch zur Laufzeit darauf zugreifen.
Settings.Designer.cs
Generierte Klasse für den Zugriff auf die Einstellungen
Resources.Designer.cs
Generierte Klasse für den typsicheren Zugriff auf Ressourcen der Anwendung
Neben den Quellcode- und Konfigurationsdateien erzeugt die Entwicklungsumgebung beim Kompilieren das ausführbare Programm. Zum Kompilieren werden die temporären Verzeichnisse obj\debug (für die Debug-Version) bzw. obj\release (für die Endversion des Programms, das an den Kunden weitergeben werden soll) verwendet. Das Endergebnis, d.h. die ausführbare Datei .exe sowie eventuell zusätzliche Debugging-Informationen zur Fehlersuche (Datei .pdb) werden anschließend in das Verzeichnis bin kopiert. Diese Vorgehensweise mutet vielleicht ein wenig kompliziert an, sie hat aber Vorteile bei komplexen Projekten, weil dann nur die Dateien neu kompiliert werden müssen, die sich geändert haben. Allerdings befinden sich immer mindestens zwei Kopien des ausführbaren Programms im Projektverzeichnis. Am besten ist es, wenn Sie das obj-Verzeichnis einfach ignorieren und nur das bin-Verzeichnis berücksichtigen.
Sandini Bib
60
2 Erste Schritte
Damit ist der erste Einstieg in C# geschafft. Wie Sie vor allem am Inhalt der Datei Form1.Designer.cs sehen enthält bereits ein so kleines Programm wie das Hello-WorldBeispiel eine recht ansehnliche Anzahl an Programmzeilen – zumindest wenn es sich um die Windows-Version handelt. Das ist natürlich der Hauptgrund für die Verwendung einer Entwicklungsumgebung wie dem Visual Studio. Die Programmierung von WindowsAnwendungen nur mithilfe eines Texteditors ist eine Sisyphosarbeit, die einem den Spaß am Programmieren wirklich verleiden kann.
Sandini Bib
3
Das Visual Studio 2005
Bereits die Vorgängerversion des Visual Studio 2005 wurde vielerorts als die beste Entwicklungsumgebung gepriesen, die es für Geld zu kaufen gibt. Aber auch das Beste kann noch verbessert werden. In die 2005er Version der Microsoftschen Entwicklungsumgebung flossen zahlreiche neue und nützliche Ideen ein, die den Programmiereralltag erleichtern. Sämtliche Möglichkeiten des Visual Studio zu beschreiben ist allerdings unmöglich – damit würde nicht nur der Rahmen dieses Kapitels sondern vermutlich des gesamten Buchs gesprengt. Es geht in diesem Kapitel lediglich darum, die wichtigsten Elemente der Entwicklungsumgebung hervorzuheben.
3.1
Einführung
Die Voraussetzungen an aktuelle Applikationen steigen, ebenso die Möglichkeiten aber auch die Fehlerquellen. Die Zeiten, in denen ein einfacher Editor als Entwicklungsumgebung herhalten konnte, sind vorbei – eine moderne IDE (Integrated Development Environment == Entwicklungsumgebung) hilft nicht nur bei der Codeeingabe sondern stellt dem Entwickler eine Vielzahl von Tools und Möglichkeiten zur Verfügung, die ihm das Leben stark erleichtern. Für .NET gibt es derzeit eigentlich nur zwei Entwicklungsumgebungen, nämlich das Visual Studio und SharpDevelop von Mike Krüger. Borland hatte sich zwar an einer IDE versucht, der C#Builder ist aber als eigenständige Version nicht mehr erhältlich sondern liegt nur noch Borlands Delphi 2005 bzw. auch der neuen Version 2006 bei.
VERWEIS
SharpDevelop ist ein sehr interessantes OpenSource-Projekt, das daher auch im Quellcode vorliegt. Auch fortgeschrittene Programmierer können noch viel aus dem Quellcode lernen. Die IDE ist zwar noch nicht an .NET 2.0 angepasst und ein paar Sachen fehlen auch noch, sie wird allerdings ständig weiterentwickelt und ist vor allem im Bereich Open Source sehr beliebt. Für kleinere Applikationen ist sie sicherlich eine Alternative. Besonders zu erwähnen ist, dass der Quellcode mit SharpDevelop auch für Mono kompiliert werden kann. Unter Linux existiert ein Projekt namens MonoDevelop, mit dem eine reine Mono-IDE zur Verfügung gestellt werden soll. Diese basiert auf SharpDevelop. Mehr Informationen über SharpDevelop finden Sie im Internet auf der Sharpdevelop-Website unter http://www.icsharpcode.net/OpenSource/SD/Default.aspx.
Sandini Bib
3 Das Visual Studio 2005
62
3.1.1
Übersicht
Das Visual Studio ist aus zahlreichen Gründen die optimale Wahl für die Anwendungsentwicklung unter .NET: f Der visuelle Designer wurde im Vergleich zum Vorgänger komplett überarbeitet und bietet nun eine verbesserte Ausrichtungsmöglichkeit durch Snap Lines sowie das TaskMenü für Komponenten, mit denen sich die gebräuchlichsten Einstellungen sofort im Designer vornehmen lassen. f Der Debugger wurde verbessert, hier vor allem die Anzeige von Exceptions, die wesentlich detaillierter ist als im Vorgänger, sowie die Anzeige und mögliche Änderung von Werten direkt im Editor. Der Debugger unterstützt nun auch Edit&Continue, womit Anhalten, Ändern von Werten und das darauf folgende Fortsetzen eines Programms an der gleichen Stelle möglich ist. f Die IntelliSense-Hilfe bietet nun bereits nach den ersten Buchstaben ihre Hilfe an und versucht, das gewünschte Wort zu erkennen. Reservierte Wörter wurden ebenso in die Liste aufgenommen wie selbst erstellte Klassen oder Objekte. f Smarttags helfen bei gängigen Operationen im Editor. Unter anderem können geänderte Variablennamen direkt für die gesamte Methode oder auch eine gesamte Klasse übernommen werden. Das hilfreichste Feature ist allerdings die Möglichkeit, über Smarttags benötigte Namespaces einzubinden. Ist der Name einer Klasse bekannt, aber nicht der Namespace, kann dieser über eine Smarttag-Anweisung eingefügt werden. f Bekannte Datentypen werden im Editor nun farblich hervorgehoben. Damit können Sie jederzeit erkennen, ob alle benötigten Namespaces eingebunden sind bzw. Sie sehen sofort, ob Sie sich vertippt haben. Für unterschiedliche Datentypen können Sie in den Optionen auch unterschiedliche Farben einstellen, falls Sie das möchten. f Layoutanpassungen der Entwicklungsumgebung wurden verbessert. Das Andocken der diversen Tool-Fenster des Visual Studio, das in der Vorgängerversion immer ein Problem darstellte, wird nun über so genannte Guides (Ablageflächen) erleichtert. f Integrierte Tools, beispielsweise Code Snippets zum Einfügen kleiner Codebestandteile oder auch Refactoring-Tools erleichtern die Arbeit beim Programmieren. Integriert wurde auch ein Tool namens FxCop, unverzichtbar wenn es darum geht, Code zu erzeugen, der uneingeschränkt aus anderen Programmiersprachen heraus verwendbar sein soll. In den größeren Versionen des Visual Studio finden Sie dieses Tool in den Projekteigenschaften unter der Bezeichnung Code Analyse. Leider nicht in der Professional-Version, weshalb Sie hier auf das Internet bzw. auf die entsprechende FxCopVersion angewiesen sind. Sie finden FxCop unter http://www.gotdotnet.com/team/fxcop/
Sandini Bib
63
Einführung
3.1.2
Systemvoraussetzungen und Versionen
Systemvoraussetzungen Microsoft gibt typischerweise recht konservative Systemvoraussetzungen für die Installation seiner Produkte vor, die nicht ganz der Realität entsprechen. Die Mindestvoraussetzungen für die Installation einer Visual Studio-Version sind laut Microsoft wie folgt: f Betriebssystem: Windows 2000 (Client/Server) mit Service Pack 4, Windows XP (Home/Pro) mit Service Pack 2 oder Windows 2003 Server f Hauptspeicher: Mindestens 128 MB, empfohlen mindestens 256 MB f Festplatte: Mindestens 2,5 GB freier Festplattenspeicher auf dem Installationslaufwerk, mindestens 1,2 GB freier Speicher auf dem Systemlaufwerk (also dem Laufwerk, auf dem Windows installiert ist). Das gilt für eine Installation inklusive MSDN. Da die MSDN auch die Hilfefunktion beinhaltet, dürfte dies das Standardvorgehen sein f Bildschirmauflösung: Mindestens 800x600, empfohlen 1024x768 f CD-/DVD-Laufwerk sowie (natürlich) eine Maus werden benötigt Diese Vorgaben sind sehr optimistisch. Zwar läuft das Visual Studio mit einer derart eingerichteten Umgebung, allerdings weder performant noch ist ein sinnvolles Arbeiten möglich. Vor allem die Bildschirmauflösung ist offensichtlich scherzhaft gemeint, denn mit 800x600 Bildpunkten zu arbeiten ist enorm frustrierend bis unmöglich. Ein heute aktueller Computer liefert üblicherweise ausreichend Performance für ein flüssiges Arbeiten. Die folgenden Vorgaben sind als empfehlenswert zu betrachten; liegen Ihre Systemdaten irgendwo zwischen dem absoluten Minimum (wie von Microsoft vorgegeben) und den empfehlenswerten Daten sind Sie ausreichend ausgerüstet. f Betriebssystem: Windows 2000 (Client/Server) mit Service Pack 4, Windows XP (Home/Pro) mit Service Pack 2 oder Windows 2003 Server f Prozessor: Ein P4 mit mindestens 1,5 GHz oder ein entsprechender AMD-Prozessor. Hier gilt: Je schneller desto besser. f Hauptspeicher: Mindestens 512 MB, besser 1024 MB oder gar 2048 MB f Festplatte: Mindestens 4 GB freier Festplattenspeicher auf dem Installationslaufwerk, mindestens 2 GB freier Speicher auf dem Systemlaufwerk. Da Programme bei einer Standardinstallation üblicherweise auch auf dem Windows-Systemlaufwerk installiert sind, sollten dort also ca. 6 GB frei sein. Beachten Sie, dass zusätzlich auch noch Platz für die programmierten Applikationen vorhanden sein muss. f Bildschirmauflösung: Mindestens 1280x1024, je mehr desto besser. Ab 1600x1200 Bildpunkten macht das Arbeiten Spaß, unterhalb von 1280x1024 wird es schnell frustrierend. 1024x768 gehen gerade noch so.
Sandini Bib
3 Das Visual Studio 2005
64
Visual Studio-Versionen Die Entwicklungsumgebung ist in zahlreichen Versionen verfügbar. Am unteren Ende der Skala, als preisgünstigste weil kostenlose Alternative, stehen die Express-Versionen. Sie sind auf eine einzige Sprache bzw. eine einzige Technologie beschränkt. So gibt es Editionen für C#, Visual Basic oder C++, die ausschließlich auf die Entwicklung von Windows.Forms-Applikationen ausgelegt sind. Für den Webentwickler existiert ebenfalls eine Express-Version, die Web Developer Edition. Hier ist die Entwicklung auf ASP.NET beschränkt, dafür kann aber mit allen Programmiersprachen gearbeitet werden. Den Einstieg in die Visual-Studio-Linie bildet die Standard-Edition des Visual Studio .NET, gefolgt von der Professional-Version. Beide bieten bereits den Vorteil, mit einer beliebigen Programmiersprache arbeiten zu können. Was in der Hauptsache bei der Standard-Edition fehlt sind die Crystal-Reports-Steuerelemente für Auswertungen sowie die Möglichkeit, Setup-Projekte zu erstellen. Lediglich ClickOnce (siehe auch Abschnitt 26.5 ab Seite 926) wird hier unterstützt. Ab der Professional-Version ist auch der SQL Server 2005 in der Developer-Edition enthalten. Das Nonplusultra bilden die Team-Editions des Visual Studio, zusammengefasst unter dem Oberbegriff Team System. Microsoft bietet hier für jeden das richtige Produkt. Die Visual-Studio-Editionen richten sich an den Software-Architekten, den Programmierer (Developer) sowie den Tester. Entsprechend sind in den Versionen unterschiedliche Tools enthalten.
HINWEIS
Die Basis bildet bei allen diesen Produkten die Professional-Edition des Visual Studio. Zusätzliche Tools ergeben sich lediglich für die entsprechende Rolle des Entwicklers. So wird der Softwarearchitekt Modeling-Tools erhalten, die an UML erinnern, aber mehr in die .NET-Richtung zielen; Der Software-Tester erhält die Möglichkeit, Testabläufe zu erstellen und durchzuführen. Diese Versionen sind die teuersten und umfangreichsten.
3.2
Das vorliegende Buch wurde mit der Professional-Edition des Visual Studio geschrieben, daher stammen auch alle Screenshots von dieser Version. Die Beispiele des Buchs laufen aber auch mit jeder anderen Version des Visual Studio, insbesondere auch mit der Express-Edition von Visual C#.
Wichtige Fenster der Entwicklungsumgebung
Die Abbildungen auch in diesem Abschnitt stammen sämtlichst aus der ProfessionalEdition des Visual Studio. Die hier beschriebenen wichtigsten Fenster sind jedoch in allen Editionen enthalten und – was noch wichtiger ist – auch am gleichen Platz zu finden. Die Bedienung gestaltet sich in jeder Version des Visual Studio gleich.
Sandini Bib
Wichtige Fenster der Entwicklungsumgebung
65
Abbildung 3.1: Das Visual Studio, Professional-Edition in der Standardeinstellung
Abbildung 3.1 zeigt das Visual Studio in der Standardeinstellung. Die wichtigsten Fenster sind sofort sichtbar. Links angedockt befindet sich die Toolbox, die alle verfügbaren Steuerelemente enthält. Auf der rechten Seite sehen Sie den Projektmappen-Explorer, der eine Übersicht über die im Projekt (oder in mehreren Projekten) enthaltenen Dateien liefert. Darunter finden Sie das Eigenschaftsfenster, eigentlich ein kombiniertes Eigenschafts/Ereignisfenster, das Ihnen Zugriff auf die Eigenschaften eines Steuerelements bietet. Mithilfe dieser Eigenschaften ändern Sie u.a. Aussehen und Verhalten der Steuerelemente. Im unteren Bereich finden Sie die Fehlerliste, in der Sie nach einem erfolglosen Kompiliervorgang sämtliche aufgetretenen Compilerfehler finden. Der Hauptarbeitsbereich (der mittlere Bereich des Visual Studio) ändert sein Aussehen je nach Erfordernis. Für das Design einer Form (im Buch auch häufig als Formular bezeichnet) wird hier der visuelle Designer eingeblendet. In ihm können Sie Steuerelemente auf der Form platzieren und ausrichten. Wird eine Textdatei (d.h. eine Datei mit Quellcode) geöffnet, befindet sich hier der Texteditor des Visual Studios, je nach Programmiersprache mit unterschiedlichen Möglichkeiten. Für andere Dateien, z.B. Ressourcendateien, existieren noch weitere Ansichten. Jedes geöffnete Dokument wird als Registerkarte am oberen Rand des Arbeitsbereichs abgelegt, wodurch ein schneller Zugriff möglich ist. Ebenso ist es natürlich auch möglich, über den Projektmappen-Explorer auf die Datei zuzugreifen. Ist die Datei bereits geöffnet, wird sie in den Vordergrund gebracht, ansonsten geöffnet.
Sandini Bib
3 Das Visual Studio 2005
66
Sie haben die Möglichkeit, diese Anzeige auf eine MDI-Anzeige (Multiple Document Interface) umzustellen. Die entsprechende Einstellmöglichkeit finden Sie in den Programmoptionen (EXTRAS|OPTIONEN) unter UMGEBUNG|ALLGEMEIN. Die Auswahl eines gewünschten Dokuments ist dann allerdings nur noch über den Projektmappenexplorer bzw. das FENSTER-Menü möglich; die Registerkarten sind da weit komfortabler. Alle angedockten Fenster können, um die Arbeitsfläche (etwa bei einer niedrigen Bildschirmauflösung) zu vergrößern, auch ausgeblendet werden. Hierzu finden Sie rechts oben einen kleine Pin in jedem Fenster. Ist ein Fenster ausgeblendet, erscheint es als Schaltfläche an der Seite; wenn Sie die Maus darüber ziehen, wird das Fenster eingeblendet, verkleinert dann aber nicht den Arbeitsbereich, sondern überdeckt ihn. Vor allem beim Gestalten der Anwendungsoberfläche ist es weit angenehmer, sämtliche benötigten Fenster ständig geöffnet zu haben. Aus diesem Grund auch die vom Autor als fast schon Minimum angesehene Auflösung von 1028x1024 Bildpunkten. Als das Optimum hat sich die Arbeit mit zwei Bildschirmen herausgestellt; in diesem Fall können Sie die gesamte Arbeitsfläche nutzen und dennoch alle Toolfenster auf dem zweiten Bildschirm platzieren.
3.2.1
Der Projektmappen-Explorer
Im Projektmappen-Explorer verwalten Sie Ihre Projekte. Die Aufteilung in Projektmappe und Einzelprojekt ist wichtig, weil unter .NET jede Assembly auch ein Projekt ist. Innerhalb einer Gesamtanwendung wird also auch jede DLL (Projekttyp Klassenbibliothek) als Einzelprojekt angesehen. Die Projektmappe reflektiert dabei die Gesamtanwendung, die Projekte die jeweiligen DLLs sowie die ausführbare .exe-Datei.
Ordner == Namespace Doch der Projektmappen-Explorer ist mehr als eine einfache Anzeige der enthaltenen Dateien. Er zeigt außerdem die Ordnerstruktur innerhalb eines Projekts (d.h. im Projektverzeichnis), wobei ein Ordner automatisch einem Namespace entspricht. Namespaces sind eine (virtuelle) Unterteilungsmöglichkeit für Klassen innerhalb von Projekten. Sie haben dadurch die Möglichkeit, Ihre Klassen in Kategorien zu unterteilen, die sinnvoll die Verwendung der enthaltenen Klassen darstellen. Auch das .NET Framework ist so angelegt. Die Klassen für den Dateizugriff befinden sich beispielsweise im Namespace System.IO (wobei IO für Input/Output steht). Virtuell ist diese Unterteilungsmöglichkeit deshalb, weil es sich bei Namespaces nicht um »physikalisch vorhandene Klassen« handelt. Es existiert keine Klasse namens Namespace, aus der heraus alle darin enthaltenen Datentypen ermittelt werden könnten. Vielmehr ist jedem Datentyp bekannt, in welchem Namespace er sich befindet. Zwar reflektiert die Ordnerstruktur im Projektmappen-Explorer die vorgeschlagene Unterteilung, der Namespace, in dem sich eine Klasse befindet, wird aber in der Datei festgelegt, in der die Klasse programmiert ist. Dieser ist somit beliebig änderbar. Der Name des Ordners wird vom Visual Studio hier nur standardmäßig vorgegeben.
Sandini Bib
Wichtige Fenster der Entwicklungsumgebung
67
HINWEIS
Der »Haupt-Namespace« eines Projekts entspricht dem Projektnamen. Alle weiteren Namespaces sind diesem Projektnamen untergeordnet (bzw. sollten es sein). Sie arbeiten am Angenehmsten, wenn Sie sich den Automatismus des Visual Studios zunutze machen und die Ordnerstruktur innerhalb des Projektmappen-Explorers auch als NamespaceStruktur des Projekts annehmen. Die Ordner, die in einem Projekt enthalten sein sollen, müssen entweder über das Kontextmenü des Projekts im Projektmappen-Explorer angelegt werden oder später hinzugefügt. Ein Ordner, den Sie im Dateisystem anlegen, ist nicht automatisch auch Bestandteil des Projekts. Das gleiche gilt für Dateien – neue Klassen beispielsweise, die sich in der Regel auch immer in einer eigenen Datei befinden, sollten ebenfalls über den Projektmappen-Explorer hinzugefügt werden.
Aktives Projekt Es liegt in der Natur der Sache, dass immer nur ein Projekt auch das Startprojekt sein kann (also das Projekt, das ausgeführt wird, wenn sie aus dem DEBUGGEN-Menü entweder STARTEN/(F5) oder STARTEN OHNE DEBUGGEN/(Strg)+(F5) auswählen). Das aktive Projekt ist immer fett dargestellt. Sie können es ändern, indem Sie das Kontextmenü eines nichtaktiven Projekts aufrufen und dort den Menüpunkt ALS STARTPROJEKT FESTLEGEN auswählen.
HINWEIS
Durch einen Doppelklick auf eines der enthaltenen Elemente wird dieses in der Entwicklungsumgebung geöffnet oder, falls es schon geöffnet ist, in den Vordergrund gebracht. Die Datei, an der Sie gerade arbeiten, ist im Projektmappen-Explorer automatisch markiert. Eine ärgerliche Tatsache des Visual Studio 2003 war es, dass die Änderung eines Dateinamens nicht sofort auch die Änderung des Namens der darin enthaltenen Klasse bedeutete. Das war vor allem bei Formularen frustrierend. Das Hauptformular der Anwendung hieß standardmäßig Form1, die Datei Form1.cs. Nun musste man sowohl die Klasse umbenennen als auch den Dateinamen und auch die Instanzierung der Klasse in Main(). Das Visual Studio 2005 bietet beim Ändern des Dateinamens nun sofort ein Refactoring an und ändert alle referenzierten Namen im Projekt.
Neue Elemente Eine Applikation kann viele unterschiedliche Elemente beinhalten, z.B. Formulare oder Klassen. Der Projektmappen-Explorer ist auch hierfür verantwortlich. Über einen Rechtsklick auf entweder den Projektnamen oder einen im Projekt angelegten Ordner können Sie neue Elemente anlegen. Diese werden dann entweder innerhalb des Ordners (und damit innerhalb des korrespondierenden Namespaces) oder aber im Hauptbereich der Applikation (also als Bestandteil des Hauptnamespace) angelegt.
Sandini Bib
3 Das Visual Studio 2005
68
Verweise auf DLLs Um die Klassen innerhalb einer DLL verwenden zu können, müssen Sie diese referenzieren bzw. den Verweisen des Projekts hinzufügen. Jedes Projekt besitzt dazu einen Ordner namens Verweise. Die wichtigsten DLLs (je nach Projekttyp) sind bereits eingefügt, aber falls Ihr Projekt komplexer wird, ist es sehr wahrscheinlich, dass sie weitere DLLs benötigen. Über das Kontextmenü des Verweise-Ordners, Menüpunkt VERWEIS HINZUFÜGEN, können Sie über einen Dialog weitere DLLs hinzufügen. Dabei stehen sowohl sämtliche DLLs des .NET Frameworks zur Auswahl als auch COMKomponenten bzw. die DLLs, die Bestandteil Ihrer Projektmappe sind. Der Dialog stellt auch eine Registerkarte DURCHSUCHEN zur Verfügung, über die Sie auch DLLs hinzufügen können, die nicht in einer der Listen auftauchen (z.B. DLLs von Drittanbietern). Weiterhin merkt sich das Visual Studio, welche DLLs Sie zuletzt hinzugefügt haben und bietet diese ebenfalls unter einer eigenen Registerkarte an. Den Dialog sehen Sie in Abbildung 3.2.
Abbildung 3.2: Der Dialog zum Hinzufügen von Verweisen
HINWEIS
Über das gleiche Kontextmenü ist es auch möglich einen so genannten Webverweis hinzuzufügen. Dabei handelt es sich um Web Services – Sie können also mithilfe des Visual Studios sehr einfach Web Services konsumieren und erstellen. Ein kurzer Hinweis zum Aufbau des .NET Frameworks. Die Klassen von .NET sind in DLLs organisiert. Die absolute Basisfunktionalität befindet sich in der Datei mscorlib.dll, die immer automatisch eingebunden ist – ohne sie wäre überhaupt nichts möglich. Die übrigen DLLs, die Verwendung finden können, tragen per Konvention den Namen des enthaltenen (Haupt-)Namespaces. Die Klassen für die Windows.Forms-Programmierung, die im Namespace System.Windows.Forms angesiedelt sind, befinden sich demnach in der DLL System.Windows.Forms.dll.
Sandini Bib
VERWEIS
Wichtige Fenster der Entwicklungsumgebung
3.2.2
69
Detaillierte Informationen über das Arbeiten mit dem Projektmappen-Explorer erhalten Sie über die Hilfe, indem Sie einfach nach »Projektmappen-Explorer« suchen. Der direkte Link ist: ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.VisualStudio.v80.de/dv_vsprojopt/html/ca0ad8e7eda8-40d4-a76e-2a6864b16e00.htm
Die Toolbox
Der Aufbau einer Windows.Forms-Anwendung erfolgt über Steuerelemente, die sowohl visuell sein können (beispielsweise eine TextBox oder ein Label) oder nicht-visuell (wie z.B. die Dialoge – diese werden erst zur Laufzeit angezeigt, sind aber im Formular nicht sichtbar). Zugriff auf alle diese Steuerelemente erhalten Sie über die Toolbox. Sie ist in Kategorien angeordnet und arbeitet kontextabhängig. Wenn Sie also ein Dokument im Texteditor geöffnet haben, finden Sie darin keine Steuerelemente, ist der visuelle Designer geöffnet, zeigt die Toolbox die verfügbaren Steuerelemente an. Sie können zusätzlich zu den in der Toolbox enthaltenen Kategorien eigene hinzufügen (über das Kontextmenü der Toolbox). Außerdem ist es auch möglich, der Toolbox weitere Steuerelemente hinzuzufügen. Dabei kann es sich um selbst geschriebene Steuerelemente oder um Komponenten von Drittanbietern handeln. Zum Erweitern der Toolbox wählen Sie aus dem Kontextmenü den Eintrag ELEMENTE AUSWÄHLEN.
VERWEIS
Sollte Ihnen die Anordnung in Kategorien nicht zusagen ist das auch kein Problem. Die Toolbox enthält auch eine Kategorie, in der alle Steuerelemente für Windows.Forms enthalten sind. Dennoch ist die Einteilung in Kategorien vorteilhaft – Sie finden einfach schneller das Steuerelement bzw. die Komponente, die Sie suchen.
3.2.3
Weitere Informationen über die Toolbox finden Sie in der integrierten Hilfe, indem Sie nach »Toolbox« suchen. Der direkte Link: ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.VisualStudio.v80.de/dv_vwdgenref/html/35e9320dfcbd-474b-8b8f-55705e9a1870.htm
Das Eigenschafts-/Ereignisfenster
Die Einstellungen der Komponenten und Steuerelemente auf dem Formular erfolgt über Eigenschaften. Über diese steuern Sie sowohl das Aussehen als auch in vielen Fällen das Standardverhalten eines Steuerelements. Das Eigenschaftsfenster ist daher das Fenster, das am häufigsten zum Einsatz kommt. Die wichtigsten Einstellungen ein Steuerelement betreffend können Sie ab dieser Version des Visual Studios auch direkt im Designer über das Aufgabenmenü vornehmen, das bei vielen Steuerelementen erscheint. Auch die Einträge im Eigenschaftsfenster sind in Kategorien angeordnet. Wesentlich schneller zu erreichen sind die Eigenschaften hier aber über die alphabetische Anordnung,
Sandini Bib
3 Das Visual Studio 2005
70
da der Name (oder wenigstens der ungefähre Name) einer Eigenschaft in der Regel bekannt ist. Sie können die Anordnung über den zweiten Button von links in der Toolbar des Eigenschaftsfensters umstellen. Der erste Button steht für die kategorisierte Darstellung.
Ereignisse Applikationen unter Windows sind nicht an einen festen Ablauf gebunden. Stattdessen arbeiten sie mit Ereignissen, reagieren also auf die Aktionen des Benutzers. Auch die einzelnen Steuerelemente besitzen verschiedene Ereignisse (und nicht nur diese – Sie können selbst Klassen schreiben, die Ereignisse verwenden bzw. auf Ereignisse anderer Klassen reagieren). Das Eigenschaftsfenster ist eigentlich ein kombiniertes Fenster, denn es erlaubt auch den Zugriff auf die Ereignisse, die ein Steuerelement auslösen kann. Hierfür finden Sie in der Toolbar des Eigenschaftsfensters einen entsprechenden Button (der vierte von links, mit dem Blitz). Um nun auf ein Ereignis zu reagieren gibt es mehrere Möglichkeiten. f Schreiben Sie in das Feld neben dem Ereignisnamen einfach den Namen der gewünschten Methode, die aufgerufen werden soll, wenn das Ereignis auftritt. Falls die Methode noch nicht existiert, wird sie vom Visual Studio automatisch angelegt. f Klicken Sie doppelt auf den Ereignisnamen oder auf das Auswahlfeld daneben. Das Visual Studio erzeugt daraufhin einen Namen für die Ereignismethode und fügt diese in den Programmcode ein. f Falls bereits mehrere Ereignisbehandlungsroutinen existieren, können Sie über das Auswahlfeld neben dem Ereignisnamen auch eine dieser Methoden auswählen und sie dem Ereignis zuordnen.
3.2.4
Die Projekteigenschaften
In .NET 1.1 wurden die Projekteigenschaften noch in einem kleinen Dialog eingestellt. In .NET 2.0 hat sich das geändert. Alle Einstellungen lassen sich nun komfortabel im mittleren Arbeitsbereich der Entwicklungsumgebung einstellen. Markieren Sie hierzu das Projekt (nicht die Projektmappe, sondern das Projekt, dessen Einstellungen Sie ändern wollen) und wählen Sie aus dem Menü PROJEKT den Menüpunkt EIGENSCHAFTEN. Alternativ können Sie auch den entsprechenden Button im Projektmappen-Explorer anklicken (oben ganz links). Abbildung 3.3 zeigt die neu gestalteten Projekteigenschaften.
Sandini Bib
Der visuelle Designer
71
Abbildung 3.3: Die Projekteigenschaften im Visual Studio 2005
3.3
Der visuelle Designer
Die Gestaltung einer Anwendung bzw. der in der Anwendung enthaltenen Formulare war bereits im Visual Studio 2003 sehr komfortabel. Steuerelemente konnten per Drag&Drop auf das Formular gezogen werden und wurden dann an einem Grid ausgerichtet.
Snap Lines Leider war es bisher nicht möglich, beispielsweise ein Label automatisch so auszurichten, dass sein enthaltener Text auf der gleichen Grundlinie läuft wie der Text eines daneben angeordneten Textfelds. Korrekte Abstände und die Länge von Elementen konnten vor allem bei umfangreicheren Formularen oft nur schlecht abgeschätzt werden – um wirklich detailliert richtig zu liegen war es meist nötig, selbst Hand anzulegen und die Positionsdaten über das Eigenschaftsfenster manuell festzulegen. Mit dem neuen Designer ist das Grid als Standard-Anordnungsgrundlage verschwunden. Es lässt sich zwar über die Optionen noch einblenden, allerdings sind die neuen Snap Lines wesentlich komfortabler (sie wurden auch schon in Abschnitt 2.2.2 ab Seite 54 angesprochen). Mit ihnen lassen sich die Elemente schneller und komfortabler anordnen – und die Snap Lines arbeiten pixelgenau, beispielsweise bei der Ausrichtung von Labels auf der Grundlinie einer TextBox.
Sandini Bib
3 Das Visual Studio 2005
72
Aufgabenmenü (Task-Menü) Ebenso neu ist die Möglichkeit, die wichtigsten Einstellungen eines Steuerelements bereits im Designer selbst festzulegen, statt die Eigenschaft erst noch im Eigenschaftsfenster suchen zu müssen. Viele der verfügbaren Steuerelemente ermöglichen dies durch ein neues Feature, das Task-Menü oder Aufgabenmenü. Nach dem Einfügen des Steuerelements erscheint oben rechts ein kleiner schwarzer Pfeil. Ein Klick darauf und das Aufgabenmenü wird eingeblendet. Bei manchen Steuerelementen, z.B. dem DataGridView, erfolgt die Einblendung gar automatisch. Abbildung 3.4 zeigt das Aufgabenmenü einer ListBox.
Abbildung 3.4: Das Aufgabenmenü einer ListBox. Die Felder für die Datenbindung erscheinen nur, wenn die ListBox auch als datengebundenes Steuerelement eingesetzt werden soll.
3.4
Der Editor
Der Texteditor des Visual Studio hilft durch zahlreiche kleine Hilfestellungen, die oft im Verborgenen liegen. Er unterstützt Syntaxhervorhebung, die im Vergleich zum Vorgänger noch erweitert wurde, lässt sich beliebig anpassen, liefert Informationen über die letzten Änderungen innerhalb der aktuellen Visual-Studio-Session und beinhaltet eine BookmarkVerwaltung, mit deren Hilfe Sie auch in komplexen und umfangreichen Anwendungen schnell zu einem bestimmten Punkt innerhalb der Applikation springen können. Wieder dabei ist auch die IntelliSense-Hilfe, auch sie wurde erweitert. Hinzugekommen ist eine Unterstützung für Smarttags vor allem bei Klassennamen, die unbekannt sind.
3.4.1
Anpassung des Editors
Vor allem in C-basierten Sprachen gibt es immer wieder das Problem, dass jeder Programmierer seinen Code anders formatiert. Wenn Sie einen Codeabschnitt nehmen und ihn fünf verschiedenen Programmierern geben, mit der Vorgabe, ihn zu formatieren, werden Sie ziemlich sicher mindestens drei verschiedene Ergebnisse erhalten.
Sandini Bib
Der Editor
73
Um diesem Manko aus dem Weg zu gehen, können Sie im Visual Studio einstellen, wie der Code formatiert werden soll. Und das nicht grundlegend wie in der vorherigen Version, sondern bis zum letzten Leerzeichen. Unter anderem können sie festlegen, ob die öffnende geschweifte Klammer in der gleichen Zeile stehen soll wie ein Methodenkopf, ob es zwischen leeren Klammern beim Methodenaufruf ein Leerzeichen geben soll, ob Deklarationen in einer Zeile stehen sollen oder getrennt, usw. Diese Unmenge an Einstellungen nehmen Sie im Optionsdialog des Visual Studio vor (Menüpunkt EXTRAS|OPTIONEN). Wählen Sie dort den Eintrag TEXT-EDITOR|C#|FORMATIERUNG. Abbildung 3.5 zeigt den Dialog.
Abbildung 3.5: Der Dialog für die Formatierungsoptionen
Ganz gleich, welche Datei Sie öffnen – Sie können sicher sein, dass der Inhalt (falls es sich um eine C#-Datei handelt) genau so formatiert und angezeigt wird, wie Sie es vorgeben. Formatierungen sind möglich im Bezug auf Einzüge, Zeilenwechsel, Leerzeichen und Umbrüche.
HINWEIS
Die Einstellungen der Textfarbe und Schriftart ist ebenfalls möglich. Während die Formatierungseinstellungen sich allerdings auf eine Sprache beschränken, sind die Einstellungen für die Schriftart global für alle Sprachen und damit auch unter UMGEBUNG|SCHRIFTARTEN UND FARBEN zu finden. Leider formatieren Entwickler nicht nur unterschiedlich, sie haben auch unterschiedliche Vorgehensweisen und benennen ihre Variablen ebenso unterschiedlich. Wenn es darum geht, im Team zu arbeiten, ist der erste Schritt immer die Festlegung so genannter Code-Conventions, die Benamung, Formatierung und Vorgehensweisen für das gesamte Team festlegen.
Sandini Bib
3 Das Visual Studio 2005
74
3.4.2
IntelliSense
Die IntelliSense-Hilfe wurde ebenfalls im Vergleich zum Vorgänger verbessert. Sie schlägt bereits nach den ersten eingegebenen Buchstaben passende Wörter vor. Dabei bezieht sie auch bekannte Klassennamen und reservierte Wörter mit ein. Das ist eine deutliche Verbesserung; im Vorgänger sprang die IntelliSense-Hilfe in der Regel erst nach Eingabe eines Punkts als qualifizierendem Operator ein.
3.4.3
Smarttags
Der Visual Studio Editor unterstützt nun auch Smarttags, die Sie vielleicht bereits aus den Office-Produkten kennen. Falls Sie beispielsweise innerhalb einer fertigen Klasse den Namen einer Variablen nachträglich ändern, können Sie über einen Smarttag diese Änderung für die gesamte Klasse (oder eine gesamte Methode) übernehmen. Dabei handelt es sich um ein so genanntes Refactoring; das Visual Studio unterstützt vor allem für C# zahlreiche Refactorings, wie sie in Fowlers gleichnamigem Buch beschrieben sind. Besonders hilfreich ist dieses Feature dann, wenn Sie eine Klasse verwenden wollen, aber nicht wissen, in welchem Namespace sich die Klasse befindet. Über Smarttags können sie entweder diese Klasse vollständig qualifizieren (also den gesamten Klassennamen inklusive des Namespaces verwenden) oder den Namespace einbinden (siehe auch Abbildung 3.6). Das setzt allerdings voraus, dass die DLL, in der sich die Klasse und der Namespace befinden, referenziert ist.
Abbildung 3.6: Smarttags im Visual Studio
Weitere Funktionalitäten, die über Smarttags ausgeführt werden können, sind z.B. Änderungen von Bezeichnern oder auch das Implementieren von Interfaces. Die Änderung eines Bezeichners entspricht einem Refactoring. Ändern Sie beispielsweise einen Methodennamen, erscheint sofort ein Smarttag, mit dem Sie das Refactoring durchführen und den Methodennamen auch an allen referenzierten Stellen ändern können.
3.4.4
Änderungen innerhalb einer Sitzung
Ebenfalls mitunter komfortabel ist die neue Möglichkeit, die gemachten Änderungen zu verfolgen. Diese Änderungsverfolgung bezieht sich grundsätzlich auf eine Sitzung mit dem Visual Studio, d.h. wenn Sie das Visual Studio schließen werden Sie später nicht mehr feststellen können, welche Änderungen Sie vorgenommen haben. Innerhalb einer Sitzung ist es allerdings komfortabel.
Sandini Bib
Tools und Hilfsmittel
75
Wenn Sie Quellcode ändern, wird an der linken Seite des Editors ein farbiger Balken angezeigt, der während der gesamten Sitzung bestehen bleibt. Bei »frischen« Änderungen, die noch nicht abgespeichert sind, ist dieser Balken gelb. Haben Sie gespeichert, wird er grün dargestellt. Damit können Sie auch unter den gemachten Änderungen unterscheiden. Leider können Sie nicht nach dem Auftreten solcher Änderungsbalken suchen.
3.4.5
Refactoring
Refactoring, das Verbessern des Quellcodes, ist ein ständiger Vorgang. Jeder ernsthafte Programmierer ist ständig dabei, Refactorings durchzuführen. Was dieser Begriff bedeutet, ist schnell erklärt. Beim Programmieren geht es meist zuerst einmal darum, dass etwas funktioniert. Häufig tritt danach die unbefriedigende Situation auf, dass der Quelltext nicht »sauber« ist, beispielsweise weil der gleiche Code mehrfach verwendet wurde (Copy&Paste), weil Methoden zu lang wurden (und damit unüberschaubar) oder weil man feststellt, dass eine gewisse Funktionalität doch besser in einer eigenen Klasse Platz gefunden hätte. Was auf diese Erkenntnis folgt ist in der Regel der entsprechende Umbau. Mehrfach verwendeter Code wird in eine eigene Methode ausgelagert, die dann aufgerufen wird, unüberschaubare Methoden werden zerteilt und somit überschaubar (und wartbar), für eine umfangreiche Funktionalität wird eine eigene Klasse erzeugt usw.
VERWEIS
Dieses Vorgehen nennt man Refactoring. Das Umbauen des Quelltextes, sodass er danach leichter lesbar und damit auch leichter wartbar ist. Das Visual Studio hilft bei diesen Vorgehensweisen, indem es zahlreiche Refactorings zur Verfügung stellt. Unter anderem sind darunter das Kapseln eines Felds in einer Eigenschaft, die Extraktion einer Methode, Extraktion von Interfaces, das Umbenennen von Klassen (wobei auch die Referenzen projektweit geändert werden) oder auch das Vertauschen von Parametern einer Methode. All das ist sowohl in den Visual Studio-Editionen als auch in der Visual C# Express-Edition enthalten.
3.5
Das Standardwerk bzgl. Refactoring ist das gleichnamige Buch von Martin Fowler. Auf Deutsch ist es bei Addison-Wesley erschienen. Die ISBN lautet 3-8273-2278-2. Obwohl für absolute Einsteiger noch etwas schwierig ist es doch schon für den fortgeschrittenen Programmierer zu empfehlen; nach der Lektüre dieses Buchs werden Sie definitiv besseren Code schreiben.
Tools und Hilfsmittel
Nicht alles, was das Visual Studio an Tools bietet, kann hier angesprochen werden. Zwei der Tools sollen im Vordergrund stehen, weil sie neu und enorm nützlich sind: Der Klassendesigner (Klassendiagramm) und die Object Test Bench, in der deutschen Version fehlerhaft als »Objecttestcenter« überschrieben. Beide Tools sind nicht in den ExpressVersionen, aber ab der Standard-Version des Visual Studio verfügbar.
Sandini Bib
3 Das Visual Studio 2005
76
3.5.1
Das Klassendiagramm
Die meisten Programmierer schreiben den Code für ihre Klassen selbst. Andere hätten gerne eine Möglichkeit, die Klasse erst zu designen (d.h. vorzugeben, welche Methoden, Eigenschaften und Felder enthalten sind) und erst später die Funktionalität hinzuzufügen. Die klassische Vorgehensweise ist dabei UML, allerdings bedarf es teurer Tools, um die mittels UML designten Klassen auch in Quellcode umzusetzen. Das Visual Studio beinhaltet in der Version 2005 ein Tool namens Klassendiagramm, mit dem genau dies möglich ist. Ähnlich wie UML können hier Klassen und Interfaces zusammengestellt werden. Das Visual Studio kümmert sich darum, entsprechende Dateien und die Funktionsrümpfe anzulegen. Die Anzeige des Klassendesigners und der Code in der entsprechenden Datei bleiben dabei absolut synchron. Auch aus bestehenden Applikationen können Sie ein Klassendiagramm erzeugen. Wählen sie einfach aus dem Kontextmenü des Projekts den Eintrag KLASSENDIAGRAMM ANZEIGEN, und es wird automatisch erstellt. Danach können Sie Ihre Klassen »gestalten« statt sie zu programmieren. Die Funktionalität müssen Sie allerdings selbst hinzufügen. Abbildung 3.7 zeigt das Klassendiagramm des Visual Studios. Dort wird auch die Kontextsensitivität der Toolbox deutlich – in dieser Ansicht (ebenfalls nur eine Ansicht des Hauptarbeitsbereichs) zeigt sie die möglichen Elemente, die hinzugefügt werden können. Dazu gehören unter anderem natürlich Klassen, aber auch Strukturen (struct), Aufzählungen (enum), Interfaces oder abstrakte Klassen.
Abbildung 3.7: Der Klassendesigner des Visual Studio. Die Toolbox zeigt die verfügbaren Elemente.
3.5.2
Das Objekttestcenter
Aus dem Klassendesign heraus haben Sie auch Zugriff auf das Objekttestcenter, eine weitere Neuerung in Visual Studio 2005. Softwaretests sind allgemein Usus und es gibt auch zahlreiche Programme dafür. Unter anderem wäre da nUnit zu nennen, das für .NET recht populär ist.
Sandini Bib
Tools und Hilfsmittel
77
Das Visual Studio bringt in der Team Edition eine Testumgebung mit, die das gleiche kann wie nUnit, aber eben in das Visual Studio integriert ist. Doch nicht immer muss es so groß sein – manchmal soll nur schnell ein einziges Objekt bzw. eine einzige Klasse getestet werden. Zu diesem Zweck existiert in den Visual-Studio-Versionen die Object Test Bench (oder Objekttestcenter), die genau das ermöglicht. Zugriff darauf erhalten Sie aus dem Klassendesigner oder aus der Klassenansicht (ANSICHT|KLASSENANSICHT, (Strg)+(ª)+(C)). Über das Kontextmenü einer Klasse können Sie diese instanzieren (INSTANZ ERSTELLEN), was in der Anzeige des Objekttestcenters und eines erzeugten Objekts resultiert. Mit diesem Objekt können Sie arbeiten, d.h. sämtliche Methoden des Objekts aufrufen oder auch Werte zuweisen. Abbildung 3.8 zeigt das Objekttestcenter mit einem erzeugten Objekt, von dem eine Methode aufgerufen wird.
Abbildung 3.8: Das Objekttestcenter des Visual Studio
Es versteht sich von selbst, dass die Klasse, die instanziiert werden soll, auch kompilierbar sein muss, also keine Fehler enthalten darf. Das Ganze funktioniert natürlich nur, wenn sie kompiliert werden kann – dann aber richtig. Selbst für eine evtl. Parameterübergabe ist gesorgt, wie in Abbildung 3.9 zu sehen. Die Methode erwartet in diesem Fall einen Parameter vom Typ String, also eine Zeichenkette. Dieser muss daher in Anführungszeichen gesetzt werden.
Abbildung 3.9: Übergabe eines Parameters für das Objekttestcenter
Sandini Bib
3 Das Visual Studio 2005
78
3.5.3
Code Snippets (Codeausschnitte)
Code Snippets sind kleine Codefragmente, die immer wieder benötigt werden. In den Vorgängerversionen konnten solche Fragmente in der Toolbox abgelegt werden (das geht immer noch), in der Version 2005 gibt es hierfür den Codeausschnitte Manager (oder im Original Snippet Manager). In ihm können Sie beliebig viele Codeausschnitte unterbringen, die innerhalb des Editors dann über entweder ein Tastenkürzel oder über das Kontextmenü eingefügt werden können. Leider hat diese Sache auch einen Wermutstropfen. Der Codeausschnitte-Manager, erreichbar über das Menü EXTRAS, sieht zwar schön aus – dennoch gibt es derzeit noch keine Möglichkeit, einen eigenen Codeschnipsel auf einfache Art und Weise hinzuzufügen. Sie müssen in der Tat (in der Hilfe ist es beschrieben) eine XML-Datei schreiben und diese mit der Endung .snippet abspeichern. Diese XML-Datei müssen Sie zu allem Überfluss auch noch von Hand schreiben – hier wäre eine bessere Lösung angebracht gewesen.
3.6
Fazit
In einem kurzen Abschnitt innerhalb eines Buches können freilich nicht alle Vorzüge einer Entwicklungsumgebung dargestellt werden – schon gar nicht, wenn es sich um eine so umfangreiche Software wie dem Visual Studio 2005 handelt. Sie dürften allerdings bereits erkannt haben, dass diese Entwicklungsumgebung wirklich zahlreiche nützliche Tools enthält, die Ihnen die Arbeit stark erleichtern. Wenn Sie damit arbeiten – gleich mit welcher Version – werden Sie sicherlich auch feststellen, dass derzeit kein vergleichbares Tool existiert. Das Visual Studio ist definitiv die beste Wahl für die Entwicklung unter Microsoft .NET.
Sandini Bib
Teil II Grundlagen
Sandini Bib
Sandini Bib
4
Datentypen
Die Datentypen einer Programmiersprache sind die Basis jeder Programmierung. Im Falle von .NET, das ja programmiersprachenunabhängig aufgebaut ist, sind die Datentypen nicht allein für C# definiert, sondern als Bestandteil des .NET Frameworks für alle Sprachen gültig.
4.1
Werte- und Referenztypen
Das .NET Framework ist vollständig objektorientiert und hat eine lückenlose Vererbungskette. Sämtliche Datentypen sind direkt oder indirekt vom Basisdatentyp System.Object abgeleitet. Dennoch unterscheidet das Framework zwischen zwei Arten von Datentypen, Werte- und Referenztypen. Der Ursprung beider ist gleich, das Verhalten jedoch unterscheidet sich. Sollten sie aus einer nicht-objektorientierten Sprache kommen, sind die Unterschiede möglicherweise etwas schwieriger zu verstehen. Dennoch ist das Wissen darum unverzichtbar, da das Verhalten eines Programms durch diese Unterschiede beeinflusst werden kann.
4.1.1
Unterschiede zwischen Werte- und Referenztypen
Der Unterschied zwischen den beiden im .NET Framework vertretenen Arten von Datentypen besteht wie angesprochen nicht unbedingt in der Herkunft. Beide Arten sind auf irgendeine Weise von Object abgeleitet, der ultimativen Basisklasse im .NET Framework. Der Hauptunterschied besteht im Verhalten des Datentyps sowohl intern als auch zum Programmierer hin. Um das zu verstehen, müssen erst zwei andere Begriffe geklärt werden, nämlich Stack und Heap. Bei beiden handelt es sich um eine Speichermöglichkeit für Daten. Der Stack ist ein Stapelspeicher. Auf ihm werden Werte abgelegt und später wieder entnommen. Derartige Speicher werden auch als Kellerspeicher oder LIFO-Speicher bezeichnet (LIFO=Last In First Out, was als letztes hineingelegt wird wird auch als erstes wieder entnommen). Jede Applikation erhält ihren eigenen Stack, auf den andere Applikationen nicht zugreifen können. Der Heap ist ein dynamischer Speicher, der vom System verwaltet wird und daher so groß werden kann, wie der maximale Speicher, den das System verwalten kann. Es handelt sich dabei nicht um einen Kellerspeicher, die Anordnung entspricht eher einem verzweigten Baum. Der Zugriff ist wahlfrei, Daten können also auch mittendrin entnommen werden. Im Falle von Referenztypen (auch als Objekte bezeichnet) liegen die Werte eines solchen Datentyps im Heap.
Sandini Bib
82
4 Datentypen
Parameterübergabe Müssen Daten zwischengespeichert werden, beispielsweise wenn ein Methodenaufruf erfolgt und Parameter übergeben werden, passiert im Falle eines Wertetyps folgendes: Der enthaltene Wert wird auf den Stack abgelegt. Die Methode wird aufgerufen, und es wird erkannt, dass ein Parameter übergeben wurde. Der entsprechende Wert wird nun wieder vom Stack entnommen. Das Resultat ist, dass innerhalb der Methode eine Kopie des Werts existiert. Bei Referenztypen läuft die Sache ein wenig anders. Wird einer Variablen ein Referenztyp, ein Objekt, zugewiesen, so liegen die Daten dieses Objekts auf dem Heap. Die Variable speichert nicht etwa die Werte, sondern lediglich eine Referenz auf die Daten, die auf dem Heap ohnehin vorhanden sind. Wird nun eine solche Variable als Parameter an eine Methode übergeben, passiert genau das Gleiche: Der in der Variablen enthaltene Wert wird auf den Stack gelegt, die Methode aufgerufen und der Wert wieder entnommen. Der Unterschied ist jedoch, dass der Wert nur eine Referenz auf die Daten darstellt. Das bedeutet, innerhalb der Methode existiert nur eine Kopie der Referenz. Werden nun Daten geändert, so geschieht dies auf dem Heap – und zwar an der gleichen Stelle. Die Werte im ursprünglichen Objekt werden also ebenfalls geändert. Bei Wertetypen passiert das nicht, da der Wert kopiert wird. Dieser Unterschied ist einer der Hauptunterschiede und auch einer der wichtigsten. Durch das unterschiedliche interne Verhalten ergibt sich auch für den Programmierer ein Unterschied – er muss darauf achten, was er einer Methode übergibt. Soll wirklich eine Objektkopie übergeben werden (und damit ein Ändern der ursprünglichen Werte ausgeschlossen werden), muss die Kopie explizit erstellt werden, d.h. im Programm muss ein neues Objekt mit den gleichen Daten erzeugt werden.
Vergleiche Bei einem Vergleich von Wertetypen und Referenztypen wird grundsätzlich das verglichen, was sich auf dem Stack befindet. Handelt es sich bei dieser Variablen um einen Wertetyp, so wird auch der enthaltene Wert verglichen. Zwei Wertetypen werden also dann als gleich angesehen, wenn sie die gleichen Daten enthalten. Im Falle von Referenztypen steht im Stack lediglich die Referenz. Zwei Referenztypen sind demnach dann als gleich anzusehen, wenn sie auf die gleiche Speicherstelle im Heap verweisen.
Der Wert null Die Deklaration einer Variablen bedeutet noch nicht, dass diese auch einen Wert besitzt. Im Falle von Wertetypen ist in diesem Fall wirklich kein Wert vorhanden, der Compiler verweigert in diesem Fall dann die Arbeit, wenn die Möglichkeit besteht, dass die Variable vor ihrer ersten Verwendung nicht initialisiert ist. Handelt es sich bei dem Datentyp der Variablen um einen Referenztyp, wird automatisch ein Wert zugewiesen. Im Gegensatz zu Wertetypen können Referenztypen einen Wert
Sandini Bib
Integrierte Datentypen
83
beinhalten, der besagt, dass sie »leer« sind, also eben derzeit keine echte Referenz darstellen. Dieser Wert ist der Wert null. Bei Wertetypen geht dies nicht. null ist ein sehr häufig verwendeter Wert, denn mit ihm kann beispielsweise kontrolliert werden, ob ein Objekt wirklich instanziiert ist, denn null bedeutet nichts anderes, als dass
von einem Objekt eben keine Instanz existiert.
Weitere Unterschiede Die angeführten Unterschiede sind die wichtigsten, weil sie das Verhalten Ihres Programms beeinflussen können. Einige weitere Unterschiede ergeben sich aus der Handhabung der Datentypen durch den Programmierer. Ein Wertetyp muss nicht »erzeugt« werden, er beinhaltet nur Werte. Daher können Sie einer entsprechenden Variablen direkt Werte zuweisen. Sie dürfen aber eine solche Variable erst benutzen (also einer Methode übergeben oder ihren Inhalt anderweitig verwenden), wenn sie mit einem Wert initialisiert wurde. Bei Referenztypen muss Platz auf dem Heap reserviert werden. Das geschieht durch den Aufruf des Konstruktors, was explizit geschehen muss. In C# gibt es hierfür das reservierte Wort new. Im Konstruktor (den Sie auch selbst festlegen können) werden dann Basiswerte zugewiesen. Die Verwendung des Konstruktors ist bei Referenztypen Pflicht, bei Wertetypen optional (aber möglich). Auch wenn Sie die Unterschiede an dieser Stelle noch nicht verinnerlicht haben, oder noch Probleme mit den Begriffen Referenz oder Objekt haben, stecken Sie nicht auf – alles wird mit der Zeit klarer. Die Programmierung, vor allem bei einem vollkommen objektorientierten System, lässt sich nicht innerhalb von ein paar Seiten erläutern. Sie werden die Begriffe später wieder finden, wenn es um Klassen und Objekte und damit um die Grundlagen der objektorientierten Programmierung selbst geht. Für den Moment genügt es, wenn Sie sich darüber im Klaren sind, dass es Unterschiede gibt, und dass Sie in Wertetypen eben Werte speichern können.
4.2
Integrierte Datentypen
Die integrierten Datentypen werden vom .NET Framework direkt bereitgestellt. Es handelt sich dabei ausnahmslos um Wertetypen. Daneben gibt es noch selbst definierte Typen, wie structs und enums, die ebenfalls Wertetypen darstellen. Alle Wertetypen sind grundsätzlich direkt von ValueType abgeleitet. Diese Ableitung müssen Sie allerdings nicht vornehmen, um eigene Wertetypen zu erstellen. Stattdessen generieren Sie einfach einen struct (der implizit von ValueType abgeleitet ist). Die integrierten Wertetypen von .NET sind ebenfalls als Strukturen implementiert. C# kennt 13 integrierte Datentypen, die in der folgenden Tabelle aufgelistet sind.
Sandini Bib
4 Datentypen
84
Datentyp
Größe
Wertebereich
Alias
bool
8 Bit
true, false
System.Boolean
byte
8 Bit
-128 bis 127
System.Byte
sbyte
8 Bit
0 bis 255
System.Sbyte
char
16 Bit
ein Unicode-Zeichen
System.Char
decimal
128 Bit
±1.0 × 1028 to ±7.9 × 1028
System.Decimal
double
64 Bit
±5.0 × 10324 to ±1.7 × 10308
System.Double
float
32 Bit
±1.5 × 1045 to ±3.4 × 1038
System.Single
int
32 Bit
-2,147,483,648 bis 2,147,483,647
System.Int32
uint
32 Bit
0 bis 4,294,967,295
System.UInt32
long
64 Bit
-9,223,372,036,854,775,808 bis 9,223,372,036,854,775,807
System.Int64
ulong
64 Bit
0 bis 18,446,744,073,709,551,615
System.UInt64
short
16 Bit
-32768 bis 32767
System.Int16
ushort
16 Bit
0 bis 65535
System.UInt16
Auffallend an obiger Tabelle ist, dass der Datentyp decimal offensichtlich einen kleineren Wertebereich besitzt als beispielsweise der Datentyp double, obwohl es sich um einen 128 Bit breiten Datentyp handelt. Die größere Anzahl verfügbarer Bits wurde bei decimal allerdings nicht in den Wertebereich umgesetzt, sondern in die Genauigkeit. Während der Datentyp double mit einer Genauigkeit von 15-16 Stellen nach dem Komma arbeitet (float arbeitet mit 7 Stellen hinter dem Komma), liefert decimal eine Genauigkeit von 28 bzw. 29 Stellen hinter dem Komma. Daher ist dieser Datentyp sehr gut für Finanzkalkulationen geeignet. Ebenso mag dem einen oder anderen geschätzten Leser auffallen, dass der Datentyp string fehlt, der eine Zeichenkette repräsentiert und vermutlich am häufigsten verwendet wird. Der Grund dafür ist, dass string kein Wertetyp ist, auch wenn er sich so verhält. Es handelt sich (eigentlich zwangsläufig) um einen Referenztyp, da die Größe der Zeichenkette erst zur Laufzeit festgelegt wird und ein String somit keine feste Größe haben kann. In Abschnitt 4.2.4 ab Seite 88 werden Strings angesprochen, Abschnitt 12.2 ab Seite 244 liefert weitere Informationen zum Arbeiten mit Strings.
4.2.1
Der Datentyp bool
Da es sich bei C# um eine typsichere Sprache handelt, muss dem Datentyp bool ein wenig mehr Aufmerksamkeit geschenkt werden. Es handelt sich dabei nämlich wirklich um einen eigenständigen Datentyp, der nicht, wie in anderen Sprachen zum Teil üblich, durch einen Zahlenwert interpretiert werden kann.
Sandini Bib
Integrierte Datentypen
85
Vor allem C++-Programmierer haben bei solchen Werten gerne so gearbeitet, dass sie Verzweigungen anhand des Zahlenwerts einer Variable durchgeführt haben. Ein Zahlenwert von 0 entspricht in C++ dem booleschen Wert false. Es kam daher häufig zu dem Fehler, dass statt eines Vergleichs, der mit doppeltem Gleichheitszeichen durchgeführt wird, eine Zuweisung stattfand. Das Programm erkannte diesen Fehler nicht, denn mit der Zuweisung hatte die Variable einen Wert, der wiederum als boolescher Wert ausgewertet wurde. Sollte beispielsweise der Wert einer Variablen daraufhin überprüft werden, ob er 0 ist, würde der Vergleich folgendermaßen aussehen: // C++ - Code if ( a == 0 ) { // Anweisungen ... }
Der Fehler, der oft passierte, war, dass eines der Gleichheitszeichen vergessen wurde: // C++ - Code if ( a = 0 ) { // Anweisungen }
In diesem Fall wird der Variablen a im Kopf des Vergleichs der Wert 0 zugewiesen. Das entspricht aber (unter C++) dem Wert false, wodurch sich genau das Gegenteil des gewünschten Verhaltens einstellt – der Wert ist 0, aber die Anweisungen werden nie ausgeführt, obwohl die Bedingung (eigentlich) erfüllt ist. In C# kann dies nicht passieren. Der Compiler mahnt die Zuweisung an und beschwert sich, dass ein Vergleich stattfinden muss. Ein Zahlenwert kann nicht als boolescher Wert interpretiert werden.
4.2.2
Der Datentyp char
HINWEIS
Der Datentyp char hat in C# (bzw. im .NET Framework) eine Größe von 16 Bit oder 2 Byte. Das liegt daran, dass .NET vollständig auf Unicode basiert. Alle Zeichen (auch in der Entwicklungsumgebung selbst) werden mit 2 Bytes pro Zeichen dargestellt. Die Verwendung von Unicode ist durchaus logisch, denn mit ASCII oder ANSI sind sprachenübergreifende Anwendungen aufgrund der unterschiedlichen Sonderzeichen nur sehr schwer zu realisieren. Mithilfe von Unicode können nun alle Sonderzeichen aller Weltsprachen in einem Zeichensatz untergebracht werden – und es ist sogar noch Platz für weitere Sprachen.
Zuweisungen an eine Variable vom Typ char geschehen in einfachen Anführungszeichen. Geschieht eine Zuweisung mithilfe von doppelten Anführungszeichen, so betrachtet der Compiler das zugewiesene Zeichen als string und meldet einen Fehler. char c = 'a'; // Zeichen a wird zugewiesen – korrekt char c = "a"; // "a" wird als string repräsentiert - Fehler
Sandini Bib
4 Datentypen
86
Escape-Sequenzen Mithilfe spezieller Literale, die auch als Escape-Sequenzen bezeichnet werden, können Sonderzeichen als char dargestellt werden. Ihre Verwendung ist sowohl als einzelnes Zeichen als auch innerhalb von Strings möglich. Sequenz
Bedeutung
\a
Alarm – Ein Signalton wird ausgegeben.
\b
Backspace
\c
Entspricht einem Zeichen zusammen mit [Strg].
\f
Seitenumbruch
\r
Carriage Return (Wagenrücklauf)
\n
Zeilenumbruch (NewLine)
\t
Horizontaler Tabulator
\"
Anführungszeichen innerhalb eines Strings
\'
Einfaches Anführungszeichen innerhalb eines Strings
\\
Backslash
\v
Vertikaler Tabulator
\e
Die Taste [Esc]
\uXXXX
Entspricht einem Unicode-Zeichen. XXXX entspricht dem Hex-Wert des Zeichens.
Die Möglichkeit, beliebige Unicode-Sequenzen anzugeben, ist nicht auf die Datentypen char und string beschränkt. Da auch die Entwicklungsumgebung mit Unicode arbeitet, werden solche Sequenzen auch dort ausgewertet, beispielsweise als Bestandteil eines Variablenbezeichners. Als Beispiel: int \u0041\u0042\u0043 = 10; Console.WriteLine("Wert von ABC: {0}", ABC);
liefert als Ausgabe: Wert von ABC: 10
Eine solche Vorgehensweise empfiehlt sich allerdings nicht, da es kaum eine Möglichkeit gibt, Code noch schlechter lesbar zu machen als durch die Verwendung von UnicodeSequenzen in Variablenbezeichnern.
Sandini Bib
Integrierte Datentypen
4.2.3
87
Numerische Datentypen
Bei den numerischen Datentypen wird zwischen integralen und Gleitkommatypen unterschieden. Integrale Typen sind alle ganzzahligen Typen wie z.B. int oder long, zu den Gleitkommatypen gehören float und double. Der Datentyp decimal nimmt eine Sonderstellung ein, da er speziell für finanzmathematische Funktionen vorgesehen ist. Es handelt sich jedoch ebenfalls um einen Gleitkommatyp.
Suffixe Suffixe dienen der genauen Festlegung des Datentyps bei numerischen Werten. Beispielsweise ist standardmäßig festgelegt, dass ein Gleitkommawert, so nicht anders angegeben, immer als double gehandhabt wird. Bei einem ganzzahligen Wert ist int der Standard-Datentyp. Durch Suffixe können Sie dieses Verhalten ändern und den Datentyp des Werts festlegen. Die Groß-/Kleinschreibung spielt dabei keine Rolle, außer beim Suffix L. Hier sollte in jedem Fall der Großbuchstabe verwendet werden, da es ansonsten zu Verwechslungen kommen kann. Das kleine l ähnelt zu stark der Ziffer 1. Die folgende Tabelle zeigt die Suffixe und die repräsentierten Datentypen. Datentyp
D, d
Der Wert wird als double interpretiert.
F, f
Der Wert wird als float interpretiert.
L,(l)
Der Wert wird als long interpretiert.
UL, ul
Der Wert wird als ulong interpretiert.
M,m
Der Wert wird als decimal interpretiert.
ACHTUNG
Suffix
Vor allem bei der Zuweisung von float-Werten sind diese Suffixe wichtig. Da Werte mit Kommastelle vom Compiler als double angesehen werden, float aber einen kleineren Wertebereich als double besitzt, ergibt sich bei folgender Zuweisung ein Fehler: float f = 2.5;
Daher sollten Sie sich die Verwendung der Suffixe fast schon grundsätzlich angewöhnen. Vor allem im Grafik-Kapitel wird diese Möglichkeit häufig Verwendung finden, da viele der Grafik-Parameter auf dem Datentyp float basieren.
Sandini Bib
4 Datentypen
88
4.2.4
Der Datentyp string
Zeichenketten, üblicherweise im Fachjargon auch Strings genannt, sind der wohl am häufigsten verwendete Datentyp in nahezu jeder Programmiersprache. Im .NET Framework hat der Datentyp string eine ganz besondere Bedeutung, weil es sich bei ihm um einen Zwitter handelt. Eigentlich ist string ein Referenztyp, nach außen hin verhält er sich allerdings wie ein Wertetyp. Die Basisklasse des .NET Frameworks befindet sich im Namespace System und heißt String. Weisen Sie einem String einen Wert zu, geschieht zweierlei. Zunächst wird auf dem Heap Speicher für den String bereitgestellt, und zwar genau so viel, wie benötigt wird. Die zugewiesene Zeichenkette wird dort gespeichert. Danach ist der String nicht mehr änderbar (er ist immutable). Nun wird sicherlich die eine oder andere Stimme laut, dass das sehr wohl gehe – immerhin kann ein String erweitert werden, es kann ein weiterer String angehängt werden. Der eine oder andere geschätzte Leser mag das vielleicht aus Java auch kennen. Nach außen hin stimmt das, intern allerdings passiert in einem solchen Fall etwas vollkommen anderes. Bei der Zuweisung eines Wertes an eine string-Variable wird diese initialisiert. Soll der Inhalt nun verändert werden, z.B. indem eine weitere Zeichenkette angehängt wird, geschieht Folgendes: f Die Gesamtlänge des neuen Strings wird ermittelt. f Auf dem Heap wird entsprechend Speicher reserviert. f Die Daten werden an diesen neuen Speicherplatz kopiert. f Die Referenz wird auf den neuen Platz auf dem Heap umgelegt. Bei jeder Änderung des Inhalts einer String-Variablen wird also intern eine Kopie der Zeichenkette erzeugt. Und das beeinflusst die Performance, obwohl es im Falle weniger Zuweisungen nicht auffällt. Übrigens ist das Verhalten in Java exakt das gleiche. Mehr über Strings und ihre Verwendung finden Sie in Abschnitt 12.2 ab Seite 244.
4.2.5
Nullable Types
Neu in .NET 2.0 sind die so genannten Nullable Types. Dabei handelt es sich um ein enorm hilfreiches Feature, das vor allem in Datenbankanwendungen Verwendung finden dürfte. Aber nicht nur dort, sondern überall, wo ein Wertetyp eben keinen Wert besitzen soll. Ein Problem stellt dies vor allem in Datenbankanwendungen dar. Für einen SQL Server oder auch eine Access-Datenbank ist es kein Problem, ein Datumsfeld mit dem Wert null zu belegen (innerhalb der Datenbank), was letztlich besagt, dass noch kein Datum bekannt ist. Das einfachste Beispiel hierzu ist eine Benutzerdatenbank, bei der sicherlich nicht vorausgesetzt werden kann, dass für jeden Benutzer auch ein Geburtsdatum bekannt ist. Der korrespondierende Datentyp des .NET Frameworks, DateTime, muss allerdings immer ein Datum beinhalten, dass es sich um einen Wertetyp handelt (der dementsprechend nicht null werden kann). Die Behandlung des Umstands eines nicht vorhandenen Datums
Sandini Bib
89
Integrierte Datentypen
führt hier zu Mehrarbeit. In der Praxis wird häufig der niedrigste mögliche Datumswert zugewiesen und später im Programm kontrolliert.
HINWEIS
Wesentlich sinnvoller wäre es, wenn sowohl Werte- als auch Referenztypen einheitlich behandelt werden könnten und der Wert null für beide möglich wäre. Und genau das ist im Falle von Nullable Types der Fall. Ein Nullable Type ist eigentlich ein so genannter generischer Datentyp. Generics werden erst später behandelt, weil sie sehr häufig im Zusammenhang mit Collections vorkommen. Die Syntax eines generischen Typs lernen Sie bereits hier kurz kennen, detaillierte Informationen zu generischen Datentypen erhalten Sie in Kapitel 11 ab Seite 225.
Funktionsweise Ein Nullable Type wird durch die Klasse Nullable definiert, wobei T für den verwendeten Wertetyp steht. Die spitzen Klammern sind die Syntax für generische Datentypen. T ist in diesem Fall ein Platzhalter für jeden beliebigen Wertetyp. Die internen Vorgänge eines Nullable Type sind recht einfach. Die read-only-Eigenschaft HasValue vom Typ bool liefert die Information, ob ein Wert enthalten ist. Die (ebenfalls read-only) Eigenschaft Value liefert den Wert, wenn er enthalten ist. Ist HasValue false, liefert der Zugriff auf Value eine Exception. Die herkömmliche Art, einen Nullable Type zu deklarieren, sieht folgendermaßen aus: Nullable i = new Nullable();
// Deklariert einen Nullable Type vom Typ int
Ohne Zuweisung liefert i.HasValue den Wert false. Was aber noch viel interessanter ist, ist die Möglichkeit, jetzt auch folgendermaßen zu vergleichen: if ( i == null ) { // Anweisungen }
Damit verhält sich der Wertetyp i nun wie ein Referenztyp (bleibt aber letztlich ein Wertetyp). Der Zugriff auf den Wert kann auch auf zwei Arten geschehen (sofern ein Wert zugewiesen ist). Einerseits kann die Eigenschaft Value ausgewertet werden, viel einfacher ist es jedoch, i einfach wie einen normalen Wertetyp zu verwenden: Nullable i = new Nullable(); if ( i == null ) i = 10; Console.WriteLine( "Wert von i: " + i.ToString() );
Sandini Bib
4 Datentypen
90
Kurzschreibweise Um die Deklaration eines Nullable Type zu vereinfachen, wurde eine Kurzschreibweise eingeführt. Den obigen Codeausschnitt können Sie auch folgendermaßen schreiben: int? i; if ( i == null ) i = 10; Console.WriteLine( "Wert von i: " + i.ToString() );
Das Fragezeichen hinter dem Datentyp bei der Deklaration teilt dem Compiler mit, dass es sich hierbei um einen Nullable Type handelt.
Zuweisungen Da sich Nullable Types in der Hauptsache wie Wertetypen verhalten, aber eben auch null sein können, müssen Sie vor allem bei Zuweisungen bzw. bei der Verwendung von Operatoren Vorsicht walten lassen. Die folgende Zuweisung würde beispielsweise zu einem Fehler führen: int? x = null; int y = x;
// y ist nicht nullable, daher Zuweisung nicht möglich: Fehler
Für diesen Fall existiert ein neuer Operator in C#, der ??-Operator. Er ermöglicht die Zuweisung eines Standardwerts an einen Wertetyp, wenn der zugewiesene Nullable Type eben den Wert null hat: int? x = null; int y = x ?? -1;
// Wenn x == null, wird -1 zugewiesen, ansonsten der Wert von x
Rechnen mit Nullable Types Grundsätzlich können alle Operatoren auf einen Nullable Type angewendet werden, die auch auf den enthaltenen Typ angewendet werden können. Allerdings ist darauf zu achten, was passiert, wenn einer der Typen bei der Zuweisung null ist, wie in folgendem Beispiel: int? x = 10; int? y = null; x *= 10; // x ist jetzt 100 x += y; // da y null ist, ist x jetzt auch null
Sandini Bib
91
Variablen
4.3
Variablen
Zum Speichern und Verarbeiten der Daten innerhalb eines Programms werden Variablen verwendet. In C# besitzt jede Variable einen expliziten Datentyp, der ihr bei der Deklaration zugewiesen wird. Es gibt im Großen und Ganzen drei Arten von Variablen: f Lokale Variablen werden innerhalb von Methoden verwendet. Sie sind so lange gültig, wie der Block, in dem sie deklariert wurden, abgearbeitet wird. Danach werden sie aus dem Speicher entfernt. Auch Parameter von Methoden werden als lokale Variablen angesehen. f Instanzvariablen sind Bestandteil einer Klassendeklaration (Klassen werden im Detail in Abschnitt 6.3 ab Seite 136 beschrieben). Ihre Lebensdauer entspricht der des Objekts, also der Instanz der jeweiligen Klasse. Instanzvariablen werden häufig auch als Felder einer Klasse bzw. eines Objekts bezeichnet. Auch in diesem Buch werden wir diese Bezeichnung verwenden. f Klassenvariablen sind ebenfalls Bestandteil der Klassendefinition, allerdings nicht auf Instanzebene, sondern auf Klassenebene. Ihre Lebensdauer entspricht der des Programms, in der die Klasse deklariert wurde. Klassenvariablen existieren jeweils nur einmal (nämlich in Bezug auf die Klasse), nicht für jedes Objekt. Man bezeichnet sie häufig auch als statische Variablen oder statische Felder.
HINWEIS
Auf Klassen- bzw. Instanzvariablen wird im Kapitel über Klassen und Objekte noch genauer eingegangen. Dieser Abschnitt widmet sich in der Hauptsache den lokalen Variablen. Die Bezeichnung Objektvariable bedeutet nicht das Gleiche wie Klassenvariable. Als Objektvariable werden die Variablen bezeichnet, deren zugewiesener Typ ein Referenztyp ist (und die somit ein Objekt referenzieren). Klassenvariablen hingegen sind auf Klassenebene deklarierte Felder, die unabhängig von einer Instanz verwendet werden können.
4.3.1
Deklaration und Initialisierung
Die Deklaration einer Variablen erfolgt durch die Angabe des Datentyps, gefolgt vom Bezeichner der Variablen. Als Datentyp kann dabei sowohl der .NET-Datentyp als auch der entsprechende Alias der Sprache C# verwendet werden. Die Deklaration einer 32-BitInteger-Variablen kann demnach auf zwei Arten erfolgen: int a;
oder System.Int32 a;
Beide Male handelt es sich um den gleichen Datentyp.
Sandini Bib
4 Datentypen
92
Es ist auch möglich, mehrere Variablen des gleichen Typs auf einen Schlag zu deklarieren. Dazu werden die Bezeichner durch Komma getrennt: int a, b, c;
Wo die Deklaration einer Variablen innerhalb einer Methode erfolgt, ist irrelevant. Der Compiler sieht eine Deklaration als eine Anweisung an und führt sie aus, sobald er darauf stößt. Anders als beispielsweise in Delphi gibt es keine Notwendigkeit, Variablen am Anfang einer Methode bekannt zu machen. Allerdings müssen Variablen vor der ersten Verwendung sowohl deklariert als auch initialisiert sein. Unter Initialisierung versteht man das erste Zuweisen eines Werts. Die Initialisierung kann auch bereits bei der Deklaration erfolgen, indem einfach die Zuweisung angehängt wird: int a = 10;
oder, bei einer mehrfachen Deklaration: int a=10, b=15, c;
In diesem Fall wären drei Variablen deklariert, zwei davon wurden auch initialisiert. Alle drei sind vom Typ int (bzw. System.Int32).
4.3.2
Bezeichner
Variablenbezeichner (und auch Bezeichner von Klassen, Methoden und anderen Bestandteilen eines Programms) unterliegen bestimmten Regeln, was sowohl ihre Verwendung als auch ihren Aufbau angeht. Ein gültiger Bezeichner beginnt entweder mit einem alphanumerischen Zeichen oder einem Unterstrich. Innerhalb des Bezeichners dürfen auch Zahlen auftauchen. Ebenso ist es erlaubt, Sonderzeichen der jeweiligen Landessprache zu verwenden (ein Dank an Unicode). Leerzeichen innerhalb eines Bezeichners sind hingegen nicht erlaubt. Die folgende Liste zeigt einige gültige und ungültige Bezeichner: int _myValue; double 1Wert; string Währung; int ein Wert;
// // // //
korrekt, beginnt mit Unterstrich Fehler, Bezeichner beginnt mit einer Ziffer korrekt, beinhaltet Sonderzeichen Fehler, Leerzeichen innerhalb des Bezeichners
Denken Sie immer daran, dass C# als C-ähnliche Sprache zwischen Groß- und Kleinschreibung unterscheidet. Sie sollten sich daher gewisse Konventionen für Schreibweisen aneignen, die Sie in Ihren eigenen Programmen verwenden. Die Konventionen in diesem Buch richten sich nach den Konventionen, die auch im .NET Framework verwendet werden.
Reservierte Wörter als Bezeichner C# enthält eine große Anzahl reservierter Wörter, die nicht als Bezeichner verwendet werden dürfen. Trotzdem ist es möglich. Durch Voranstellen des at-Zeichens (auch als Klammeraffe bezeichnet, @) können Sie festlegen, dass ein Bezeichner »wörtlich« genommen,
Sandini Bib
93
Variablen
also nicht vom Compiler interpretiert wird. Die folgende Deklaration wäre durchaus möglich (und wird auch ohne weiteres vom Compiler akzeptiert): string @string = ""; for ( int @int=1; @int < 10; @int++ ) { @string = @string + @int.ToString(); } Console.WriteLine( @string );
Eine solche Art der Programmierung ist allerdings für jeden Programmierer nahezu undurchschaubar, sogar schon bei einem derart kleinen Beispiel. Aus diesem Grund sollten Sie diese Möglichkeit nie in Betracht ziehen.
4.3.3
Gültigkeitsbereich
Auf den Gültigkeitsbereich lokaler Variablen wird nochmals kurz eingegangen, da sich hier sich einige Besonderheiten ergeben. Lokale Variablen sind, wie schon weiter oben angemerkt, in dem Block gültig, in dem sie deklariert wurden. Sie sind nicht gültig in dem Block, der dem Deklarationsblock übergeordnet ist. Ein kleines Beispiel soll das veranschaulichen: class Class1 { static void Main( string[] args ) { int i = 5; // i ist deklariert und initialisiert for ( int u = 0; u < 10; u++ ) { i = i + u; Console.WriteLine( i ); } Console.WriteLine ( u ); // Fehler!!! Console.ReadLine(); }
Der Compiler meldet hier bereits beim Kompilieren einen Fehler (die entsprechende Zeile ist durch einen Kommentar markiert). Die Variable u wurde im Kopf der for-Anweisung deklariert, einer Schleifenanweisung. Variablen, die im Kopf einer Schleife (oder einer anderen Anweisung, die einen Block beinhaltet) deklariert wurden, gehören zum Block der Schleife, nicht zum übergeordneten Block der Methode. Die Variable u ist dem Compiler daher unbekannt, ihre Existenz endete mit dem Verlassen des Blocks. Anders verhält es sich mit der Variablen i, die im Block der Methode, vor der Schleife, deklariert wurde. Diese ist sehr wohl innerhalb des untergeordneten Blocks sichtbar und dürfte dort auch nicht mehr deklariert werden. Umgekehrt funktioniert es auch nicht. Wenn eine Variable in einem Programmblock deklariert ist und dann in einem untergeordneten Block erneut deklariert würde, würde die gleiche Variable des übergeordneten Blocks verdeckt. Das ist nicht erlaubt, daher meldet der Compiler auch hier einen Fehler.
Sandini Bib
4 Datentypen
HINWEIS
94
4.3.4
Instanzvariablen einer Klasse werden im Vergleich zu Methoden auch in einem übergeordneten Block deklariert. Sie können jedoch sehr wohl verdeckt werden, weil man auf Instanzvariablen explizit mithilfe des reservierten Wortes this zugreifen kann. Es handelt sich dabei sogar um eine übliche und weit verbreitete Vorgehensweise. Mehr darüber in Abschnitt 6.3 ab Seite 136.
Konstanten
Konstanten werden mithilfe des reservierten Wortes const deklariert. Es handelt sich dabei um unveränderliche Werte, deren Initialwert bereits bei der Deklaration zugewiesen werden muss. const int myConstant = 10;
VERWEIS
Wenn Sie Konstanten verwenden wollen, müssen Sie diese als Bestandteil einer Klasse deklarieren. Innerhalb einer Methode würde eine Konstante ohnehin keinen Sinn machen (nach Beendigung der Methode wäre sie wieder aus dem Speicher gelöscht). Konstanten sind implizit statische Elemente einer Klasse und nicht Bestandteil eines Objekts.
4.4
Der bessere Weg, konstante Werte zu verwenden, ist der Weg über einen Aufzählungstyp (enum). Mehr über diesen Datentyp, bei dem es sich ebenfalls um einen Wertetyp handelt, erfahren Sie in Abschnitt 4.6 ab Seite 106.
Konvertierungen und Boxing
Als typsichere Sprache fordert C#, dass jede Variable einen bestimmten Datentyp hat, dessen Werte sie aufnehmen kann. Es gibt keine Einstellung Option Strict wie z.B. in Visual Basic .NET – C# verhält sich immer entsprechend der Einstellung Option Strict On, d.h. die Typen einer Variablen und der ihr zugewiesenen Werte müssen identisch sein. Innerhalb einer Anwendung tritt jedoch häufig der Fall auf, dass bei einer Zuweisung eben diese Datentypen nicht übereinstimmen. Dabei gibt es drei mögliche Szenarien: f Die Datentypen beider Werte sind von der gleichen Art (z.B. in beiden Fällen numerische Datentypen) und der Datentyp, dem zugewiesen wird, hat einen größeren Wertebereich als der zugewiesene Datentyp. In diesem Fall erfolgt eine implizite Konvertierung. f Die Datentypen sind von der gleichen Art, und der Datentyp, dem zugewiesen wird, hat einen kleineren Wertebereich als der zugewiesene Datentyp. In diesem Fall muss explizit konvertiert werden. f Die Datentypen sind unterschiedlicher Art. In diesem Fall muss eine Typumwandlung vorgenommen werden.
Sandini Bib
Konvertierungen und Boxing
4.4.1
95
Implizite und explizite Konvertierung
HINWEIS
Die implizite Konvertierung tritt dann in Kraft, wenn der Datentyp, der einen Wert aufnehmen soll, größer ist als der zugewiesene Datentyp. Ein konkretes Beispiel wäre die Zuweisung eines Werts von Typ float an einen Wert vom Typ double. double hat einen Wertebereich von 64 Bit, während float-Werte maximal 32 Bit groß sein können. Der Wert »passt« also von der Größe her auf jeden Fall in den Zieldatentyp. Für eine implizite Konvertierung gilt daher auch, dass sie niemals fehlschlagen kann. C# ermöglicht das Überladen von Operatoren. Implizite und explizite Konvertierungen werden ebenfalls durch Operatoren bereitgestellt. Sollten Sie also Ihren eigenen Datentyp erstellen (ob Werte- oder Referenztyp ist egal) und darin die Konvertierungsoperatoren überladen, müssen Sie darauf achten, dass bei der impliziten Konvertierung kein Fehler auftreten darf. Die Konvertierung wird automatisch durch .NET vorgenommen und Sie können sie nicht beeinflussen.
Bei der expliziten Konvertierung ist das Gegenteil der Fall. Da hier ein großer Wert in einem Datentyp untergebracht werden soll, der einen möglicherweise zu kleinen Wertebereich besitzt, können Fehler auftreten. Das konkrete Beispiel hierzu wäre die Zuweisung eines Werts vom Typ double an eine Variable vom Typ float, oder eines Werts vom Typ int an eine Variable vom Typ short. Der Wertebereich der Zielvariablen ist also möglicherweise zu klein, um den gesamten Wert aufnehmen zu können. In diesem Fall müssen Sie dem Compiler explizit mitteilen, dass er die Konvertierung durchführen soll, auch wenn es dabei zu Fehlern kommen kann. Das geschieht durch das so genannte Casting. Bei dieser Art von Umwandlung wird der Zieldatentyp in Klammern vor den zu konvertierenden Wert geschrieben. // Beispiel für explizite Konvertierung short s = 0; int i = 125; // Der Wertebereich von i ist größer als der von s // Achtung: Es geht hier nicht um den Wert, sondern um den Wertebereich
HINWEIS
s = i; // Fehler !! s = (short)i; // ok, Casting
Zahlen gelten in .NET natürlich auch als Datentypen und besitzen auch einen Typ, den der Compiler überprüfen kann. Standardmäßig sind alle ganzen Zahlen vom Datentyp System.Int32 und alle Fließkommazahlen vom Datentyp System.Double. Sie können das leicht testen, mit zwei einfachen Zeilen Code: Console.WriteLine( 10.GetType().ToString() ); Console.WriteLine( 10.0.GetType().ToString() );
Sandini Bib
4 Datentypen
96
Zeichenumwandlung Casting wird auch angewendet bei der Umwandlung von Zahlenwerten in das entsprechende Zeichen des Alphabets. Das Zeichen A beispielsweise wird durch den Wert 65 repräsentiert. Die folgende Zuweisung ist damit korrekt und führt zum gewünschten Ergebnis: char c = (char)65;
In die andere Richtung funktioniert es natürlich auch: int i = (int)'A';
4.4.2
Boxing und Unboxing
Um den Vorgang des Boxing zu verstehen, müssen wir erneut auf den Unterschied zwischen Werte- und Referenztypen zurückkommen. Obwohl Werte- und Referenztypen sich unterschiedlich verhalten, sind sie innerhalb des .NET Frameworks wie eigentlich alles auch als Klassen (bzw. Strukturen) implementiert. Sie stammen von einer Klasse ab (ValueType), die selbst wiederum von Object abgeleitet ist. Der Datentyp Object steht als Basisdatentyp für alle im Framework enthaltenen Typen auch für jeden beliebigen anderen Datentyp. Er ist quasi der Ersatz für den Datentyp Variant aus VB6, auch wenn es ein wenig anders funktioniert. Es handelt sich um einen Referenztyp. Da dieser Datentyp sehr häufig zum Einsatz kommt, gibt es für ihn ebenfalls einen Alias in C#, nämlich das kleingeschriebene object. Methoden, die universell (also mit mehreren verschiedenen Datentypen) arbeiten sollen, erwarten als Übergabeparameter einen Wert vom Typ object. Damit kann jeder beliebige Datentyp übergeben werden, die Auswertung des korrekten Typs erfolgt innerhalb der Methode. Was geschieht, wenn ein Wertetyp an einen Parameter vom Typ object übergeben wird, ist das so genannte Boxing. Der Wert wird sozusagen in ein Objekt »verpackt«, d.h. er wird vom Stack entnommen, dann auf dem Heap abgelegt und die Referenz darauf gespeichert. Boxing ist eine implizite Form der Konvertierung, d.h. der Compiler kümmert sich automatisch darum. Anders sieht es aus, wenn der Wert wieder entnommen werden soll. In diesem Fall, dem Unboxing, müssen Sie den Wert über ein Casting explizit zurück verwandeln. Das folgende Beispiel zeigt, wie das dann funktioniert. int i = 100; object o = i; int u = (int)o;
// Boxing // Unboxing
Das Objekt o, ein Referenztyp, beinhaltet also einen Wert vom Typ int. Dass dem so ist und dass der Compiler das auch genau weiß, wird deutlich, wenn der Typ von o ausgegeben wird: Console.WriteLine( o.GetType() );
// liefert als Ausgabe: System.Int32
Das Casting zurück muss demnach in den korrekten Datentyp erfolgen, da der Compiler den Datentyp kennt und C# als typsichere Sprache keine Vermischung von Datentypen er-
Sandini Bib
Konvertierungen und Boxing
97
laubt. Soll also der oben angegebene Wert 100 in eine Variable vom Typ byte überführt werden, muss doppelt gecastet werden, nämlich einmal wegen des Unboxing und dann zur expliziten Konvertierung: int i = 100; object o = i; // Boxing byte b = (byte)(int)o; // Doppeltes Casting, erst Unboxing, dann Konvertierung
4.4.3
Typumwandlung
Die häufigste Form der Umwandlung eines Datentyps ist die Konvertierung in einen String. Die Konvertierung in einen String ist dabei nicht weiter schwierig, da jeder Datentyp (auch diejenigen, die Sie selbst schreiben) eine Methode namens ToString() beinhaltet, die genau diese Konvertierung durchführt. Diese Methode können Sie in eigenen Datentypen überschreiben und somit selbst für eine String-Repräsentation Ihres Datentyps sorgen. Die ToString()-Methode der .NET-Datentypen ist sogar eine sehr flexible Methode, da sie auch die Formatierung des zu konvertierenden Werts erlaubt. Mehr Informationen zu diesen Formatierungen erhalten Sie in Abschnitt 12.4 ab Seite 271. Aus einem String wieder einen entsprechenden Wertetyp zu machen ist da schon etwas schwieriger. Hierzu müssen Sie eine Methode verwenden, die den String auswertet (»parsed«) und das Ergebnis dann der Zielvariablen zuweist. Hier gibt es zwei Möglichkeiten: f Die Klasse System.Convert liefert eine Anzahl von Methoden für die Umwandlung von Datentypen. f Die Methode Parse() eines Wertetyps ermöglicht ebenfalls die Umwandlung von einem String. Eine Umwandlung kann daher folgendermaßen vor sich gehen: int i = System.Int32.Parse( s ); // oder: int u = Convert.ToInt32( s );
Die Klasse Convert beinhaltet Methoden, um jeden Basisdatentyp des .NET Frameworks in einen anderen Basisdatentyp zu konvertieren.
Kontrollieren des Werts Falls ein String keine Zahl enthält, schlägt eine evtl. Konvertierung natürlich fehl. Um dies zu vermeiden, ist es möglich, den enthaltenen Wert darauf hin zu kontrollieren, ob er eine Zahl ist. Die entsprechende Methode des Zieldatentyps heißt TryParse(). Im einfachsten Fall benötigen Sie als Parameter den zu konvertierenden String und einen out-Parameter (mehr zu diesen Parametern auf Seite 143), der bei erfolgreicher Konvertierung den Wert aufnimmt.
Sandini Bib
4 Datentypen
HINWEIS
98
In .NET 1.1 existierte diese Methode auch schon, allerdings nur als Bestandteil des Datentyps double. Dennoch konnte die Kontrolle für jede beliebige Zahlenart durchgeführt werden. In .NET 2.0 wurden alle Basisdatentypen mit dieser sinnvollen Methode ausgerüstet.
Die erweiterte Version der Methode TryParse() erwartet als Parameter zusätzlich zu den genannten noch eine Variable des Typs System.Globalization.NumberStyles sowie einen Parameter des Typs IFormatProvider. Der zurückgelieferte Wert ist in beiden Fällen ein boolescher Wert, der angibt, ob die Konvertierung möglich ist. NumberFormat ist ein Aufzählungstyp, genauer gesagt ein Bitfeld, und im Namespace System.Globalization deklariert. Zu Aufzählungstypen (enum) erfahren Sie mehr in Abschnitt 4.6 ab Seite 106, Bitfelder werden in Abschnitt 4.6.3 ab Seite 109 behandelt. Über NumberFormat können Sie festlegen, welcher Art die zu konvertierende Zahl sein soll.
Der Typ IFormatProvider ist ebenfalls noch nicht bekannt. IFormatProvider ist ein Interface, das von verschiedenen Klassen implementiert wird. Mehr zu Interfaces erfahren Sie in Kapitel 9 ab Seite 199. Im Falle dieser Konvertierung können Sie durch Übergabe einer Instanz der Klasse CultureInfo festlegen, welche Landesinformationen bei der Konvertierung berücksichtigt werden sollen. Die Landesinformationen geben unter anderem an, welches Zeichen als Dezimaltrenner verwendet wird. Der Vorteil von TryParse() ist, dass keine Exception bei einer fehlerhaften Konvertierung ausgelöst wird. Es wird lediglich false zurückgeliefert. Hier ein Beispiel für die Kontrolle bei int-Werten: private bool CanConvert( string stringToConvert ) { int result; // Hilfsvariable return Int32.TryParse( stringToConvert, out result ); }
4.5
Arrays
Arrays dienen dazu, mehrere Werte gleichen Datentyps zusammenzufassen. Anders als in vielen Sprachen, bei denen Arrays ein Bestandteil der Sprache selbst sind, handelt es sich in C# dabei um Instanzen der Klasse Array, die im Namespace System deklariert ist. Ein Array ist also ein Objekt, ein Referenztyp. Elemente von Arrays hingegen können sowohl Wertetypen als auch Referenztypen beinhalten. Weil sie ein häufig genutztes Mittel zur Gruppierung von Daten sind, werden sie an dieser Stelle besprochen.
Sandini Bib
99
Arrays
4.5.1
Eindimensionale Arrays
Die Deklaration eines Arrays sieht fast so aus wie die Deklaration einer herkömmlichen Variablen, mit dem Unterschied, dass an den Datentyp selbst eckige Klammern angehängt werden: [] ;
Allein durch die Deklaration erhält ein Array noch keine Größe. Diese wird bei der Initialisierung des Arrays festgelegt. Wie auch bei den anderen Variablen gilt, dass Deklaration und Initialisierung zusammengefasst werden können. Die folgenden Zeilen deklarieren jeweils ein Array aus int-Variablen mit einer Größe von fünf Elementen. int[] arr1; // Deklaration arr1 = new int[5]; // Initialisierung int[] arr2 = new int[5]; // Deklaration und Initialisierung
Der Zugriff auf den entsprechenden Wert innerhalb eines Arrays erfolgt über den Index, der bei der Auswertung ebenfalls in eckigen Klammern angegeben wird. Arrays in .NET beginnen immer mit dem Index 0, die folgende Abfrage liefert also den Wert im zweiten Feld des Arrays:
ACHTUNG
int result = arr1[1]; // Index 1 entspricht dem zweiten Wert im Array
Vor allem Umsteiger von Visual Basic müssen hier aufpassen, denn dort wird bei der Deklaration eines Arrays eben nicht die Anzahl der enthaltenen Elemente, sondern der Index des höchsten Elements angegeben. Dennoch beginnt das Array auch in Visual Basic .NET bzw. 2005 mit dem Indexwert 0.
Wenn bereits bei der Deklaration feststeht, welche Werte die Elemente des Arrays haben sollen, können diese sofort zugewiesen werden. In diesem Fall ist es nicht mehr notwendig, die Größe des Arrays anzugeben, da diese durch die Anzahl der übergebenen Elemente bestimmt wird. Der Operator new ist weiterhin notwendig, da es sich bei Arrays um Objekte handelt und daher eine Instanz erzeugt werden muss. Die Werte für die Elemente werden in geschweiften Klammern direkt hinter die Deklaration geschrieben. Eine solche Zuweisung muss zwingend bei der Deklaration erfolgen. int[] arr = new int[] { 1, 1, 2, 3, 5, 8 };
Das Beispiel initialisiert ein Array mit sechs Elementen des Typs int. Bei dieser Art der Initialisierung kann eine verkürzte Schreibweise angewendet werden, bei der die new-Klausel entfällt: int[] arr = { 1, 1, 2, 3, 5, 8 };
Sandini Bib
4 Datentypen
HINWEIS
Obwohl es sich bei den Elementen der hier als Beispiel verwendeten Arrays um Wertetypen handelt, die ja eigentlich vor ihrer ersten Verwendung initialisiert werden müssten, ist das bei einer Array-Deklaration nicht notwendig. Jeder Wert des Arrays wird automatisch mit dem Standardwert des jeweiligen Datentyps initialisiert (im Falle des Datentyps int ist das der Wert 0). Der Grund hierfür ist, dass es sich bei einem Array um einen Referenztyp handelt – die Initialisierung mit einem Standardwert für die enthaltenen Variablen ist hier der Standard.
ACHTUNG
100
Die Größe eines Arrays ist in .NET final. Visual-Basic-Entwickler kennen zwar die Anweisungen ReDim bzw. ReDim Preserve, mit denen die Größe eines Arrays nachträglich verändert werden kann, diese bewirken aber nichts anderes als dass ein neues Array (mit der neuen Größe) erzeugt wird und die Daten hinein kopiert werden. In C# existieren diese Anweisungen nicht, das Kopieren muss in einem solchen Fall von Hand geschehen.
4.5.2
Mehrdimensionale Arrays
Ein Array muss nicht zwangsläufig nur eine Dimension haben. Es ist beispielsweise auch denkbar, die Werte einer Tabelle in einem Array zu speichern. In diesem Fall werden zwei Dimensionen benötigt, eine für die Spalten und eine für die Zeilen. Die Deklaration eines mehrdimensionalen Arrays ist sehr ähnlich zur Deklaration eines eindimensionalen Arrays. Dass mehrere Werte angegeben werden, das Array also mehrere Dimensionen hat, wird durch ein Komma signalisiert: int[,] multiArray = new int[5, 7];
Diese Programmzeile deklariert ein Array mit fünf Spalten und jeweils sieben Zeilen (oder fünf Zeilen mit jeweils sieben Spalten, ganz wie Sie es sehen wollen). Weitere Dimensionen sind durch eine weitere Verwendung des Kommas möglich: int[,,,] multiArray = new int[2, 3, 2, 2];
HINWEIS
Das obige Array besitzt vier Dimensionen. Die Gesamtanzahl der Werte dieses Arrays ist also 24 (die Anzahl der Elemente der einzelnen Dimensionen wird multipliziert). Prinzipiell ist es möglich, ein Array mit so vielen Dimensionen wie gewünscht zu deklarieren. In der Regel machen mehr als drei Dimensionen allerdings kaum Sinn. Mit jeder weiteren Dimension wird ein Array schlechter durchschaubar.
Auch für mehrdimensionale Arrays gilt, dass den einzelnen Elementen bereits bei der Deklaration ein Wert zugewiesen werden kann. Auch hier geschieht dies durch Werte in geschweiften Klammern. Dimensionen und Werte werden dabei durch Kommata getrennt. Die Größe des Arrays wird durch die Anzahl der Werte festgelegt. int[,] arr = new int[,] { { 0, 1 },{ 2, 3 },{ 4, 5 } };
Sandini Bib
101
Arrays
Das deklarierte Array besitzt drei Dimensionen mit je zwei Werten. Die Werte zugeordnet zum jeweiligen Element sind: arr[0,0] arr[0,1] arr[1,0] arr[1,1] arr[2,0] arr[2,1]
: : : : : :
0 1 2 3 4 5
Wieder ist auch hier die verkürzte Schreibweise zulässig. Das gleiche Array hätte also auch folgendermaßen deklariert werden können: int[,] arr = { { 0, 1 },{ 2, 3 },{ 4, 5 } };
4.5.3
Ungleichförmige Arrays
Alle oben genannten Arrays haben eine Gemeinsamkeit: Sie sind gleichförmig. Jede Dimension hat die gleiche Anzahl Elemente. Deklariert man beispielsweise ein Array folgendermaßen: int[,] arr = new int[2, 2];
dann besitzt dieses Array zwei Dimensionen mit je zwei Werten.
HINWEIS
Es ist mit C# allerdings auch möglich, Arrays zu deklarieren, bei denen die Anzahl der Elemente pro Dimension unterschiedlich ist. Solche Arrays nennt man dann ungleichförmige oder jagged Arrays. In der Online-Hilfe des Visual Studios wird dieser Arraytyp als »verzweigtes Array« bezeichnet. Der Begriff »ungleichförmig« drückt aber besser aus, worum es sich handelt. Wenn Sie sich das Aussehen eines solchen Arrays auf einem Stück Papier aufzeichnen, werden Sie sehr wohl eine ungleichförmige Struktur, aber auf keinen Fall eine Verzweigung feststellen.
Ein ungleichförmiges Array wird als »Array eines Arrays« deklariert. Derartige Arrays sind allerdings mit Vorsicht zu genießen. Es gibt die Möglichkeit, die Verwendung ist aber häufig nicht notwendig oder sinnvoll. Bei der Deklaration eines ungleichförmigen Arrays wird zunächst die Anzahl der Elemente der ersten Dimension festgelegt. Danach erfolgt die Initialisierung der Elemente mit jeweils einem weiteren Array, für das dann die Größe festgelegt wird: int[][] myArray = new int[2]; int[0] = new int[3]; int[1] = new int[5]; int[2] = new int[7];
Sandini Bib
4 Datentypen
102
Da es sich dabei eigentlich um Deklarationen herkömmlicher Arrays handelt, ist es auch möglich, die Werte gleich bei der Deklaration festzulegen. Das funktioniert dann analog zu einem eindimensionalen Array: int[][] myArray = new int[2]; int[0] = new int[] { 1, 2, 4, 3 }; int[1] = new int[] { 2, 7 }; int[2] = new int[] {3, 9, 1, 2, 4, 2 };
Schließlich ist es auch möglich, die Initialisierung eines Arrays wie bei den anderen ArrayArten auch direkt an die Deklaration anzuhängen. Verkürzte Schreibweisen sind auch hier wieder erlaubt. Die Deklaration zweier (eigentlich gleicher) ungleichförmiger Arrays sieht folgendermaßen aus: int[][] myArray1 = new int[] { new int[3], new int[5], new int[7] }; int[][] myArray2 = { new int[3], new int[5], new int[7] };
Möglich, aber wiederum verkomplizierend, ist, statt eindimensionaler Arrays mehrdimensionale Arrays zu verwenden. Eine solche Deklaration könnte auf die gleiche Art wie schon beschrieben vorgenommen werden (nur eben mit einem mehrdimensionalen Array). Sie sehen aber sicherlich schon jetzt, dass ein »jagged« Array eine komplizierte Sache werden kann, weshalb diese Möglichkeit nur selten benutzt werden sollte.
4.5.4
Arbeiten mit Arrays
Arrayinformationen ermitteln Alle Arrays sind abgeleitet von der Klasse Array aus dem Namespace System. Damit besitzt jedes Array Methoden und Eigenschaften, mit deren Hilfe verschiedene Werte ermittelt oder Funktionen ausgeführt werden können. Die Größe eines Arrays kann beispielsweise über die Eigenschaft Length ermittelt werden: int[] myArray = new int[5]; Console.WriteLine( myArray.Length ); // Liefert den Wert 5. Length liefert die Anzahl aller Elemente des Arrays über alle Dimensionen hinweg. Diese angabe ist daher bei mehrdimensionalen Arrays oft wenig sinnvoll. Liegt ein mehrdimensionales oder ein ungleichförmiges Array vor, können Sie die Methode GetLength() verwenden, die die Größe einer bestimmten Array-Dimension zurückliefert. Auch hier gilt, dass die Zählung bei 0 beginnt. int[,] myArray = new int[4, 5]; int lengthZero = myArray.GetLength( 0 ); // Liefert die Länge der Dimension 0, also 4
Sandini Bib
103
Arrays
HINWEIS
Die Anzahl der Dimensionen eines Arrays ist ebenfalls sofort verfügbar. Die entsprechende Eigenschaft heißt Rank. Da ein Array eine Klasse ist, können Sie auch auf einfache Art und Weise kontrollieren, ob das Array überhaupt bereits initialisiert wurde: bool arrayIsInitialized = ( aArray != null );
Arrays löschen Auch die Klasse Array selbst bietet einige statische Methoden zur Bearbeitung von Arrays. Eine dieser Methoden ist Clear(), die zum Löschen eines Arrays dient. Damit ist nicht gemeint, das Array aus dem Speicher zu löschen, sondern die einzelnen Elemente auf ihren Standardwert zurückzusetzen. Die Methode erwartet das Array, die Nummer des Elements, ab dem gelöscht werden soll, und die Anzahl der zu löschenden Elemente. Array.Clear( myArray, 3, 6 );
// Löscht 6 Elemente ab dem dritten Element
Arrays kopieren Es gibt zwei Arten, ein Array zu kopieren. Die erste Möglichkeit ist eine Kopie des gesamten Arrays, die zweite Möglichkeit das Kopieren nur bestimmter Elemente eines Arrays. Zum Kopieren des gesamten Arrays ist die Methode Clone() zuständig. Sie liefert eine Instanz der Klasse Object zurück, die die Array-Kopie enthält. Der Inhalt muss durch Casting wieder in ein Array des entsprechenden Typs konvertiert werden. int[] arr = new int[3]; arr[0] = 3; arr[1] = 4; arr[2] = 5;
HINWEIS
int[] arr2 = (int[])arr.Clone(); Clone() erzeugt eine so genannte flache Kopie (shallow copy) eines Arrays. Das be-
deutet, dass lediglich die Elemente kopiert werden, nicht aber Objekte, auf die diese Elemente unter Umständen verweisen. Damit verweisen Elemente in einem kopierten Array auf die gleichen Objekte wie die Elemente des Ursprungsarrays.
Das Kopieren einzelner Bestandteile eines Arrays in ein anderes geschieht über die Methode Copy() der Klasse Array. Copy() liefert zwei Möglichkeiten, Elemente zu kopieren. Bei der ersten Möglichkeit wird eine bestimmte Anzahl Elemente von einem in das andere Array transferiert. Bei der zweiten Möglichkeit ergeben sich weitere Parameter. Hier kann angegeben werden, wie viele Elemente ab welcher Position im ersten Array in das zweite Array kopiert werden. Weiterhin wird auch angegeben, an welche Position im zweiten Array sie kopiert werden.
Sandini Bib
104
4 Datentypen
Das folgende Beispiel zeigt die Anwendung dieser Methoden: int[] arr = new int[10]; ... // Befüllen der Elemente int[] arr2 = new int[5]; // Kopieren von zwei Elementen beginnend bei Element 0: Array.Copy( arr, arr2, 2 ); // Kopieren von Elementen von einem bestimmten Index: Array.Copy( arr, 1, arr2, 0, 2 );
Die letzte Anweisung kopiert zwei Elemente ab Position 1 im ersten Array nach Position 0 im zweiten Array.
Arrayinhalte umdrehen Die Methode Array.Reverse() dreht ein Array um. Das letzte Element wird zum ersten Element und umgekehrt (natürlich wird die gesamte Reihenfolge umgedreht, nicht nur zwei Elemente). Vor allem bei sortierten Arrays kann diese Möglichkeit sinnvoll sein, beispielsweise wenn man die Sortierreihenfolge umkehren will. Statt einer zeitaufwändigen Neusortierung kann das Array so einfach gedreht werden. int[] arr = new int[5]; // Werte zuweisen Array.Reverse( arr );
Arrays sortieren Die Methode Array.Sort() ermöglicht das Sortieren entweder eines kompletten Arrays oder nur eines Teils desselben. Diese Methode existiert in mehreren Varianten. Für Elemente, die selbst keine Vergleichsroutine implementieren (z.B. Instanzen eigener Klassen), können Sie mithilfe des Interfaces IComparer die Sortierung beeinflussen. Da Interfaces noch nicht besprochen wurden, erfolgt an dieser Stelle hierzu noch kein Beispiel. Mehr über Interfaces erfahren Sie in Kapitel 9 ab Seite 199. Das folgende Beispiel sortiert ein Array aus string-Elementen und gibt das sortierte Array auf der Konsole aus.
Sandini Bib
105
Arrays namespace StringSort { class Class1 { [STAThread] static void Main(string[] args) { // Deklaration string[] values = new string[5]; // Werte einlesen for ( int i=0; i<5; i++ ) { Console.Write( "Wert "+(i+1).ToString() + ": " ); values[i] = Console.ReadLine(); } // Sortieren Array.Sort( values ); // Ausgabe foreach ( string s in values ) Console.WriteLine(s); Console.ReadLine(); } } }
4.5.5
Syntaxzusammenfassung
Arrays deklarieren und verwenden Datentyp[] arr = new Datentyp[n]
deklariert und initialisiert ein Array mit n Elementen des Datentyps Datentyp. Die Werte der Elemente werden auf den Standardwert des Datentyps gesetzt.
Datentyp[,] arr = new Datentyp[Wert1, Wert2];
deklariert und initialisiert ein zweidimensionales, gleichförmiges Array.
Datentyp[][] arr;
deklariert ein ungleichförmiges oder, wie es in der Dokumentation heißt, »verzweigtes« Array. Die Anzahl der Elemente wird erst bei der Initialisierung festgelegt.
Datentyp[] arr = new Datentyp[] { Wert1, Wert2, Wert3 };
deklariert ein eindimensionales Array des Typs Datentyp mit drei Elementen, deren Werte durch Wert1, Wert2 und Wert3 vorgegeben sind.
Sandini Bib
4 Datentypen
106
Eigenschaften und Methoden der Klasse Array (aus System) Array.BinarySearch( arr, obj ) Array.BinarySearch( arr, o, ICompare )
durchsucht das Feld arr nach dem Eintrag obj. Die Methode setzt voraus, dass das Feld sortiert ist, und liefert als Ergebnis die Indexnummer des gefundenen Eintrags. Optional kann eine eigene Vergleichsmethode angegeben werden.
Array.Clear ( arr, n, m )
setzt m Elemente beginnend mit arr[n] auf den Standardwert des zugrunde liegenden Datentyps.
arr2 = (Datentyp[])( arr1.Clone () )
weist arr2 eine Kopie von arr1 zu.
Array.Copy ( arr1, n1, arr2, n2, m )
kopiert m Elemente vom Feld arr1 in das Feld arr2, wobei n1 der Startindex in arr1 und n2 der Startindex in arr2 ist.
arr = Array.CreateInstance ( Typ, n [,m [,o]] )
erzeugt ein Feld der Größe (n,m,o), wobei in den einzelnen Elementen Objekte des Typs type gespeichert werden können.
arr.GetLength ( dimension );
ermittelt die Anzahl der Elemente der Dimension dimension des Arrays arr.
arr.GetUpperBound ( dimension );
liefert die obere Grenze der Dimension dimension des Arrays arr.
arr.GetLowerBound( dimension );
liefert die untere Grenze der Dimension dimension des Arrays arr. Diese Funktion ist zwar selten nützlich, es kann jedoch vorkommen, dass ein Array nicht die Untergrenze 0 hat.
Array.Reverse ( arr )
vertauscht die Reihenfolge der Elemente des Arrays arr.
arr.SetValue ( data, n [,m [,o]] )
speichert im Element arr(n, m, o) den Wert data.
Array.Sort( arr [,ICompare] )
sortiert arr (unter Anwendung der Vergleichsfunktion eines IComparer-Objekts, falls angegeben).
arr.Length
ermittelt die Gesamtzahl der Elemente des Arrays arr in allen Dimensionen.
arr.Rank
gibt die Anzahl der Dimensionen des Arrays arr an.
4.6
Aufzählungstypen (enum)
4.6.1
Grundlagen
Aufzählungen (Enumerationen oder kurz Enums) dienen dazu, mehrere konstante Werte zu gruppieren. Der Vorteil dieser Methode ist, dass die Konstanten in einem Datentyp gekapselt sind und somit als Klartext verwendet werden können. Da es sich um einen eigenständigen Datentyp handelt, kann die Deklaration auch außerhalb einer Klasse geschehen. Enums können damit global zur Verfügung gestellt werden.
Sandini Bib
Aufzählungstypen (enum)
107
Die Verwendung von Aufzählungen erhöht die Lesbarkeit eines Programms. Die Zeile aDay = WeekDays.Monday;
ist leichter zu lesen als die Zeile aDay = 1;
Leichter zu lesen bedeutet in diesem Falle, dass derjenige, der die Zeile liest, sofort weiß, welcher Tag denn jetzt wirklich zugewiesen wird. Um den eigentlichen Wert muss er sich nicht mehr kümmern. Wenn die Aufzählung konstant über das Programm hinweg verwendet wird, sind die Werte immer korrekt, aber vor allem auch immer lesbar. Da, wie schon öfter angesprochen, in .NET alles eine Klasse ist, verwundert es nicht, dass es auch für Aufzählungen eine Basisklasse gibt. Diese befindet sich im Namespace System und heißt Enum. Der Unterschied zum Schlüsselwort enum besteht nur darin, dass der erste Buchstabe großgeschrieben wird. Die Klasse Enum bietet einige Methoden, die im Zusammenhang mit Aufzählungen nützlich sind.
HINWEIS
Da es sich bei einem enum um einen benutzerdefinierten Datentyp handelt, kann dieser sowohl außerhalb einer Klasse als auch innerhalb einer Klasse deklariert werden. Für einen enum, der innerhalb einer Klasse deklariert wird, gilt, dass es sich dabei immer um einen statischen Typ handelt, d.h. der Zugriff erfolgt über die Klasse selbst (nicht über eine Instanz der Klasse).
4.6.2
Das .NET Framework bietet in den Namespaces System.Collections, System. Collections.Generic und System.Collections.Specialized eine umfangreiche Anzahl verschiedener Listen an (z.B. einen Stack, eine ArrayList usw.). Diese Listen werden oftmals ebenfalls als Aufzählungen bezeichnet, was eigentlich nicht richtig ist. In diesem Buch wird daher streng zwischen den Begriffen »Liste« bzw. »Collection« und »Aufzählung« unterschieden.
Deklaration und Anwendung
Die Deklaration einer Aufzählung hat folgende Syntax: enum [:] { <enum-Liste> }
Der Datentyp ist optional, standardmäßig wird der Datentyp int verwendet. Die Deklaration einer einfachen Aufzählung sieht folgendermaßen aus: enum WeekDays { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday }
Sandini Bib
108
4 Datentypen
Dabei wird WeekDays.Sunday automatisch der Wert 0 zugewiesen und für jeden weiteren Eintrag um eins erhöht. WeekDays.Monday enthält dementsprechend den Wert 1, WeekDays. Tuesday den Wert 2 usw. Die Zuweisung eines anderen Datentyps als Grundlage für die Aufzählung ist ebenfalls möglich. Es muss sich dabei um einen zählbaren Datentyp, also einen der Datentypen handeln, die ganze Zahlen repräsentieren. Wie aus der Syntaxbeschreibung folgt, wird dieser nach dem Bezeichner angegeben: public enum WeekDays : long { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday Saturday }
Der Datentyp ist jetzt als long (System.Int64) festgelegt. Die Werte innerhalb der Aufzählung sind allerdings die gleichen geblieben, es wird mit 0 begonnen und dann in Einerschritten weitergearbeitet.
Benutzerdefinierte Werte Die verwendeten Werte sind nicht verbindlich. Für die Konstanten von Aufzählungen gilt, dass sich der Wert auch individuell festlegen lässt. Dabei sind mehrere Szenarien möglich: f Nur der erste Wert wird verbindlich festgelegt. Alle anderen Werte werden automatisch zugewiesen, indem der Initialwert jeweils um eins erhöht wird. f Alle Werte werden verbindlich zugewiesen, jeder Wert ist eindeutig. f Alle Werte werden verbindlich zugewiesen, wobei Werte doppelt vorkommen. Bei einer Aufzählung, die Monate darstellen soll, könnte es beispielsweise nützlich sein, die Anzahl der Tage des jeweiligen Monats als Wert festzulegen. Dass es dabei zu Dopplungen kommt, ist in diesem Fall nicht relevant, da von außen nur die Konstanten sichtbar sind. public enum Months { Januar = 31, Februar = 28, Maerz = 31, April = 30, Mai = 31, Juni = 30, Juli = 31, August = 31,
Sandini Bib
Aufzählungstypen (enum)
109
September = 30, Oktober = 31 November = 30, Dezember = 31
HINWEIS
}
Bei der Zuweisung mehrerer gleicher Werte an verschiedene Konstanten ist allerdings Vorsicht geboten. Diese Vorgehensweise ist zwar hilfreich an den Stellen, an denen es angebracht und gewünscht ist, kann aber zu Fehlern an den Stellen führen, wo die Werte wirklich eindeutig sein sollen. Da es eine korrekte und erlaubte Vorgehensweise ist, wird kein Fehler angezeigt.
Es ist nicht notwendig, alle Werte explizit zuzuweisen. Beispielsweise könnte es möglich sein, dass die konstanten Werte statt automatisch mit 0 mit einer anderen Zahl beginnen sollen. In diesem Fall genügt es, dem ersten Element eine Zahl zuzuweisen, C# erhöht diesen Wert dann für jedes nachfolgende Element um 1. public enum Quality { LowQuality = 1, // hat Wert 1 MediumQuality, // hat automatisch Wert 2 HighQuality, // hat automatisch Wert 3 }
Eine solche Zuweisung ist auch innerhalb der Aufzählung möglich. public enum Quality { LowQuality, // Wert 0 MediumQuality = 10, // Wert 10 HighQuality // automatisch Wert 11 }
4.6.3
Bitfelder
Normalerweise schließen sich die Werte einer Aufzählung gegenseitig aus, d.h. es kann immer nur ein Wert relevant sein. Es gibt aber Situationen, in denen durchaus mehrere Werte einer Aufzählung gleichzeitig Anwendung finden können. Ein solches Beispiel ist die Aufzählung FileAttributes aus dem Namespace System.IO. Diese Aufzählung repräsentiert die verschiedenen Attribute einer Datei, und eine Datei kann ja bekanntlich mehrere Attribute aufweisen. Alles was zur Umwandlung einer »normalen« Aufzählung in ein Bitfeld zu tun ist, ist das Attribut Flags zu verwenden. Bei Attributen handelt es sich um Klassen, die die Eigenschaften beispielsweise von Datentypen erweitern. Die Deklaration eines solchen Bitfelds sieht folgendermaßen aus: [Flags()] enum Bitfeld { ... }
Sandini Bib
110
4 Datentypen
Damit die Auswertung eines solchen Bitfelds funktioniert, müssen Sie als Werte Zweierpotenzen zuweisen. Nur dann kann die Laufzeitumgebung die einzelnen Werte wirklich eindeutig unterscheiden. Im Falle des Bitfelds FileAttributes könnte das z.B. folgendermaßen aussehen: [Flags()] public enum FileAttributes { Archive = 1, Hidden = 2, Readonly = 4, System = 8 }
Sie können zur Zuweisung auch die bereits deklarierten Konstanten verwenden. Wenn Sie beispielsweise einen Wert All hinzufügen wollen, der alle Attribute zurückliefert, können Sie das folgendermaßen tun: [Flags()] public enum FileAttributes { Archive = 1, Hidden = 2, Readonly = 4, System = 8, All = Archive | Hidden | Readonly | System }
Wertzuweisung und -vergleich Das Zuweisen eines Werts an ein Bitfeld gestaltet sich etwas anders als bei einer herkömmlichen Aufzählung. Da dort die Werte eindeutig sind, genügt eine Zuweisung der Art WeekDays myDay = WeekDays.Monday;
Bei Bitfeldern ist es nicht ganz so einfach, da eine solche Zuweisung einen bereits vorhandenen Wert überschreiben würde. Um also einen Wert hinzuzufügen, muss der Operator | (logisches Oder) herangezogen werden: FileAttributes myAttributes = FileAttributes.Archive; // Hinzufügen eines weiteren Bits myAttributes = myAttributes | FileAttributes.Hidden;
Die Funktionsweise dieser Verknüpfung ist recht einfach zu verstehen: Der Initialwert wird mit einem weiteren Wert logisch oder-verknüpft. Im Resultat ist damit jedes Bit gesetzt, das entweder im ersten oder im zweiten Wert 1 ist: Erster Wert: 0001 Zweiter Wert: 0010 Resultat: 0011
Sandini Bib
Aufzählungstypen (enum)
111
Bei der Kontrolle muss immer darauf kontrolliert werden, ob ein bestimmtes Bit gesetzt ist. Das geschieht durch den Operator & (logisches Und). // Kontrolle auf ein Attribut if ( ( myAttributes & FileAttributes.Hidden ) = FileAttributes.Hidden ) // FileAttributes.Hidden ist gesetzt.
Die Kontrolle basiert auf binärer Logik. Beim und-Vergleich enthält das Resultat jedes Bit, das in beiden Werten gesetzt ist. Dieses Resultat muss daher zwingend dem Wert entsprechen, auf den verglichen wird, wenn das entsprechende Bit gesetzt ist.
HINWEIS
Erster Wert: 0011 Zweiter Wert: 0010 Resultat: 0010
4.6.4
Weitere Informationen zu Attributen (die häufig auch als Meta-Attribute bezeichnet werden, da durch sie die Metadaten einer Assembly erweitert werden) finden Sie in Kapitel 10 ab Seite 215. Dort erfahren Sie auch, wie Sie eigene Attribute erstellen und verwenden können.
Arbeiten mit Aufzählungen
HINWEIS
Aufzählungen sind generell von der Klasse Enum aus dem Namespace System abgeleitet. Diese Klasse bietet einige statische Methoden, mit denen Sie mehr Informationen über Aufzählungen ermitteln bzw. deren Verwendung optimieren können. In diesem Abschnitt werden einige dieser Methoden erläutert. Manchmal ist es in einem Fachbuch nicht ganz einfach, Vorgehensweisen zu beschreiben, ohne ein etwas detaillierteres Wissen als zu diesem Zeitpunkt vorhanden vorauszusetzen. So ist es auch hier. Einige Dinge, die in diesem Abschnitt vorausgesetzt werden, werden erst in den folgenden Kapiteln genauer erklärt. Dieser Abschnitt richtet sich daher an diejenigen, die bereits ein wenig Erfahrung mit C# gesammelt haben.
Als Basis für alle in der Folge beschriebenen Methoden dient die Aufzählung WeekDays, die wie folgt definiert ist: public enum WeekDays { Montag, Dienstag, Mittwoch, Donnerstag, Freitag, Samstag, Sonntag }
Sandini Bib
112
4 Datentypen
Namen und Werte ermitteln Die Methode Enum.GetNames() liefert alle Namen der in einer Aufzählung deklarierten Konstanten. Analog liefert die Methode Enum.GetValues() die dazugehörigen Werte. Beide werden gleich sortiert, sodass eine Ausgabe prinzipiell leicht möglich wäre. Sowohl GetValues() als auch GetNames() erwarten als Parameter den Typ der Aufzählung, der in diesem Beispiel mithilfe des Operators typeof ermittelt wird. Während GetNames() jedoch ein string-Array zurückliefert, liefert GetValues() eine Instanz der Array-Klasse. Das ist verständlich, denn der Datentyp ist ja nicht bekannt (es könnte sich z.B. um byte handeln). Daher ist ein wenig Umwandlungsarbeit angesagt. Über die Instanzmethode GetValue() der Klasse Array wird der Wert ermittelt. Dieser muss dann noch durch Casting in den korrekten Wert überführt werden, da GetValue() den Datentyp object zurückliefert. string[] names; Array values; names = Enum.GetNames( typeof( WeekDays ) ); values = Enum.GetValues( typeof( WeekDays ) ); for ( int i = 0; i < names.Length; i++ ) Console.WriteLine( "{0}:{1}", names[i], (int)values.GetValue( i ) );
Soll der Name eines bestimmten, vorhandenen Werts ermittelt werden, bietet sich die Methode Enum.GetName() an. Sie erwartet als Parameter den Typ der Aufzählung sowie den Wert des Elements, dessen Name ermittelt werden soll. WeekDays myDay = WeekDays.Mittwoch; Console.WriteLine( Enum.GetName( typeof( WeekDays ), myDay ) ); // liefert "Mittwoch"
Wert aus dem Namen ermitteln Die Verwendung von Aufzählungen ist häufig zum Füllen von Auswahlfeldern (Comboboxen) oder Listboxen interessant. Wenn ein solcher Wert ausgewählt wird, ist allerdings nur der Name des entsprechenden Aufzählungswerts bekannt, der dann üblicherweise auch noch als string vorliegt. Die Methode Enum.Parse() ermöglicht es, aus einem solchen String, der dem Namen eines der enthaltenen Elemente enspricht, den dazugehörigen Wert zu ermitteln. Über einen optionalen booleschen Parameter kann zusätzlich angegeben werden, ob Groß-/Kleinschreibung berücksichtigt werden soll oder nicht. Das folgende Beispiel zeigt, wie der entsprechende Wert ermittelt werden kann. string myDay = ComboBox1.Text; int theValue = (int)( Enum.Parse( typeof( WeekDays ), myDay, true ) ); Console.WriteLine( "Wert von {0}: {1}", myDay, theValue );
Da ein Wert des Typs object zurückgeliefert wird, muss hier ein Casting durchgeführt werden. Alternativ könnte aber auch nach WeekDays gecastet werden:
Sandini Bib
113
Aufzählungstypen (enum) WeekDays theValue = (WeekDays)( Enum.Parse( typeof( WeekDays ), myDay, true ) );
Parse() kommt auch mit Bitfeldern zurecht. In diesem Fall müssen alle Werte innerhalb des Strings übergeben werden, getrennt durch Komma. Der zurückgelieferte Wert entspricht dann einem Bitfeld, in dem die angegebenen Werte gesetzt sind. Bei folgendem Bitfeld: [Flags()] public enum Privileges { Read, Write, Search }
können Werte auf die folgende Weise zugewiesen und kontrolliert werden: Privileges priv; priv = (Privileges)( Console.WriteLine( ( Console.WriteLine( ( Console.WriteLine( (
Enum.Parse( typeof( Privileges ),"Read, Write" ) ); priv & Privileges.Read ) == Privileges.Read ); priv & Privileges.Write ) == Privileges.Write ); priv & Privileges.Search ) == Privileges.Search );
Die Ausgabe ist: true true false
Kontrollieren, ob ein Wert definiert ist Zum Überprüfen, ob ein bestimmter Wert in der Aufzählung enthalten ist, bietet die Klasse Enum die Methode IsDefined() an, die einen booleschen Wert zurückliefert. Übergeben werden der Typ der Aufzählung und der zu ermittelnde Wert. IsDefined() ist grundsätzlich nicht geeignet für Bitfelder. bool check = Enum.IsDefined( typeof( WeekDays ), 5 );
4.6.5
Syntaxzusammenfassung
Aufzählungen deklarieren und verwenden enum AufzType { element1 [ = wert1 ] element2 [ = wert2 ] ... }
deklariert eine Aufzählung. Den Elementen werden automatisch durchlaufende Zahlen zugewiesen, wenn Sie nicht explizit eigene Werte angeben. Der Datentyp kann optional angegeben werden und muss ein ordinaler Typ sein (int, long, byte oder short). Standard für alle Aufzählungen ohne Datentyp ist int.
Sandini Bib
4 Datentypen
114
Aufzählungen deklarieren und verwenden [Flags()] enum AufzType { element1 [ = 1 ] element2 [ = 2 ] element3 [ = 4 ] ... }
deklariert ein Bitfeld, d.h. eine Aufzählung, bei der mehrere Werte angenommen werden können. Wenn explizit Werte angegeben werden, sollte es sich um Zweierpotenzen handeln, in jedem Fall aber müssen sie eindeutig sein (wegen der Kontrolle).
Eigenschaften und Methoden der Klasse Enum (aus System) Enum.GetNames ( typeof( AufzType ) );
liefert ein Zeichenkettenfeld, das die Namen aller Konstanten der Aufzählung AufzType enthält.
Enum.IsDefined ( typeof( AufzType ), n );
testet, ob n ein gültiger Wert einer Enum-Konstante von AufzType ist.
aufzObj = (AufzType)( Enum.Parse ( typeof( AufzType ), s [,true|false] );
wertet die Zeichenkette s aus und liefert die entsprechende Enum-Konstante von AufzType. AufzObj ist eine Instanz von AufzType.
Enum.GetValues ( typeof( AufzType ) );
liefert alle Werte der Aufzählung AufzType.
Enum.GetName ( typeof( AufzType ), AufzObj );
liefert den Namen des Elements mit dem Wert AufzObj der Aufzählung AufzType.
Sandini Bib
5
Ablaufsteuerung
Wie jede andere Programmiersprache bietet auch C# einige Anweisungen zur Steuerung des Programmablaufs. In diesem Kapitel lernen Sie Schleifen, Verzweigungen und bedingte Zuweisungen kennen. Den Abschluss bildet eine Übersicht über die in C# vorhandenen Operatoren.
5.1
Verzweigungen
5.1.1
Die if-Anweisung
Die if-Anweisung dient dazu, innerhalb des Programms abhängig von einer Bedingung unterschiedliche Anweisungen oder Anweisungsblöcke auszuführen. Die grundsätzliche Syntax einer if-Anweisung sieht folgendermaßen aus: if ( ) { // Anweisungen } [ else { // Anweisungen } ]
Die Anweisungen im if-Block werden ausgeführt, wenn die Bedingung wahr ist, die Anweisungen im (optionalen) else-Block dann, wenn die Bedingung falsch ist. Ein Beispiel soll das verdeutlichen: Random rnd = new Random( DateTime.Now.Millisecond ); int a = rnd.Next( 101 ); if ( a > 50 ) { Console.WriteLine( "a ist größer als 50" ); } else { Console.WriteLine( "a ist kleiner oder gleich 50" ); }
Die Klasse Random dient dem Erzeugen von Zufallszahlen, die Anweisung rnd.Next( 101 ) liefert eine Zufallszahl kleiner als 101. In diesem Fall beinhalten der if- und der else-Block jeweils nur eine Anweisung. Die geschweiften Klammern können in einem solchen Fall auch weggelassen werden. Die folgenden Zeilen bewirken exakt das Gleiche: if ( a > 50 ) Console.WriteLine( "a ist größer als 50" ); else Console.WriteLine( "a ist kleiner oder gleich 50" );
Sandini Bib
116
5 Ablaufsteuerung
Oftmals werden die geschweiften Klammern allerdings allein aufgrund der besseren Übersichtlichkeit gesetzt. Sie sollten aufgrund der besseren Übersichtlichkeit und Lesbarkeit auf jeden Fall für alle Blöcke Klammern verwenden, wenn ein Block mehr als eine Anweisung beinhaltet. Ein else-Block kann auch eine weitere Bedingung beinhalten. Dazu formulieren Sie einfach eine weitere if-Anweisung direkt nach dem else. Da derart geschachtelte Verzweigungen mitunter recht komplex werden können, sollten Sie auf jeden Fall geschweifte Klammern verwenden. Dadurch stellen Sie sicher, dass Sie immer wissen, welche Anweisungen zu welchem if-Block gehören. if ( a > 50 ) { Console.WriteLine( "a ist größer als 50" ); } else if ( a == 50 ) { Console.WriteLine( "a ist gleich 50" ); } else { Console.WriteLine( "a ist kleiner 50" ); }
Bedingungen Die Bedingung muss immer einen booleschen Wert zurückliefern. C# ist typsicher, damit werden Zahlenwerte anders als in C++ nicht automatisch in boolesche Werte umgewandelt. Mehrere Anweisungen können mit den Operatoren && (und) bzw. || (oder) miteinander verknüpft werden. Im Falle des &&-Operators wird der zweite Ausdruck nur dann ausgewertet, wenn der erste Ausdruck den Wert true zurückliefert. Diese Art der Auswertung nennt man auch Short-Circuit-Evaluation. if ( ( a > 1 ) && ( a < 10 ) ) Console.WriteLine( "a liegt zwischen 1 und 10" );
Der logische Operator & funktioniert ebenfalls, in diesem Fall werden aber alle Operanden ausgewertet. Aus diesem Grund hat sich die Verwendung des doppelten kaufmännischen Und als Standard durchgesetzt, der Operator & wird zum Zweck der Verknüpfung von Bedingungen eigentlich nie eingesetzt. Auch für den ||-Operator gilt diese Art der Auswertung. Hier aber im umgekehrten Fall. Ist der erste Operand true, wird nicht weiter ausgewertet, da eine oder-Verknüpfung dann true ist, wenn wenigstens einer der Operanden diesen Wert liefert. Hier funktioniert auch der logische Operator |, der aber immer alle Operanden auswertet. Aus diesem Grund wird | eigentlich nie verwendet, sondern immer ||.
Sandini Bib
HINWEIS
Verzweigungen
117
Sie sollten der Übersichtlichkeit halber die Vergleiche immer in Klammern setzen, also if ( ( a > 1 ) && ( a < 10 ) ) ...
statt if ( a > 1 && a < 10 ) ...
5.1.2
Die switch-Anweisung
Die switch-Anweisung dient zum Ausführen verschiedener Anweisungen bzw. Anweisungsblöcke abhängig vom Wert einer Variablen. Die grundsätzliche Syntax der switchAnweisung sieht folgendermaßen aus: switch ( ) { case <Wert1>: // Anweisungen // Sprung-Anweisung case <Wert2>: // Anweisungen // Sprung-Anweisung [default: // Anweisungen // Sprung-Anweisung] }
Als Sprung-Anweisung wird üblicherweise break verwendet, was zur Folge hat, dass die switch-Konstruktion verlassen und mit der nächsten Anweisung nach dem switchKonstrukt fortgefahren wird. Es kann sich aber auch um eine goto-Anweisung handeln, wobei als Ziel ein Label oder ein anderer case-Block infrage kommen, oder natürlich auch um die Anweisung return, die die Methode verlässt und ggf. einen Wert zurückliefert. Der optionale default-Block kommt dann zum Einsatz, wenn keiner der Werte aller caseAnweisungen zum Ausdruck passt. Die Sprunganweisung am Ende eines case-Blocks ist nicht optional, kann aber weggelassen werden um mehrere case-Blöcke zusammenzufassen. Das folgende Beispiel zeigt eine solche switch-Anweisung: int a = Int32.Parse( Console.ReadLine() ); switch ( a ) { case 1: case 2: case 3: Console.WriteLine("a ist zwischen 1 und 3"); break;
Sandini Bib
5 Ablaufsteuerung
118 case 4: Console.WriteLine("a ist 4"); break; case 5: case 6: Console.WriteLine("a ist 5 oder 6"); break; default: Console.WriteLine("a ist größer als 6"); break; }
switch funktioniert auch mit Strings, wobei zwischen Groß- und Kleinschreibung unter-
schieden wird. In diesem Fall würde eine Auswertung folgendermaßen aussehen: switch ( s ) { case "1": case "2": Console.WriteLine( "s ist entweder 1 oder 2" ); break; ... }
5.1.3
Die bedingte Zuweisung (tenärer Operator)
Eigentlich handelt es sich bei der bedingten Zuweisung nicht um eine Art der Verzweigung, sie kann aber die Verwendung einer if-Konstruktion in manchen Fällen ersetzen (nämlich dann, wenn die if-Anweisung zur Zuweisung eines Werts verwendet wird). Die Verwendung ist relativ einfach, die resultierende Anweisung kann jedoch zu Verwirrung führen und sollte daher nur mit Bedacht (also nur bei eindeutigen Zuweisungen) eingesetzt werden. Die grundsätzliche Syntax sieht folgendermaßen aus: = ? <Wert1> : <Wert2>;
Ist die Bedingung true, wird der erste Wert zugewiesen, ansonsten der zweite. Ein typisches Beispiel, bei dem eine bedingte Zuweisung Verwendung finden kann, ist das folgende: private int Compare( int a, int b ) { return ( a < b ) ? -1 : 1; }
Damit ersetzt diese bedingte Zuweisung eine if-Konstruktion wie die folgende: if ( a < b ) return -1; else return 1;
Sandini Bib
HINWEIS
Verzweigungen
5.1.4
119
Setzen Sie die bedingte Zuweisung mit Vorsicht ein. Sie kann auch verwendet werden, um abhängig von einer Bedingung Methoden aufzurufen (die dann allerdings einen Wert zurückliefern müssen, da ja zwangsläufig eine Zuweisung vorgenommen wird). Dadurch wird der Quelltext mit der Zeit sehr unübersichtlich, was eine schlechte Wartbarkeit zur Folge hat.
Die goto-Anweisung
Eigentlich handelt es sich bei der goto-Anweisung nicht um eine Verzweigung, sondern eher um die Möglichkeit, einen absoluten Sprung zu einer bestimmten Position innerhalb des Quelltexts vorzunehmen. Vor vielen Jahren war goto ein häufig verwendetes Sprachkonstrukt, mittlerweile wird diese Anweisung allerdings kaum noch verwendet. Sie ist auch hier nur der Vollständigkeit halber erwähnt. Die Syntax der goto-Anweisung ist sehr einfach. Der Sprung führt zu einem so genannten Label, das innerhalb des Quelltextes festgelegt wird: goto Label;
Ein kurzes Beispiel zeigt die Anwendung der goto-Anweisung: public bool FindNumber( int[] theArray, int theNumber ) { foreach ( int i in theArray ) if ( i == theNumber ) goto Found; return false; // Hier kommt das Label "Found" Found: return true; }
Wichtig bei der Verwendung von goto ist, dass man nicht in eine Schleife hineinspringen kann (wohl aber aus einer Schleife heraus). Die folgende Anweisung ist daher nicht möglich: public void WriteNumbers( int[] theArray ) { foreach ( int i in the Array ) { if ( i == 50 ) { goto PrintNum; } }
Sandini Bib
5 Ablaufsteuerung
120 for ( int u=0; u
Es gibt nach wie vor Programmierer, die Gutes an der goto-Anweisung finden. Das soll Ihnen unbenommen bleiben. Allerdings wird durch die Verwendung von goto der Code schlechter wartbar. Außerdem kann alles, was mit goto programmiert wurde, auch ohne dieses Konstrukt programmiert werden – vermutlich in den meisten Fällen sogar besser.
5.2
Schleifen
5.2.1
Die for-Schleife
Schleifen dienen dazu, eine Anweisung oder einen Anweisungsblock wiederholt auszuführen. Die for-Schleife ist die flexibelste Schleifenform in C#. Mit ihr können Sie sowohl eine feste Anzahl an Schleifendurchläufen bestimmen, als auch abhängig von einer Bedingung die Schleife abbrechen. Die grundsätzliche Syntax der for-Schleife sieht folgendermaßen aus: for ( ; ; ) { // Anweisungen }
Bei der Initialisierung wird der Wert einer Schleifenvariable oder Laufvariable festgelegt. Die Schleifenvariable kann an dieser Stelle auch deklariert werden, womit sich ihr Gültigkeitsbereich nur auf den Schleifenkörper erstreckt. Die Schleife wird abgebrochen, wenn die Kontrolle der Bedingung false ergibt. Der Inkrement-Teil dient dem Erhöhen der Schleifenvariablen um einen beliebigen Wert bzw. dem Erniedrigen, beides ist möglich. Enthält der Schleifenkörper nur eine Anweisung, können die geschweiften Klammern auch weggelassen werden. Das folgende Beispiel zeigt eine herkömmliche for-Schleife, wie sie am häufigsten eingesetzt wird. for ( int i = 1; i < 11; i++ ) { Console.WriteLine( i ); }
Der Operator ++ erhöht einen Wert um 1. Die Anweisungen im Kopf der Schleife sind optional. Die folgende Schleife würde ebenfalls funktionieren:
Sandini Bib
121
Schleifen int i = 1; for ( ; i < 11; ) { Console.WriteLine( i ); i++; }
break und continue Die Anweisung break dient dem Verlassen der Schleife, die Anweisung continue startet den nächsten Schleifendurchlauf. Wird die Schleife mit break verlassen, dann wird das Programm mit der nächsten auf die Schleife folgenden Anweisung fortgesetzt. Damit kann die for-Schleife auch folgendermaßen programmiert werden: int i=1; for ( ; ; ) { Console.WriteLine( i ); i++; if ( i >= 10 ) break; }
Die folgende Schleife nutzt die Anweisung continue um nur Werte größer 5 auszugeben: for ( int i = 0; i < 11; i++ ) { if ( i <= 5 ) continue; Console.WriteLine( i );
HINWEIS
}
5.2.2
Grundsätzlich können Sie alle Schleifen auch mit einer Sprunganweisung verlassen. Die Anweisung return funktioniert auch, verlässt aber nicht nur die Schleife, sondern die gesamte Methode.
Die while-Schleife
Die while-Schleife ist eine so genannte abweisende Schleife. Abweisend bedeutet in diesem Fall, dass die Möglichkeit besteht, dass die Anweisungen im Schleifenkörper überhaupt nicht ausgeführt werden. Das ist der Fall, wenn die Bedingung im Schleifenkopf von Anfang an false liefert. Die grundsätzliche Syntax sieht folgendermaßen aus: while ( ) { // Anweisungen }
Sandini Bib
5 Ablaufsteuerung
122
Die Ausgabe der Zahlen 1 bis 10 wie in der im vorherigen Abschnitt vorgestellten forSchleife kann auch mit einer while-Schleife realisiert werden: int i=1; while( I < 11 ) { Console.WriteLine( i ); i++; }
Ebenso greifen auch hier die Anweisungen break und continue. break verlässt die Schleife, continue ermöglicht einen weiteren Schleifendurchlauf. Hierbei muss allerdings darauf geachtet werden, dass keine automatische Inkrementierung erfolgt. Die Gefahr, eine Endlosschleife zu programmieren, ist hierbei relativ groß. int i = 1; while ( i < 11 ) { if ( i < 5 ) { i++; continue; } i++; Console.WriteLine( i ); }
5.2.3
Die do-Schleife
Die do-Schleife ist eine nicht-abweisende Schleife. Die Anweisungen im Schleifenkörper werden mindestens einmal durchlaufen, bevor die Schleife beendet wird. Die Bedingung, die kontrolliert wird, steht am Ende der Schleife. Die Syntax der do-Schleife sieht folgendermaßen aus: do { // Anweisungen } while ( );
Auch bei der do-Schleife funktionieren die Anweisungen break und continue. Die gleiche Ausgabe der Zahlen 1 bis 10 diesmal mit einer do-Schleife: int i = 1; do { Console.WriteLine( i ); i++; } while ( i < 11 );
Auch hier muss darauf geachtet werden, dass bei der Verwendung von continue nicht etwa eine Endlosschleife gebildet wird.
Sandini Bib
Operatoren
5.2.4
123
Die foreach-Schleife
Die foreach-Schleife dient dazu, alle Elemente einer Liste oder eines Arrays zu durchlaufen. Innerhalb des Schleifenkörpers kann das jeweils aktuelle Element manipuliert werden. Die Syntax der Schleife sieht folgendermaßen aus: foreach ( [] in ) { // Anweisungen }
Die Laufvariable kann direkt im Kopf der Schleife deklariert werden. Wichtig bei der foreach-Schleife ist, dass die Elemente, die in der Liste enthalten sind, in den Datentyp der Laufvariablen konvertiert werden können. Eine typische foreach-Schleife zeigt das folgende Programmfragment. In einem Array sind Strings gespeichert, die jetzt nacheinander ausgegeben werden. Die Anzahl der Strings ist nicht bekannt, die foreach-Schleife durchläuft einfach alle Elemente des Arrays. private void PrintArray( string[] values ) { foreach ( string s in values ) { Console.WriteLine( s ); } }
Mit dieser Methode können Sie innerhalb Ihres Programms jedes beliebige Array aus string-Werten auf die Konsole (oder in ein Steuerelement) ausgeben.
Voraussetzungen Die Möglichkeit der Verwendung einer foreach-Schleife ist nicht immer gegeben. Die Liste muss die Methoden des Interface IEnumerable implementieren, damit sichergestellt ist, dass foreach diese Liste durchlaufen kann. Dazu kann entweder von IEnumerable abgeleitet werden (was zur Folge hat, dass garantiert jede der benötigten Methoden implementiert wird) oder die benötigten Methoden können ohne Ableitung von einem Interface implementiert werden. In .NET 2.0 existiert mit dem neuen Schlüsselwort yield eine sehr einfache Möglichkeit, foreach für eine selbst definierte Collection zu implementieren; mehr dazu in Abschnitt 13.4.2 ab Seite 302. Mehr über Interfaces erfahren Sie in Kapitel 9 ab Seite 199, mehr über Collections in Kapitel 13 ab Seite 281.
5.3
Operatoren
C# kennt mehrere Arten von Operatoren. Die meisten davon sind so genannte zweistellige Operatoren, d.h. sie erwarten zwei Argumente. Daneben gibt es noch einstellige und zusammengesetzte Operatoren.
Sandini Bib
5 Ablaufsteuerung
124
Die Operatoren in diesem Abschnitt sind nach ihrem Anwendungsgebiet aufgeschlüsselt, in arithmetische Operatoren, logische Operatoren, bitweise Operatoren und Vergleichsoperatoren. Außerdem gibt es in C# noch einige Schlüsselwörter, die ebenfalls als Operatoren fungieren.
HINWEIS
Am Anfang soll der Zuweisungsoperator = stehen, der eigentlich allein steht. Man könnte ihn in die Gruppe der zusammengesetzten Operatoren stellen, bei denen es sich ebenfalls um Zuweisungsoperatoren handelt. Er soll jedoch gesondert betrachtet werden. Die Funktionsweise dieses Operators ist die Zuweisung eines Werts.
5.3.1
Operatoren können in C# überladen werden, d.h. ihre Funktionsweise kann angepasst werden. Das ist nicht immer sinnvoll, kann aber im mathematischen Raum zur Erstellung von Zahlensystemen mit anderen als den herkömmlichen Gesetzen verwendet werden. Genaueres über die Operatorenüberladung erfahren Sie in Abschnitt 6.6 ab Seite 161.
Arithmetische Operatoren
Die meisten arithmetischen Operatoren sind bereits aus der Schule bekannt, nämlich die Operatoren für Addition, Subtraktion, Multiplikation und Division. Zusätzlich bietet C# noch Operatoren für Prä- und Postinkrement bzw. Prä- und Postdekrement. Der Datentyp ist bei solchen Operationen wichtig. C# liefert das Ergebnis immer in dem Datentyp zurück, der die höchste Genauigkeit/Größe bietet. Bei einer Division zwischen einem int- und einem double-Wert ist das Ergebnis demnach vom Typ double, bei einem int- und einem byte-Wert vom Typ int. Die folgende Tabelle liefert eine Übersicht über die arithmetischen Operatoren. Arithmetische Operatoren +
Addition zweier Werte. Im Falle des Datentyps string werden zwei Strings aneinander gereiht.
-
Subtraktion zweier Werte
/
Division zweier Werte
*
Multiplikation zweier Werte
++
Inkrement, Post- oder Präinkrement. a++ ergibt a und erhöht den Wert dann um eins. ++a erhöht a um eins und liefert dann den Wert.
--
Dekrement, Post- oder Prädekrement. a-- liefert a und erniedrigt a um eins, --a erniedrigt a um eins und liefert dann den Wert zurück.
%
Restwert. a%b ergibt den Rest der Division von a durch b
Sandini Bib
Operatoren
5.3.2
125
Vergleichsoperatoren
Vergleichsoperatoren liefern immer einen Wert vom Typ bool zurück. Sie finden Anwendung in Verzweigungen, können aber auch bei Zuweisungen verwendet werden, z.B. folgendermaßen: bool isEqual = false; isEqual = (a == b) isEqual ist true, wenn a gleich b ist. In diesem Fall wird davon ausgegangen, dass es sich bei a und b um Wertetypen handelt. Die folgende Tabelle zeigt alle Vergleichsoperatoren,
die in C# verfügbar sind. Vergleichsoperatoren ==
Vergleich auf Gleichheit
!=
Vergleich auf Ungleichheit
<
Kleiner
>
Größer
<=
Kleiner oder Gleich
>=
Größer oder Gleich
5.3.3
Logische Operatoren
Logische Operatoren werden verwendet um mehrere Vergleichsausdrücke zusammenzufassen. Die Operanden der logischen Operatoren müssen einen booleschen Wert zurückliefern. Die Verknüpfungen und bzw. oder werden in zwei Varianten zur Verfügung gestellt, die von der Funktion her gleich, vom Verhalten aber unterschiedlich sind. Die folgende Tabelle zeigt alle logischen Operatoren: Logische Operatoren !
not-Operator. Dieser Operator verkehrt das Ergebnis in das Gegenteil, aus true wird false und umgekehrt.
&&
und-Operator mit Short-Circuit-Evaluierung. Dieser Operator bildet eine und-Verknüpfung zweier boolescher Werte bzw. Ausdrücke, wobei der zweite und nachfolgende Ausdrücke nicht mehr ausgewertet werden, wenn der erste bereits false ergibt (denn dann kann der Gesamtausdruck nicht mehr true werden).
&
und-Operator ohne Short-Circuit-Evaluierung. Es wird immer der gesamte Ausdruck ausgewertet. Da dies weniger performant ist, wird diese Version fast nicht verwendet.
||
oder-Operator mit Short-Circuit-Evaluierung. Dieser Operator bildet eine oder-Verknüpfung zweier boolescher Werte bzw. Ausdrücke. Ist der erste Operand bereits true, wird der restliche Ausdruck nicht mehr ausgewertet, weil dann der gesamte Ausdruck zwangsläufig true sein muss.
Sandini Bib
5 Ablaufsteuerung
126
Logische Operatoren |
oder-Operator ohne Short-Circuit-Evaluierung. Es wird immer der gesamte Ausdruck ausgewertet.
^
Exklusiv-oder-Operator. a^b ergibt true, wenn beide Ausdrücke unterschiedliche boolesche Werte liefern.
5.3.4
Bitweise Operatoren
C# ist in der Lage, binäre Operationen durchzuführen. Die Operanden werden als binäre Werte angesehen, die Manipulationen, die diese Operatoren bewirken, beziehen sich auf die einzelnen Bits der Operanden. Die folgende Tabelle listet die bitweisen Operatoren auf. Bitweise Operatoren ~
Einerkomplement. Alle Bits des Operanden werden invertiert, aus 0 wird 1 und umgekehrt.
&
Bitweises Und. Jedes Bit, das in beiden Operanden 1 ist, ist auch im Ergebnis 1. Alle anderen ergeben 0.
|
Bitweises Oder. Jedes Bit, das in einem der Operanden 1 ist, ist auch im Ergebnis 1.
>>
Rechtsschieben. a >> b ergibt den Wert, der entsteht, wenn alle Bits von a um b Stellen nach rechts verschoben werden. Ist a ein vorzeichenbehafteter Datentyp, enthalten höherwertige leere Bits das Vorzeichenbit. Ist a nicht vorzeichenbehaftet, werden sie mit 0 aufgefüllt.
<<
Linksschieben. a << b ergibt den Wert, der entsteht, wenn alle Bits von a um b Stellen nach links verschoben werden. Die höherwertigen Bits werden verworfen, die niederwertigen mit 0 aufgefüllt.
5.3.5
Zusammengesetzte Operatoren
Zusammengesetzte Operatoren enthalten immer eine Rechenoperation gepaart mit einer Zuweisung. Die Anweisung a += b entspricht a = a + b. Die folgende Tabelle listet die zusammengesetzten Operatoren von C# auf. Zusammengesetzte Operatoren +=
Addition mit anschließender Zuweisung. a += b entspricht a = a + b
-=
Subtraktion mit anschließender Zuweisung. a -= b entspricht a = a - b
*=
Multiplikation mit anschließender Zuweisung. a *= b entspricht a = a * b
/=
Division mit anschließender Zuweisung. a /= b entspricht a = a / b
%=
Modulo mit anschließender Zuweisung. a %= b entspricht a = a % b
&=
Binäres Und mit anschließender Zuweisung. a &= b entspricht a = a & b
Sandini Bib
Operatoren
127
Zusammengesetzte Operatoren |=
Binäres Oder mit anschließender Zuweisung. a |= b entspricht a = a | b
^=
Einerkomplement mit anschließender Zuweisung. a ^= b entspricht a = a ^ b
>>=
Rechtsschieben mit anschließender Zuweisung. a >>= b entspricht a = a >> b
<<=
Linksschieben mit anschließender Zuweisung. a <<= b entspricht a = a << b
5.3.6
Sonstige Operatoren
Der new-Operator Der new-Operator dient dazu, ein Objekt zu erzeugen. Der zurückgelieferte Wert ist die Instanz der Klasse (des Referenztyps), die angegeben wird, oder, im Falle beispielsweise von Arrays, das Array-Objekt. Da fast alle Klassen im .NET Framework Referenztypen sind, kommt dieser Operator entsprechend häufig zum Einsatz.
Der typeof-Operator Der typeof-Operator mutet etwas seltsam an, muss ihm doch ein Datentyp übergeben werden, um den Typ dieses Datentyps zu ermitteln. Verständlicher wird dies erst, wenn man sich ansieht, wie dieser Datentyp repräsentiert wird, nämlich in Form eines Objekts des Datentyps Type, der im Namespace System deklariert ist. Type besitzt keinen öffentlichen Konstruktor, die Erstellung einer Instanz dieses Typs erfolgt daher über typeof. Wenn Sie den Typ eines Objekts zur Laufzeit herausfinden wollen, verwenden Sie einfach die Methode GetType(), die jeder Datentyp zur Verfügung stellt. Auch GetType() liefert eine Instanz der Klasse System.Type zurück.
Der is-Operator Der is-Operator findet hauptsächlich bei Vergleichen Anwendung. Mit ihm kann kontrolliert werden, ob ein Objekt von einem bestimmten Datentyp ist. Um beispielsweise zu überprüfen, ob das Objekt o vom Typ TextBox ist, kann der is-Operator verwendet werden: if ( o is TextBox ) { // Anweisungen }
Diese Art der Kontrolle wird häufig in Ereignisbehandlungsroutinen verwendet, um beispielsweise den Typ des Objekts zu überprüfen, das das Ereignis ausgelöst hat.
Der as-Operator Der as-Operator dient der Typumwandlung bei Referenztypen. Im Prinzip wird durch diesen Operator ein Casting ausgeführt, das allerdings nicht zu einer Fehlermeldung führt.
Sandini Bib
5 Ablaufsteuerung
128
Stattdessen wird bei einer fehlerhaften Konvertierung null zurückgeliefert, also der Wert, der für ein leeres Objekt steht. Die Verwendung des as-Operators ist einfach: private bool IsString( object o ) { string s = o as string; return ( s != null ); }
Das Gleiche könnte man auch folgendermaßen schreiben: private bool IsString( object o ) { return ( o is string ); }
Der ??-Operator Das doppelte Fragezeichen findet Anwendung bei Nullable Types. Wird der Wert eines Nullable Type einer Variablen zugeordnet, deren Datentyp nicht nullable ist, würde die Zuweisung eines leeren Werts (null) zu einer Exception führen. Mit dem ??-Operator ist es möglich, in diesem Fall einen Standardwert zuzuweisen: int? x = null; int y = x ?? -1;
// weist den Wert von x zu bzw. -1, wenn x == null
Zeigerbezogene Operatoren C# ermöglicht die Verwendung von so genanntem unsafe Code, in dem der Entwickler selbst darauf angewiesen ist, mit Zeigern, Referenzen und deren Inhalt zu arbeiten. Diese Art der Programmierung wird in diesem Buch allerdings nicht beschrieben. Das Feature ist zwar vorhanden, Basis für dieses Buch ist aber allein managed Code bzw. safe Code. Der Vollständigkeit halber sollen die Operatoren aber doch genannt werden. Zeigerbezogene Operatoren ->
Zugriff auf Member eines Objekts in unmanaged Code
&
Liefert in unmanaged Code die Adresse eines Zeigers
*
Zeiger auf einen Datentyp. Die Deklaration byte* b; deklariert b als einen Zeiger auf den Datentyp byte.
Unsafe Code wird durch das reservierte Wort unsafe eingeleitet. In solchen Codeabschnitten können Zeiger verwendet werden, die Garbage Collection greift hier jedoch nicht und auch die CLR kann solchen Code nicht überprüfen. Dass C# diese Möglichkeit unterstützt, ist zwar ein schönes Feature, wird aber vermutlich nicht sehr häufig Verwendung finden, im Gegensatz zu P/Invoke, wodurch betriebssystemspezifische Funktionen aufgerufen werden können.
Sandini Bib
6
Klassen und Objekte
Klassen und Objekte sind die Basis eines jeden C#-Programms, sogar eines jeden .NET Programms. Sie sind auch die Basiskonstrukte in sämtlichen objektorientierten Umgebungen. In C# bzw. .NET wird die Verwendung von Klassen erzwungen, da sie sämtliche Funktionalität enthalten. Methoden beispielsweise (in anderen Sprachen auch als Funktionen oder Prozeduren bekannt) sind immer Bestandteil einer Klasse und können nicht alleine auftreten. In diesem Kapitel werden die grundlegenden Elemente einer Klasse und der Umgang damit vorgestellt. Zum Verständnis der unterschiedlichen Möglichkeiten, die Klassen bzw. Objekte bieten, ist es allerdings notwendig, zunächst einen genaueren Blick auf die Basiskonzepte der Objektorientierung zu werfen.
6.1
Grundlagen der Objektorientierung
Objektorientierte Programmierung (OOP) erfordert häufig ein Umdenken, vor allem bei den Programmierern, die bisher noch nicht mit einer objektorientierten Sprache gearbeitet haben. Wirklich objektorientiert war bisher lediglich Java, viele andere Sprachen hatten zwar OOP-Eigenschaften (wie z.B. Klassen, Interfaces oder Vererbung), waren aber nicht konsequent auf Objektorientierung ausgelegt. Mit dem .NET Framework ändert sich das nun. Jede Sprache, die auf .NET basiert, ist zwangsläufig objektorientiert. Dementsprechend gilt auch, dass jeder Programmierer unter .NET objektorientiert arbeiten muss – es gibt eben nichts anderes. Grundsätzlich gibt es vier Konzepte, die für objektorientierte Programmiersprachen gelten: f Abstraktion, die Trennung von Konzept und Umsetzung, f Kapselung, die Zusammenfassung von Daten und dazugehöriger Funktionalität, f Polymorphie, die Fähigkeit eines Objekts, eine Instanz einer von seiner Klasse abgeleiteten Klasse aufzunehmen, und f Vererbung, die die Möglichkeit der Spezialisierung und die Erstellung einer Klassenhierarchie ermöglicht. Unter .NET sind diese vier Konzepte konsequent umgesetzt, und C# erfordert ihre konsequente Anwendung.
6.1.1
Abstraktion
Die Trennung von Konzept und Umsetzung ist in C# durch die Verwendung von Klassen und Objekten gelöst. Während die Klasse den grundsätzlichen Bauplan eines Objekts darstellt, ist das Objekt selbst eine konkrete Instanz, mit der gearbeitet werden kann. Der Vergleich mit dem täglichen Leben liegt nahe. Eine Klasse kann mit dem Bauplan eines Fahr-
Sandini Bib
6 Klassen und Objekte
130
HINWEIS
zeugs verglichen werden, ein entsprechendes Objekt mit dem tatsächlichen Fahrzeug, das sich auf der Straße bewegt. Dabei kann von einer Klasse nicht nur ein Objekt erzeugt werden, sondern mehrere – ganz wie im richtigen Leben. Und diese Objekte können sich auch unterscheiden, also unterschiedliche Eigenschaften haben, obwohl sie alle vom gleichen Typ sind.
6.1.2
Eine Klasse ist gleichzeitig ein Datentyp oder ein Typ (genauer gesagt ein Referenztyp). Im allgemeinen Sprachgebrauch sagt man, ein Objekt ist vom Typ x oder ein Objekt ist eine Instanz der Klasse x.
Kapselung
Das Konzept der Kapselung fasst zusammen, was zusammengehört, nämlich Daten und die Methoden, die mit den Daten arbeiten. In strukturierten Programmiersprachen sind diese beiden Konstrukte in der Regel voneinander getrennt – es gibt Prozeduren, die mit bestimmten Datentypen arbeiten können, die Datentypen selbst sind jedoch autonom. Ganze Datensätze (z.B. eine Adresse) werden ebenfalls nur als Daten zusammengefasst, auf die dann aus dem ganzen Programm zugegriffen werden kann, und zwar auch aus den Programmteilen, für die das eigentlich nicht der Fall sein dürfte. Das Prinzip der Kapselung verhindert es, dass Daten »allgemeingültig« sind. Sie sind (in der Regel) in Klassen bzw. Objekten zusammengefasst und der Zugriff ist nur auf die Daten möglich, die für die »Außenwelt« relevant sind. Der Zugriff wird durch Methoden bzw. Eigenschaften gesteuert. Auf Daten, die nur für den internen Gebrauch bestimmt sind, kann von außerhalb der Klasse nicht zugegriffen werden. Durch folgende Elemente einer Klasse wird die Kapselung unter .NET realisiert: f Felder oder Instanzvariablen, die die Daten repräsentieren f Eigenschaften für den Zugriff auf die Daten, die nach außen hin verfügbar sein sollen. Eigenschaften sind ein .NET-Konstrukt, das zwei Methoden zusammenfasst: Den so genannten Getter zum Ermitteln von Daten und den Setter zum Zuweisen von Daten. Diese Zusammenfassung schafft einen besseren Überblick für den Programmierer; dennoch bleiben Getter und Setter im Grundsatz Methoden. f Methoden mit oder ohne Rückgabewert, die die Funktionalität zur Verfügung stellen. Methoden werden in der Regel nicht für den Datenzugriff verwendet, sondern führen Operationen mit den Daten aus. f Ereignisse, die ausgelöst werden können, wenn eine bestimmte Aktion durchgeführt wurde und die im restlichen Programm abgefangen werden können. Ereignisse werden aufgrund ihrer Komplexität im Detail noch in Abschnitt 8.2.3 ab Seite 189 erläutert.
Sandini Bib
HINWEIS
Grundlagen der Objektorientierung
131
Sie sollten sich angewöhnen, den Zugriff auf Ihre Daten ausschließlich mithilfe von Eigenschaften zu ermöglichen. In einem sauberen Design gibt es keine öffentlichen Instanzvariablen und auch Methoden werden nicht für den Datenzugriff verwendet.
Wiederverwendbarkeit Durch Abstraktion und Kapselung ergibt sich noch ein weiterer Vorteil, nämlich der der Wiederverwendbarkeit. Obwohl eine Klasse intern recht komplex aufgebaut sein kann und komplizierte Operationen durchführt, kann man durch geschicktes Design dennoch eine einfache Schnittstelle nach »außen« zur Verfügung stellen. Dabei können gleichartige Klassen auch gleichartige Schnittstellen, d.h. gleich lautende Methoden für gleiche Operationen zur Verfügung stellen. Für den Anwender (der Anwender einer Klasse ist natürlich ein anderer Programmierer, der die Klasse nutzt) bedeutet das, dass er nicht wissen muss, wie die Klasse intern funktioniert. Stattdessen verwendet er sie einfach. Die Methode ToString() beispielsweise ist in jeder .NET-Klasse implementiert und liefert eine Zeichenkettenentsprechung des Inhalts oder der Klasse selbst. Diese Methode wird beispielsweise vom ListBox-Steuerelement zum Anzeigen der enthaltenen Elemente benutzt. Obwohl das Ergebnis unterschiedlich sein kann (je nach Klasse bzw. Implementierung der Methode), ist die Schnittstelle zum Ergebnis (also die aufgerufene Methode) immer die gleiche.
6.1.3
Vererbung
Das Prinzip der Vererbung bedeutet einfach ausgedrückt, dass eine Klasse von einer anderen abgeleitet werden kann und deren nicht private Daten und Methoden »erbt«. Dieses Konzept dient der Spezialisierung, denn abgeleitete Klassen stellen immer eine spezialisierte Variante ihrer Basisklasse dar. Nehmen wir wieder das Fahrzeug als Beispiel. Man könnte eine (generelle) Basisklasse namens Vehicle erstellen, in der die grundlegenden Eigenschaften und Methoden festgelegt werden, die für alle Fahrzeuge gelten. Solche Eigenschaften könnten z.B. die Farbe, die Beschleunigung oder das Baujahr sein. Davon könnten drei weitere Klassen abgeleitet werden: LandVehicle, WaterVehicle, Aircraft. Für alle drei gilt, dass sie speziellere Eigenschaften und Methoden benötigen. Die Klasse LandVehicle benötigt beispielsweise eine Eigenschaft Tires, die die Klasse WaterVehicle nicht benötigt. Die Klasse Aircraft hätte dann vielleicht eine Eigenschaft Height, mit der die aktuelle Flughöhe angegeben werden könnte. Auf diese Weise kann die Spezialisierung immer weiter vorangetrieben werden. Vererbung ist in verschiedenen Programmiersprachen auf unterschiedliche Weise implementiert. Unterschieden werden dabei generell zwei Möglichkeiten: Mehrfachvererbung und Einfachvererbung. Bei der Mehrfachvererbung kann eine Klasse mehrere Basisklassen besitzen, von denen sie erbt. Bei der Einfachvererbung kann es nur eine einzige Basisklasse geben. C# unterstützt bei Klassen lediglich die Einfachvererbung. Die Sprache beinhaltet
Sandini Bib
6 Klassen und Objekte
132
aber außerdem noch so genannte Interfaces, bei denen Mehrfachvererbung möglich ist. Genaueres über Interfaces erfahren Sie in Kapitel 9 ab Seite 199.
6.1.4
Polymorphie
Das Wort Polymorphie bedeutet in etwa »Vielgestaltigkeit«. Ausgedrückt wird damit die Fähigkeit eines Objekts, ein Objekt eines anderen Typs aufzunehmen. Voraussetzung dafür ist, dass der Typ des ersten Objekts dem Typ des zweiten Objekts übergeordnet ist (also der erste Typ irgendwo in der Vererbungshierarchie dem ersten untergeordnet ist). Um beim vorhergehenden Beispiel zu bleiben: Ein Objekt des Typs Vehicle kann ein Objekt des Typs LandVehicle aufnehmen, umgekehrt aber funktioniert das nicht. Der Grund dafür ist die Beziehung, die zwischen beiden herrscht. Ein LandVehicle ist ein Vehicle, aber ein Vehicle ist kein LandVehicle. Es kann sich um eines handeln, muss aber nicht. Eine solche Beziehung nennt man daher auch ist-ein-Beziehung, oder im englischen Original is-aBeziehung. Klarer wird es noch, wenn man ein Gedankenexperiment durchführt. Eine Klasse B, die von einer Klasse A abgeleitet ist, hat in der Regel mehr Eigenschaften oder Methoden als diese (sie ist mehr spezialisiert). Da B von A abgeleitet ist, kann ein Objekt des Typs A ein Objekt des Typs B also aufnehmen (B ist ein A). Wäre es nun möglich, dass ein Objekt des Typs B ein Objekt des Typs A aufnimmt, könnte es passieren, dass eine Methode aufgerufen wird, die nur in B existiert (da diese Klasse ja spezieller ist). Damit hätte die Laufzeitumgebung Probleme, denn sie würde versuchen, diese Methode in der Klasse A aufzurufen, da B A enthält. Das kann aber nicht funktionieren, weil A diese Methode nicht kennt.
HINWEIS
Das Konzept der Polymorphie ist für das Verständnis mancher Vorgänge im .NET Framework enorm wichtig. Es erklärt beispielsweise die Tatsache, dass eine Instanz des Typs Object jedes beliebige andere Objekt aufnehmen kann, denn die Klasse Object steht in der Klassenhierarchie an erster Stelle und ist die einzige Klasse, die keine Basisklasse besitzt.
6.1.5
Wenn Sie eigene Klassen erstellen, sind Sie nicht dazu gezwungen, eine Basisklasse anzugeben. Wenn Sie keine Basisklasse angeben, wird automatisch von Object abgeleitet. Damit ist auch sichergestellt, dass die Klasse Object universell verwendet werden und jeden anderen Datentyp aufnehmen kann. Die Klasse Object aus dem Namespace System besitzt ebenfalls einen Alias in C#, nämlich object (mit kleinem »o«). Das ist auch die bevorzugte Schreibweise.
Aggregation
Kein zwingendes Konzept objektorientierter Programmiersprachen, aber dennoch sozusagen zwangsläufig enthalten ist das Konzept der Aggregation. Auch dieses Konzept hat mit Wiederverwendung zu tun und ist sehr einfach zu verstehen.
Sandini Bib
Gliederung einer Anwendung: Namespaces
133
Bleiben wir wieder beim Fahrzeug, also der fiktiven Klasse Vehicle. Viele Fahrzeuge haben Motoren. Ein Motor wäre also etwas, was man der Fahrzeugklasse als Datum (die Einzahl von Daten) mitgeben müsste. Nun ist aber ein Motor auch ein recht komplexes Gebilde, das eigene Eigenschaften besitzt (die dann alle in Vehicle nachgebildet werden müssten).
HINWEIS
Viel einfacher als diese Eigenschaften in jedem Fahrzeug nachzubilden ist es, eine eigene Klasse Engine zu erstellen, die all die benötigten Eigenschaften beinhaltet. Innerhalb des Fahrzeugs wird dann einfach ein Feld des Typs Engine zur Verfügung gestellt, die für die Anwender der Klasse wichtigen Eigenschaften von Engine können dennoch nach außen geführt werden. Man sagt in diesem Fall auch, Vehicle hat eine Engine. Diese hat-ein-Beziehung nennt man Aggregation. Eine derartige Beziehung, bei der ein Objekt Bestandteil eines anderen Objekts ist, wird auch häufig als Komposition bezeichnet. Der Unterschied ist einleuchtend: Kann das eingebundene Objekt alleine nicht existieren, nennt man den Vorgang Komposition. Kann es alleine existieren, wird der Begriff Aggregation verwendet.
Mithilfe von Kapselung, Vererbung, Polymorphie und Aggregation können beliebig komplizierte eigene Frameworks geschaffen werden, die für den speziellen Fall Ihrer Anwendung angepasst sind. Das Framework stellt ja trotz der unzähligen enthaltenen Klassen nur die Basis für die Programmierarbeit dar; nach wie vor müssen Sie Ihre eigenen Klassen und Objekte erzeugen und die für Sie wichtigen Daten verwalten. Aus diesem Grund ist auch Mehrfachvererbung nicht implementiert, denn sie ist schlicht nicht notwendig und würde die Arbeit mit .NET nur komplizieren.
6.2
Gliederung einer Anwendung: Namespaces
Natürlich stellen Klassen nicht nur die Grundlage der Programmierung in C# dar, sondern auch eine Möglichkeit der Strukturierung eines Programms. Diese Art der Strukturireung alleine genügt jedoch nicht, denn Sie müssten immer noch alle Klassen mit dem Vornamen kennen. Allein das .NET Framework bietet über 4000 öffentliche Klassen. Deshalb existiert das Konzept der Namespaces, das Sie auch in eigenen Anwendungen verwenden können. Namespaces oder Namensräume »kategorisieren« die in einer Anwendung enthaltenen Klassen. Sie stellen also eine übergeordnete Unterteilungsschicht dar. Klassen mit ähnlichen Funktionalitäten fallen in eine bestimmte Kategorie und werden einem entsprechenden Namespace zugeordnet. Das erleichtert das Auffinden einer benötigten Klasse ungemein. Sie sollten daher in Ihren Programmen Namespaces ausgiebig nutzen, um ein sauberes Design zu erhalten, in dem Sie sich auch nach Jahren noch auskennen. Ein Namespace ist physisch nicht vorhanden, es handelt sich weder um eine Datei, noch um ein ansonsten irgendwie »greifbares« Programmelement. Namespaces dienen lediglich der Unterteilung eines Programms oder eines Frameworks – auch das .NET Framework selbst ist in zahlreiche Namespaces untergliedert.
Sandini Bib
6 Klassen und Objekte
134
6.2.1
Deklaration
Die Deklaration eines Namespace beginnt mit dem reservierten Wort namespace und einem Bezeichner. In dem darunter liegenden Block werden dann die Datentypen deklariert, die zu diesem Namespace gehören. auf Namespace-Ebene können ausschließlich Datentypen deklariert werden, keine Methoden oder gar Eigenschaften. Das folgende Beispiel zeigt die Deklaration eines Namespace: namespace FeHelperClasses { class FileSearcher { ... } }
// Ende FeHelperClasses
Die Klasse FileSearcher ist nun Bestandteil des Namespace FeHelperClasses. Der Zugriff erfolgt über die vollständige Qualifizierung des Klassennamens (FeHelperClasses.FileSearcher) oder durch Einbinden des Namespaces mithilfe von using am Anfang der Datei.
Schachteln von Namespaces Sie können Namespaces auch schachteln. Dazu deklarieren Sie den untergeordneten Namespace innerhalb des übergeordneten Namespace. namespace FeHelperClasses { namespace FeHelperIO { class FileSearcher { ... } } }
// Ende FeHelperIO // Ende FeHelperClasses
Eine andere Möglichkeit ist die direkte Deklaration und die Trennung der beiden Namespaces mit dem Punkt: namespace FeHelperClasses.FeHelperIO { class FileSearcher { // Klassenbestandteile } }
// Ende FeHelperClasses.FeHelperIO
Der Zugriff auf die Klasse würde in beiden Fällen entweder durch die volle Qualifizierung des Klassennamens (FeHelperClasses.FeHelperIO.FileSearcher) erfolgen oder durch Einbinden des Namespaces FeHelperClasses.FeHelperIO mittels using.
Sandini Bib
HINWEIS
Gliederung einer Anwendung: Namespaces
6.2.2
135
Mittels using wird stets ausschließlich der angegebene Namespace eingebunden, nicht seine untergeordneten Namespaces. Diese müssen, falls benötigt, mit einer getrennten using-Anweisung eingebunden werden.
Aufteilung der Deklaration
Da Namespaces keine physikalische Gruppierung von Programmelementen darstellen, sondern lediglich eine sinngemäße, kann die Deklaration an den verschiedensten Stellen erfolgen und auch aufgeteilt werden. Ein Namespace kann in verschiedenen Dateien verwendet werden und auch innerhalb einer Datei können mehrere Namespaces deklariert werden: namespace FeHelperClasses { class CommonHelper { ... } }
// Ende FeHelperClasses
namespace FeHelperClasses.FeHelperIO { class FileSearcher { ... } }
// Ende FeHelperClasses.FeHelperIO
namespace FeHelperClasses { class AnotherHelper { ... } }
// Ende FeHelperClasses
In diesem Fall würden sich die Klassen CommonHelper und AnotherHelper im Namespace FeHelperClasses befinden, die Klasse FileSearcher im Namespace FeHelperClasses.HelperIO. Das Ganze ist natürlich nicht beschränkt auf untergeordnete Namespaces und auch nicht auf Dateien. Sie können innerhalb unterschiedlicher Dateien jeweils einen Namespace FeHelperClasses deklarieren, innerhalb des Programms erscheint dieser als ein einziger zusammenhängender Namespace. Diese dateiübergreifende Arbeitsweise erstreckt sich nicht nur über Dateien eines Projekts, sondern über mehrere Projekte hinweg. Das beste Beispiel hierfür bietet das .NET Framework selbst. Der Namespace System, der alle grundlegenden Deklarationen z.B. von Datentypen beinhaltet, ist in zwei verschiedene DLL-Dateien aufgeteilt, die System.dll und die mscorlib.dll. In beiden ist der Namespace System definiert, der Zugriff ist aber nur dann auf alle Klassen dieses Namespace möglich, wenn beide DLLs in die Verweisliste des Projekts aufgenommen wurden.
Sandini Bib
6 Klassen und Objekte
HINWEIS
136
6.3
Das Visual Studio hilft Ihnen bei der Unterteilung Ihrer Programmbestandteile in Namespaces. Sie können im Projektmappenexplorer innerhalb eines Projekts Ordner anlegen. Mithilfe dieser Ordner können Sie Ihr Projekt visuell strukturieren. Wenn Sie in einem solchen Ordner eine Klasse anlegen, so wird der Ordnername als untergeordneter Namespace betrachtet und entsprechend gleich in eine neu erstellte Datei eingefügt.
Klassen
HINWEIS
In C# gilt nicht nur der Satz »Alles ist ein Objekt«, es gilt auch »Alles ist eine Klasse«. Sämtliche Funktionalität eines C#-Programms wird in Klassen ausgeführt. Auch bei einem simplen Hello-World-Beispiel wird bereits eine Klasse verwendet, alle Steuerelemente und Formulare sind ebenfalls Klassen, genauso wie die meisten Elemente der FCL, mit denen Sie in Ihrem Programm arbeiten. Klassen sind also die Grundbausteine des Programms.
6.3.1
Es gilt der Grundsatz, dass jede Klasse in ihrer eigenen Datei definiert werden sollte. Mit .NET 2.0 haben Partial Classes Einzug gehalten, die eine Aufteilung einer Klasse in mehrere Dateien ermöglichen; nie jedoch sollte eine Datei mehrere Klassen beinhalten. Damit kann der Dateiname auch den Namen der Klasse reflektieren, was es Ihnen später einfacher macht, den Quellcode innerhalb des Projekts aufzufinden. Auch wenn die Projekte am Anfang noch kleiner sind, können .NET-Applikationen schnell einen nicht unerheblichen Umfang erreichen. Sie sollten sich dieses Vorgehen daher von Anfang an angewöhnen.
Klassendeklaration
Die Deklaration einer Klasse wird eingeleitet durch das reservierte Wort class gefolgt von einem Bezeichner. Die Elemente der Klasse werden in geschweiften Klammern eingeschlossen. Bei diesen Elementen kann es sich um Felder (Instanz- oder Klassenvariablen), Methoden, Eigenschaften und Ereignisse handeln. Die grundlegende Syntax einer Klassendeklaration sieht folgendermaßen aus: [Modifizierer] class Bezeichner { [Felder (Instanzvariablen)] [Eigenschaften] [Methoden] [Ereignisse] }
Sandini Bib
137
Klassen
HINWEIS
Eine Klasse muss nicht alle Elemente enthalten, die hier angegeben sind, meist sind aber zumindest Felder, Eigenschaften und Methoden enthalten. Die Daten der Klasse sind dabei grundsätzlich in den Feldern enthalten, der Zugriff darauf geschieht über Methoden oder Eigenschaften, nie jedoch direkt. Die Sichtbarkeit der einzelnen Bestandteile in Bezug auf das restliche Programm kann über so genannte Modifizierer gesteuert werden, die in Abschnitt 6.5 ab Seite 158 näher besprochen werden. Teilweise werden auch Arrays als Felder bezeichnet (ein Array ist grundsätzlich ja auch ein Datenfeld). In diesem Buch wird zwischen Feldern (Fields), die Bestandteil einer Klasse sind, und Arrays strikt unterschieden. Felder einer Klasse werden auch als Instanzvariablen oder Membervariablen bezeichnet. Die Bestandteile der Klasse selbst bezeichnet man auch als Member der Klasse. Die Bezeichnung Attribute für die Bestandteile einer Klasse, wie sie in anderen Programmiersprachen verwendet wird, ist in C# nicht sinnvoll, da Attribute ein eigener Bestandteil der Sprache sind und es somit zu Verwechslungen kommen kann. Mehr über Attribute erfahren Sie in Kapitel 10 ab Seite 215.
Die Reihenfolge, in der Felder, Methoden etc. deklariert werden, ist nicht relevant. Sie können diese Bestandteile innerhalb des Klassenblocks an beliebiger Stelle deklarieren. Der Compiler betrachtet die Klasse ganzheitlich, wodurch es nicht erforderlich ist, dass eine Instanzvariable vor einer Methode, die sie verwendet, deklariert wird. Eines sollten Sie allerdings beachten: Bleiben Sie innerhalb des Projekts konsistent.
6.3.2
Felder (Instanzvariablen)
Die Deklaration einer Instanzvariablen entspricht im Grundsatz der Deklaration einer lokalen Variablen, mit dem Unterschied, dass man zusätzlich einen Modifizierer angeben kann, der die Sichtbarkeit der Variablen im Bezug auf das restliche Programm festlegt. Die grundsätzliche Syntax sieht folgendermaßen aus: [Modifizierer] Datentyp Bezeichner [=Initialwert];
Die Angabe eines Modifizierers ist nicht zwingend notwendig. Wird kein Modifizierer angegeben, gilt für Instanzvariablen, dass sie für das restliche Programm nicht sichtbar sind (d.h. der Zugriff darauf nicht möglich ist), wohl aber innerhalb der Klasse darauf zugegriffen werden kann. Dieses Verhalten entspricht dem Modifizierer private. Das ist auch die normale Vorgehensweise. Der Zugriff auf die in den Instanzvariablen enthaltenen Daten geschieht normalerweise über Methoden bzw. Eigenschaften. Die folgenden Zeilen verwenden den Modifizierer public, um öffentliche Instanzvariablen zu deklarieren. Das geschieht allerdings an dieser Stelle nur zu Demonstrationszwecken und sollte in der Praxis so nicht vorkommen. class Coordinates { public int x; public int y; }
Sandini Bib
138
6 Klassen und Objekte
public legt fest, dass die so bezeichneten Elemente von außerhalb der Klasse sichtbar sind.
Ebenso wie bei lokalen Variablen können Instanzvariablen auch bei der Deklaration initialisiert werden. Ist das nicht der Fall, wird der Standardwert des angegebenen Typs verwendet. class Coordinates { public int x = 0; public int y = 0; }
Auf diese Weise angelegte Felder sind nicht auf Klassenebene, sondern nur auf Objektebene gültig, d.h. um darauf zugreifen zu können muss eine Instanz der Klasse erzeugt werden. Das geschieht durch die Verwendung des Operators new. Intern wird dabei einer der Konstruktoren der Klasse aufgerufen, die in Abschnitt 6.3.4 ab Seite 147 näher besprochen werden. Der Zugriff auf die Elemente des Objekts geschieht über den Punkt-Operator. Anders als in C++ gibt es in C# nur den Punkt-Operator für den Zugriff auf Member einer Klasse bzw. eines Objekts. public static void Main() { // Instanz der Klasse Coordinates erzeugen Coordinates myCoords = new Coordinates(); // Werte zuweisen myCoords.x = 5; myCoords.y = 5; }
Selbstverständlich können Instanzvariablen nicht nur Wertetypen beinhalten. Da auch Klassen wie angesprochen lediglich Datentypen sind, kann eine Instanzvariable auch von einem solchen Typ sein. Die Art der Deklaration ist dabei die gleiche. class MyFileClass { private StreamReader reader; private StreamWriter writer; }
Der Unterschied besteht in diesem Fall darin, dass diese Felder explizit initialisiert werden müssen. Was bei Wertetypen wie int automatisch geschieht, ist bei Referenztypen nicht möglich; hier muss eine Instanz erzeugt werden, bevor das Feld verwendet werden kann.
Read-only-Felder Für den Zugriff auf die Daten, die in Feldern gespeichert sind, ist es normalerweise sinnvoll, Eigenschaften oder Methoden zu verwenden (die konsequente Einhaltung objektori-
Sandini Bib
139
Klassen
entierter Konzepte erzwingt dies sogar). Da Felder jedoch durchaus öffentlich deklariert werden können, liefert C# auch eine Möglichkeit, solche Felder unveränderlich zu machen. Die Werte von read-only-Feldern können nur von innerhalb der Klasse, in der sie deklariert wurden, geändert werden. Die entsprechende Deklaration geschieht durch den Modifizierer readonly. class Coordinates { // Nur-Lese-Felder public readonly int x = 0; public readonly int y = 0; // Konstruktor initialisiert Werte ... public Coordinates( int x, int y ) { this.x = x; this.y = y; } }
6.3.3
Methoden
Methoden können zu zwei Dingen dienen. Einerseits wird durch sie definiert, welches Verhalten ein Objekt aufweist bzw. welche Funktionalität in einer Klasse enthalten ist. Eine zweite Möglichkeit besteht darin, Methoden für den Zugriff auf die in dem Objekt enthaltenen Daten zu verwenden.
Deklaration von Methoden Alle Methoden in C# sind grundsätzlich Bestandteil einer Klasse, es gibt keine globalen Methoden. Wie wir später noch sehen werden, gibt es allerdings durchaus die Möglichkeit, Methoden und auch Felder als static zu definieren, sodass keine Instanz für den Zugriff benötigt wird, sondern dieser auf Klassenebene stattfindet. Methoden haben in C# immer einen Rückgabe- oder Ergebniswert. Die grundsätzliche Deklaration einer Methode sieht folgendermaßen aus: [Modifizierer] Datentyp Bezeichner([Parameter]) { // Anweisungen }
Als Datentyp kann wieder jeder beliebige Typ verwendet werden, den das .NET Framework zur Verfügung stellt. Es kann sich also um einen Werte- oder Referenztyp handeln. Soll kein Wert zurückgeliefert werden, kann der speziell für diesen Fall vorgesehene Datentyp void verwendet werden, der so viel wie »leer« bedeutet. Eine solche Methodendeklaration würde folgendermaßen aussehen: public void MyMethod() { // Anweisungen }
Sandini Bib
6 Klassen und Objekte
140
HINWEIS
Eine Methode wird beendet, indem entweder innerhalb der Methode return aufgerufen oder das Ende der Methode erreicht wird. Letzteres gilt allerdings nur, wenn die Methode keinen Wert zurückliefert. Ist ein Ergebniswert angegeben, muss die Methode auch einen Wert zurückliefern. Der zurückgelieferte Wert wird hinter return angegeben und muss dem im Methodenkopf angegebenen Datentyp entsprechen. Methoden ohne Ergebniswert werden typischerweise wie eine einfache Anweisung aufgerufen. Der Aufruf von Methoden mit Ergebniswert hingegen steht normalerweise auf der rechten Seite einer Zuweisung. C# erzwingt dies jedoch nicht. Auch Methoden, die einen Wert zurückliefern, können als Anweisung aufgerufen werden, der zurückgelieferte Wert wird dann verworfen.
Bei Methoden, die einen Rückgabewert liefern, ist es wichtig, dass in jedem Fall ein Wert zurückgeliefert wird. Im folgenden Codeausschnitt ist das nicht zwangsläufig der Fall: public bool MyMethod() { int int a = b =
a; b; Int32.Parse(Console.ReadLine()); Int32.Parse(Console.ReadLine());
if ( a > b ) return true; // Keine Rücklieferung, wenn a kleiner oder gleich b ist, Compiler meckert. }
Für den Fall, dass a kleiner oder gleich b ist, würde kein Wert zurückgeliefert. Da eine solche Methode auch innerhalb einer Zuweisung verwendet werden kann (was ohne Rückgabewert einen Laufzeitfehler zur Folge hätte), verweigert der Compiler hier die Arbeit und fordert, dass alle Codepfade einen Wert zurückliefern. Die folgende Methode wäre korrekt: public bool MyMethod() { int int a = b =
a; b; Int32.Parse(Console.ReadLine()); Int32.Parse(Console.ReadLine());
if ( a > b ) return true; return false; }
Sandini Bib
Klassen
141
Hier wird true zurückgeliefert, wenn a>b ist, ansonsten false. Da es sich um einen booleschen Wert handelt und der Term (a > b) ohnehin einen booleschen Wert liefert, geht es sogar noch einfacher: public bool MyMethod() { int int a = b =
a; b; Int32.Parse( Console.ReadLine() ); Int32.Parse( Console.ReadLine() );
return ( a > b ); }
Parameterübergabe Methoden können Parameter übergeben werden. Diese werden im Kopf der Methode deklariert und innerhalb der Methode wie lokale Variablen behandelt, sind also nur innerhalb der Methode gültig. Mehrere Parameter werden durch Komma getrennt. Der Aufruf einer Methode geschieht wieder mithilfe des Punkt-Operators. Die Übergabe eines Parameters kann dabei auf zwei Arten erfolgen, nämlich entweder by value oder by reference. Die erste Möglichkeit entspricht dem Standard und bedeutet, dass lediglich der Wert übergeben wird. Da der Parameter innerhalb der Methode als lokale Variable fungiert, kann deren Wert auch geändert werden, was aber in der aufrufenden Methode nicht auffällt. Einen solchen Parameter nennt man auch Werteparameter. Das folgende kleine Codebeispiel zeigt einen solchen Aufruf: public class Example { public void PrintValue( int valueToPrint ) { valueToPrint += 5; Console.WriteLine( "In PrintValue: {0}", valueToPrint ); } } public class MainClass { public static void Main() { int myValue = 5; Example myExample = new Example(); Console.WriteLine( "In Main: {0}", myValue ); myExample.PrintValue( myValue ); Console.WriteLine( "Zurück in Main: {0}", myValue ); } }
Sandini Bib
6 Klassen und Objekte
142
Die Ausgabe des Programms ist: In Main: 5 In PrintValue: 10 Zurück in Main: 5
Bei der Übergabe by value muss nicht zwangsläufig eine Variable verwendet werden, es ginge auch direkt ein Wert. Das entspräche dann einer einfachen Initialisierung der lokalen Variable, die durch den Parameter repräsentiert wird: myExample.PrintValue( 5 ); // würde auch funktionieren
Übergabe by Reference Um einen Wert by reference zu übergeben, wird das reservierte Wort ref benutzt. Dieses muss sowohl vor dem Parameter als auch vor der zu übergebenden Variable im Methodenaufruf stehen. ref bewirkt, dass eine Änderung der Variable in der aufgerufenen Methode auf die tatsächlich übergebene Variable durchschlägt. Daher müssen zwei Bedingungen erfüllt sein: f es muss eine Variable übergeben werden, nicht einfach ein Wert, und f die Variable muss initialisiert sein. Entsprechend abgeändert sieht obiger Code dann folgendermaßen aus: public class Example { public void PrintValue( ref int valueToPrint ) { valueToPrint += 5; Console.WriteLine( "In PrintValue: {0}", valueToPrint ); } } public class MainClass { public static void Main() { int myValue = 5; Example myExample = new Example(); Console.WriteLine( "In Main: {0}", myValue ); myExample.PrintValue( ref myValue ); Console.WriteLine( "Zurück in Main: {0}", myValue ); } }
Sandini Bib
Klassen
143
Die Ausgabe ist: In Main: 5 In PrintValue: 10 Zurück in Main: 10
Die ursprüngliche Variable wurde also geändert.
out-Parameter Ein Sonderfall ist die Übergabe by Reference einer nicht initialisierten Variable. Auch das ist möglich, indem das reservierte Wort out statt ref verwendet wird. Das Verhalten ist das gleiche wie bei ref, allerdings muss die übergebene Variable nicht initialisiert worden sein. Diese Initialisierung würde dann innerhalb der Methode passieren, die aufgerufen wurde. out-Parameter werden normalerweise in Methoden verwendet, die mehrere Werte zurück liefern müssen. Die Methode Int32.TryParse() beispielsweise, die in .NET 2.0 neu hinzugekommen ist, kontrolliert, ob eine übergebene Zeichenkette in eine Integerzahl konvertiert werden kann. Dazu muss auch ein out-Parameter übergeben werden. Ist die Konvertierung möglich, wird true zurückgeliefert und der angegebene out-Parameter enthält die konvertierte Zahl.
Übergabe von Objekten Bei der Übergabe von Objekten als Parameter zeigt sich die Wichtigkeit der Unterscheidung von Werte- und Referenztypen. Die Parameterübergabe ist ja nichts anderes als eine Zuweisung, nämlich des Werts oder der Referenz einer lokalen Variable an den Parameter. Wir wissen aber, dass eine Zuweisung bei Objekten bedeutet, dass lediglich die Referenz übergeben wird, nicht aber der enthaltene Wert. Das bedeutet, wenn ein Objekt übergeben und daran etwas geändert wird, schlägt dies immer auf das ursprüngliche Objekt durch.
ACHTUNG
Die Übergabe eines Objekts entspricht demnach immer einer Übergabe by reference. Soll verhindert werden, dass das ursprüngliche Objekt verändert wird, muss vorher eine Kopie des Objekts erzeugt werden. Viele Klassen stellen zum Erzeugen einer Kopie die Methode Clone() zur Verfügung. Diese Methode kann allerdings unterschiedlich implementiert sein, sodass sie möglicherweise keine Kopie erzeugt, sondern lediglich ein neues Objekt, das auf dieselben Daten zeigt wie das ursprüngliche Objekt. Daher ist es manchmal sicherer, manuell eine Kopie zu erstellen. Bei eigenen Klassen kann die Methode Clone() selbstverständlich so implementiert werden, dass sie eine reale Kopie (auch tiefe Kopie genannt) erzeugt und zurückliefert.
Eine weitere Eigenschaft der Übergabe eines Objekts als Parameter ist eher positiv zu werten. Da nämlich hier nicht die gesamten Daten des Objekts, sondern lediglich die Zeiger-
Sandini Bib
6 Klassen und Objekte
144
adresse (also ein int-Wert) übergeben wird, ist ein solcher Methodenaufruf bereits per Definitionem performant.
Optionale Parameter In C# gibt es nicht wirklich optionale Parameter. Es ist jedoch möglich, mithilfe des reservierten Worts params mehrere Parameter in einer Methodendeklaration anzugeben, wobei deren Anzahl variabel ist. Dabei ist auf zwei Dinge zu achten: f Das reservierte Wort params darf nur einmal in einer Methodendeklaration verwendet werden. f Nach params dürfen keine weiteren Parameter übergeben werden. Sinn macht diese Vorgehensweise, wenn eine Methode für unterschiedliche Vorgehensweisen mit unterschiedlichen Parametern verwendet werden soll, wobei aber die Anzahl der Parameter beliebig ist. Das folgende Beispiel zeigt eine solche Methode. Als Parameter können hier string- oder int-Werte übergeben werden, auch gemischt. Innerhalb der Methode wird ausgewertet, welcher Datentyp übergeben wurde, und entsprechend verfahren: class Example { public void MyMethod( params object[] objects ) { int iResult = 0; string sResult = ""; foreach ( object o in objects ) { if ( o is string ) { sResult += (string)o; sResult +="\r\n"; } else if ( o is Int32 ) { iResult += (int)o; } } if (iResult != 0) Console.WriteLine(iResult); Console.WriteLine(sResult); } }
Sandini Bib
Klassen
145
class Program { [STAThread] static void Main( string[] args ) { Example myExample = new Example(); myExample.MyMethod( "a", 1, "b", 2, "c", 3 ); Console.ReadLine(); } }
Die Ausgabe des Programms ist 6 a b c
Instanz- und lokale Variablen (this) Während es innerhalb einer Methode untersagt ist, eine Variable doppelt zu deklarieren (z.B. innerhalb des Methodenblocks und dann erneut innerhalb einer Schleife), können lokale Variablen durchaus den Namen einer Instanzvariablen tragen. Der Compiler kann das unterscheiden. Er greift bei Verwendung des Variablennamens immer auf die erste Variable zu, die er findet. Innerhalb einer Methode handelt es sich dabei in der Regel um die lokale Variable. Daher muss es eine Möglichkeit geben, dem Compiler mitzuteilen, dass er auf die Instanzvariable zugreifen soll. Das geschieht durch das reservierte Wort this. this bezeichnet immer eine Referenz auf das aktuelle Objekt, in dem sich der Aufruf befindet. Die folgende Methodendeklaration würde also der Instanzvariable name den Wert des Parameters name zuweisen: public class PasswordExample { private string name = String.Empty; private string passWord = String.Empty; public bool CheckPassword( string name, string passWord ) { if ( this.name.Equals( name ) && this.passWord.Equals( passWord ) ) return true; return false; } }
Sandini Bib
146
6 Klassen und Objekte
Ist keine Namenskollision zu befürchten, kann this auch weggelassen werden. In diesem Fall sucht der Compiler automatisch in der Klassendeklaration nach einem Feld mit angegebenem Namen.
Methoden überladen Methoden können in C# überladen werden. Darunter versteht man die Deklaration mehrerer Methoden mit gleichem Namen, die sich lediglich in den Typen und der Anzahl der Parameter unterscheiden. Der Compiler trifft die Unterscheidung anhand der Parameterliste und sucht sich beim Aufruf einer dieser Methoden die passende heraus. Der Rückgabewert trägt hingegen nicht zur Unterscheidung bei. Grund dafür ist, dass Methoden mit Rückgabewert auch ohne Verwendung desselben aufgerufen werden können (also als einfache Anweisung). In diesem Fall hätte der Compiler aber keine Unterscheidungsmöglichkeit mehr. Ein typisches Beispiel für eine überladene Methode ist die Addition mehrerer Zahlen: public int Add( int a, int b ) { return a + b; } public int Add( int a, int b, int c ) { return Add( a, b ) + c; } public int Add( int a, int b, int c, int d ) { return Add( Add( a, b, c ), d ); }
In diesem Fall wird die Methode Add() sozusagen innerhalb von sich selbst aufgerufen. Der Compiler kann diese Methoden jedoch unterscheiden, da sie eine unterschiedliche Anzahl Parameter aufweisen, und ruft immer die korrekte Version auf. Bei der Verwendung numerischer Datentypen ist allerdings Vorsicht geboten. Der Compiler kann diese zwar unterscheiden, aber bei der Übergabe von Werten kann es dennoch dazu kommen, dass immer die gleiche Methode aufgerufen wird. Sehen Sie sich folgendes Beispiel an: public long Add( int a, int b ) { return a + b; } public long Add( long a, long b ) { return a + b; }
Sandini Bib
147
Klassen
Werden an eine dieser Methoden reine Zahlen übergeben, so werden diese standardmäßig als int interpretiert (die Methode mit long-Parametern würde nie aufgerufen). Hier ist also unter Umständen eine explizite Angabe des korrekten Datentyps nötig.
6.3.4
Konstruktoren und Destruktoren
VERWEIS
Konstruktoren In diesem Abschnitt werden lediglich herkömmliche Konstruktoren besprochen. C# ermöglicht aber noch zwei weitere Arten, private Konstruktoren und statische Konstruktoren. Letztere werden in Abschnitt 6.4.2 auf Seite 157 besprochen. Informationen zu private Konstruktoren erhalten Sie im Zusammenhang mit statischen Eigenschaften im Abschnitt 6.4.1 ab Seite 156.
Durch das reservierte Wort new wird eine neue Instanz einer Klasse erzeugt. Intern wird hierbei der so genannte Konstruktor der Klasse aufgerufen. Jede Klasse besitzt automatisch einen parameterlosen Standardkonstruktor, wenn keiner explizit deklariert ist. Die Deklaration eines Konstruktors enspricht der einer Methode, mit zwei Unterschieden: f Der Konstruktor trägt immer den gleichen Namen wie die Klasse selbst und f der Konstruktor hat keinen Rückgabewert (auch nicht void).
ACHTUNG
Der Konstruktor dient dazu, die Standardeinstellungen für ein neues Objekt festzulegen. Wie bei Methoden auch können Konstruktoren überladen werden, wobei die Anzahl der Parameter wieder als Unterscheidungsmerkmal dient. Der Compiler sucht sich den richtigen Konstruktor heraus. Bei der Deklaration eines eigenen Konstruktors mit Parametern »verschwindet« der automatisch erzeugte parameterlose Konstruktor. Falls dieser nach wie vor vorhanden und aufrufbar sein soll, müssen Sie ihn explizit deklarieren.
Das folgende Beispiel zeigt eine Klasse mit zwei Konstruktoren, wobei einer der Standardkonstruktor ist und der andere Parameter erhält: public class Example { string name; string vorname; public Example() { this.name = String.Empty; this.vorname = String.Empty; }
Sandini Bib
6 Klassen und Objekte
148 public Example( string name, string vorname ) { this.name = name; this.vorname = vorname; } }
Wie bei Methoden auch kann das reservierte Wort this innerhalb des Konstruktors zum Zugriff auf Instanzvariablen verwendet werden.
Konstruktoren verketten Konstruktoren können in C# verkettet werden. Dazu wird wiederum das reservierte Wort this verwendet, gefolgt von den benötigten Parametern. Der Aufruf findet jedoch nicht innerhalb des Konstruktors statt, sondern wird durch Doppelpunkt getrennt an die Deklaration angehängt. Das folgende Beispiel zeigt die Konstruktorenverkettung: public class Example { string name; string vorname; public Example() : this( String.Empty, String.Empty ) {
}
public Example( string name, string vorname ) { this.name = name; this.vorname = vorname; } }
Der Aufruf des Standardkonstruktors wird automatisch auf den parametrisierten Konstruktor umgeleitet. Da sich diese Verkettung verhält wie ein Methodenaufruf, wird zum Konstruktor zurückgekehrt und die im Konstruktor deklarierten Anweisungen werden ausgeführt.
Basiskonstruktor aufrufen Ähnlich wie das Verketten von Konstruktoren einer Klasse funktioniert auch der explizite Aufruf eines Konstruktors der Basisklasse. Dazu muss aber zunächst erforscht werden, wie die Instanzierung einer Klasse vor sich geht. Beim Aufruf eines Konstruktors erfolgt als erstes der Aufruf des Standardkonstruktors der Basisklasse (also des Konstruktors ohne Parameter), falls dieser existiert. Danach werden die Instanzvariablen initialisiert (mit ihrem Standardwert belegt) und dann erst erfolgt die Ausführung der Anweisungen innerhalb des Konstruktors. Sollte es nun erforderlich sein, statt des Standardkonstruktors einen parametrisierten Konstruktor der Basisklasse aufzurufen, so kann dies über das reservierte Wort base geschehen. Die Vorgehensweise ist die gleiche wie bei der Verkettung der Konstruktoren.
Sandini Bib
HINWEIS
Klassen
149
base dient nicht nur dem Aufruf des Konstruktors des Basisklasse, sondern ähnlich wie this dem Zugriff auf die Member der Basisklasse. Was this für die aktuelle Klasse bedeutet, bedeutet base für die Basisklasse.
Der Destruktor Destruktoren werden aufgerufen, wenn ein Objekt aus dem Speicher entfernt wird. Hier jedoch gibt es einen großen Unterschied zwischen C# und C++. Während in C++ der Zeitpunkt, zu dem ein Destruktor aufgerufen wird, bekannt ist (nämlich dann, wenn ein Objekt seinen Gültigkeitsbereich verlässt), ist das in C# bzw. .NET nicht so. C# bzw. .NET verfügt bekanntlich über eine Garbage Collection, die sich um das Entfernen der Objekte aus dem Speicher und um das Freigeben des Speichers kümmert. Sie tut das in der Regel, wenn der Computer im Leerlauf oder der Speicher zu voll ist. Da dieser Vorgang automatisiert abläuft, wissen Sie niemals, wann die Anweisungen innerhalb eines Destruktors aufgerufen werden oder in welcher Reihenfolge. Hinzu kommt ein weiterer Punkt, dessen detaillierte Erklärung wir Ihnen hier ersparen wollen. Wenn Sie einen Destruktor deklarieren, benötigt die Garbage Collection aufgrund des Designs zwei Durchläufe, um das Objekt zu entfernen. Der Grund liegt in einem Mechanismus, der auch als Resurrection (Wiedergeburt) bezeichnet wird. Näheres finden Sie in der MSDN, wenn Sie nach Garbage Collection suchen. Die Deklaration eines Destruktors entspricht der eines Konstruktors, mit drei Unterschieden: f Vor dem Namen des Destruktors steht eine Tilde (~, das Symbol für das Einerkomplement). Die Bedeutung ist klar, es handelt sich um die umgekehrte Funktion des Konstruktors. f Es gibt immer nur einen Destruktor. f Ein Destruktor hat keine Parameter. Ein Beispiel zeigt die Deklaration eines Destruktors: class Example // Deklarationen, Methoden, etc. public ~Example { // Anweisungen } }
Grundsätzlich müssen bzw. sollten Sie keinen Destruktor deklarieren. Überlassen Sie die Arbeit der Garbage Collection – Destruktoren machen nur dann Sinn, wenn der Zeitpunkt bekannt ist, an dem sie aufgerufen werden. Das ist in .NET nicht der Fall.
Sandini Bib
6 Klassen und Objekte
150
6.3.5
Eigenschaften (Properties)
Eigenschaften wurden bereits kurz angesprochen. Sie dienen dem Zugriff auf die Daten, die in einer Klasse (bzw. einem Objekt) enthalten sind. Im Prinzip handelt es sich bei Eigenschaften auch um Methoden, die Syntax zur Deklaration und auch für den Zugriff darauf ist allerdings eine andere. Natürlich könnten prinzipiell auch Methoden verwendet werden, um auf die Daten eines Objekts zuzugreifen. Die Verwendung von Eigenschaften erlaubt aber eine bessere Unterscheidung. Eigenschaften verhalten sich wie öffentliche Variablen einer Klasse bzw. eines Objekts. Die Zuweisung eines Werts erfolgt wie auch von lokalen Variablen gewohnt mittels des =Operators. Innerhalb der Klasse selbst ergeben sich auch Vorteile. Die Syntax von Eigenschaften ist wesentlich kompakter, wobei auch hier zwischen dem Setzen des Werts und dem Auslesen unterschieden wird. Da hierzu im Prinzip auch nur Methoden verwendet werden, ist es sehr leicht, Daten vor der Ausgabe nochmals zu kontrollieren oder zu manipulieren.
Deklaration Der Zugriff auf die Daten kann lesend, schreibend oder beides sein. Auch das wird über Eigenschaften festgelegt. Der Typ der Eigenschaft ist immer auch der Typ des Werts, den sie repräsentiert. Die grundlegende Syntax sieht folgendermaßen aus: [Modifizierer] Datentyp Bezeichner { get { // Anweisungen } set { // Anweisungen } }
Der Modifizierer, der üblicherweise für Eigenschaften verwendet wird, ist natürlich public, denn dabei soll es sich ja um öffentliche Bestandteile der Klasse handeln. Die beiden Methoden get und set, die dem Auslesen und dem Zuweisen von Daten dienen, werden typischerweise auch als Getter und Setter bezeichnet. Die Methode get dient dem Zurückliefern eines Werts. Wie bei Methoden üblich wird dieser über eine return-Anweisung an den Aufrufer zurückgeliefert. Die Methode set dient dazu, einen Wert einzustellen, wobei es eine Besonderheit der Sprache C# gibt. Obwohl der Methode set nichts übergeben wird, ist dennoch eine lokale Variable value definiert, die den zuzuweisenden Wert enthält. Ein kleines Beispiel macht das etwas deutlicher: class Example { private string name = ""; private string vorname = "";
Sandini Bib
Klassen
151
public string Name { get { return this.name; } set { this.name = value; } } public string Vorname { get { return this.vorname; } set { this.vorname = value; } } }
Beachten Sie an dieser Stelle bitte die Groß- und Kleinschreibung. Die Eigenschaften beginnen mit einem Großbuchstaben, die Instanzvariablen mit einem Kleinbuchstaben. Diese Vorgehensweise wird von vielen Programmierern so gehandhabt. Eine andere Möglichkeit besteht darin, den Bezeichnern der Instanzvariablen einen Unterstrich voranzustellen. Oftmals wird für Eigenschaften auch eine kompaktere Schreibweise verwendet, die bei einem reinen Zugriff auf eine Membervariable durchaus Sinn macht: class Example { private string name = ""; private string vorname = ""; public string Name { get { return this.name; } set { this.name = value; } } public string Vorname { get { return this.vorname; } set { this.vorname = value; } } }
Sandini Bib
152
6 Klassen und Objekte
Readonly- und Writeonly-Eigenschaften Im obigen Fall geschieht einfach nur ein Zugriff auf die jeweilige Instanzvariable. Mit Recht könnten Sie jetzt behaupten, bei dieser Konstellation mache es keinen Unterschied, ob die Instanzvariablen direkt als public deklariert oder Eigenschaften verwendet werden. Sobald Sie aber beispielsweise den Zugriff nur lesend ermöglichen wollen, können Sie das mit öffentlichen Instanzvariablen nicht mehr tun. Im Falle von Eigenschaften entfernen Sie einfach den set-Teil: class Example { private string name = ""; private string vorname = ""; public string Name { get { return this.name; } } public string Vorname { get { return this.vorname; } } }
Jetzt kann auf Name und Vorname nur noch lesend zugegriffen werden. Wollen Sie lediglich ermöglichen, dass der Wert geschrieben, aber danach nicht mehr ausgelesen werden kann, entfernen Sie einfach den get-Teil. Das macht allerdings wenig Sinn.
get- und set-Methoden nutzen Ein weiterer Vorteil ergibt sich, wenn Sie beispielsweise Name und Vorname gemeinsam zurückliefern und dem Benutzer Ihrer Klasse außerdem ermöglichen wollen, beide gleichzeitig zu setzen. Das Resultat wäre eine Eigenschaft FullName (beispielsweise), die folgendermaßen deklariert werden könnte: class Example { private string name = String.Empty; private string firstName = String.Empty; // Eigenschaften public string Name { get { return this.name; } set { this.name = value; } }
Sandini Bib
Klassen
153
public string FirstName { get { return this.firstName; } set { this.firstName = value; } } public string FullName { get { return this.firstName + " " + this.name; } set { string[] s = value.Split( ' ' ); this.firstName = s[0]; this.name = s[1]; } } }
Die Methode Split() der Klasse String wurde noch nicht besprochen. Es handelt sich dabei einfach nur um eine Methode, die die einzelnen Wörter eines Strings »aufsplittet« und als String-Array zurückliefert. Dabei kann angegeben werden, welches Zeichen als Trennzeichen für die Wörter dienen soll (in diesem Fall ist es das Leerzeichen). Die obige Eigenschaft FullName ermöglicht es nun, den Wert von Namen und Vornamen zuzuweisen, indem einfach der komplette Name übergeben wird. Die set-Methode kümmert sich um die Auftrennung in Vor- und Nachname. Beispielsweise folgendermaßen: public static void Main() { Example myExample = new Example(); myExample.FullName = "Frank Eller"; Console.WriteLine( myExample.Name + Environment.NewLine + myExample.FirstName ); }
HINWEIS
Mit Eigenschaften haben Sie also eine komfortable Möglichkeit des Zugriffs auf die privaten Bestandteile einer Klasse bzw. eines Objekts, wobei Sie selbst steuern, welche Daten öffentlich gemacht werden. Der Zugriff erfolgt wie bei Variablen, was Eigenschaften nach außen hin wie Instanzvariablen erscheinen lässt. Eigenschaften sind nicht an Instanzvariablen gebunden, sondern können auch ohne eine solche Variable verwendet werden. Der große Vorteil von Eigenschaften ist der einfache Zugriff, der in der gleichen Art wie auf eine öffentliche Instanzvariable erfolgt. Außerdem können Sie innerhalb einer Eigenschaft das Verhalten beim Variablenzugriff ändern, ohne die Zugriffsschnittstelle ändern zu müssen.
Unterschiedliche Modifizierer für Eigenschaften Mit .NET 2.0 erhalten die Eigenschaften ein weiteres Feature. Getter und Setter können nun unterschiedliche Sichtbarkeit besitzen. Beispielsweise können Sie nun festlegen, dass
Sandini Bib
154
6 Klassen und Objekte
eine Eigenschaft lediglich innerhalb einer Klasse gesetzt werden kann (der Setter wäre also private), aber öffentlich gelesen (der Getter wäre dann public). Da die Deklaration einer Eigenschaft bereits einen Modifizierer beinhaltet, muss lediglich der geänderte Modifizierer angegeben werden. Das geschieht vor dem Bestandteil, der eine von der Eigenschaft selbst unterschiedliche Sichtbarkeit besitzen soll. Die Klasse Example aus dem vorigen Abschnitt sieht geändert folgendermaßen aus: public class Example { private string name = String.Empty; private string firstName = String.Empty; // Eigenschaften public string Name { get { return this.name; } private set { this.name = value; } } public string FirstName { get { return this.firstName; } private set { this.firstName = value; } } public string FullName { get { return this.firstName + " " + this.name; } set { string[] s = value.Split( ' ' ); this.firstName = s[0]; this.name = s[1]; } } }
In diesem Fall sind die beiden set-Methoden der Eigenschaften Name bzw. FirstName als private deklariert, obwohl die Eigenschaften selbst (und damit deren Getter) als public deklariert sind.
Der Indexer Klassen in C# besitzen die Möglichkeit, eine ganz spezielle Eigenschaft festzulegen, die es innerhalb der Klasse genau einmal gibt, den so genannten Indexer. Er wird ausschließlich für Listen verwendet und dient dazu, auf einfache Art und Weise auf die Elemente der Liste zugreifen zu können. Der Indexer ist die Eigenschaft, die abgerufen wird, wenn Sie beispielsweise auf ein Element eines Arrays zugreifen.
Sandini Bib
HINWEIS
Statische Klassenelemente
155
Da diese Sprachkonstruktion in einer herkömmlichen Klasse kaum Sinn macht (sie enthält ja normalerweise nicht mehrere Objekte), werden Indexer detailliert im Zusammenhang mit den Collection-Klassen in Abschnitt 13.4.1 ab Seite 300 besprochen.
6.4
Statische Klassenelemente
Bisher wurden lediglich Bestandteile einer Klasse besprochen, bei denen für den Zugriff eine Instanz der Klasse erforderlich ist. Eine andere Art des Zugriffs ermöglichen statische Klassenelemente. Bei diesen ist die Deklaration nicht an eine Instanz der Klasse gebunden, sondern an die Klasse selbst. Das ist ein gravierender Unterschied. Während in Instanzvariablen gespeicherte Werte für jedes Objekt getrennt existieren, sind die Werte statischer Variablen für alle Instanzen gleich (da sie ja nicht an eine Instanz gebunden sind). Statische Elemente treten im .NET Framework dort auf, wo keine Instanz einer Klasse benötigt wird (z.B. in der Klasse Console, die ausschließlich statische Elemente besitzt oder auch in der Klasse Math, deren statische Elemente Rechenoperationen darstellen). Ein weiteres Einsatzgebiet sind die in objektorientierten Sprachen gerne verwendeten Patterns, also allgemeingültige Vorgehensweisen für die Implementierung bestimmter Funktionalitäten. Um ein Element einer Klasse als statisch zu deklarieren, muss diesem lediglich der Modifizierer static vorangestellt werden. Sämtliche Elemente einer Klasse können auch statisch sein. Für Felder und Methoden gilt, dass der Zugriff dann auf Klassenebene möglich ist. Auf diese Weise können »quasi-globale« Variablen und Methoden realisiert werden.
Beispiel Als Beispiel dient in diesem Fall eine eigene Klasse namens RgbColor. Sie steht für eine beliebige Farbe, die aus einem Rot-, Grün- und Blauanteil gebildet werden kann. Um es dem Anwender zu vereinfachen, auf Standardfarben (wie z.B. Rot, Grün und Blau) zuzugreifen, werden statische Eigenschaften implementiert, die ein entsprechend initialisiertes Objekt zurückliefern. public class RgbColor { private int red = 0; private int green = 0; private int blue = 0; public static RgbColor ColorRed { get { return new RgbColor( 255, 0, 0 ); } }
Sandini Bib
6 Klassen und Objekte
156 public static RgbColor ColorGreen { get { return new RgbColor( 0, 255, 0 ); } } public static RgbColor ColorBlue { get { return new RgbColor( 0, 0, 255 ); } } public RgbColor( int red, int green, int blue ) { this.red = red; this.green = green; this.blue = blue; } }
Aus statischen Methoden oder Eigenschaften heraus können Sie nicht auf Instanzvariablen oder allgemein Instanzenmember der Klasse zugreifen. Bei diesen ist für den Zugriff eine Instanz der Klasse Voraussetzung, die innerhalb einer statischen Methode oder Eigenschaft nicht existiert. Wie in der Beispielklasse ist es aber durchaus möglich, eine Instanz der Klasse zu erzeugen. Das ist übrigens auch bereits ein Pattern – da innerhalb der getMethode der Eigenschaft ein fertiges Objekt der Klasse gebildet wird, bezeichnet man dieses Pattern als Fabrik-Pattern oder Factory-Pattern.
6.4.1
Private Konstruktoren
Wenn Sie eine solche Klasse zusammenstellen und die Instanzierung von »außerhalb« verhindern wollen, haben Sie die Möglichkeit, den Konstruktor der Klasse als private zu deklarieren. Das bewirkt, dass auf herkömmlichem Weg keine Instanz der Klasse mehr erzeugt werden kann, wohl aber »von innen«, also durch eine statische Methode oder Eigenschaft der Klasse oder innerhalb einer verschachtelten Klasse. Mehr zu verschachtelten Klassen finden Sie in Abschnitt 7.3.3 ab Seite 178. Ein häufig verwendetes Pattern ist in diesem Fall das Singleton-Pattern. Es stellt sicher, dass von einer Klasse immer nur eine Instanz erzeugt werden kann. Dies geschieht nicht auf herkömmliche Art und Weise, sondern über eine statische Methode oder Eigenschaft, die ihrerseits den (privaten) Konstruktor der Klasse aufruft, falls noch keine Instanz existiert. Eine erzeugte Instanz wird in einem ebenfalls statischen Feld gespeichert. Das ist notwendig, weil aus einer statischen Eigenschaft oder Methode heraus nicht auf Instanzfelder zugegriffen werden kann. Dieses statische Feld wird aber dennoch als private deklariert, auch hier ist also der Zugriff von Außen nicht möglich. Ein Standard-Singleton-Pattern zeigt das folgende Listing. Beachten Sie, dass es sich um eine herkömmliche Klasse handelt, Sie können also beliebig Instanzmethoden, -eigenschaften oder -felder hinzufügen. Verhindert wird lediglich, dass mehr als eine Instanz dieser Klasse existiert.
Sandini Bib
Statische Klassenelemente
157
public class Singleton { // statisches Feld zum Speichern der Instanz private static Singleton instance = null; // Eigenschaft zum Erzeugen der Instanz public static Singleton CreateInstance { get { if ( instance == null ) instance = new Singleton(); return instance; } } // privater Konstruktor private Singleton() { // Initialisierung }
HINWEIS
}
6.4.2
Wenn die Rede von Patterns oder Entwurfsmustern ist, denken viele sofort an komplexe Gebilde, die nur von professionellen Programmierern verwendet werden. Das ist keineswegs der Fall. Es ist vielmehr so, dass Patterns das Programmieren an vielen Stellen erleichtern, weil sie ein Standardvorgehen für bestimmte Anwendungsfälle beschreiben. Auch wenn die Namen mitunter noch so kompliziert sein mögen, schauen Sie sich Patterns ruhig genauer an. Die Singleton-Klasse ist auch ein Pattern, und sicherlich nicht übertrieben kompliziert, oder?
Statische Konstruktoren
Statische Konstruktoren dienen der einmaligen Initialisierung von Werten. Es wird wieder das Schlüsselwort static verwendet. Der genaue Aufrufzeitpunkt eines statischen Konstruktors ist nicht bekannt. Er wird aber in jedem Fall aufgerufen, bevor der erste statische Member einer Klasse aufgerufen oder eine Instanz der Klasse erzeugt wird. Für statische Konstruktoren gelten allerdings einige weiterführende Regeln: f Statische Konstruktoren haben außer static keinen Modifizierer, denn der Zugriff darauf ist nicht explizit möglich. Stattdessen entscheidet die Laufzeitumgebung über den Zeitpunkt des Aufrufs. f Der Benutzer kann nicht steuern, wann ein statischer Konstruktor aufgerufen wird. f Ein statischer Konstruktor besitzt keine Parameter (da er automatisch aufgerufen wird, gibt es ja auch keine Möglichkeit, ihm diese zu übergeben)
Sandini Bib
6 Klassen und Objekte
158
f Der explizite Aufruf eines statischen Konstruktors ist nicht möglich. Weiterhin gilt aber auch: f Der statische Konstruktor wird nach der Initialisierung etwaiger statischer Variablen der Klasse aufgerufen. f Ein statischer Konstruktor wird während eines Programmlaufs nur ein einziges Mal aufgerufen.
6.4.3
Statische Klassen
Im .NET Framework existieren einige Klassen, die ausschließlich statische Member beinhalten. Ein Beispiel dafür ist die Klasse Console, ein weiteres Beispiel die Klasse Math mit ihren zahlreichen mathematischen Funktionen. Häufig besitzen derartige Klassen auch die Eigenschaft, nicht instanziierbar zu sein. Das wird realisiert durch einen als private deklarierten Konstruktor, wodurch dieser nicht mehr aufgerufen und die Klasse nicht mehr instanziiert werden kann. .NET 2.0 ermöglicht nun die Deklaration so genannter statischer Klassen. Der Unterschied zu einer herkömmlichen Klasse besteht darin, dass im Deklarationskopf das reservierte Wort static verwendet wird, wie im folgenden Listing: public static class MyStaticClass { // Deklarationen }
Die Deklaration einer gesamten Klasse als statisch bewirkt zunächst das gleiche wie die Deklaration eines privaten Konstruktors, die Klasse kann nicht mehr instanziert werden. Gleichzeitig fordert der Compiler für eine derartige Klasse, dass alle ihre Member als static definiert sein müssen. Das bleibt Ihnen also nicht erspart.
6.5
Modifizierer
Modifizierer oder auf englisch Modifier wurden im Verlauf dieses Kapitels bereits öfter verwendet. Sie sind die Steuerzentrale für das Verhalten bzw. die Sichtbarkeit der Klassenelemente. Anders als in C++, wo Modifizierer bei der Deklaration einer Klasse für einen ganzen Bereich von Deklarationen gültig sein können, ist ein Modifizierer in C# immer nur für das Element gültig, dem er vorangestellt ist. Kennen gelernt haben Sie bereits die Modifizierer public und private für die Sichtbarkeit sowie static für die Deklaration von Elementen auf Klassenebene. Die folgende Tabelle listet alle verfügbaren Modifizierer von C# und ihren Verwendungszweck auf.
Sandini Bib
159
Modifizierer Modifizierer
Verwendungszweck/Bedeutung
public
Zugriff/Sichtbarkeit. Elemente, die als public deklariert sind, sind von außerhalb der Klasse sichtbar, d.h. auf diese Member kann zugegriffen werden.
private
Zugriff/Sichtbarkeit. Auf Elemente, die als private deklariert sind, kann nur aus der Klasse heraus zugegriffen werden, in der sie deklariert sind. Der Zugriff aus von der Klasse abgeleiteten Klassen ist ebenfalls nicht möglich.
internal
Zugriff/Sichtbarkeit. Der Zugriff auf ein als internal deklariertes Element ist nur aus Dateien der gleichen Assembly heraus möglich. Dieser Zugriffsmodifizierer kann mit protected kombiniert werden.
protected
Zugriff/Sichtbarkeit. Der Zugriff auf ein als protected deklariertes Element ist nur innerhalb der Klasse oder innerhalb einer abgeleiteten Klasse möglich. Dieser Modifizierer kann mit internal kombiniert werden.
abstract
Als abstrakt (abstract) werden unvollständige Member einer Klasse bezeichnet, beispielsweise Methoden ohne konkrete Implementierung. Dadurch wird die Klasse selbst automatisch ebenfalls abstrakt. Von abstrakten Klassen kann/darf keine Instanz erzeugt werden, erzwingen, dass eine andere Klasse abgeleitet wird. Mehr über abstrakte Klassen erfahren Sie in Abschnitt 7.3.2 ab Seite 178.
event
Der Modifizierer event wird oftmals fälschlicherweise als herkömmliches Schlüsselwort bzw. reserviertes Wort bezeichnet. Tatsächlich ist es so, dass durch event aus Delegates Ereignisse deklariert werden. Der Modifizierer hat Einfluss auf die Verwendungsmöglichkeit des damit bezeichneten Delegates. Mehr über Ereignisse und ihre Implementierung finden Sie in Abschnitt 8.2.3 auf Seite 189.
extern
Der Modifizierer extern bezieht sich auf Methoden. Er gibt an, dass die Methode nicht innerhalb des aktuellen Projekts, sondern in einer DLL des Betriebssystems deklariert ist. Dieser Modifizierer wird für das so genannte P/Invoke, den Zugriff auf WinAPI-Funktionen, verwendet.
override
override dient zum Überschreiben einer Methode der Basisklasse, die als virtual bezeichnet ist und sowohl gleiche Signatur als auch gleiche Sichtbarkeit aufweisen muss.
partial
Der Modifizierer partial ermöglicht das Aufteilen einer Klasse in mehrere Dateien.
readonly
Der Modifizierer readonly ermöglicht Nur-Lese-Zugriff auf öffentliche Instanzvariablen. Normalerweise sollte es aber keine öffentlichen Instanzvariablen geben, sondern der Zugriff immer über Eigenschaften erfolgen.
sealed
sealed ist sozusagen das Gegenteil von abstract. Von einer »versiegelten« Klasse kann nicht abgeleitet werden. Bekanntester Vertreter solcher Klassen ist (zum Leidwesen vieler Programmierer) die Klasse String.
static
Der Modifizierer static legt fest, dass der damit bezeichnete Member einer Klasse Bestandteil auf Klassenebene und nicht auf Instanzebene ist.
unsafe
Der Modifizierer unsafe dient zum Erzeugen von »unsicherem« Code, also Code, der nicht von der Laufzeitumgebung kontrolliert wird und auf den auch die Garbage Collection keine Einwirkung hat. In diesem Buch wird unsafe Code nicht weiter behandelt.
Sandini Bib
6 Klassen und Objekte
160
Modifizierer
Verwendungszweck/Bedeutung
virtual
Der Modifizierer virtual legt fest, dass ein damit bezeichneter Member in einer abgeleiteten Klasse überschrieben werden kann.
volatile
volatile
new
Achtung – hier ist der Modifizierer new gemeint, nicht der Operator new zum Erzeugen einer neuen Instanz. Der Modifizierer new dient dazu, Member einer Klasse in einer davon abgeleiteten Klasse zu verdecken. Genaueres hierzu erfahren Sie in Abschnitt 0 ab Seite 175.
ist ein Schlüsselwort, das in diesem Buch keine weitere Verwendung findet. Es wird nur für Felder (Instanzvariablen) verwendet. Das so bezeichnete Feld kann vom Betriebssystem ausgelesen werden.
Standardmodifizierer für den Zugriff Nicht immer müssen Zugriffsmodifizierer verwendet werden, teilweise bietet bereits der Standard-Zugriffsmodifizierer die gewünschte Funktion. Beachten Sie bitte, dass es hier nur um die vier Zugriffsmodifizierer public, private, internal und protected geht, nicht um die übrigen in der Tabelle angegebenen. f Aufzählungen (Enums): Auf alle Member einer Aufzählung kann immer zugegriffen werden, diese gelten grundsätzlich als public. Eine Änderung mittels Modifizierer ist bei Aufzählungen nicht möglich. f Klassen: Der Standard-Zugriffsmodifizierer für eine Klasse (also für die Klasse selbst) ist internal. Innerhalb von DLLs, bei denen die Klassen auch von außerhalb der DLL sichtbar und verwendbar sein sollen, sollten Sie public als Modifizierer für die Klasse selbst verwenden. Innerhalb der Klasse gilt, dass jeder Member ohne expliziten Modifizierer als private deklariert ist. Zulässige Zugriffsmodifizierer sind alle oben angegebenen. f Interfaces: Interfaces stellen Schnittstellen, also einen allgemeingültigen Zugriff auf Objekte mit unterschiedlicher Funktionalität zur Verfügung. Für Interfaces gilt, dass der Standard-Zugriffsmodifizierer für das Interface selbst internal ist. Anders als bei Klassen jedoch sind alle Member eines Interface public. Eine Änderung der Sichtbarkeit ist nicht möglich. f Strukturen/Structs: Auch Strukturen wurden noch nicht besprochen, was aber keinen Einfluss auf diese Erklärung hat. Für einen struct gilt das Gleiche wie für Klassen, was sowohl den Zugriff auf den struct selbst als auch auf seine Member angeht. Der Unterschied besteht lediglich in den erlaubten Zugriffsmodifizierern für die Member. Bei einem struct dürfen hier nur die Modifizierer public, internal und private verwendet werden, protected ist nicht erlaubt. Details über Strukturen erhalten Sie in Abschnitt 6.8 ab Seite 164.
Sandini Bib
Operatorenüberladung
6.6
161
Operatorenüberladung
Datentypen können mit Operatoren umgehen, oder anders herum gesagt, Operatoren können auf Datentypen angewendet werden. Beispielsweise resultiert die Anwendung des +-Operators bei Zahlen in deren Addition und der Rückgabe eines Werts. Da eines der Grundprinzipien der Objektorientierung besagt, dass alle Operationen mit den Daten eines Datentyps ausschließlich durch Methoden durchgeführt werden dürfen, die innerhalb dieses Datentyps definiert sind, verwundert es nicht, dass auch die anwendbaren Operatoren bei Datentypen in Form spezieller Methoden ausgeführt sind. Diese Methoden lassen sich ändern, und damit auch das Verhalten von Datentypen bei der Anwendung von Operatoren.
6.6.1
Überladen mathematischer Operatoren
Eine Überladung der mathematischen Operatoren kommt normalerweise nur in einem bestimmten Umfeld zum Einsatz, nämlich wenn es um Zahlensysteme geht, die nicht den uns bekannten arithmetischen Regeln unterliegen sollen. Operatoren sind generell statische Elemente einer Klasse oder eines struct. Die grundsätzliche Syntax für die Operatorenüberladung ähnelt der einer herkömmlichen Methode, wobei aber zusätzlich das reservierte Wort operator zum Einsatz kommt. Die folgende kleine Beispielklasse überlädt den Operator ^, sodass er künftig statt für eine Exklusiv-oderVerknüpfung zum Potenzieren verwendet werden kann. Auch wenn es sich hierbei nicht wirklich um eine sinnvolle Anwendung dieses Features handelt, so dient das Beispiel doch dazu, die grundsätzliche Vorgehensweise aufzuzeigen (und sehr viel tiefer wollen wir an dieser Stelle auch nicht einsteigen). public struct SpecialInteger { int value; public static SpecialInteger operator ^( SpecialInteger x, SpecialInteger y ) { double result = Math.Pow( (double)x.value, (double)y.value ); return new SpecialInteger( (int)result ); } ... // Weitere Deklarationen public SpecialInteger( int x ) { this.value = x; } public SpecialInteger() { this.value = 0; } }
Sandini Bib
6 Klassen und Objekte
162
Die beiden Parameter der operator-Methode entsprechen den beiden Werten, die vor und hinter dem Operator stehen. Dementsprechend benötigen Sie für binäre Operatoren (also für solche, die mit zwei Werten arbeiten) zwei Parameter, für unäre (z.B den Operator ++) nur einen.
Überladbare und nicht überladbare Operatoren Nicht jeder Operator kann überladen werden und einige müssen gemeinsam überladen werden. Zusammengesetzte Operatoren beispielsweise können nicht überladen werden, da es sich nicht um eigenständige Operatoren handelt. Das .NET Framework fasst hier den eigentlichen Rechenoperator mit dem Zuweisungsoperator zusammen. Gemeinsam überladen werden müssen die Vergleichsoperatoren == und !=. Das hat mit der Erwartung des Benutzers zu tun, dass sich != genau umgekehrt wie == verhält. Überladbare Operatoren sind +, -, *, /, %, &, |, ^, >>, <<, true und false. Die beiden letztgenannten nehmen eine gewisse Sonderstellung ein. Zwar kann nur ein Datentyp die Werte true und false annehmen (nämlich der Datentyp bool), ein Vergleich zweier Werte kann aber ebenfalls einen booleschen Wert zurückliefern. Genau darum geht es in diesem Fall. Der Hintergrund für die Verwendung dieser Operatoren sind die Vergleiche mittels && bzw. ||. Wie Sie sicherlich festgestellt haben befinden sich diese Operatoren nicht in der Liste der überladbaren Operatoren. Und das aus gutem Grund, werden sie doch anders ausgewertet, nämlich über die Operatoren true und false. Die folgenden Codezeilen entsprechen dem Äquivalent von x && y bzw. x || y: x && y wird ausgewertet als: T.false( x ) ? x : T.&( x, y ); x || y wird ausgewertet als: T.true( x ) ? x : T.|( x, y );
Hinter dieser zunächst etwas kompliziert anmutenden Auswertung steht eigentlich nur das Short-Circuit-Konzept. Im Falle einer und-Verknüpfung wird die Auswertung abgebrochen, wenn der erste Vergleich bereits false liefert (dann kann der übrige Vergleich nicht mehr zu einem endgültigen true werden). Bei einer oder-Verknüpfung wird abgebrochen, sobald der erste Vergleichswert true liefert (auch in diesem Fall ist die gesamte oderVerknüpfung bereits true).
6.6.2
Überladen der Konvertierungsoperatoren
Sie kennen bereits die zwei Möglichkeiten des Castings, nämlich implizites Casting und explizites Casting. Das Verhalten eines Objekts beim Anwenden dieser Technik wird ebenfalls durch Operatoren gesteuert, die Sie auch überladen können. Dass es sich um die Konvertierungsoperatoren handelt, wird durch die reservierten Wörter implicit und explicit ausgedrückt. Auch diese Operatoren werden in Form einer Methode dargestellt. Wollten Sie eine implizite und eine explizite Konvertierung für den oben kurz angerissenen Datentyp SpecialInteger festlegen (z.B. zum Konvertieren von und in den Datentyp int), würde das folgendermaßen aussehen:
Sandini Bib
Partielle Klassen
163
public static explicit operator SpecialInteger( int x ) { return new SpecialInteger( x ); // Explizite Konvertierung }
HINWEIS
public static implicit operator int( SpecialInteger s ) { return s.value; // Implizite Konvertierung }
6.7
Für weitere Informationen zur Operatorenüberladung konsultieren Sie bitte die Online-Hilfe. Der Grund, warum dieses Thema an dieser Stelle nur recht rudimentär behandelt wird, ist, dass man Operatorenüberladung so gut wie nie braucht. Sollten Sie diese Möglichkeit dennoch benötigen, werden Sie sich sicher schnell damit zurechtfinden – im Grunde handelt es sich nur um eine andere Art von Methode und ein zusätzliches Schlüsselwort.
Partielle Klassen
Ein weiteres neues Feature in .NET 2.0 sind die so genannten partiellen Klassen oder Partial Classes. Der Grund für diese Erweiterung war, dass eine Klasse schnell unübersichtlich werden konnte. Sehr deutlich wurde das im Bereich Windows.Forms bei den Form-Klassen. Hier generiert das Visual Studio eine enorme Menge Code, die für den Entwickler letztlich uninteressant ist – da dieser Code ständig neu generiert wird, wenn ein Element hinzugefügt wird, sind Änderungen darin ohnehin nutzlos. Partial Classes ermöglichen die Aufteilung einer Klasse in mehrere Dateien. Das Visual Studio macht bei den Formularen auch gleich von dieser neuen Möglichkeit Gebrauch. Die generierten Codebestandteile befinden sich in einer eigenen Datei mit Namen .Designer.cs, während der Code des Entwicklers wie vorher in der Datei .cs platziert wird. Diese Aufteilung macht den Code auf einen Schlag übersichtlicher.
HINWEIS
Um aus einer Klasse eine partielle Klasse zu machen genügt es, den Modifizierer partial zu verwenden. Ist die Klasse von einer anderen Klasse abgeleitet, genügt es in diesem Fall, wenn diese Ableitung in einer Datei vorgenommen wird. Zu diesem Feature gab es Bedenken bezüglich Klassen, die sich bereits kompiliert in einer DLL befinden. Sollte es sich um partielle Klassen handeln, könnten diese leicht erweitert werden, indem einfach eine Klasse gleichen Namens definiert wird, die ebenfalls mit dem Schlüsselwort partial versehen ist. Das .NET Framework bzw. die Compiler des .NET Frameworks erlauben ein solches Vorgehen jedoch nicht. Um eine partielle Klasse erweitern zu können, muss der gesamte Quellcode der Klasse vorliegen und kompiliert werden können.
Sandini Bib
6 Klassen und Objekte
164
6.8
Strukturen (struct)
Eigentlich sollte es für Strukturen ein eigenes Kapitel geben. Sie ähneln jedoch den Klassen so sehr, dass sie gleich hier in diesem Kapitel besprochen werden. Während es sich bei Klassen um Verweistypen handelt, sind Strukturen Wertetypen. Damit verhalten sie sich natürlich anders als Klassen, können aber fast die gleichen Member aufnehmen. Anders als in Sprachen wie Delphi, dessen Records mit Strukturen vergleichbar sind, können Strukturen nicht nur Daten aufnehmen, sondern auch Eigenschaften oder Methoden besitzen. Sie besitzen auch einen Konstruktor. Es gibt aber dennoch einige Unterschiede zu Klassen: f Strukturen können nicht Basis einer Vererbungshierarchie sein, erben selbst von ValueType, das seinerseits implizit von Object abgeleitet ist. f Der Standardkonstruktor ist in Strukturen implizit vorhanden und kann nicht deklariert oder entfernt werden. f Strukturen können ohne Initialisierung durch new verwendet werden. f Strukturen können Interfaces implementieren. Alle Wertetypen (int, double usw.) sind intern als Strukturen ausgelegt. Das Vorhandensein eines Konstruktors bei einem Wertetyp mag Verwirrung stiften, dieser dient allerdings nur dazu, die Felder des struct mit Standardwerten zu initialisieren. Daher muss das Schlüsselwort new nicht verwendet werden. Falls Sie auf new verzichten, müssen die Felder des struct vor seiner erstmaligen Benutzung allerdings mit Werten belegt werden. Die Implementierung eines Interface geschieht auf die gleiche Weise wie bei Klassen. Worum es sich dabei genau handelt und wie diese Implementierung vor sich geht, erfahren Sie in Kapitel 9 ab Seite 199.
6.8.1
Deklaration
Die Deklaration eines typischen struct sieht folgendermaßen aus: public struct Coords { private int x; private int y; private int z; public int X { get { return this.x; } set { this.x = value; } }
Sandini Bib
Strukturen (struct)
165
public int Y { get { return this.y; } set { this.y = value; } } public int Z { get { return this.z; } set { this.z = value; } } }
Wie Sie sehen greift auch hier das Konzept der Kapselung; auch im Falle eines struct ist die einzige Zugriffsmöglichkeit auf die Daten eine Eigenschaft. Der Zugriff auf einen solchen struct kann auf zwei Arten geschehen. Entweder, indem zur Initialisierung new verwendet wird oder indem der struct einfach deklariert und Werte zugewiesen werden. Das ist mit Klassen nicht möglich. public static void Main() { Coords myCoords; myCoords.X = 15; myCoords.Y = 20; myCoords.Z = 30; }
Außer dem Standardkonstruktor, der bei Strukturen grundsätzlich immer vorhanden ist, können Sie natürlich auch einen eigenen Konstruktor deklarieren. In diesem Fall allerdings ist der Standardkonstruktor nach wie vor verfügbar. Auch darin besteht ein Unterschied zu einer Klasse. public struct Coords { [ ... ] public Coords( int x, int y, int z ) { this.x = x; this.y = y; this.z = z; }
HINWEIS
}
Obwohl ein struct ein Wertetyp ist, funktioniert dennoch der Zugriff auf die Member mittels des reservierten Worts this. Teilweise wird in diesem Buch auch von der »Instanz einer Struktur« gesprochen (in Ermangelung eines anderen Ausdrucks). In diesem Fall ist ein mit Werten gefüllter struct gemeint.
Sandini Bib
6 Klassen und Objekte
166
Der Standardkonstruktor steht jetzt nach wie vor zur Verfügung. Daher kann der angegebene struct auf drei Arten verwendet werden: f durch Verwendung des Standardkonstruktors (über new), f durch Verwendung des expliziten Konstruktors (ebenfalls über new), f ohne Konstruktor durch einfache Zuweisung der Werte.
6.8.2
Nullable Structs
Das neue Feature des Nullable Types gilt selbstverständlich auch für Strukturen. Als Wertetyp kann auch ein struct nicht den Wert null annehmen. Dieses Verhalten ist jedoch häufig sinnvoll, beispielsweise bei dem Datentyp DateTime (denn nicht immer ist ein Datum auch bekannt). Die folgende Deklaration ist problemlos möglich: DateTime? dt = null; if ( dt == null ) dt = new DateTime( 2005, 08, 12 )
Sandini Bib
7
Vererbung und Polymorphie
Die Vererbung ist eines der grundlegendsten Merkmale einer objektorientierten Programmiersprache. Oder anders ausgedrückt: Wenn eine Sprache Vererbung nicht unterstützt, ist sie auch nicht objektorientiert. Visual Basic 6 ist (man sollte schon fast sagen »war«) eine solche Sprache. Sie ermöglicht zwar die Verwendung von Objekten und die Deklaration von Klassen, beinhaltet aber keine Möglichkeit der Implementierungsvererbung und ist somit lediglich als objektbasiert zu bezeichnen. Es wird zwischen zwei Arten der Vererbung unterschieden, der Einfachvererbung, bei der jede Klasse nur einen Vorfahren, eine Basisklasse haben kann, und der Mehrfachvererbung, bei der eine Klasse mehrere Vorfahren haben kann. In C# ist bei Klassen die Einfachvererbung implementiert. Wie wir später aber noch sehen werden, gibt es im Falle von Interfaces durchaus die Möglichkeit der Mehrfachvererbung. Genaueres darüber erfahren Sie in Kapitel 9 ab Seite 199.
7.1
Vererbung
7.1.1
Ableiten von Klassen
Zum Ableiten einer Klasse von einer anderen Basisklasse als Object wird der DoppelpunktOperator benutzt. Die Basisklasse wird im Deklarationskopf hinter den Namen der neuen Klasse geschrieben. Die neue Klasse erbt daraufhin alle nicht-privaten Eigenschaften, Variablen und Methoden der Basisklasse, d.h. wenn ein Objekt der neuen Klasse erzeugt wird, besitzt dieses bereits die meisten Möglichkeiten, die die Basisklasse zur Verfügung stellt. Die Vererbung ist dabei nicht auf eine einzige Basisklasse beschränkt. Es ist durchaus möglich (wie auch die Klassen in .NET zeigen), dass von einer abgeleiteten Klasse eine weitere Klasse abgeleitet wird und dementsprechend weiter spezialisiert werden kann.
HINWEIS
Anhand eines Beispiels soll die Vorgehensweise erläutert werden. Als Grundlage dienen Fahrzeugklassen. Im Beispiel wird eine Basisklasse Vehicle deklariert, die als Grundmerkmal für alle Fahrzeuge lediglich einige Eigenschaften zur Verfügung stellt, die allen Fahrzeugen gemeinsam sind (z.B. hat jedes Fahrzeug eine Farbe und eine Maximalgeschwindigkeit). Davon werden zwei weitere Klassen abgeleitet, WaterVehicle und LandVehicle. Diese werden nun spezialisiert. Während WaterVehicle beispielsweise ein Segel haben kann, hat LandVehicle garantiert Räder. Das Ableiten von Klassen von einer Basisklasse wird auch als Spezialisierung bezeichnet. Eine abgeleitete Klasse ist grundsätzlich spezieller als eine Basisklasse, weil sie zusätzlich zu deren Features weitere Methoden, Eigenschaften oder auch Ereignisse spezifiziert. Das ist auch eine Grundlage für die Polymorphie, die in Abschnitt 7.2 ab Seite 173 beschrieben wird.
Sandini Bib
7 Vererbung und Polymorphie
CD
168
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_07\Vererbung.
public class Vehicle { private string baseColor = String.Empty; private double maxSpeed = 0.0; private double currentSpeed = 0.0; public string BaseColor { get { return this.baseColor; } set { this.baseColor = value; } } public double MaxSpeed { get { return this.maxSpeed; } set { this.maxSpeed = value; } } public double CurrentSpeed { get { return this.currentSpeed; } set { this.currentSpeed = value; } } public Vehicle() { } } // Das Landfahrzeug class LandVehicle : Vehicle { private int wheels = 4; public int Wheels { get { return this.wheels; } set { this.wheels = value; } } public LandVehicle() { } public LandVehicle( string baseColor, int wheels ) { this.wheels = wheels; this.BaseColor = baseColor; } }
Sandini Bib
Vererbung
169
// Das Wasserfahrzeug class WaterVehicle : Vehicle { private bool hasOutboard = false; private bool hasSail = false; public bool HasOutboard { get { return this.hasOutboard; } set { this.hasOutboard = value; } } public bool HasSail { get { return this.hasSail; } set { this.hasSail = value; } } public WaterVehicle() { } public WaterVehicle( string baseColor, bool hasOutBoard ) { this.hasOutboard = hasOutBoard; this.BaseColor = baseColor; } }
In dieser kleinen Beispielapplikation wurden für die abgeleiteten Objekte nur jeweils zwei Konstruktoren vorgesehen (einmal der Standardkonstruktor und einmal ein Konstruktor, mit dem einige Werte eingestellt werden können). In umfangreichen Anwendungen ist es häufig der Fall, dass Konstruktoren vielfach überladen sind, sodass jede erdenkliche Kombination an (sinnvollen) Werten bereits bei der Instanziierung eingestellt werden kann. Im Konstruktor der abgeleiteten Klassen wird auf eine Eigenschaft zugegriffen, die von der Basisklasse zur Verfügung gestellt wird (BaseColor). Der direkte Zugriff auf Elemente der Basisklasse ist möglich, sofern diese nicht als private gekennzeichnet sind. Der Zugriff auf die Eigenschaften der Basisklasse ist bei jedem erzeugten Objekt auch der abgeleiteten Klassen möglich, nicht nur aus dem Konstruktor heraus, sondern auch »von außen«. Der folgende Codeausschnitt erzeugt ein neues LandVehicle-Objekt und weist den Eigenschaften Werte zu. class Program { static void Main( string[] args ) { // Neues Landfahrzeug LandVehicle myCar = new LandVehicle( "Blue", 4 ); myCar.MaxSpeed = 240; // Ausgabe: Console.WriteLine( "Geschwindigkeit: {0}", myCar.CurrentSpeed ); Console.WriteLine( "Maximalgeschw. : {0}", myCar.MaxSpeed );
Sandini Bib
7 Vererbung und Polymorphie
170
Console.WriteLine( "Farbe..........: {0}", myCar.BaseColor ); Console.WriteLine( "Räder..........: {0}", myCar.Wheels ); Console.ReadLine(); } }
Abbildung 7.1 zeigt die Ausgabe auf der Konsole.
Abbildung 7.1: Ausgabe der Eigenschaften des neuen Objekts LandVehicle
7.1.2
Gemeinsame Methoden aller Klassen
Da alle Klassen implizit von der Klasse Object erben, besitzen auch alle Klassen die Methoden, die Object zur Verfügung stellt: f Equals(): Die Methode Equals() dient dem Vergleich, ob zwei Objekte gleich sind. Da es sich hier um Referenztypen handelt, ist die Standardfunktion der Methode Equals() der Objektvergleich, d.h. es wird kontrolliert, ob zwei Objektvariablen auf das gleiche Objekt auf dem Heap zeigen. Die Methode kann jedoch überschrieben werden. f GetHashCode(): Ein Hashcode ist ein eindeutiger Code für ein betreffendes Objekt. Auf diese Art Code werden wir später noch zurückkommen, wenn es bei den Collections um die Klasse Hashtable geht. Die Standardimplementierung dieses Algorithmus ist allerdings weniger geglückt, da hier teilweise nur ein int-Wert zurückgeliefert wird, der mit jedem neuen Objekt um eins erhöht wird. Bei Strings ist es so, dass gleiche Strings auch den gleichen Hashcode ergeben, was nicht wirklich sinnvoll ist. f GetType(): Die Methode GetType() liefert eine Instanz der Klasse Type, die den Datentyp des aktuellen Objekts repräsentiert. f ToString(): Die Methode ToString() liefert eine Zeichenkette, die das aktuelle Objekt oder dessen Inhalt repräsentiert. ToString() wird unter anderem zur Anzeige von Objekten in Listboxen und anderen Steuerelementen verwendet und kann zu eigenen Zwecken einfach überschrieben werden.
Sandini Bib
171
Vererbung
7.1.3
Virtuelle Methoden
Die Sprachdefinition von C# erlaubt es, nicht nur neue Member in einer abgeleiteten Klasse hinzuzufügen, sondern im Falle von Methoden bestehende Methoden auch zu spezialisieren. Diese Möglichkeit bezeichnet man als Überschreiben oder Überlagern von Methoden. Da der Begriff des Überschreibens bereits für gleichnamige Methoden innerhalb einer Klasse verwendet wurde, die eine unterschiedliche Signatur aufweisen, wird im Folgenden der Begriff »überlagerte Methode« verwendet. Damit eine Methode überlagert werden kann, muss sie mit dem Modifizierer virtual versehen sein. Für die überlagernde Methode gilt, dass sie mit dem Modifizierer override versehen sein muss, was soviel bedeutet wie »sich darüber hinwegsetzen«. Mittels override erhält die Methode in der abgeleiteten Klasse eine neue Bedeutung. Wird eine als virtual deklarierte Methode nicht überlagert, ist sie natürlich dennoch existent. Der Compiler geht immer vom aktuellen Objekt aus und sucht sich dann die Implementierung der Methode, die am nächsten liegt. Wurde die ursprüngliche Implementierung nicht geändert, wird auch auf die ursprüngliche Methode zugegriffen. Zu Demonstrationszwecken wird die Klasse Vehicle um eine Methode Accelerate() erweitert. Wie der Name schon sagt dient diese Methode zum Beschleunigen des jeweiligen Fahrzeugs. Diese ist natürlich für jedes Fahrzeug unterschiedlich. Deshalb wird die Methode als virtual deklariert und kann überlagert werden.
CD
Das Beispielprogramm unterscheidet sich nur wenig vom vorhergehenden Beispiel. Sie finden es auf der beiliegenden CD im Verzeichnis Buchdaten\Beispiele\Kapitel_07\Vererbung2.
class Vehicle { [...] public virtual void Accelerate() { this.currentSpeed += 1.0; Console.WriteLine( "Beschleunige Vehicle: Geschwindigkeit " + this.currentSpeed.ToString() ); } }
In der Klasse LandVehicle wird diese Methode jetzt überlagert, sodass die Ausgabe eine andere ist: public class LandVehicle {
Sandini Bib
172
7 Vererbung und Polymorphie
// Überlagern der Methode Accelerate() public override void Accelerate() { this.CurrentSpeed += 3.0; Console.WriteLine( "Beschleunige LandVehicle: Geschwindigkeit " + this.currentSpeed.ToString() ); } }
Das folgende kleine Hauptprogramm liefert jetzt unterschiedliche Ausgaben für Objekte des Typs LandVehicle und Vehicle (bzw. auch LandVehicle und WaterVehicle). Die Beschleunigung von WaterVehicle entspricht der Beschleunigung von Vehicle, da hier die Methode Accelerate() nicht überlagert wurde. Im Falle von LandVehicle ist die Beschleunigung eine andere, denn hier wurde die Methode überlagert. static void Main( string[] args ) { // Beispiel 2: LandVehicle myCar = new LandVehicle( "Blue", 4 ); myCar.MaxSpeed = 240; myCar.CurrentSpeed = 125.5; WaterVehicle myShip = new WaterVehicle( "White", false ); myShip.MaxSpeed = 40; myShip.CurrentSpeed = 25.5; //Ausgabe: myCar.Accelerate(); myCar.Accelerate(); myShip.Accelerate(); myShip.Accelerate(); Console.ReadLine(); }
Abbildung 7.2 zeigt die Ausgabe dieser Methode.
Abbildung 7.2: Unterschiedliche Ausgaben, einmal mit und einmal ohne überlagerte Methode
Durch das Überlagern wird die Methode der Basisklasse nicht vollkommen unerreichbar. Durch das reservierte Wort base, das für den Zugriff auf die Basisklasse der aktuellen Klasse zuständig ist, kann die ursprüngliche Methode explizit aufgerufen werden.
Sandini Bib
Polymorphie
173
Ebenso ist es möglich, die überlagerte Methode zu überschreiben. Die verwendete Methode Accelerate() besitzt keinen Parameter. Wenn Sie aber die Höhe der Beschleunigung zusätzlich auch von außen festlegen möchten, können Sie die Methode auch überschreiben, wie im folgenden Beispiel: class LandVehicle : Vehicle { [...] // Überlagern der Methode Accelerate() public override void Accelerate() { this.CurrentSpeed += 3.0; Console.WriteLine( "Beschleunige LandVehicle: Geschwindigkeit " + this.CurrentSpeed.ToString() ); } // Überschreiben der überlagerten Methode public void Accelerate( double acceleration ) { this.CurrentSpeed += acceleration; } [...] }
Diese Möglichkeit besteht auch dann, wenn die Methode nicht überlagert wurde. Der Compiler sucht sich in jedem Fall die verwendete Methode heraus. Wichtig ist nur, dass die Methode der Basisklasse, soll sie verwendet werden, auch erreichbar ist (also entweder als public oder protected deklariert).
7.2
Polymorphie
Abgeleitete Klassen sind wie bereits erwähnt spezialisierter als die Basisklasse, von der sie abgeleitet sind. In der objektorientierten Programmierung spricht man auch von einer istein-Beziehung. Im Bezug auf das Beispiel aus den vorhergehenden Abschnitt sagt man auch, LandVehicle ist ein Vehicle. Damit kann ein Objekt vom Typ Vehicle auch Objekte aufnehmen, die vom Typ LandVehicle sind (denn ein LandVehicle ist ja ein Vehicle). In diesem Fall wird implizit konvertiert. Dieses Verhalten kann verwendet werden, um beispielsweise eine generelle Ausgaberoutine zu erstellen. Sie wissen bereits, dass alle Objekte implizit von Object abgeleitet sind (wenn auch mitunter über mehrere Hierarchien) und auch, dass die Methode ToString() in der Regel dazu verwendet wird, eine String-Repräsentation eines Objekts zu liefern. Im folgenden Beispiel wird diese Methode nun für alle Objekte eingebaut (auch dieses Beispiel basiert wieder auf den Klassen Vehicle, LandVehicle und WaterVehicle). Das Hauptprogramm erhält eine zusätzliche Ausgabemethode, die ein beliebiges Vehicle erwartet und dann dessen Methode ToString() aufruft, um die Ausgabe vorzunehmen. Die folgenden Listings zeigen die Methoden ToString() der Klassen.
Sandini Bib
7 Vererbung und Polymorphie
CD
174
Das Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis Buchdaten\Beispiele\Kapitel_07\Polymorphie.
class Vehicle { [...] public override string ToString() { return "Dieses Objekt ist vom Typ Vehicle."; } [...] } class LandVehicle { [...] public override string ToString() { return "Dieses Objekt ist vom Typ LandVehicle."; } [...] } class WaterVehicle { [...] public override string ToString() { return "Dieses Objekt ist vom Typ WaterVehicle."; } [...] }
Im Hauptprogramm (Klasse Program) wird nun folgende Methode hinzugefügt, die die Ausgabe erledigt. public static void Output( Vehicle aVehicle ) { Console.WriteLine( aVehicle.ToString() ); }
Sandini Bib
Polymorphie
175
Das Hauptprogramm erledigt nun das Erzeugen der Objekte und den Aufruf der Ausgabemethode. static void Main( string[] args ) { Vehicle vehicle = new Vehicle(); LandVehicle landVehicle = new LandVehicle(); WaterVehicle waterVehicle = new WaterVehicle(); Output( vehicle ); Output( landVehicle ); Output( waterVehicle ); Console.ReadLine(); }
Abbildung 7.3 zeigt die Ausgabe des Beispielprogramms.
Abbildung 7.3: Die Ausgabe des Beispielprogramms. Obwohl der Parameter vom Typ Vehicle ist, wird bei überlagerten Methoden immer die korrekte ToString()-Methode verwendet.
Die Laufzeitumgebung kontrolliert also in diesem Fall, ob es für eine virtuelle Methode der Basisklasse in dem aufgenommenen Objekt eine Überladung gibt. Da C# typsicher ist, weiß die Laufzeitumgebung immer, um welches Objekt es sich handelt bzw. von welcher Klasse es eine Instanz ist. Ist diese überlagerte Methode vorhanden, wird sie verwendet, ansonsten wird auch hier die Methode der Basisklasse aufgerufen.
Verdeckte Member Im Gegensatz zu überlagerten Methoden gibt es in C# auch die Möglichkeit, Methoden (oder allgemein alle Member einer Basisklasse) zu verdecken. Wenn ein Member in einer Basisklasse nicht als virtual deklariert ist und in einer abgeleiteten Klasse mit gleichem Namen bzw. gleicher Signatur deklariert wird, so gilt er als verdeckt. Der Compiler gibt dann auch eine Warnung aus (allerdings keinen Fehler), die besagt, dass man bei verdeckten Membern den Modifizierer new verwenden soll. Dabei handelt es sich um das gleiche Schlüsselwort, das zum Erzeugen einer neuen Instanz einer Klasse verwendet wird. Hier jedoch dient es nicht als Operator, sondern als Modifizierer und hat eine andere Bedeutung. Worin der Unterschied zwischen einer verdeckten und einer überlagerten Methode liegt, wird klar, wenn das obige Beispiel entsprechend angepasst wird. Dazu deklarieren Sie einfach in der Klasse Vehicle eine (nicht virtuelle) Methode ToString2(), die den gleichen Inhalt wie die Methode ToString() hat. In den abgeleiteten Klassen wird diese Methode als new deklariert, d.h. die Methode der Basisklasse wird verdeckt.
Sandini Bib
176
7 Vererbung und Polymorphie
public class Vehicle { [...] public string ToString2() { return "Dieses Objekt ist vom Typ Vehicle."; } } public class LandVehicle { [...] public new string ToString2() { return "Dieses Objekt ist vom Typ LandVehicle."; } } class WaterVehicle { [...] public new string ToString2() { return "Dieses Objekt ist vom Typ WaterVehicle."; } }
Um einen direkten Vergleich zu erhalten wurde auch das Hauptprogramm um eine Methode Output2() erweitert, die ihrerseits ToString2() der übergebenen Objekte aufruft, aber ansonsten genau wie Output() deklariert ist. Abbildung 7.4 zeigt die Ausgabe, die sich ergibt, wenn beide Ausgabemethoden nacheinander aufgerufen werden.
Abbildung 7.4: Das gleiche Programm mit verdeckten Methoden
Die Laufzeitumgebung überprüft bei verdeckten Methoden nicht, welcher korrekte Typ übergeben wurde, sondern verwendet unmittelbar die Methode des vorhandenen Typs. Auch wenn ein Objekt des Typs LandVehicle übergeben wurde, so ist doch der aktuelle Typ Vehicle, sodass dessen Methode ToString2() aufgerufen wird.
Sandini Bib
Abstrakte, versiegelte und verschachtelte Klassen
177
Das Verdecken ist nicht nur mit Methoden erlaubt, sondern funktioniert auch mit anderen Membern einer Klasse. Das Wörtchen new sollten Sie dabei immer verwenden, denn damit wird explizit angedeutet, dass es sich hierbei um eine verdeckte Methode handelt. Die Warnung, die ohne dieses reservierte Wort erzeugt wird, hat durchaus einen tieferen Sinn. Angenommen, ein Kollege von Ihnen entwirft eine Klasse, in der er einige Methoden und Eigenschaften deklariert. Sie selbst verwenden diese Klasse, erweitern sie aber um weitere Methoden. So weit ist alles in Ordnung. Einige Zeit später liefert Ihr Freund Ihnen eine neue Version seiner Klasse. Nun hat er darin aber zufälligerweise eine neue Methode implementiert, die die gleiche Signatur wie eine Ihrer eigenen Methoden aufweist, aber etwas anderes tut. Laut Sprachenspezifikation handelt es sich nun um eine verdeckte Methode, denn er hat diese nicht als virtual deklariert. In einem Szenario wie im vorigen Beispiel (das keineswegs unüblich ist, sondern sehr häufig vorkommt) würde nun immer die Methode der Basisklasse aufgerufen (und nicht ihre eigene). Das resultiert unter Umständen in fehlerhaften Berechnungen oder einem fehlerhaften Verhalten des Programms. Deshalb weist der Compiler Sie darauf hin, eben durch diese Warnung, dass eine Methode zwar verdeckt, dieses aber nicht explizit angegeben wurde. Abhilfe würde schaffen, einen zusätzlichen Parameter zu übergeben und die eigene Methode dementsprechend zu modifizieren. Damit wären beide Methoden überschrieben, und der Compiler könnte sich wieder die Methode heraussuchen, die am besten passt.
7.3
Abstrakte, versiegelte und verschachtelte Klassen
7.3.1
Versiegelte Klassen
Als versiegelte (sealed) Klasse wird eine Klasse bezeichnet, von der keine weitere Ableitung möglich ist. Solche Klassen können nicht als Basisklassen verwendet werden. Um eine Klasse als nicht ableitbar zu bezeichnen, verwenden Sie den Modifizierer sealed. Bekanntester Vertreter dieser Art von Klassen ist die Klasse String aus dem Namespace System. Zum Leidwesen vieler Programmierer, die diese Klasse gerne erweitert hätten und aufgrund der Versiegelung gezwungen sind, eine komplett neue String-Klasse zu implementieren. Mehr ist zu diesem Klassentyp eigentlich nicht zu sagen. Beachten Sie bitte, dass Sie bei der Implementierung sorgfältig vorgehen müssen. Diese Klasse muss wirklich komplett sein, denn sie kann nicht erweitert werden.
Sandini Bib
7 Vererbung und Polymorphie
178
7.3.2
Abstrakte Klassen
Mit dem reservierten Wort abstract werden eine Klasse oder ein bzw. mehrere Member einer Klasse als »abstrakt« deklariert. Das bedeutet, dass diese Member zwar deklariert, aber nicht implementiert sind. Wenn Sie einen Member einer Klasse als abstract kennzeichnen, müssen Sie auch die gesamte Klasse als abstract kennzeichnen. Sinn und Zweck einer abstrakten Klasse ist, dass von ihr geerbt und der nicht implementierte Member ausprogrammiert werden muss, bevor die Klasse verwendet werden kann. Sie ist also das genaue Gegenteil einer versiegelten Klasse. Damit ergibt sich automatisch, dass von einer abstrakten Klasse keine direkte Instanz erzeugt werden kann, da nicht alle Member der Klasse implementiert sind und so nicht die gesamte Funktionalität zur Verfügung steht. Ein Beispiel für eine abstrakte Klasse könnte folgendermaßen aussehen: public abstract class AbstractVehicle { private string baseColor = ""; private double maxSpeed = 0.0; private double currentSpeed = 0.0; public abstract string GetSpeed(); }
Bei der Verwendung dieses Features sind folgende Dinge zu beachten: f Abstrakte Methoden sind implizit virtual, die Implementierung in der abgeleiteten Klasse muss daher mittels override erfolgen. f Da eine abstrakte Methode keine Implementierung bereitstellt, wird ihre Deklaration ohne Angabe eines Methodenkörpers durch das Semikolon abgeschlossen. f Abstrakte Methoden dürfen nicht statisch sein, umgekehrt können statische Methoden niemals abstrakt sein. Solche Methoden stehen bekanntlich auf Klassenebene zur Verfügung und ihre Verwendung ist nicht an eine Instanz gekoppelt. f Das reservierte Wort override verbietet sich ebenfalls, da dies implizit bedeuten würde, dass eine Implementierung in einer Basisklasse zur Verfügung stünde. f Das reservierte Wort sealed darf selbstverständlich auch nicht für abstrakte Klassen verwendet werden, denn dann würde der Sinn fehlen – es würde sich um eine Klasse handeln, von der abgeleitet werden muss, von der aber nicht abgeleitet werden kann.
7.3.3
Verschachtelte Klassen
Bei verschachtelten Klassen (Nested Classes) handelt es sich prinzipiell nur um den Umstand, dass innerhalb einer Klasse eine weitere Klasse deklariert ist. Die Zugriffsmodifizierer, die für andere Elemente einer Klasse gelten, haben natürlich auch hier Gültigkeit.
Sandini Bib
Abstrakte, versiegelte und verschachtelte Klassen
179
Über den Sinn und Zweck dieser Möglichkeit lässt sich natürlich trefflich streiten. Die Möglichkeit jedenfalls ist vorhanden, unter Umständen kann dieses Feature auch recht nützlich sein. Angenommen, Sie haben eine spezielle Listenklasse erstellt, die nur innerhalb eines bestimmten Kontexts Verwendung finden soll (sprich: nur von einer bestimmten Klasse und davon abgeleiteten Klassen benutzt werden darf). In diesem Fall können Sie diese Listenklasse als verschachtelte Klasse erstellen und mit dem Modifizierer protected kennzeichnen. Der Zugriff ist jetzt nur noch aus der umgebenden Klasse und aus abgeleiteten Klassen möglich. Wenn Sie die umgebende Klasse zusätzlich noch versiegeln, ist der Zugriff auf die verschachtelte Klasse gesperrt (es sei denn, sie ist als public deklariert), nur noch innerhalb der umgebenden Klasse ist der Zugriff möglich. In diesem Buch wird allerdings kein Gebrauch von verschachtelten Klassen gemacht, und auch im Alltagsgeschäft ist die Verwendung dieses Features nicht zwingend notwendig. Da es auch nicht viele Besonderheiten gibt, soll das Thema hiermit schon wieder abgeschlossen sein.
Sandini Bib
Sandini Bib
8
Delegates und Events
Alle Steuerelemente und viele weitere Klassen im .NET Framework stellen Ereignisse zur Verfügung. Als Ereignis bezeichnet man alles, was so bei der Verwendung eines Programms geschehen kann – das Bewegen der Maus, Ziehen der Maus über ein Steuerelement, Klicken oder Doppelklicken – all das sind Ereignisse. Aber das ist nicht alles; auch Sie können Ereignisse in Ihren eigenen Klassen zur Verfügung stellen, die andere Programmierer dann nutzen können. Die Grundlage für die Programmierung von Ereignissen sind Delegates.
8.1
Grundlagen zu Delegates
Ein Delegate ist, einfach ausgedrückt, ein typsicherer Funktionszeiger. Der Zweck dieser Konstrukte besteht darin, es dem Programmierer zu ermöglichen, eine Methode aufzurufen, von der erst zur Laufzeit der Name bekannt ist. Oder anders ausgedrückt: Ein Delegate ermöglicht es, einer Methodensignatur einen Namen zu geben, sodass jede beliebige Methode mit der gleichen Signatur zur Laufzeit mithilfe des Delegates aufgerufen werden kann. Häufig finden diese Konstrukte (bei denen es sich eigentlich auch nur um Klassen handelt) Verwendung in Multithread-Umgebungen, da durch sie so genannte CallbackMethoden realisiert werden. Eine Callback-Methode meldet das Ende eines asynchronen Vorgangs; Sie könnten so beispielsweise im Hintergrund eine komplette Datei verarbeiten, während der Anwender im Vordergrund weiterarbeiten kann. Sobald diese Dateiverarbeitung beendet ist, wird eine von Ihnen bestimmte Methode aufgerufen, in der Sie beispielsweise auch evtl. aufgetretene Fehler ermitteln können.
HINWEIS
Wie alles in .NET werden auch Delegates durch eine Klasse repräsentiert. Es handelt sich dabei um die Klasse Delegate, die sich im Namespace System befindet. Ein Delegate muss nicht zwangsläufig nur eine Methode aufnehmen, die dann bei seinem Aufruf ausgeführt wird. Werden hier mehrere Methoden angegeben, werden diese nacheinander abgearbeitet. Die Methoden werden dann in die so genannte Invocation Chain (Aufrufkette) eingereiht. Werden einem Delegate mehrere Methoden zugewiesen, muss vor allem auf einen eventuellen Rückgabewert geachtet werden. Beim Aufruf der Methoden in der Aufrufkette werden nämlich alle Rückgabewerte außer dem letzten verworfen.
Deklaration Die Deklaration eines Delegate ähnelt der Deklaration einer Methode. Es handelt sich dabei jedoch um eine Klasse. Das bedeutet im Umkehrschluss, dass ein Delegate als Bestandteil eines Namespace deklariert werden kann (was bei Methoden nicht möglich ist). Die Deklaration beginnt mit einem Modifizierer (public/private/protected usw.) gefolgt von
Sandini Bib
8 Delegates und Events
182
dem reservierten Wort delegate. Darauf folgen der Datentyp, den die später aufzurufende Methode zurückliefern soll, der Bezeichner des Delegates und die erforderlichen Parameter der Methode. [Modifizierer] delegate Datentyp Bezeichner([Parameter]);
Nochmals der Hinweis: es handelt sich um eine Klasse. Die Deklaration public delegate void MyPublicDelegate( string aParameter );
HINWEIS
deklariert eine Klasse namens MyPublicDelegate, die von der Klasse Delegate abgeleitet ist. Dieser Klasse kann daraufhin jede beliebige Methode zugewiesen werden, die einen Parameter vom Typ string erwartet. Der Name des Parameters ist dabei nicht von Belang, lediglich der Datentyp. Wird nach einer solchen Zuweisung der Delegate aufgerufen, ruft dieser seinerseits die ihm zugewiesene Methode auf.
8.2
Intern werden für Delegates eigentlich zwei Klassen verwendet, System.Delegate und System.MulticastDelegate. Letztere kommt automatisch zum Einsatz, wenn mehrere Methoden zugewiesen werden. Das geschieht transparent für Sie als Programmierer, Sie müssen sich nicht darum kümmern.
Verwenden von Delegates
Ein Delegate kann auf jede beliebige Methode verweisen. Ein Haupteinsatzgebiet sind damit so genannte Callback-Methoden, die häufig bei asynchronen Vorgängen verwendet werden. Ein asynchroner Vorgang ist ein Prozess, der im Hintergrund abläuft, während der Benutzer Ihrer Anwendung weiterarbeiten kann. Häufig werden derartige Vorgänge durch Multithread-Programmierung realisiert. In .NET 2.0 wurde zur Erleichterung von Hintergrundprozessen die Komponente BackgroundWorker eingeführt, die dies erleichtert. Auch sie arbeitet mit einem Callback, also einer Rückmeldung, sobald der Vorgang im Hintergrund fertig abgelaufen ist. Obwohl in solchen Fällen klar definiert ist, wie eine derartige Callback-Methode aussehen muss, kann doch nicht vorgegeben werden, welche Methode aufgerufen werden soll. Aus diesem Grund werden Delegates verwendet. Damit bestimmen Sie als Programmierer, welche Ihrer Methoden den Callback darstellen soll.
8.2.1
Eine Sortierroutine
Ein kleines Beispiel soll den Umgang mit Delegates verdeutlichen. Die Beispielapplikation soll ein Array aus int-Werten sortieren. In diesem Fall wird der Einfachheit halber nur aufsteigend sortiert. Als Sortieralgorithmus kommt ein BubbleSort zum Einsatz. Diese Art der Sortierung ist zwar nur wenig performant (eigentlich ist sogar jeder andere Sortieralgorithmus schneller), dafür aber schnell implementiert. Es handelt sich um eine Konsolenanwendung, die diesmal nicht mittels eines Editors sondern im Visual Studio erstellt wird.
Sandini Bib
Verwenden von Delegates
183
Rufen Sie dazu den Menüpunkt DATEI|NEU|PROJEKT auf. Unter den Projekttypen VISUAL C#|WINDOWS finden Sie auch die Konsolenapplikation. Erstellen Sie ein neues Projekt auf dieser Basis. Der Delegate soll zum Einsatz kommen, wenn es um die Ausgabe geht. Die Sortierroutine selbst wird so angelegt, dass sie bei jedem Sortiervorgang den Ausgabe-Delegate aufruft. Welche Methode endgültig aufgerufen wird, bestimmen Sie im Hauptprogramm (bzw. später der Nutzer der Anwendung über eine Eingabe). In diesem Beispiel können auch ein paar neue Features der Eingabeaufforderung (Konsole) gezeigt werden. Ab .NET 2.0 beherrscht die Klasse Console nicht mehr nur die schwarz/weiß-Ausgabe, Sie können auch Farben verwenden. Außerdem kann auf einen einzelnen Tastendruck reagiert werden. Beide Möglichkeiten finden hier Anwendung. Der Anwender soll durch seine Eingabe (einen Tastendruck) entscheiden können, mit welcher Farbe die Ausgabe erfolgen soll.
HINWEIS
Das Beispiel wurde im Visual Studio programmiert und auch direkt aus diesem heraus ausgeführt. Das Visual Studio besitzt intern ein Direktausgabefenster, das bei Konsolenanwendungen automatisch eingeblendet wird. In der Standardeinstellung werden alle Ausgaben auf die Konsole auf das Direktfenster des Visual Studio umgeleitet. Um die hier verwendete Methode Console.ReadKey() verwenden zu können, müssen Sie diese Verhaltensweise abschalten. Wechseln Sie dazu in die Optionen (EXTRAS|OPTIONEN), wählen Sie den Eintrag DEBUGGEN und deaktivieren Sie die Checkbox neben ALLE KONSOLENAUSGABEN AUF DAS DIREKTFENSTER UMLEITEN.
CD
Statt der Farben könnten Sie auch eine entsprechende Methode schreiben, mit der die Ausgabe in eine Datei umgeleitet oder über das Internet verschickt wird. Es sind Ihnen keine Grenzen gesetzt. Dabei muss die eigentliche Sortierroutine (die sich möglicherweise in einer DLL befindet, auf deren Quellcode Sie keinen Zugriff haben) nicht geändert werden – lediglich die Methode im Delegate wird geändert.
Sie finden das Beispielprogramm auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_08\SortDelegate.
Der Delegate Die erste Deklaration, die wir benötigen, ist die des Delegates selbst. Da dieser nur eine Ausgabe vornehmen soll, genügt ein Parameter vom Typ string. Im Beispiel wurde der Delegate der Übersicht halber in einer eigenen Datei definiert. public delegate void FeedbackDelegate( string message );
Der Delegate trägt den Namen FeedbackDelegate und erwartet als Parameter einen string. Ein Rückgabewert wurde nicht angegeben. Beachten Sie bitte, dass FeedbackDelegate eine von System.Delegate abgeleitete Klasse ist, d.h. der Delegate ist zwar definiert, aber noch nicht instanziiert. Das geschieht erst, wenn ihm eine Methode zugewiesen wird.
Sandini Bib
184
8 Delegates und Events
Erzeugen des Arrays für die Sortierung Die Sortierroutine selbst findet ihren Platz in einer eigenen Klasse mit dem Namen Sorter. Diese Klasse sollte in eine eigene Datei programmiert werden. Um das Ganze interessanter zu gestalten, soll auch die Größe des Arrays variabel sein, d.h. Arraygröße und die enthaltenen Werte werden mittels eines Zufallsgenerators ermittelt. Die Deklaration der Klasse Sorter mit der Methode zum Erzeugen des Arrays sieht folgendermaßen aus: class Sorter { private int[] arrayToSort; // Das zu sortierende Array private void CreateArray() { Random rnd = new Random( DateTime.Now.Millisecond ); // Zufallsgenerator int arraySize = rnd.Next( 10, 51 ); // Größe des Array this.arrayToSort = new int[arraySize]; // Array initialisieren // Werte über Schleife festlegen for ( int i = 0; i < arraySize; i++ ) this.arrayToSort[i] = rnd.Next( 1000 ); } }
Die Klasse Random befindet sich im Namespace System und steht für einen PseudoZufallsgenerator. Bei ihrer Instanziierung wurde dem Konstruktor ein so genannter Seed übergeben. Dabei handelt es sich um einen Wert, mit dem der Zufallsgenerator initialisiert wird. Da ein Computer by Design keine echten Zufallszahlen (zumindest nicht auf einfache Weise) erstellen kann, bestimmt der Seed sozusagen die erzeugte Zahlenmenge. Bei gleichem Seed werden nacheinander auch die gleichen Zufallszahlen erzeugt. Um den Seed wenigstens ein bisschen zufällig zu machen, wird hier der Millisekunden-Wert der aktuellen Zeit übergeben, die über DateTime.Now ermittelt werden kann. Damit werden zwar immer noch keine echten Zufallszahlen erzeugt, die Möglichkeit, dass zweimal nacheinander die gleiche Zahlenreihe erzeugt wird, ist damit aber sehr unwahrscheinlich. Die Methode Next() der Klasse Random liefert eine Zufallszahl innerhalb eines angegebenen Bereichs. Im Beispiel wird die Größe des Arrays auf mindestens 10 und maximal 50 Werte festgelegt. Die obere Grenze muss dazu auf 51 festgelegt werden, da der erzeugte Wert immer kleiner ist als der obere Grenzwert (aber den unteren mit einschließt). Bei der Erzeugung der Zahlen innerhalb des Arrays wird lediglich ein Wert angegeben. Dieser bestimmt den oberen Grenzwert. Die Klasse Random erzeugt keine negativen Zahlen, der Zahlenwert liegt damit zwischen 0 und 999. Um die Sortierung zu erleichtern, wurde das Array außerdem als Feld der Klasse Sorter implementiert. Damit ist der Zugriff von innerhalb der Klasse aus jeder Methode heraus möglich.
Sandini Bib
Verwenden von Delegates
185
Die Sortierroutine Die eigentliche Sortierroutine (ebenfalls in der Klasse Sorter) trägt den Namen DoSort(). Als Parameter erwartet Sie lediglich eine Instanz des deklarierten Delegate. Dieser steht für die Methode, die für die Ausgabe aufgerufen werden soll. Diese Methode wird erst später festgelegt – durch den Delegate ist es in der Klasse Sorter immer möglich, ein Feedback zu geben. Was der Benutzer der Klasse (also der Programmierer) damit macht, also wie die Informationen ausgegeben werden, ist innerhalb der Klasse Sorter nicht von Belang. Der Delegate legt in seiner Definition bereits fest, wie die aufzurufende Methode auszusehen hat, die Typsicherheit von C# bzw. .NET sorgt dafür, dass die letztendlich aufgerufene Methode auch genau so aussieht. public void DoSort( FeedbackDelegate feedback ) { for ( int i = 0; i < this.arrayToSort.Length - 1; i++ ) { for ( int u = 0; u < this.arrayToSort.Length - 1 - i; u++ ) { if ( this.arrayToSort[u + 1] < this.arrayToSort[u] ) { Array.Reverse( this.arrayToSort, u, 2 ); feedback( "Tausche Indizes " + u.ToString() + " und " + ( u + 1 ).ToString() ); } } } feedback( "Sortieren beendet. " + this.arrayToSort.Length.ToString() + " Werte" ); string valueString = "Werte: "; foreach ( int i in this.arrayToSort ) valueString += i.ToString() + " "; feedback( valueString ); }
Die eigentliche Sortierung findet innerhalb der geschachtelten for-Schleifen statt. Der Algorithmus durchläuft die gesamte Länge des Arrays (das geschieht mit der ersten Schleife). Die zweite for-Schleife durchläuft nun ihrerseits die Werte des Arrays und vertauscht jeweils benachbarte Werte falls erforderlich. Weitere derartige Sortieralgorithmen finden Sie im Internet. Suchen Sie nach SelectionSort, InsertionSort oder QuickSort. Letzterer ist auch im .NET Framework integriert. Die Sortierfunktion eines Arrays (Methode Array.Sort()) verwendet einen Quicksort-Algorithmus, der allgemein als der schnellste Sortieralgorithmus gilt. Doch zurück zum Delegate. Der Methode wurde eine Instanz von FeedbackDelegate übergeben, der die Ausgabe übernimmt. Er steht für eine Methode, die erst später hinzugefügt wird und an dieser Stelle nicht interessiert. Zur Ausgabe wird einfach der Delegate aufgerufen (und damit die an ihn angehängte Methode).
Sandini Bib
8 Delegates und Events
186
Nachzuholen ist noch die Initialisierung des Arrays, die noch nicht vorgenommen wurde (die Methode CreateArray() wurde noch nicht aufgerufen). Das erledigt der Konstruktor der Klasse. Hierzu überschreiben Sie einfach den (ansonsten automatisch generierten) Standardkonstruktor. public Sorter() { CreateArray(); }
Das Hauptprogramm Ab der Version 2005 ist die Methode Main(), der Einsprungpunkt des Programms, in eine eigene Klasse namens Program ausgelagert. Diese wird vom Visual Studio automatisch erzeugt und kann natürlich auch erweitert werden. Main() ist eine statische Methode. Sie muss es sein, da es die erste aufgerufene Methode ist und somit noch keine Instanz irgendeiner Klasse der Applikation existieren kann. Damit auch die Ausgabemethoden keine Instanz der Klasse benötigen, werden sie einfach ebenfalls als statisch deklariert, in der Klasse Program. Es handelt sich um zwei Methoden, die einmal die Farbe grün und einmal die Farbe rot verwenden (was dann auch die farblichen Fähigkeiten der Klasse Console darstellt). static void OutputRed( string message ) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine( message ); } static void OutputGreen( string message ) { Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine( message ); }
// Farbe festlegen // Ausgabe
// Farbe festlegen // Ausgabe
Innerhalb der Methode Main() muss nun noch die eigentliche Programmlogik erstellt werden. Zunächst wird eine Variable des Typs FeedbackDelegate benötigt. Der Anwender des Programms erhält die Wahl, ob er die Ausgabe in roter oder in grüner Farbe vornehmen will. Entsprechend seiner Auswahl wird der Delegate-Variablen die passende Methode zugeordnet und die Sortierroutine aufgerufen. static void Main( string[] args ) { Console.ForegroundColor = ConsoleColor.Black; Console.BackgroundColor = ConsoleColor.White; FeedbackDelegate feedback = null; // Delegate für Ausgabemethode ConsoleKeyInfo keyInfo; // Variable für Benutzer-Tastendruck Console.Write( "Welche Farbe soll verwendet werden (1-rot/2-grün): " ); bool keyOk;
Sandini Bib
Verwenden von Delegates
187
do { keyOk = false; keyInfo = Console.ReadKey( true ); // Taste von Benutzer holen switch ( keyInfo.KeyChar ) { // Tastenkontrolle case '1': feedback = new FeedbackDelegate( OutputRed ); keyOk = true; break; case '2': feedback = new FeedbackDelegate( OutputGreen ); keyOk = true; break; } } while ( !keyOk ); // Sortieren und ausgeben Sorter sorter = new Sorter(); sorter.DoSort( feedback ); // Auf Tastendruck für Ende warten Console.ReadKey( true ); }
Die Klasse ConsoleKeyInfo steht für eine vom Benutzer gedrückte Taste. Anders als in der Vorgängerversion muss in .NET 2.0 nicht mehr auf die Eingabetaste gewartet werden. Die Methode ReadKey() der Klasse Console liest genau einen Tastendruck ein. Über die Eigenschaften von ConsoleKeyInfo erhalten Sie weitere Informationen; Sie können unter anderem ermitteln, ob es sich um eine der Funktionstasten ((F1) bis (F12)) oder eine Steuertaste (wie (Strg) oder (Shift)) handelt. In diesem Fall muss lediglich das enthaltene Zeichen ausgewertet werden. Die do-Schleife wird beendet, wenn dieses eine 1 oder eine 2 war. Damit wäre das Programm fertig und startbereit (mit (F5) oder (Strg)+(F5)). Die Ausgabe sehen Sie in Abbildung 8.1.
Abbildung 8.1: Der untere Teil der Ausgabe des Programms, in diesem Fall mit roter Schrift.
Sandini Bib
8 Delegates und Events
188
8.2.2
Ein Delegate – mehrere Methoden
Ein Delegate ist nicht auf eine einzige Methode beschränkt, sondern kann, wie bereits angesprochen, mehrere Methoden aufnehmen. Diese werden in einer Aufrufliste gespeichert (der Invocation List oder Invocation Chain) und in der Reihenfolge ihres Auftretens (des Hinzufügens) aufgerufen. Bei dieser Vorgehensweise gibt es zwei mögliche Syntaxvarianten, eine komplizierte und eine einfache. Die komplizierte Variante verwendet die statische Methode Combine() der Klasse Delegate. Diese Methode erwartet als Parameter ein Array aus Delegates, die dann miteinander bzw. mit den bereits in der Aufrufliste vorhandenen Delegates verkettet werden. Sie liefert den resultierenden Delegate zurück. public delegate void TestDelegate( string s ); public void Main() { TestDelegate td = (TestDelegate)Delegate.Combine( new TestDelegate[] { new TestDelegate( this.Test1 ), new TestDelegate( this.Test2 ) } ); }
Da ein Objekt vom Typ Delegate zurückgeliefert wird (dem Basisdatentyp aller Delegates), muss dieses erst in den gewünschten TestDelegate gecastet werden. Alles in allem ist diese Syntax doch recht komplex, weshalb sie nicht weiter verfolgt wird. Für den Interessierten: Das Entfernen geschieht über die statische Methode Remove() der Klasse Delegate. Einfacher ist es, die aus der Mathematik gewohnte Syntax zu benutzen. Der Operator += fügt einen Delegate der Aufrufliste hinzu. Das gleiche Beispiel wie oben mit anderer Syntax, aber gleichem Erfolg: public delegate void TestDelegate( string s ); public void Main() { TestDelegate td = new TestDelegate( this.Test1 ); td += new TestDelegate( this.Test2 ); }
Das Entfernen eines Delegate geschieht entsprechend mittels des Operators -=. td -= new TestDelegate( this.Test2 );
Sandini Bib
ACHTUNG
Verwenden von Delegates
8.2.3
189
Wenn Sie einem Delegate einen weiteren Delegate hinzufügen, achten Sie sorgfältig darauf, nicht das Gleichheitszeichen alleine zu benutzen. In diesem Falle würde die Aufrufliste nämlich gelöscht und durch den angegebenen Delegate ersetzt. Wenn die Delegates einer Liste einen Wert zurückliefern, wird nur der Wert des zuletzt ausgeführten Delegate zurückgeliefert, die anderen Werte verfallen. Daher sind Delegates üblicherweise ohne Rückgabewert definiert.
Anonyme Methoden
Anonyme Methoden sind ein Segen und ein Fluch zugleich. Wer sie gewohnt ist (vor allem Java-Programmierer) wird in gewissen Situationen gerne mit ihnen arbeiten. Der umfangreiche Gebrauch dieser Möglichkeit führt aber auch sehr schnell dazu, dass Code nicht mehr lesbar bzw. wartbar wird. Eine anonyme Methode kann an Stelle eines Delegates stehen, wenn dieser keinen Wert zurückliefert (dieser Wert würde verworfen). Statt der Delegate-Variablen eine existierende Methode zuzuwiesen, weist man ihr den gesamten Methodenblock zu, allerdings nicht ohne vorher noch die Signatur festzulegen. Die Syntax hierfür sieht folgendermaßen aus (unter Verwendung des FeedbackDelegate aus dem vorherigen Beispiel): FeedbackDelegate feedback = delegate( <Parameterliste> ) { // Anweisungen }
CD
Mit diesem Wissen könnte auch die Methode Main() aus dem Sortierbeispiel umgebaut werden. Statt zuerst die Methoden zu definieren und sie dann der Delegate-Variablen zuzuweisen, werden einfach die kompletten Methodenrümpfe zugewiesen. Dass so etwas sehr schnell unübersichtlich werden kann, können Sie bereits an diesem kleinen Stück Code feststellen. Sie finden das Beispielprogramm auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_08\SortDelegateAnonymous.
static void Main( string[] args ) { Console.ForegroundColor = ConsoleColor.Black; Console.BackgroundColor = ConsoleColor.White; FeedbackDelegate feedback = null; // Delegate für Ausgabemethode ConsoleKeyInfo keyInfo; // Variable für Benutzer-Tastendruck Console.Write( "Welche Farbe soll verwendet werden (1-rot/2-grün): " ); bool keyOk;
Sandini Bib
8 Delegates und Events
190 do { keyOk = false; keyInfo = Console.ReadKey( true );
// Taste von Benutzer holen
switch ( keyInfo.KeyChar ) { // Tastenkontrolle case '1': // Hier Änderung: direkte Angabe der Methode feedback = delegate( string message ) { Console.ForegroundColor = ConsoleColor.Red; // Farbe festlegen Console.WriteLine( message ); // Ausgabe }; keyOk = true; break; case '2': // Hier Änderung: direkte Angabe der Methode feedback = delegate( string message ) { Console.ForegroundColor = ConsoleColor.DarkGreen; // Farbe festlegen Console.WriteLine( message ); // Ausgabe }; keyOk = true; break; } } while ( !keyOk ); // Sortieren und ausgeben Sorter sorter = new Sorter(); sorter.DoSort( feedback ); // Auf Tastendruck für Ende warten Console.ReadKey( true ); }
Die Notwendigkeit, eigene Methoden zu definieren, die die Ausgabe durchführen, hat sich damit erledigt. Diese sind zwar noch da, allerdings nicht mehr direkt aufrufbar (da sie keinen Namen tragen). Deshalb auch anonyme Methoden.
Anonyme Methoden und Variablen Für Variablen in C# gilt, dass ihr Gültigkeitsbereich sich auf den Block beschränkt, in dem sie deklariert wurden. Untergeordnete Blöcke werden dabei als Bestandteile dieses Codeblocks angesehen, auch dort ist eine Variable also noch sichtbar. Es stellt sich daher die Frage, wie das bei anonymen Methoden aussieht. Diese sind ja, wenn sie wie im Beispiel innerhalb einer Methode deklariert werden, auch untergeordnete Blöcke dieser Methode. Damit müssten die Variablen dieser Methode auch innerhalb der anonymen Methode gültig sein.
Sandini Bib
Verwenden von Delegates
191
Ob das so ist, zeigt ein einfaches Beispiel, bzw. eine Änderung in SortDelegateAnonymous. In der Main()-Methode wird einfach eine Variable deklariert und dann in der anonymen Methode verwendet. Der folgende Codeausschnitt zeigt die Änderung. [...] switch ( keyInfo.KeyChar ) { // Tastenkontrolle case '1': int counter = 0; // Änderung für Variablentest feedback = delegate( string message ) { Console.ForegroundColor = ConsoleColor.Red; // Farbe festlegen message += " Änderung Nummer " + (++counter).ToString(); Console.WriteLine( message ); // Ausgabe }; keyOk = true; break; [...]
Die Ausgabe der Methode in Abbildung 8.2 zeigt, dass die lokale Variable in der Tat verwendet werden kann und sogar ihren Wert nicht ändert, d.h. sie wird nicht bei jedem Aufruf auf 0 zurückgesetzt. Stattdessen wird ihr Wert zum Zeitpunkt des Aufrufs »eingefroren« und ihre Lebenszeit verlängert.
Abbildung 8.2: Der Variablentest innerhalb einer anonymen Methode.
Die Lebenszeit einer lokalen Variable endet üblicherweise mit dem Verlassen der Methode, in der sie deklariert ist. Im Falle einer anonymen Methode, die eine solche Variable verwendet, wird diese Lebenszeit verlängert, d.h. innerhalb der anonymen Methode ist eine solche Variable weiterhin gültig. Die Gültigkeit von Variablen, die innerhalb einer anonymen Methode deklariert sind, beschränkt sich weiterhin ausschließlich auf den Block der anonymen Methode. Weiterhin ist es nicht möglich, in einen anonymen Methodenblock zu springen (was dem Verhalten von Schleifen entspricht).
Sandini Bib
8 Delegates und Events
192
8.3
Ereignisse (Events)
Ereignisse treten in .NET bzw. unter Windows ständig auf. Jede Aktion, die der Anwender vornimmt, resultiert in einem Ereignis (z.B. ein Mausklick auf einen Button). Auf zahlreiche dieser Ereignisse können Sie innerhalb Ihres Programms reagieren. Der zweite Punkt ist die Implementierung von Ereignissen in eigenen Klassen, d.h. in diesem Fall reagieren Sie nicht auf ein Ereignis, sondern Sie lösen unter bestimmten Umständen ein eigenes Ereignis aus. In diesem Abschnitt wird beides besprochen.
8.3.1
Deklaration
Die Basis von Ereignissen sind Delegates. Eine Klasse stellt einen Ereignishandler zur Verfügung (bei dem es sich eigentlich nur um einen Delegate handelt), und Sie als der Konsument des Ereignisses bestimmen, welche Methode beim Auftreten ausgeführt werden soll. Die Benennung von Ereignissen und auch die Parametervergabe folgt unter .NET einer einheitlichen Konvention. Die Basis ist ein Delegate mit folgenden Eigenschaften: f Sein Name endet auf EventHandler, davor steht der Name des Ereignisses, das ausgelöst werden soll. Handelt es sich beispielsweise um ein Ereignis namens ValueChanged, so würde der entsprechende Delegate den Namen ValueChangedEventHandler tragen. f Es werden immer ein Objekt sender vom Typ object und die Ereignisargumente übergeben. Letztere in Form einer Klasse, die von der bestehenden Klasse System.EventArgs abgeleitet ist. Für Standardereignisse (also wenn keine besonderen Daten zu übermitteln sind) kann auch EventArgs selbst benutzt werden. Auch in den von EventArgs abgeleiteten Klassen gilt die Konvention, dass der Name des Ereignisses am Anfang steht, z.B. ValueChangedEventArgs. f Ereignishandler geben niemals einen Wert zurück, werden also immer als void deklariert. Der Parameter sender vom Typ object steht übrigens immer für die Klasse oder das Steuerelement, das das Ereignis ausgelöst hat. Die Deklaration des eigentlichen Events geschieht über das reservierte Wort event. Zunächst wird ein Delegate festgelegt, der die Signatur der Ereignisbehandlungsroutine festlegt, dann mittels event das eigentliche Ereignis, auf das später von außerhalb zugegriffen wird. Die grundsätzliche Syntax für eine Ereignisdeklaration sieht folgendermaßen aus: [Modifizierer] event EventnameEventHandler EventName;
Das setzt voraus, dass der entsprechende Delegate folgendermaßen deklariert wurde: [Modifizierer] delegate void EventNameEventHandler(object sender, EventNameEventArgs e);
Sandini Bib
HINWEIS
Ereignisse (Events)
193
Der Delegate für den Event kann sowohl innerhalb einer Klasse als auch außerhalb einer Klasse deklariert werden. Der Event selbst wird natürlich als Bestandteil einer Klasse deklariert. Wenn der Delegate außerhalb der Klasse deklariert wird, hat das den Vorteil, dass mehrere Klassen auf einfache Art und Weise das gleiche Ereignis implementieren können. Falls keine besonderen Daten für das Ereignis übermittelt werden müssen, können Sie auch den Standard-Delegate EventHandler verwenden, der folgende Parameter erwartet: object sender, EventArgs e.
Implementiert eine Klasse ein Ereignis, kann sich eine andere Klasse (bzw. ein Objekt) leicht an dieses Ereignis »anhängen« und die Ereignisbehandlungsroutine zur Verfügung stellen (dieser Vorgang wird manchmal auch als »abonnieren eines Ereignisses« bezeichnet). Durch die Namensgebung ist bekannt, wie der Ereignisdelegate heißen muss (das geht aus dem Ereignisnamen hervor) und welche Parameter er hat. Im Übrigen werden diese ohnehin durch IntelliSense angegeben. Für den ValueChanged-Event, der für die Erläuterung der Namensgebung Pate stand, muss also lediglich eine Methode mit der passenden Signatur erstellt werden, z.B.: public void MyValueChanged( object sender, ValueChangedEventArgs e ) { ... }
Diese kann dann zur Laufzeit an den Event ValueChanged angehängt werden. Die Syntax ist die gleiche wie bei Delegates. Angenommen, das Ereignis stammt aus einer Klasse Calculator, dann würde die Methode MyValueChanged folgendermaßen an die Invocation List des Ereignisses angehängt: Calculator myCalculator = new Calculator(); myCalculator.ValueChanged += new ValueChangedEventHandler( MyValueChanged );
Damit wird MyValueChanged in die Liste der im Falle dieses Ereignisses aufzurufenden Methoden eingehängt. Sie werden sich sicherlich fragen, warum der Umweg über die Deklaration mittels event notwendig ist. Einer der Gründe ist, dass das Ereignis dann auch erkannt wird, sowohl von IntelliSense als auch im Falle von Komponenten im Ereignisfenster. Der zweite Grund ist viel gravierender. Bereits bei den Delegates wurde darauf hingewiesen, dass bei der Zuweisung eines Delegate der Operator += nicht mit dem Operator = verwechselt werden darf, da ansonsten die Aufrufliste gelöscht wird. Das Schlüsselwort event erzwingt in der Folge die Verwendung von +=; eine Zuweisung eines Ereignisses mit = ist nicht möglich. Dadurch wird automatisch verhindert, dass die Aufrufkette eines Ereignisses versehentlich gelöscht wird.
Sandini Bib
8 Delegates und Events
194
8.3.2
Ereignisse implementieren und verwenden
Das Vorgehen bei der Implementierung eines eigenen Ereignisses wird deutlicher, wenn es an einem Beispiel durchgeführt wird. Als Beispiel soll wieder eine Sortierroutine dienen. Die Sortierklasse soll immer dann ein Ereignis auslösen, wenn zwei Elemente vertauscht wurden. Dieses Beispiel beinhaltet daher alle Schritte, die für die Implementierung eines Ereignisses nötig sind: f Eine eigene EventArgs-Klasse, die die Positionen liefern kann, muss deklariert werden. f Ein Delegate mit entsprechender Signatur wird benötigt. f Der Event muss innerhalb der Klasse deklariert und ausgelöst werden.
CD
Wie das Ereignis innerhalb der Klasse ausgelöst wird, wurde noch nicht besprochen. Für das Auslösen steht üblicherweise eine eigene Methode zur Verfügung, die mit On beginnt und den Namen des Ereignisses trägt, z.B. OnValueMoved(). Sie werden sehen, dass Ereignisse wesentlich einfacher zu verstehen sind, als es zunächst den Anschein hat. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_08\SimpleEvent.
Deklaration der Ereignisparameter Die Klasse für die Ereignisparameter wird von der Klasse EventArgs abgeleitet. Da hier Werte bewegt werden, soll das Ereignis den Namen ValueMoved tragen, die entsprechende EventArgs-Klasse also ValueMovedEventArgs heißen. Der abgeleiteten Klasse werden noch zwei Werte hinzugefügt, die die Position der vertauschten Elemente enthalten. Die Klasse ValueMovedEventArgs sieht folgendermaßen aus: public class ValueMovedEventArgs : EventArgs { private int fromIndex = 0; private int toIndex = 0; public int FromIndex { get { return fromIndex; } set { fromIndex = value; } } public int ToIndex { get { return toIndex; } set { toIndex = value; } }
Sandini Bib
Ereignisse (Events)
195
public string Message { get { return String.Format( "Werte vertauscht: {0} und {1}", fromIndex, toIndex ); } } public ValueMovedEventArgs( int fromIndex, int toIndex ) { this.fromIndex = fromIndex; this.toIndex = toIndex; } }
Die Übergabe der Werte an die Ereignisroutine geschieht über eine Instanz dieser Klasse.
Implementieren des Ereignisses Die Signatur des Ereignisses bzw. der Ereignisbehandlungsroutine wird durch einen entsprechenden Delegate vorgegeben. Dieser wird öffentlich, also außerhalb jeder Klasse, deklariert. Im Beispiel wurde dafür eine eigene Datei mit Namen Delegates.cs angelegt. public delegate void ValueMovedEventHandler( object sender, ValueMovedEventArgs e );
Innerhalb der eigentlichen Klasse zur Sortierung der Werte werden nun der Event selbst und weiterhin eine Methode, die den Event auslöst, deklariert. Die Auslösung geschieht also nicht direkt, sondern innerhalb einer speziell dafür vorgesehenen Methode. Der Grund für dieses Vorgehen ist, dass ein Ereignis auch null sein könnte. In diesem Fall wäre keine Methode angehängt (und dementsprechend muss bzw. darf das Ereignis auch nicht ausgelöst werden). Um den Quellcode übersichtlicher zu machen erfolgt daher die Auslagerung in eine eigene Methode. Auch diese Methode folgt einer bestimmten Namenskonvention. Ihr Name entspricht dem des Ereignisses mit einem davor gesetzten On, in diesem Fall also OnValueMoved(). Sie benötigt als Parameter die Indizes der beiden vertauschten Elemente. Sobald nun das Ereignis eintritt und Daten vertauscht werden, wird die Methode OnValueMoved() aufgerufen. Innerhalb dieser Methode wird kontrolliert, ob es irgendwelche Ereignisbehandlungsroutinen gibt, die dem Event angehängt wurden (damit wäre der Wert des Events ungleich null). Wenn ja, wird das Ereignis aufgerufen, wenn nicht, erfolgt keine Reaktion. Hier nun der Code der Klasse Sorter. Die Erzeugung des zu sortierenden Arrays wurde nicht erneut abgedruckt. public class Sorter { private int[] arrayToSort; // Zu sortierendes Array // Deklaration des Ereignisses public event ValueMovedEventHandler ValueMoved;
Sandini Bib
196
8 Delegates und Events
private void CreateArray() { ... } // Auslösen des Ereignisses private void OnValueMoved( int fromIndex, int toIndex ) { if ( this.ValueMoved != null ) { // Ereignisroutinen sind angehängt this.ValueMoved( this, new ValueMovedEventArgs( fromIndex, toIndex ) ); } } public void DoSort() { for ( int i = 0; i < this.arrayToSort.Length - 1; i++ ) { for ( int u = 0; u < this.arrayToSort.Length - 1 - i; u++ ) { if ( this.arrayToSort[u + 1] < this.arrayToSort[u] ) { Array.Reverse( this.arrayToSort, u, 2 ); OnValueMoved( u, u + 1 ); // Ereignis auslösen } } } } public Sorter() { CreateArray(); } }
Im Hauptprogramm wird das Ereignis mit einer Methode verknüpft. Diese Methode wird daraufhin bei jedem Auftreten des Ereignisses ausgelöst. class Program { private static void ValueMoved( object sender, ValueMovedEventArgs e ) { Console.WriteLine( e.Message ); } static void Main( string[] args ) { Sorter sorter = new Sorter(); sorter.ValueMoved += new ValueMovedEventHandler(ValueMoved); // Verknüpfung sorter.DoSort(); Console.ReadKey(); } }
Sandini Bib
Ereignisse (Events)
Das Ergebnis dieses Programms sehen Sie in Abbildung 8.3.
Abbildung 8.3: Die Ausgabe des Programms
197
Sandini Bib
Sandini Bib
9
Interfaces
Mithilfe von Klassen, Strukturen oder Enumerationen ist zwar schon sehr vieles möglich, aber eben noch nicht alles. Mitunter benötigen Sie auch für ansonsten vollkommen unterschiedliche Klassen einen gleichartigen Zugriff auf eine bestimmte Funktionalität. Hierfür sind Interfaces zuständig.
9.1
Grundlagen
Interfaces, auf Deutsch »Schnittstellen«, definieren eine Art »Vertrag« für die allgemeingültige Verwendung einer Klasse. Alle Member eines Interface sind implizit öffentlich, keiner der Member besitzt eine Implementierung. Implementiert eine Klasse ein Interface, müssen alle Member, die das Interface zur Verfügung stellt, in der Klasse implementiert werden. Unterschiedliche Klassen können somit, sofern sie das gleiche Interface implementieren, auf die gleiche Art und Weise angesprochen werden. Eine bekannte Möglichkeit für den Einsatz von Interfaces sind beispielsweise Plugin-Schnittstellen. Interfaces besitzen eine gewisse Ähnlichkeit zu abstrakten Klassen, es gibt jedoch Unterschiede: f In einem Interface sind grundsätzlich alle Member abstrakt, in abstrakten Klassen können auch fertige Implementierungen enthalten sein. f In einem Interface sind sämtliche Member implizit public, in abstrakten Klassen können auch andere Sichtbarkeitsstufen verwendet werden. f Felder (also Variablen) können in einem Interface nicht deklariert werden (wohl aber Eigenschaften). In abstrakten Klassen existiert diese Möglichkeit. f Da abstrakte Klassen eben Klassen sind, kann nur von einer Basisklasse abgeleitet werden. Im Gegensatz dazu können jedoch mehrere Interfaces implementiert werden. Bekannte Interfaces sind z.B. IEnumerable oder IComparable. IEnumerable ist nur interessant, wenn es sich um einen Datentyp handelt, der als Liste verwendet werden kann (das Interface dient als Basis für die Verwendung der foreach-Schleife). Wenn Sie nun eine eigene Klasse erstellen, die als Liste fungieren soll, aber nicht von einer Listenklasse abgeleitet ist, können Sie die für eine foreach-Schleife benötigte Funktionalität einfach dadurch einbauen, dass Sie zusätzlich zur Basisklasse auch noch das Interface IEnumerable implementieren. IComparable dient dem Vergleich zweier Objekte oder Werte. Das Interface erzwingt die Implementierung einer Methode CompareTo(), in der das Vergleichskriterium für die entsprechende Klasse (oder Struktur) festgelegt werden kann. Damit können Sie Einfluss auf die internen Sortierfunktionen nehmen, die in vorgefertigten Listenklassen oder auch in der Klasse Array eingebaut sind.
Sandini Bib
9 Interfaces
200
9.1.1
Deklaration
Die Deklaration eines Interfaces ähnelt der Deklaration einer Klasse. Als Konvention hat man sich darauf geeinigt, dass alle Interfaces mit dem Großbuchstaben »I« beginnen. Bei eigenen Interfaces sollten Sie sich ebenfalls an diese Konvention halten. Zur Deklaration eines Interface ändern Sie das Wort class in interface, ansonsten bleibt die Syntax gleich. Natürlich müssen Sie alle Implementierungen entfernen. Folgende Bestandteile können Member eines Interface sein: f Methoden, f Eigenschaften, f Indexer (mehr dazu in Abschnitt 13.4.1 ab Seite 300). Bei Eigenschaften werden die Methoden zum Zugriff auf die Werte (der Getter und der Setter) dazu verwendet, festzulegen, welche Implementierung später folgen muss. Ein solches Interface mit Eigenschaften sieht folgendermaßen aus: public interface ICoord { int X { get; set; } int Y { get; set; } }
Damit ist die implementierende Klasse gezwungen, zu diesen Eigenschaften jeweils eine Get- und eine Set-Methode zur Verfügung zu stellen.
Interface implementiert Interface Ein Interface ist nicht gezwungenermaßen alleinstehend. Zwar kann es zwangsläufig nicht von Klassen erben (auch nicht von abstrakten), wohl aber von anderen Interfaces. Somit kann die Deklaration mehrerer Methoden, die in einem Interface verfügbar sein sollen, auf mehrere Interfaces verteilt werden. Das gibt Ihnen eine große Flexibilität bei der Programmierung. Da dieses Vorgehen aber auch schnell zu Konfusion führen kann, muss der Einsatz von Interfaces sorgfältig geplant werden.
Sandini Bib
201
Grundlagen
9.1.2
Implementierung
CD
Als kleines Beispiel soll die Mathematik dienen. Es soll ein Interface deklariert werden, das allgemein für geometrische Formen einsetzbar ist. Es soll die Implementierung einiger Eigenschaften fordern, mit denen einige Grundmaße ermittelt werden können, die allen geometrischen Körpern gemeinsam sind. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_09\GeometricInterface.
Das Interface ist folgendermaßen definiert: public interface IGeometric { double Surface { get; } double Circumference { get; } double Volume { get; } string Name { get; } }
Modifizierer für die einzelnen Elemente sind nicht notwendig, da diese implizit public sind. Die Implementierung eines Interface gleicht sich an die von der Ableitung einer Klasse bekannte Syntax an. Das zu implementierende Interface wird getrennt durch einen Doppelpunkt hinter den Deklarationskopf der Klasse geschrieben, die es implementieren soll. Da Mehrfachvererbung erlaubt ist und die Klasse auch von einer anderen Klasse abgeleitet sein kann, werden mehrere Implementierungen/Ableitungen durch Komma getrennt. Die genaue Syntax einer Klassendeklaration mit Interfaces hat demnach folgendes Aussehen: [Modifizierer] Bezeichner [: Basisklasse[,Interface1[,Interface2 ...]]] { // Member der Klasse }
Sandini Bib
9 Interfaces
202
Beim Ableiten von einer Basisklasse und der gleichzeitigen Implementierung eines oder mehrerer Interfaces muss die Basisklasse immer an erster Stelle stehen. Das Beispiel beinhaltet auch einen solchen Fall.
HINWEIS
Innerhalb der Klasse sind Sie gezwungen, für alle Bestandteile des Interface eine Implementierung zur Verfügung zu stellen. Falls Sie das nicht tun, meldet der Compiler einen Fehler. Das Visual Studio hilft beim Implementieren eines Interface. Nach der Angabe des zu implementierenden Interfaces können Sie die vom Interface geforderten Methodenrümpfe mithilfe eines Smarttags automatisch einfügen lassen. Dabei wird zwischen expliziter und impliziter Implementierung unterschieden. Mehr über diese Implementierungsarten erfahren Sie in Abschnitt 9.1.3 ab Seite 206.
Das oben beschriebene Interface soll nun zum Einsatz kommen. Es werden zwei einfache Klassen erstellt, die geometrische Formen darstellen, aber von unterschiedlichen Basisklassen abstammen. Eine Klasse stammt dabei direkt von Object ab, die andere von einer Basisklasse namens RoundBase, die wie folgt deklariert ist: public class RoundBase { private double radien = 0.0; public double Radien { get { return this.radien; } set { this.radien = value; } } public double Diameter { get { return 2 * this.radien; } } public RoundBase( double radien ) { this.radien = radien; } public RoundBase() { } } RoundBase dient als Basis für alle anderen geometrischen Formen, die eine runde Grundfläche besitzen. Die erste Klasse des Beispielprogramms verwendet RoundBase als Basis und
stellt einen Zylinder dar. Da es sich gleichzeitig um ein geometrisches Objekt handelt, implementiert die Klasse auch das Interface IGeometric. Sie ist folgendermaßen deklariert:
Sandini Bib
Grundlagen
203
public class Cylinder : RoundBase, IGeometric { private double height = 0.0; public double Height { get { return height; } set { height = value; } } public double Surface { get { // Oberfläche == Umfang * Höhe return this.Circumference * this.height; } } public double Circumference { get { return ( this.Diameter * Math.PI ); } } public double Volume { get { // Inhalt = Grundfläche * Höhe return ( ( ( this.Diameter * this.Diameter ) * Math.PI ) / 4 ) * this.height; } } public string Name { get { return "Zylinder"; } } public Cylinder() { } public Cylinder( double radien, double height ) : base( radien ) { this.height = height; } }
In dieser Klasse wurde die Konstruktorenverkettung verwendet, die Sie bereits in Abschnitt 6.3.4 kennen gelernt haben. Allerdings erfolgt die Verkettung diesmal nicht mit einem Konstruktor innerhalb der gleichen Klasse, sondern mit dem Konstruktor der Basisklasse. Das reservierte Wort this wurde zu diesem Zweck gegen das reservierte Wort base ausgetauscht.
Sandini Bib
204
9 Interfaces
Als Letztes erfolgt die Implementierung der dritten Klasse, die von Object abgeleitet ist (implizit) und lediglich IGeometric implementiert. Sie stellt einen Würfel dar, der zwar ein geometrisches, aber kein rundes Objekt ist. Daher auch keine Ableitung von RoundBase. public class Cube : IGeometric { private double sideLength = 0.0; public double SideLength { get { return sideLength; } set { sideLength = value; } } public double Surface { get { return 6 * Math.Pow( this.sideLength, 2.0 ); } } public double Circumference { get { return 4 * this.sideLength; } } public double Volume { get { return Math.Pow( this.sideLength, 3.0 ); } } public string Name { get { return "Würfel"; } } public Cube() { } public Cube( double sideLength ) { this.sideLength = sideLength; } }
Damit sind die benötigten Klassen deklariert. Über das Interface IGeometric besteht nun die Möglichkeit, auf beide Klassen (Cube und Cylinder) zuzugreifen – auf die gleiche Art, obwohl es sich um vollkommen unterschiedliche Körper handelt. Das Interface fasst die gemeinsamen Eigenschaften der Körper zusammen und ermöglicht so den einheitlichen Zugriff. Für die Ausgabe wird im Hauptprogramm eine neue Methode namens PrintValues() definiert, die einen Parameter vom Typ IGeometric erwartet. Innerhalb der Methode ist dann ausschließlich der Zugriff auf die von IGeometric implementierten Eigenschaften oder Methoden möglich, der Methode kann aber jedes beliebige Objekt übergeben werden, das IGeometric implementiert.
Sandini Bib
205
Grundlagen public static void PrintValues( IGeometric geoObject ) { Console.WriteLine( Console.WriteLine( Console.WriteLine( Console.WriteLine( Console.WriteLine(
geoObject.Name ); "===============================" ); "Umfang: {0}", geoObject.Circumference ); "Oberfläche: {0}", geoObject.Surface ); "Rauminhalt: {0}", geoObject.Volume );
Console.WriteLine( "\r\n" ); }
Implementiert das übergebene Objekt die Schnittstelle nicht, kommt es bereits während des Kompilierens zu einer Fehlermeldung – ein Laufzeitfehler wird somit ausgeschlossen. Die Hauptmethode des Programms ist trivial, sie erzeugt je ein Objekt vom Typ Cube bzw. Cylinder und übergibt diese an die Methode PrintValues(). static void Main( string[] args ) { // Erstellen der Objekte Cube cube = new Cube( 10.0 ); Cylinder cylinder = new Cylinder( 10.0, 10.0 ); PrintValues( cube ); PrintValues( cylinder ); Console.ReadLine(); }
Die Ausgabe des Programms sehen Sie in Abbildung 9.1.
HINWEIS
Abbildung 9.1: Ausgabe mittels Interface IGeometric
Um Missverständnissen vorzubeugen: Natürlich kann von einem Interface keine Instanz erzeugt werden. Das Interface selbst als Datentyp zu verwenden, mag daher Verwirrung stiften. Eine solche Methode, mit einem Interface als Übergabeparameter, erwartet an dieser Stelle jedes beliebige Objekt, das dieses Interface implementiert. Man hätte die Objekte auch als Datentyp Object übergeben und danach ein Casting zum Interface-Typ durchführen können.
Sandini Bib
9 Interfaces
206
9.1.3
Interface explizit verwenden
Bei der Verwendung von Interfaces kann es zu dem Problem kommen, dass zwei verschiedene Interfaces die Implementierung der gleichen Methode (also einer Methode mit gleichem Namen und gleicher Signatur) oder einer Eigenschaft mit gleichem Namen fordern. Bei obigem Beispiel wurde darauf keine Rücksicht genommen. Die Methoden wurden einfach implementiert und es wurde dem Compiler überlassen, herauszufinden, ob es sich um Methoden der Klasse selbst oder um vom Interface geforderte Methoden handelt. Das funktioniert auch reibungslos, es sei denn, wie gesagt, zwei Interfaces fordern die gleiche Methode. In diesem Fall können Sie den Methodennamen voll qualifizieren. Sie geben einfach den Bezeichner des Interface mit an: public class Cube : IGeometric { [...] public double IGeometric.Surface { get { return 6 * Math.Pow( this.sideLength, 2.0 ); } } public double IGeometric.Circumference { get { return 4 * this.sideLength; } } public double IGeometric.Volume { get { return Math.Pow( this.sideLength, 3.0 ); } } public string IGeometric.Name { get { return "Würfel"; } } }
Bei dieser Art der Interface-Implementierung ist der Zugriff auf die vom Interface geforderten Methoden beschränkt, und zwar in der Art, dass der Zugriff nur noch über das Interface und nicht mehr über das Objekt möglich ist. Bei implizit implementierten Interfaces ist der Zugriff auf die Methoden, Eigenschaften oder auch Ereignisse über das Objekt direkt möglich. Der Grund dafür ist einfach zu verstehen. Bei explizit implementierten Interfaces ist es möglich, dass zwei Methoden (oder Eigenschaften bzw. Ereignisse) implementiert werden, die von ihrer Signatur her exakt gleich sind. Der Compiler kann diese daher nur noch durch das Interface unterscheiden und sperrt damit den Zugriff auf diese Elemente über das eigentliche Objekt.
Sandini Bib
207
HINWEIS
Grundlagen
9.1.4
Wenn Sie eine Klasse von einer anderen Klasse ableiten, erbt sie automatisch alle Methoden, Eigenschaften und Variablen der Basisklasse. Ist in dieser ein Interface implementiert, wird auch dieses vererbt, d.h. eine abgeleitete Klasse implementiert automatisch alle Interfaces, die auch die Basisklasse implementiert.
Nicht implementierte Methoden
Grundsätzlich sollten alle Methoden, die das Interface zur Verfügung stellt, auch implementiert werden. Leider ist das nicht immer der Fall, auch nicht im .NET Framework. Zwar wird das Schreiben eines Methodenrumpfs erzwungen und damit auch der Aufruf der Methode ermöglicht, diese muss aber nicht zwangsläufig eine Funktionalität erhalten. Falls eine solche Methode aufgerufen wird, sollten Sie dem Benutzer eine Nachricht zukommen lassen, falls keine Funktionalität implementiert ist. Ansonsten erwartet dieser nämlich, dass etwas passiert, was aber nicht der Fall ist. Hier bieten sich zwei Methoden an: f Sie zeigen eine MessageBox bzw. eine Meldung an, die dem Benutzer mitteilt, dass die Funktion nicht implementiert ist. Das funktioniert natürlich nur bei Windows.FormsApplikationen, nicht bei Konsolenanwendungen. f Sie verwenden eine Exception. Exceptions dienen der Fehlerabsicherung bzw. Fehlerbehandlung. Wenn eine Exception auftritt, wird dies dem Anwender angezeigt, und zwar sowohl in einer Konsolenanwendung als auch in einer Windows.Forms-Anwendung. Das Auslösen einer Exception ist der bevorzugte Weg, da es dem Wesen des .NET Frameworks entspricht. Auch dieses arbeitet mit Exceptions, sodass sich hier eine gewisse Konsistenz in der Fehlerbehandlung – im weitesten Sinne handelt es sich auch hier um einen Fehler – einstellt. Das Visual Studio 2005 fügt entsprechende Aufrufe bereits automatisch ein, wenn das Interface mithilfe der Smarttags des Visual Studio-Editors eingefügt wird. Sie können einen entsprechenden Aufruf aber auch selbst einfügen:
VERWEIS
throw new NotImplementedException( "Diese Funktion ist nicht implementiert" );
Detailliertere Informationen über Exceptions, Fehlerbehandlung in .NET-Programmen und über das Erstellen eigener Exception-Klassen erfahren Sie in Abschnitt 16.1.4 ab Seite 463.
Sandini Bib
9 Interfaces
208
9.2
Die Interfaces IComparer und IComparable
9.2.1
Deklaration
Die Interfaces IComparer und IComparable dienen zum Vergleich zweier Objekte und sind in verschiedenen Objekten implementiert. Da sie jeweils nur die Implementierung einer einzigen Methode erfordern, sind sie sehr leicht zu verwenden. Beide Interfaces dienen der Sortierung von Elementen. Die Methode Array.Sort() verwendet zum Sortieren die Implementierung der Schnittstelle IComparable der enthaltenen Elemente (wenn es eine solche gibt). Somit können Sie über diese Schnittstelle die Art der Sortierung Ihrer eigenen Klassen innerhalb eines Arrays selbst festlegen. Was das Interface IComparer betrifft, so besitzt die Methode Array.Sort() auch eine überladene Version, die ein Objekt erwartet, das das Interface IComparer implementiert. Auch damit können Sie die Art der Sortierung festlegen. Die Deklaration der beiden Interfaces sieht folgendermaßen aus: public interface IComparer { int Compare( object a, object b ); } public interface IComparable { int CompareTo( object o ); }
Der Unterschied besteht darin, dass Compare() zwei beliebige Objekte aufnehmen kann und diese vergleicht, während CompareTo() die aktuelle Instanz mit einem beliebigen anderen Objekt vergleicht. Für die zurückgegebenen Werte gilt bei beiden Interfaces das Gleiche, nämlich: f -1 wird zurückgegeben, wenn das erste Objekt kleiner als das zweite ist, f 0 wird zurückgegeben, wenn beide Objekte gleich sind, f 1 wird zurückgegeben, wenn das zweite Objekt kleiner ist als das erste.
HINWEIS
Im Falle der Methode CompareTo() gilt, dass das erste Objekt das Objekt ist, aus dem heraus die Methode CompareTo() aufgerufen wird. IComparable ist im Namespace System deklariert, IComparer dagegen im Namespace System.Collections. Vergessen Sie also nicht, diesen per using einzubinden.
Bisher noch nicht besprochen wurden Generics. Von beiden Interfaces existiert eine generische Version, bei der Sie den Datentyp angeben können, mit dem das Interface arbeiten soll. Da aber Generics erst in Kapitel 11 ab Seite 225 besprochen werden, zeigt dieses Beispiel die Standardimplementierung.
Sandini Bib
Die Interfaces IComparer und IComparable
9.2.2
209
Verwendung von IComparer und IComparable
IComparer ermöglicht eine relativ große Flexibilität, da sie beispielsweise der Methode Array.Sort() quasi von außen übergeben wird. Damit ist ein Eingriff in die Art der Sortierung leicht möglich. Da die Methode Compare() mit Parametern vom Typ object arbeitet,
kann diese Methode für jeden beliebigen Datentyp implementiert werden.
Sortieren mit IComparable
CD
Für dieses Beispiel kehren wir zurück zu Fahrzeugen, in diesem Fall zu Automobilen. Eine Klasse namens Car steht für verschiedene Fahrzeuge, die sortierbar sein sollen. Zu diesem Zweck implementiert die Klasse das Interface IComparable. Die Sortierung soll anhand der Leistung erfolgen. Sie finden das folgende Beispielprogramm auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_09\CarSorter.
public class Car : IComparable { private string name = String.Empty; // Fahrzeugtyp private double horsePower = 0.0; // PS private string carColor = String.Empty; // Farbe des Wagens public string Name { get { return this.name; } set { this.name = value; } } public double HorsePower { get { return this.horsePower; } set { this.horsePower = value; } } public string CarColor { get { return this.carColor; } set { this.carColor = value; } } public int CompareTo( object obj ) { Car aCar = ( obj as Car ); if ( aCar != null ) return this.HorsePower.CompareTo( aCar.HorsePower ); return 1; }
Sandini Bib
9 Interfaces
210 public override string ToString() { return this.name + ", Farbe: " + this.carColor + ", Leistung: " + this.horsePower.ToString(); } public Car( string name, string color, double horsePower ) { this.Name = name; this.carColor = color; this.horsePower = horsePower; } }
Sie können nun im Hauptprogramm ein Array aus verschiedenen Car-Objekten erzeugen und dieses sortieren lassen. Die Sortierung wird wie in der Klasse Car vorgegeben nach der Leistung des Fahrzeugs durchgeführt. Wenn wir nun ein Array aus Car-Objekten erzeugen und dieses sortieren, wird automatisch die Methode CompareTo() verwendet. Die Ausgabe sehen Sie in Abbildung 9.2. static void Main( string[] args ) { Car[] new new new new };
carArray = { Car( "BMW 320i", "silber-Metallic", 170.0 ), Car( "Golf V", "blau-gesprenkelt", 55.0 ), Car( "Porsche Boxster", "Schwarz", 240.0 ), Car( "Audi A4", "Blau-metallic", 130.0 )
// Sortieren Array.Sort( carArray ); // Ausgeben foreach ( Car c in carArray ) Console.WriteLine( c ); Console.WriteLine(); Console.ReadLine(); }
Abbildung 9.2: Die sortierten Fahrzeuge
Sandini Bib
Die Interfaces IComparer und IComparable
211
Sortieren mit IComparer Eine andere, zusätzliche Art der Sortierung kann über das Interface IComparer erstellt werden. Dazu deklarieren Sie einfach eine neue Klasse, die lediglich das Interface IComparer und dessen Methode Compare() implementiert. Eine Instanz der Klasse übergeben Sie zusätzlich zur Sort()-Methode der Klasse Array. Das .NET Framework verwendet die IComparer-Implementierung, sofern sie übergeben wurde, ansonsten die IComparable-Implementierung der enthaltenen Objekte. Sind beide nicht angegeben, wird eine Exception ausgelöst. Zur Demonstration wird das Beispiel um eine Klasse erweitert, die mittels IComparer eine Sortierung nach dem Namen durchführt. Auch das Hauptprogramm wird entsprechend erweitert, sodass beide Sortierungen untereinander stehen und verglichen werden können. Achten Sie darauf, den Namespace System.Collections einzubinden, der für das Interface IComparer benötigt wird. class NameSorter : IComparer { public int Compare( object x, object y ) { Car firstCar = ( x as Car ); Car secondCar = ( y as Car ); if ( ( firstCar != null ) && ( secondCar != null ) ) return firstCar.Name.CompareTo( secondCar.Name ); return -1; } }
Die eigentliche Sortierung geschieht durch Übergeben eines neuen NameSorter-Objekts an die Methode Array.Sort(). Beide Sortierergebnisse im Vergleich zeigt Abbildung 9.3. static void Main( string[] args ) { Car[] new new new new };
carArray = { Car( "BMW 320i", "silber-Metallic", 170.0 ), Car( "Golf V", "blau-gesprenkelt", 55.0 ), Car( "Porsche Boxster", "Schwarz", 240.0 ), Car( "Audi A4", "Blau-metallic", 130.0 )
// Sortieren, Standard Array.Sort( carArray ); // Ausgeben foreach ( Car c in carArray ) Console.WriteLine( c ); Console.WriteLine();
Sandini Bib
9 Interfaces
212 // Sortieren, nach Name Array.Sort( carArray, new NameSorter() ); // Ausgeben foreach ( Car c in carArray ) Console.WriteLine( c ); Console.WriteLine(); Console.ReadLine(); }
Abbildung 9.3: Beide Sortierungen im Vergleich. IComparer sortiert wie gewünscht nach dem Namen (untere Liste).
9.3
Das Interface IDisposable
IDisposable implementiert ebenfalls nur eine einzige Methode, nämlich Dispose(). Dass diese Schnittstelle hier besonders angesprochen wird, hat natürlich seinen Grund, und der ist in der Speicherbereinigung bzw. in der Funktionsweise der Garbage Collection zu finden.
Üblicherweise wird die Garbage Collection aufgerufen, wenn der Rechner sich entweder im Leerlauf befindet oder aber wenn der Speicher zu voll wird und nicht mehr ausreicht. In beiden Fällen entfernt die Garbage Collection nicht mehr genutzte Objekte aus dem Speicher. Bei der Verwendung großer Objekte kann es sinnvoll sein, diese Speicherbereinigung explizit auszuführen. Da der Destruktor einer Klasse nicht explizit aufgerufen werden kann, muss dies über eine Methode der betreffenden Klasse geschehen. Das Interface IDisposable dient hier der Vereinheitlichung, indem es die Verwendung der Methode Dispose() (die zu diesem Zweck vorgesehen ist) erzwingt. Wenn Sie IDisposable in einer eigenen Klasse verwenden, müssen Sie einige Dinge beachten: f Wenn Dispose() mehrfach aufgerufen wird, darf es nicht zu einem Fehler kommen. f Ein Aufruf von Dispose() impliziert, dass die Garbage-Collection den Destruktor der Klasse nicht mehr aufrufen muss. Daher soll dies der Garbage Collection auch innerhalb von Dispose() mitgeteilt werden. f Eine evtl. vorhandene Dispose()-Methode der Basisklasse sollte ebenfalls aufgerufen werden.
Sandini Bib
213
Das Interface IDisposable
f Wenn Dispose() nicht explizit aufgerufen wird, muss der Destruktor die Aufgabe des Aufräumens übernehmen (indem er Dispose() aufruft). Üblicherweise existieren zwei Implementierungen von Dispose(), nämlich einmal die Methode, die von IDisposable vorgegeben wird und einen booleschen Parameter namens disposing erwartet. Weiterhin eine Methode ohne Parameter, die im Prinzip lediglich die Methode Dispose( true ) aufruft. Der Zweck des Parameters disposing ist die Unterscheidung zwischen einem expliziten Benutzeraufruf und dem Aufruf über den Destruktor bzw. über die Garbage-Collection. Während es bei einem expliziten Aufruf notwendig ist, verwaltete und nicht verwaltete Ressourcen zu bereinigen, ist dies bei einem Aufruf über den Garbage Collector nicht der Fall. Es wäre nämlich möglich, dass eine bestimmte verwaltete Ressource bereits weggeräumt wurde, denn der Zeitpunkt des Durchlaufs der Garbage Collection ist nicht vorbestimmt, und auch nicht der Zeitpunkt des Aufrufs eines Destruktors. Beispielsweise könnte es möglich sein, dass eine Klasse zwar einen Destruktor besitzt, ein von ihr referenziertes Objekt aber nicht. Das Objekt wäre dann bereits entsorgt, wenn die Garbage Collection den Destruktor aufruft, was zu einen Fehler führen würde. Das folgende Beispiel zeigt eine exemplarische Implementierung des Interface IDisposable. Dabei wird innerhalb der Klasse eine boolesche Variable namens disposed verwendet, die dann auf true gesetzt wird, wenn die Methode Dispose() aufgerufen wurde. Es handelt sich nicht um eine Schablone, die Sie für eigene Klassen verwenden können. /* * Diese Klasse zeigt exemplarisch die Implementierung des Interface IDisposable und * beinhaltet keinen real verwendbaren Code */ public class DisposableClass : IDisposable { // Hier Deklarationen zu managed und unmanaged Ressourcen private bool disposed = false;
// Standard: Objekt existiert, disposed=false
// Implementierung IDisposable protected void Dispose( bool disposing ) { if ( !this.disposed ) { // ggf: base.Dispose(disposing); if ( disposing ) { // verwaltete Ressourcen bereinigen } // Hier unverwaltete Ressourcen bereinigen } this.disposed = true; }
Sandini Bib
214
9 Interfaces
public void dispose() { // Dispose() zum einfachen Aufruf durch den Benutzer this.Dispose( true ); GC.SuppressFinalize(); // verhindert Destruktor-Aufruf } // Destruktor public ~DisposableClass() { // Code zum Aufräumen nur in Dispose() this.Dispose( false ); } // Konstruktor public DisposableClass() { // Konstruktorcode } }
Der Aufruf der statischen Methode GC.SuppressFinalize() verhindert, dass der Destruktor bzw. Finalizer über die Garbage Collection aufgerufen wird. GC ist die Klasse im .NET Framework, die die Garbage-Collection repräsentiert. Normalerweise werden Sie diese Klasse außer zum Aufruf von SuppressFinalize() kaum benötigen (zumindest sollte es nicht der Fall sein).
Sandini Bib
10 Attribute Attribute sind eine sehr nützliche und interessante Möglichkeit Programmen zusätzliche Informationen hinzuzufügen. Genauer gesagt werden diese Informationen den Metadaten des Programms hinzugefügt und können dann von der CLR zur Laufzeit ausgewertet werden. Über eine Technik namens Reflection können Sie auch innerhalb eigener Programme solche Attribute auswerten. Ein solches Attribut, an dem die Nützlichkeit dieser Konstruktion schon etwas klar geworden sein dürfte, ist das Attribut Flags, das Sie bereits in Abschnitt 4.6.3 ab Seite 109 bei den Bitfeldern kennen gelernt haben. Gäbe es keine Attribute, müsste der Laufzeitumgebung auf andere Art mitgeteilt werden, dass es sich bei einer Aufzählung um ein Bitfeld handelt. Das würde üblicherweise durch ein eigenes Konstrukt geschehen, oder aber es würde festgelegt, dass per Definitionem alle Aufzählungstypen Bitfelder sind. Die Unterscheidungsmöglichkeit mittels Attributen ist wesentlich flexibler. Ein weiteres Beispiel ist das Attribut Obsolete, mit dem Sie einen Programmbestandteil als veraltet kennzeichnen können. Dieses Attribut bewirkt, dass der Compiler eine Warnung ausgibt, mit einem Text, den Sie selbst festlegen können. Wenn Sie also eine DLL erstellt haben, bei der einige Funktionen in Zukunft anders implementiert werden, können Sie den Benutzer Ihrer DLL sehr einfach darauf hinweisen, dass eine Funktion in Zukunft nicht mehr vorhanden sein wird, bzw. sogar, wodurch sie ersetzt werden wird.
10.1
Grundlagen
Auch Attribute sind Klassen, wie alles andere im .NET Framework eigentlich auch. Allerdings ergeben sich sowohl bei der Verwendung als auch bei der Deklaration einige Unterschiede zu herkömmlichen Klassen. f Attributklassen müssen von der Basisklasse Attribute abgeleitet sein, die im Namespace System deklariert ist. f Die Namen von Attributklassen enden immer mit dem Suffix Attribute. Dabei handelt es sich um eine durchaus sinnvolle Namenskonvention, die im Verhalten der Laufzeitumgebung verwurzelt ist. Wenn Sie ein Attribut verwenden, wird nach dem angegebenen Klassennamen gesucht (beim bereits bekannten Attribut Flags also nach einer Klasse namens Flags). Wird diese nicht gefunden, hängt die Laufzeitumgebung einfach Attribute an den Namen an und sucht nach dieser Klasse (also einer Klasse mit dem Namen FlagsAttribute). Es hat sich daher eingebürgert, bei der Deklaration immer das Suffix Attribute an den Klassennamen anzuhängen, dieses Suffix aber bei der Verwendung wegzulassen. Die Klasse für das Attribut Obsolete heißt daher ObsoleteAttribute, die Klasse für das Attribut Serializable heißt SerializableAttribute usw. f Alle Felder und Eigenschaften von Attributen, die als benannte Parameter fungieren sollen, müssen öffentlich und les-/beschreibbar sein. Zu benannten Parametern erfahren Sie gleich mehr.
Sandini Bib
10 Attribute
216
f Die Elemente, für die ein Attribut angewendet werden kann, können frei festgelegt werden. Diese Festlegung geschieht sinnigerweise ebenfalls durch ein Attribut und ist somit sehr einfach zu bewerkstelligen.
10.1.1
Verwendung
Attribute werden verwendet, indem sie einfach in eckigen Klammern über das Element geschrieben werden, zu dem sie gehören sollen. Um erneut das einfache Beispiel mit einem Bitfeld anzuführen, dort sah das so aus: [Flags()] enum Positions { Top, Bottom, Left, Right }
Die CLR sucht in diesem Fall automatisch nach dem Attribut FlagsAttribute. Es ist auch möglich, mehrere Attribute zusammen zu verwenden. Wenn Sie beispielsweise das obige Bitfeld als veraltet kennzeichnen wollen, können Sie zusätzlich das Attribut Obsolete verwenden. [Flags()] [Obsolete("Dieses Bitfeld ist veraltet. Verwenden Sie stattdessen Anchors")] enum Positions { Top, Bottom, Left, Right }
Wird der Code kompiliert, gibt der Compiler eine Warnung mit dem Text aus, der dem Attribut Obsolete als Parameter übergeben wurde.
10.1.2
Parameter
Wie aus der Art und Weise der Verwendung von Attributen hervorgeht, können Werte für etwaige Felder oder Eigenschaften nicht so zugewiesen werden, wie von anderen Klassen gewohnt. Attribute werden lediglich oberhalb eines Programmelements angegeben und später nur noch von der Laufzeitumgebung (oder von Ihrem eigenen Programm) ausgewertet. Daher gibt es eine spezielle Möglichkeit, Werte zuzuweisen, durch so genannte benannte Parameter. Einem Attribut können zwei verschiedene Arten von Parametern übergeben werden.
Sandini Bib
Eigene Attribute erstellen
217
f Positionale Parameter sind im Konstruktor des Attributs festgelegt und werden auch so übergeben. Die Meldung, die beim Attribut Obsolete angezeigt werden soll, wird durch einen solchen Parameter übergeben. f Benannte Parameter dienen der Zuweisung von Werten an Felder oder Eigenschaften. Dabei werden der Name des Felds und der Wert angegeben. Ein oft zitiertes Beispiel für benannte Parameter ist das Attribut StructLayout. Mit ihm kann für eine Struktur festgelegt werden, in welcher Reihenfolge die Elemente enthalten sein sollen. Wenn Sie nur in managed Code, also nur innerhalb des .NET Frameworks und nicht beispielsweise auch über P/Invoke mit Funktionen des Windows API agieren, benötigen Sie dieses Attribut in der Regel nicht. Bei Verwendung von P/Invoke jedoch ist es oft so, dass die aufgerufene Methode eine exakte Reihenfolge der Parameter erfordert (innerhalb des .NET Frameworks ist das nicht der Fall, da hier sowieso alles von der CLR gemanaged wird). In diesem Fall würden Sie das Attribut StructLayout verwenden. Der Parameter, den der Konstruktor fordert, ist vom Typ LayoutKind, einem Aufzählungstyp. Mit LayoutKind.Sequential legen Sie fest, dass die Reihenfolge der Elemente der Struktur beibehalten wird. Der benannte Parameter Pack gibt die Komprimierungsgröße an, die verwendet werden soll. Der Standardwert hierfür ist 8. Pack ist eine Eigenschaft der Attributklasse StructLayoutAttribute. [StructLayout( LayoutKind.Sequential, Pack=1 )] public struct MyStruct { ... }
10.2
Eigene Attribute erstellen
Eigene Attribute können selbstverständlich auch erstellt werden. Diese werden nicht von der CLR ausgewertet, sondern müssen von Ihnen zur Laufzeit selbst ausgewertet werden. Dazu steht im .NET Framework der Reflection-Mechanismus zur Verfügung. Über Reflection ist es möglich, auf die Metadaten einer Assembly zuzugreifen, d.h. Sie können darüber ermitteln, welche Methoden eine Klasse beinhaltet, welche Parameter diese Methoden besitzen oder auch, welche Attribute auf Methoden, Eigenschaften oder Klassen angewendet wurden. Doch bevor es an die Auswertung eines Attributs geht, muss zunächst eines erstellt werden. Im folgenden Beispiel erfolgt lediglich eine Auswertung der Attribute; mehr Details über die Auswertung von Metadaten mittels Reflection erfahren Sie in Kapitel 25 ab Seite 885. Die Konventionen, die für die Benamung von Attributen gelten, wurden bereits angesprochen. Grundsätzlich entspricht die Deklaration eines Attributs der Deklaration einer Klasse, die von der Basisklasse Attribute abgeleitet ist. Sie sollten immer auch die Namenskonvention beachten, also bei der Deklaration den Suffix Attribute anhängen.
Sandini Bib
10 Attribute
218
10.2.1
Verwendung festlegen
Attribute können auf alle Elemente der Programmierung angewendet werden, also beispielsweise Klassen, Strukturen, Enums oder auch Methoden und Eigenschaften. Das ist selbstverständlich nicht immer sinnvoll. Die Verwendungsmöglichkeiten für Ihr eigenes Attribut können Sie frei angeben, über ein weiteres Attribut namens AttributeUsageAttribute. Der Verwendungszweck wird durch einen positionalen Parameter vom Typ AttributeTargets angegeben, bei dem es sich um ein Bitfeld handelt. Die folgende Deklaration würde das Attribut für Methoden und Eigenschaften gültig machen, nicht aber für andere Elemente des Programmcodes. [AttributeUsage( AttributeTargets.Method | AttributeTargets.Property )] public class NewAttribute : Attribute { // Deklarationen }
Außerdem besitzt die Klasse AttributeUsageAttribute noch zwei wichtige benannte Parameter: f AllowMultiple gibt an, ob das Attribut mehrfach verwendet werden kann. Beispielsweise könnten Sie ein Todo-Attribut erstellen, mit dessen Hilfe Sie angeben, was an einer Klasse noch zu tun ist, und dann automatisiert eine Todo-Liste erstellen. Natürlich ist häufig mehr an einer Klasse zu tun als nur ein Vorgang, also muss das Attribut auch mehrfach verwendet werden können. f Inherited gibt an, ob das Attribut von abgeleiteten Klassen geerbt wird.
10.2.2
Attributparameter
Auch wenn Attribute Klassen sind, so können Sie doch für die verschiedenen Parameter nicht jeden beliebigen Datentyp verwenden. Strukturen sind beispielsweise nicht erlaubt (womit eine Datumsangabe mittels DateTime ausfällt). Lediglich die folgenden Datentypen dürfen verwendet werden: f Wertetypen (bool, byte, char, usw.), f der Datentyp string, f der Datentyp Type, f Aufzählungen, f Arrays, die eindimensional sein müssen und deren Elemente von einem der genannten Typen sind und f Objekte, die einen der angegebenen Datentypen kapseln. Wenn Sie dies nicht beachten, erhalten Sie eine Fehlermeldung, wenn Sie das Programm, das die Attribute verwendet, kompilieren wollen. Außerdem müssen die Werte, die an Attributparameter übergeben werden, konstante Werte sein.
Sandini Bib
Beispiel: Ein Todo-Attribut
10.2.3
219
Ermitteln des Attributs
Zur Ermittlung der Attribute dient die Methode GetCustomAttributes(), die von verschiedenen Klassen des Namensraums System.Reflection und auch von der Klasse Attribute selbst in Form einer statischen Methode implementiert wird. Sie liefert ein Array aus Objekten zurück, die die einzelnen Attributklassen (bzw. Instanzen dieser Klassen) repräsentieren. Die Zuweisung eines Attributs an eine Klasse ist nämlich nichts weiter als die Instanziierung der Attributklasse selbst. Am folgenden Beispielprogramm wird deutlich, wie die Ermittlung von Attributen vor sich geht.
10.3
Beispiel: Ein Todo-Attribut
CD
Das Todo-Attribut wurde ja bereits angesprochen. Das Visual Studio bietet zwar bereits eine Todo-Möglichkeit, das Gleiche könnte man aber auch mithilfe eines Attributs bewerkstelligen. In diesem Beispiel wird ein solches Attribut erstellt. Den gesamten Quellcode finden Sie wieder auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_10\CustomAttributeSample. In dieser Projektmappe sind mehrere Projekte zusammengefasst. Hier wurden auch der schöneren Ausgabe wegen Windows.Forms-Möglichkeiten verwendet.
10.3.1
Deklaration der Attributklasse
Das Attribut soll eigentlich nicht viele Daten aufnehmen. Benötigt werden lediglich ein Datum, der Name des Programmierers und natürlich der Todo-Text selbst. Diesen implementieren wir als positionalen Parameter (also als Übergabeparameter für den Konstruktor), die beiden anderen als benannte Parameter. Um das Ganze ein wenig einfacher zu gestalten, soll das Attribut nur auf Klassen anwendbar sein, dafür aber mehrfach. Vererbt werden soll es allerdings nicht. Die Attributklasse wird in mehreren Projekten benötigt und wird daher in einer eigenen DLL deklariert (das entspricht dann einem eigenen Projekt des Typs »Klassenbibliothek«). Diese DLL wird in alle Projekte eingebunden, die das Attribut benötigen. Der Quelltext ist, wie die Engländer so schön sagen, straightforward: using System; namespace TodoAtt { [AttributeUsage( AttributeTargets.Class, AllowMultiple = true, Inherited = false )] public class TodoAttribute : Attribute { /* * Felder (privat) */ private string programmer = String.Empty;
Sandini Bib
10 Attribute
220 private string item = String.Empty; private string todoDate = DateTime.Now.ToShortDateString(); /* * Eigenschaften (öffentlich) */ public string Programmer { get { return this.programmer; } set { this.programmer = value; } } public string Item { get { return this.Item; } set { this.Item = value; } } public string TodoDate { get { return this.todoDate; } set { this.todoDate = value; } } // Anzeige in Listbox über ToString() public override string ToString() { return this.programmer + " / " + this.todoDate + " :: " + this.item; } public TodoAttribute(string todoItem) { this.item = todoItem; } } }
Achten Sie darauf, dass ToString() mit override deklariert wird, damit auch wirklich diese Methode später für die Ausgabe verwendet wird. In diesem Beispiel dient ein ListBoxSteuerelement für die Anzeige. Das Attribut kann nun verwendet werden, etwa in folgender Form: [Todo("Stringsortierungen implementieren", Programmer="Frank Eller", TodoDate="21.08.2005")] public class Sorter { // Klassenimplementierung }
Sandini Bib
Beispiel: Ein Todo-Attribut
10.3.2
221
Auswertung der Attribute
Das Beispielprojekt enthält sowohl eine Windows-Anwendung, in der einige (nicht vollständig implementierte) Klassen enthalten sind, die auch mit dem Todo-Attribut belegt sind. Ein zweites Projekt dient dazu, diese Attribute auszuwerten. Bei diesem Projekt handelt es sich um eine Windows.Forms-Applikation, allein schon aus ästhetischen Gründen (eine Ausgabe in einem Windows-Formular sieht einfach besser aus als auf der Konsole). Auch wenn die verschiedenen Steuerelemente noch nicht besprochen wurden, sollte der Aufbau des Beispielprogramms keine größeren Schwierigkeiten aufwerfen. Abbildung 10.1 zeigt das Formular im Entwurfsmodus.
Abbildung 10.1: Die Oberfläche des Auswertungsprogramms im Entwurfsmodus
Detailliertere Informationen zu den einzelnen Komponenten und Steuerelementen erhalten Sie im Windows.Forms-Teil des Buchs ab Kapitel 17 beginnend bei Seite 479. Für den Moment soll es genügen, die Steuerelemente wie angezeigt (oder in ähnlicher Form) auf dem Formular zu platzieren. Die Ermittlung der auszuwertenden Assembly geschieht über einen Dialog vpm Typ OpenFileDialog. Die Verwendung eines Dialogs unter .NET ist trivial, alle folgen der gleichen Philosophie. Die Methode ShowDialog() zeigt den Dialog an und liefert einen Wert vom Typ DialogResult zurück. DialogResult selbst ist ein Enum; entspricht der Rückgabewert DialogResult.OK, hat der Anwender der OK-Button betätigt und somit die Eingabe bestätigt.
Sandini Bib
10 Attribute
222 private void BtnLoadAssembly_Click( object sender, EventArgs e ) { if ( this.dlgOpen.ShowDialog() == DialogResult.OK ) this.lblAppName.Text = this.dlgOpen.FileName; }
Der zweite Button startet die Auswertung der Attribute. Diese wurde in eine einzelne Methode ausgelagert, auch aus Übersichtsgründen. Im Grundsatz handelt es sich bei dieser Auswertung um die Ermittlung von Metadaten mittels Reflection. Diese Vorgehensweise ist nicht wirklich schwer zu verstehen, allein der Umfang der verfügbaren Informationen macht es mitunter ein wenig schwierig. Die Reflection-Klassen finden Sie im Namespace System.Reflection. Diesen müssen Sie in das Projekt einbinden. Über Reflection können alle relevanten Daten einer ausführbaren Datei, sei es nun Hauptprogramm oder DLL, ermittelt werden. Der zentrale Datentyp ist System.Type, der wiederum für die Repräsentation jedes anderen Datentyps verwendet werden kann. Über diese Klasse werden die restlichen Informationen ermittelt. In unserem Fall genügt es, die Attribute auszuwerten, die einer Klasse zugewiesen wurden, und zu ermitteln, ob es sich dabei um ein Todo-Attribut handelt. Wenn ja, fügen wir es der ListBox hinzu (durch die Methode ToString() wird der dort angegebene Text ausgegeben, der alle Informationen des Attributs beinhaltet). Wenn nicht, wird zur nächsten Klasse in der Assembly weitergegangen. private void GetTodoInformation() { // Dateinamen ermitteln und kontrollieren string fileName = this.lblAppName.Text; if ( !File.Exists( fileName ) ) { MessageBox.Show( "Die Datei \r\n" + fileName + "\r\nexistiert nicht" ); return; } // Datei zur Auswertung laden Assembly asmbl = Assembly.LoadFrom( fileName ); // Attributtyp festlegen (wird für die Auswertung benötigt) Type todoAttType = typeof( TodoAttribute ); // Typen durchlaufen foreach ( Type currentType in asmbl.GetTypes() ) { // Typ in die Listbox schreiben this.lstTodoInfo.Items.Add( currentType.FullName ); // Attribute des Typs ermitteln (nur die vom Typ TodoAttribute) object[] attribs = currentType.GetCustomAttributes( todoAttType, false );
Sandini Bib
Beispiel: Ein Todo-Attribut
223
// Wenn keine Attribute vorhanden, entsprechende Meldung ausgeben if ( attribs.Length == 0 ) { this.lstTodoInfo.Items.Add( "Keine Attribute definiert" ); } else { // Attribute durchlaufen und ausgeben foreach ( Attribute att in attribs ) { this.lstTodoInfo.Items.Add( att ); } } // Leerzeile, um die Datentypen zu trennen this.lstTodoInfo.Items.Add( string.Empty ); } }
Damit das Ganze auch funktioniert, muss die DLL, in der das Todo-Attribut definiert ist, zu den Projektverweisen hinzugefügt werden (über den Projektmappen-Explorer). Außerdem muss auch der Namespace, in dem sich die Klasse TodoAttribute befindet, mittels using eingebunden werden. Das Ergebnis zeigt Abbildung 10.2. Wie Sie sehen werden die Todo-Attribute ausgewertet und sauber in einer Listbox angezeigt.
Abbildung 10.2: Die Todo-Attribute in einer Listbox aufgelistet
Sandini Bib
Sandini Bib
11 Generics Generics sind das am häufigsten beschriebene Feature von .NET 2.0, und sicherlich auch das in Zukunft am häufigsten verwendete. Das Haupteinsatzgebiet von Generics sind die Collections; generische Collections sind weit leistungsfähiger als die bislang verwendeten Listenklassen und vermindern außerdem den zu erstellenden Codeanteil um ein Beträchtliches. Die offizielle Bezeichnung für ein derartiges Feature, das es in Form von Templates auch in C++ gibt, ist übrigens parametrisierte Polymorphie. Dennoch sind Generics nicht auf Listen beschränkt, sie können in vielen Fällen Anwendung finden. Sowohl bei Klassen als auch bei Methoden, Interfaces oder Delegates. Eine generische Klasse haben Sie auch schon kennen gelernt: Nullable Types sind mittels Generics implementiert. Der zugrunde liegende Datentyp heißt Nullable.
11.1
Grundlagen zu Generics
Eine einfache Erklärung dessen, was Generics eigentlich sind, wäre der Satz »Generics sind Platzhalter für Datentypen«. Ebenso wie Variablen als Platzhalter für Werte dienen können, stellen Generics eben solche Platzhalter für Datentypen dar. Der Vorteil ist nicht von der Hand zu weisen. Häufig ist es der Fall, dass die Funktionalität und die Daten einer Klasse sich lediglich durch den Datentyp unterscheiden. In .NET 1.1 musste für diesen Fall je eine Klasse pro Datentyp erzeugt werden. Mithilfe von Generics fällt diese Beschränkung weg; die Klasse wird ein einziges Mal programmiert und für den Datentyp wird schlicht ein Platzhalter angegeben.
11.1.1
Deklaration
Für die Deklaration eines generischen Platzhalters hat sich eingebürgert, einen einzigen Buchstaben zu verwenden. Der Hintergrund hierbei ist, dass dadurch sofort ersichtlich ist, dass es sich um einen Generic handelt; Datentypen, die real existieren, tragen aussagekräftige Namen. Der Platzhalter wird direkt hinter den Klassennamen in spitzen Klammern geschrieben (also zwischen Größer- und Kleiner-Zeichen): public class GenericsTestClass { ... }
Generics sind nicht auf einen einzigen generischen Datentyp beschränkt; es kann sich auch um mehrere Datentypen handeln. In diesem Fall werden die verwendeten generischen Typen durch ein Komma getrennt: public class GenericsTestClass { ... }
Sandini Bib
11 Generics
226
11.1.2
Beispiel: ein generischer Stack
CD
Ein Stack ist ein Stapelspeicher, was als letztes hineingelegt wird, wird als erstes wieder entnommen. Obwohl ein generischer Stack im .NET Framework bereits existiert, eignet sich diese Konstruktion doch ideal zur Demonstration. Das folgende Beispiel implementiert einen wachsenden Stack basierend auf einem Array. Der verwendete Datentyp kann angegeben werden, der Stack ist damit automatisch typsicher. Das Beispiel wurde als Windows.Forms-Applikation ausgelegt, um das Verhalten des Stacks deutlicher zu machen. Den Quellcode des Programms finden Sie auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_11\GenericStack.
Die generische Stack-Klasse Der Stack ist verhältnismäßig einfach ausgelegt. Er beinhaltet ein Array, das bei der Initialisierung über den Konstruktor entweder mit einer angegebenen Größe oder aber mit einer Standardgröße von 16 Elementen vorbelegt wird. Die Methoden Push() und Pop() dienen zum Einfügen bzw. Entnehmen von Elementen. Die private Methode Resize() vergrößert das Array bei Bedarf. Als kleiner Zusatz wurde auch eine Eigenschaft ItemArray implementiert, die ein Array mit allen enthaltenen Elementen zurückliefert. Der Quellcode ist kommentiert und sollte keine größeren Probleme aufwerfen. public class GStack { T[] items; int pointer = 0;
// Die Elemente // Zeiger auf das nächste Element
// Liefert ein Array mit allen Elementen des Stack public T[] ItemArray { get { T[] tmpArray = new T[this.pointer]; for ( int i = this.pointer-1; i >= 0; i-- ) tmpArray[i] = this.items[this.pointer-i-1]; return tmpArray; } } // Einfügen von Elementen public void Push( T item ) { if ( this.pointer >= this.items.Length ) Resize( this.items.Length + 16 ); this.items[this.pointer] = item; this.pointer++; }
Sandini Bib
Grundlagen zu Generics
227
// Entnehmen von Elementen public T Pop() { this.pointer--; if ( this.pointer >= 0 ) { return this.items[this.pointer]; } else { this.pointer = 0; throw new InvalidOperationException( "Stack ist leer" ); } } // Private Methode zum Vergrößern des internen Arrays private void Resize( int newSize ) { // Vergrößert das Element-Array, muss dazu aber die Werte kopieren T[] tmpItems = new T[this.items.Length]; this.items.CopyTo( tmpItems,0 ); this.items = new T[newSize]; tmpItems.CopyTo( this.items, 0 ); } public GStack() { // Standardgröße 16 Elemente this._items = new T[16]; } public GStack( int capacity ) { // Festlegen einer gegebenen Kapazität (Elementanzahl) this._items = new T[capacity]; } }
11.1.3
Der Standardwert eines generischen Typs
Ein Problem existiert bei der bestehenden Implementierung noch. Eigentlich handelt es sich nicht um ein Problem, lediglich um eine Unzulänglichkeit. Ist der Stack leer, wird beim Entnehmen eines Werts eine Exception ausgelöst. Das könnte verhindert werden, indem null zurückgeliefert wird, statt die Exception auszulösen. Das jedoch kann nicht funktionieren. Der generische Stack ist ein so genannter offener Datentyp. Anders ausgedrückt, der Compiler kann nicht wissen, ob der generische Typ ein Wertetyp oder ein Referenztyp sein wird – und bei einem Wertetyp wäre es eben nicht möglich, null zurückzuliefern. In diesem Fall müsste der Standardwert des Datentyps zurückgeliefert werden. Andersherum kann aber bei Referenztypen nicht einfach ein Wert zurückgeliefert werden, hier müssten Sie null verwenden. Letztendlich also ein Dilemma.
Sandini Bib
228
11 Generics
Für diese Fälle existiert in C# das reservierte Wort default. Mit diesem wird der Standardwert des angegebenen Datentyps zurückgeliefert, im Falle eines Wertetyps also ein festgelegter Wert, im Falle eines Referenztyps null. Die Methode Pop() kann also folgendermaßen abgeändert werden: public T Pop() { this.pointer--; if ( this.pointer >= 0 ) { return this.items[this.pointer]; } else { this.pointer = 0; return default( T ); } }
Verwenden des Stacks T steht für einen beliebigen Datentyp. Bei der Verwendung des Stacks muss dieser natür-
lich angegeben werden. Die Verwendung gestaltet sich danach wie mit einem herkömmlichen Stack; im Visual Studio werden Sie allerdings feststellen, dass auch die IntelliSenseHilfe bereits Kenntnis des wirklich verwendeten Datentyps hat und nur diesen akzeptiert. Für die Anzeige wurde ein Windows.Forms-Programm geschrieben. Das Formular selbst bietet nur drei Buttons sowie eine ListBox zur Anzeige des Stack-Inhalts. Entnommene Werte werden in einer TextBox angezeigt. Dank des Partial-Class-Features ist der Quellcode für die Form angenehm kurz und kann daher komplett abgedruckt werden. partial class Form1 : Form { // Deklaration des Stacks private GStack<string> stack = new GStack<string>(); public Form1() { InitializeComponent(); } private void btnExit_Click( object sender, EventArgs e ) { Close(); } private void btnPop_Click( object sender, EventArgs e ) { this.txtValue.Text = this.stack.Pop(); FillListbox(); }
Sandini Bib
Constraints
229
private void btnPush_Click( object sender, EventArgs e ) { this.stack.Push( Guid.NewGuid().ToString() ); FillListbox(); } private void FillListbox() { this.lstItems.Items.Clear(); this.lstItems.Items.AddRange( this.stack.ItemArray ); } }
Die Ausgabe des Programms zeigt Abbildung 11.1. In diesem Fall wurden GUIDs verwendet, um den Stack zu füllen. Damit ist dann auch sichtbar, dass sich die Elemente unterscheiden und dass auch wirklich das oberste Element immer entnommen wird. GUIDs sind global eindeutige Identifier (GUID == Global unique Identifier), die weltweit eindeutig sind und unter anderem auch bei der Registrierung von COM-Komponenten zum Einsatz kommen.
Abbildung 11.1: Das Beispielprogramm in Aktion. Hier wird auch sichtbar, dass der programmierte Stack problemlos wachsen kann.
11.2
Constraints
Die Verwendung eines beliebigen Datentyps im Falle des generischen Stacks ist sicherlich eine enorme Erleichterung. Doch nicht immer ist es sinnvoll, wirklich alle Datentypen zuzulassen. Aus diesem Grund ist es möglich, Bedingungen anzugeben, die der verwendete Datentyp erfüllen muss, damit er verwendet werden kann.
Sandini Bib
11 Generics
230
11.2.1
Mögliche Bedingungen
Diese Bedingungen werden am Ende der Deklaration mittels einer where-Anweisung angegeben. Diese where-Anweisung funktioniert dabei so ähnlich wie in SQL, wo sie für die Bedingungen steht, die ein Datensatz erfüllen muss, damit er zurückgeliefert wird. Eine vollständige Deklaration inklusive where-Klausel könnte etwa folgendermaßen aussehen: public class GStack where T : struct { ... }
In diesem Fall würde der generische Typ T in der Weise eingeschränkt, dass nur noch Wertetypen verwendet werden könnten. Alle Wertetypen, auch die integrierten, sind ja als struct implementiert. Die folgenden Constraints sind möglich: where T : struct: Einschränkung auf einen Wertetyp. Achtung: Nullable Types können hierfür nicht verwendet werden. where T : class: Einschänkung auf Referenztypen where T : Vehicle: Der verwendete Datentyp muss direkt oder indirekt von der Klasse Vehicle abgeleitet sein. where T : new(): Der verwendete Datentyp muss zwingend einen Standardkonstruktor zur Verfügung stellen. where T : IComparable: Der verwendete Datentyp muss zwingend das Interface IComparable
implementieren. In manchen Fällen können auch mehrere Constraints gleichzeitig zum Einsatz kommen. Beispielsweise ist es durchaus legitim, zu fordern, dass ein Datentyp, der von einer bestimmten Klasse abgeleitet ist, auch einen Standardkonstruktor zur Verfügung stellt. Auch ist es möglich, mehrere Interfaces anzugeben, die implementiert sein müssen, da bei Interfaces die Mehrfachvererbung gilt. Allerdings kann immer nur eine Basisklasse angegeben werden. Sie können demnach keine Bedingung in der Art angeben, dass entweder von der einen oder der anderen Klasse abgeleitet sein muss. Mehrere Constraints werden einfach durch Komma getrennt: public class GStack where T : class, IComparable, new() { ... }
Diese Deklaration würde erzwingen, dass der generische Typ ein Referenztyp ist, das Interface IComparable implementiert und auch einen Standardkonstruktor besitzen muss. Die Einschränkung auf IComparable ist eine sicherlich häufig verwendete, vor allem bei Listenkonstrukten. Immerhin ist die Liste dadurch implizit sortierbar. IComparable selbst allerdings arbeitet mit dem Datentyp object, der allgemeingültig ist, da das Interface in beliebigen Typen eingesetzt werden kann. Für eigene Klassen ist es daher sinnvoller, das entsprechende generische Interface zu verwenden: public class GStack where T : class, IComparable, new() { ... }
Sandini Bib
Constraints
231
Jetzt erzwingt die Klasse die Implementierung eines generischen Interface, das nicht mehr mit object arbeitet sondern mit dem angegebenen Datentyp. In der Bedingung steht T natürlich für denselben Datentyp wie in der Deklaration.
11.2.2
Erweitern des Beispiels
CD
Der generische Stack aus der Beispielapplikation GenericStack soll entsprechend erweitert werden. Dazu muss eine Klasse erzeugt werden, die dem Stack hinzugefügt werden kann und die angegebenen Bedingungen erfüllt. Für das Beispiel soll eine einfache Klasse Person genügen, die lediglich Name und Vorname beinhaltet und das Interface IComparable implementiert. Den Quellcode des Programms finden Sie auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_11\GenericPersonStack.
Die Deklaration der generischen Stack-Klasse wird dabei nur um die Constraints erweitert: public class GStack where T : class, IComparable { ... }
Der new()-Constraint ist nicht notwendig. Die Klasse Person, die in den Stack gespeichert werden soll, sieht folgendermaßen aus: class Person : IComparable { private string name = String.Empty; private string firstName = String.Empty; public string Name { get { return name; } set { name = value; } } public string FirstName { get { return firstName; } set { firstName = value; } } public override string ToString() { return this.firstName + " " + this.name; } public int CompareTo( Person other ) { return this.name.CompareTo( other.Name ); }
Sandini Bib
11 Generics
232 public Person( string name, string firstName ) { this.name = name; this.firstName = firstName; } }
Die Methode ToString() wurde implementiert, damit später in der ListBox auch wirklich der Name und der Vorname stehen. Auch ein Konstruktor wurde implementiert, um sicherzustellen, dass kein leeres Person-Objekt eingefügt werden kann. Der Code für das Hauptformular wird um eine Methode erweitert, mit der eine beliebige Person erzeugt werden kann. Zwei Arrays aus Strings liefern Namen bzw. Vornamen, die zufällig zusammengesetzt werden und so immer wieder andere Namenspaare erzeugen. Mittels dieser Methode können einfach neue Personen erzeugt werden. Ansonsten entspricht der Code des Hauptformulars fast exakt dem vorhergehenden Beispiel: partial class Form1 : Form { // Deklaration des Stacks GStack stack = new GStack(); public Form1() { InitializeComponent(); } private Person CreatePerson() { string[] names = { "Maier", "Meier", "Mayer", "Müller", "Schmidt", "Schmitt", "Schmied", "Muster", "Mustermann", "Musterfrau" }; string[] firstNames = { "Heinz", "Hans", "Max", "Dieter", "Josef", "Susi", "Steffi", "Andrea", "Babsi", "Sylvia", "Brigitte" }; Random string string return
rnd = new Random( DateTime.Now.Millisecond ); name = names[rnd.Next( names.Length )]; firstName = firstNames[rnd.Next( firstNames.Length )]; new Person( name, firstName );
} private void btnExit_Click( object sender, EventArgs e ) { Close(); }
Sandini Bib
Vererbung mit Generics
233
private void btnPop_Click( object sender, EventArgs e ) { this.txtValue.Text = this.stack.Pop().ToString(); FillListbox(); } private void btnPush_Click( object sender, EventArgs e ) { this.stack.Push( this.CreatePerson() ); FillListbox(); } private void FillListbox() { this.lstItems.Items.Clear(); this.lstItems.Items.AddRange( this.stack.ItemArray ); } }
Das Resultat des kleinen Programms sehen Sie in Abbildung 11.2.
Abbildung 11.2: Der Stack mit Personen
11.3
Vererbung mit Generics
Generische Klassen können selbstverständlich als Basisklassen für andere Klassen dienen, denn das Konzept der Vererbung wird hier nicht unterbrochen. Allerdings gibt es einige Einschränkungen, die durch die Art der Implementierung von Generics bedingt sind. Hierzu kommen einige neue Begriffe zum Einsatz. f Ein generischer Datentyp ohne angegebenen Typparameter (also etwa List) wird als offener Datentyp oder als offener konstruierter Datentyp bezeichnet.
Sandini Bib
11 Generics
234
f Ein generischer Datentyp, dessen Typparameter bereits durch einen existierenden Datentyp ersetzt ist, wird als geschlossener Datentyp bzw., geschlossener konstruierter Datentyp bezeichnet. f Klassen ohne Verwendung generischer Typparameter (z.B. Person) werden als konkrete Klassen bezeichnet.
11.3.1
Konkrete Klassen mit generischer Basisklasse
Konkrete Klassen können von generischen Klassen erben, wenn es sich bei der generischen Klasse um einen geschlossenen Datentyp handelt. In diesem Fall ist die Laufzeitumgebung in der Lage, alle benötigten Informationen des Basisdatentyps zu ermitteln. Für die Laufzeitumgebung gilt nämlich, dass durch die Angabe eines Typparameters der gesamte Typ sozusagen ebenfalls »konkret« wird. Die folgende Deklaration ist also möglich: public class IntStack : GStack { ... } IntStack erbt von der Klasse GStack, bei der allerdings schon angegeben ist, dass sie mit dem Datentyp int arbeitet.
Nicht möglich hingegen ist folgende Konstruktion: public class IntStack : GStack { ... }
Hier kann die Laufzeitumgebung nicht ermitteln, welchem Datentyp T entsprechen wird. Der Compiler liefert daher einen Fehler.
11.3.2
Generische Klassen mit generischer Basisklasse
Auch generische Klassen können von einer generischen Klasse erben. Von einer konkreten sowieso, was aber allein schon durch die Tatsache klar wird, dass jeder Typ ohne explizit angegebene Basisklasse von object erbt – auch die generischen. Erbt nun eine generische Klasse von einer weiteren generischen Klasse, so kann es sich bei dieser um einen offenen Datentyp handeln. Die folgende Ableitung ist demnach gültig: public class AnotherStack : GStack { ... }
Ein Problem entsteht erst, wenn die Basisklasse mehr generische Typparameter besitzt als die abgeleitete Klasse. In diesem Fall gilt wieder das gleiche, was auch bei konkreten Klassen gilt: Der Typparameter, der in der abgeleiteten Klasse nicht verwendet wird, muss durch einen konkreten Datentyp ersetzt werden. Eine typische Klasse mit zwei Typparametern ist z.B. das generische Dictionary (der Ersatz für die Hashtable). Die folgende Deklaration wäre absolut gültig: public class SpecialDictionary : Dictionary { ... }
Das K steht in diesem Fall für »Key«, das V für »Value«. Natürlich funktioniert es auch, wenn einfach ein zweiter Typparameter hinzugefügt wird: public class SpecialDictionary : Dictionary { ... }
Sandini Bib
Generische Methoden
235
Constraints in der Basisklasse Constraints in einer Basisklasse können in der generischen abgeleiteten Klasse nicht außer Kraft gesetzt werden. Das bedeutet schlicht, dass eine abgeleitete Klasse zumindest die gleichen Constraints zur Verfügung stellen muss wie die Basisklasse.
11.4
Generische Methoden
Generics können nicht allein auf Klassen angewendet werden. Auch Methoden können generische Parameter beinhalten, sogar Delegates können generisch sein. Letzteres ist allerdings eine Möglichkeit, die höchst selten zum Einsatz kommen sollte. Generics sind nämlich nicht CLS-compliant, und Delegates werden in der Hauptsache für Events verwendet. Events jedoch sind nur dann sinnvoll, wenn die entsprechende Klasse wieder verwendbar ist. In dem Moment verbieten sich Generics für die öffentlichen Ereignisse, da die Klasse dann aus anderen Sprachen heraus unter Umständen nicht mehr einsetzbar wäre. Intern jedoch könnten Sie diese problemlos einsetzen. Die Deklaration einer Methode mit generischen Parametern funktioniert genauso wie bei der Klassendeklaration. Die Methode wird einfach als generische Methode deklariert. Auch Constraints können angebracht werden: public void Swap(ref T firstValue, ref T secondValue ) where T : struct { T tempValue = firstValue; firstValue = secondValue; secondValue = tempValue; }
Die Klasse selbst in der sich eine solche Methode befindet muss nicht zwangsläufig auch generisch sein. Das einzig generische Element ist die Methode selbst. Beim Aufruf muss dann der verwendete Typ angegeben werden: int a = 10; int b = 10; Swap( ref a, ref b );
Hier gibt es eine Besonderheit. Der Übersichtlichkeit halber sollten Aufrufe generischer Methoden zwar immer auf die beschriebene Weise geschehen, da der Compiler aber recht intelligent ist und den Datentyp aus den übergebenen Parametern ermitteln kann, würde auch folgender Aufruf funktionieren: int a = 10; int b = 10; Swap( ref a, ref b ); a und b sind vom Typ int, der Compiler setzt daher diesen Typ für den generischen Platzhalter T ein. Aber: Diese Vorgehensweise, obwohl durchaus möglich, hat natürlich den Nachteil, dass aus dem Aufruf heraus nicht mehr deutlich wird, dass es sich hier um eine generische Methode handelt.
Sandini Bib
Sandini Bib
Teil III Grundlegende Programmiertechniken
Sandini Bib
Sandini Bib
12 Arbeiten mit Datentypen Die verschiedenen Datentypen wurden bereits in Kapitel 4 ab Seite 81 vorgestellt. In diesem Kapitel geht es mehr um die Art und Weise, wie diese in C# (respektive .NET) verwendet werden, um die Methoden, die sie bereitstellen, und um die Besonderheiten, die es zu beachten gilt.
12.1
Zahlen
12.1.1
Notation
Dezimaltrennzeichen Bei Fließkommazahlen gilt im Programmcode der Punkt als Dezimaltrennzeichen. Die Darstellung einer Zahl verwendet jedoch kulturspezifische Einstellungen, und hier gelten für Deutschland das Komma als Dezimaltrenner und der Punkt als Tausendertrenner. Für große Zahlen kann auch die wissenschaftliche Notation verwendet werden. Hierbei wird eine Basiszahl als Dezimalzahl angegeben und mit einer Potenz von 10 multipliziert. Die Schreibweise 3.1E10 steht für die Zahl 31000000000 (31 Milliarden). Richtig große Zahlen werden allerdings auch in wissenschaftlicher Notation ausgegeben, z.B. 5,31E50.
Hexadezimalwerte Zahlen können auch als Hexadezimalwerte angegeben werden. Vor den eigentlichen HexWert wird das Präfix 0x geschrieben, danach folgen (im Falle eines Werts vom Typ int) vier Hex-Werte, die jeweils ein Byte repräsentieren. Einige Zuweisungen: int i; i = 0x000F; i = 0xFFF0;
// i = 15 // i = 65520;
Die Ausgabe erfolgt natürlich als Dezimalwert. Wenn Sie Hex-Werte auch bei der Ausgabe darstellen wollen, müssen Sie auf die Formatierungsmöglichkeiten der Methode Format() der Klasse String zurückgreifen. Die Formatierung von Zahlen wird detaillierter in Abschnitt 12.4.2 ab Seite 272 besprochen.
12.1.2
Rundungsfehler
Prinzipbedingt treten bei den Datentypen double und float immer Rundungsfehler auf. Diese Fehler resultieren aus der internen Darstellung der Zahlen. float und double arbeiten mit einer festen Anzahl Nachkommastellen, d.h. periodisch sich wiederholende Werte werden nicht als solche erkannt (das ist in jeder Programmiersprache so). Wenn Sie in
Sandini Bib
12 Arbeiten mit Datentypen
240
einem Taschenrechner die Berechnung 10/3 durchführen, und das Ergebnis wieder mit 3 multiplizieren, kommt auch wieder 10 heraus. Tun Sie das Gleiche in einer beliebigen Programmiersprache, ist das Ergebnis 0,999999999… . Die Rundungsfehler treten nur im Bereich der Rechengenauigkeit auf, im folgenden Beispiel an der 16ten Nachkommastelle. Rundungsfehler lassen sich nur verhindern, indem andere Datentypen eingesetzt werden, je nach Anwendung z.B. int, long oder decimal. Vergessen Sie aber nicht, dass Berechnungen mit decimal-Werten sehr viel langsamer sind als mit double-Werten. Im folgenden Beispielprogramm wird zweimal eine Schleife von -1 bis 1 mit einer Schrittweite von 0.1 durchlaufen. Dabei wird als Schleifenvariable zuerst eine decimal-, dann eine double-Variable verwendet. Abbildung 12.1 beweist, dass im zweiten Fall im Bereich um 0 offensichtliche Rundungsfehler auftreten.
CD
Abbildung 12.1: Rundungsfehler bei der Verwendung von Double-Variablen
Sie finden das gesamte Beispielprogramm auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_12\RoundingError.
public class Program { static void Main( string[] args ) { // Schleife mit decimal-Variable Console.WriteLine( "------ mit Decimal -----" ); for ( decimal d = -1; d <= 1; d += 0.1m ) Console.Write( d + " " ); Console.WriteLine( "\r\n" ); // Schleife mit double-Variable Console.WriteLine( "------ mit Double -----" ); for ( double d = -1; d <= 1; d += 0.1d ) Console.Write( d + " " ); Console.ReadLine(); } }
Sandini Bib
241
HINWEIS
Zahlen
Beachten Sie, dass in der ersten Schleife die Schrittweite 0.1 mit dem Literal m als Decimal-Zahl gekennzeichnet wurde. Vergessen Sie das, beschwert sich ihr Compiler und weigert sich, seine Arbeit aufzunehmen.
12.1.3
Division durch Null und der Wert unendlich
Die Datentypen float und double (nicht aber decimal) kennen die Werte unendlich bzw. minus unendlich. Bei einer Division durch Null (x = y / 0.0) tritt deswegen kein Fehler auf! Stattdessen enthält x nun Double.NegativeInfinity bzw. Double.PositiveInfinity. Diese Werte können mit den statischen Methoden IsInfinity(), IsNegativeInfinity() bzw. IsPositiveInfinity() der Klasse Double festgestellt bzw. als -1.#INF bzw. 1.#INF ausgegeben werden. Die folgende Tabelle fasst diese und einige weitere Eigenschaften und Methoden zusammen, die zur Verarbeitung von unendlich bzw. NaN (Not a Number) dienen. Um zu testen, ob die double-Variable x einen ungültigen Wert enthält, müssen Sie Double.IsNaN(x) ausführen. (Die näher liegende Form x.IsNaN() ist leider nicht vorgesehen.) Eigenschaften und Methoden der Typen float und double (aus System) Epsilon
enthält die kleinste darstellbare Zahl, die noch größer als 0 ist (bei Double ca. 4.94065645841247E-324).
MaxValue
enthält die größte darstellbare Zahl (bei Double ca. 1.7976931348623157E+308).
MinValue
enthält die kleinste darstellbare Zahl.
NaN
gibt einen Wert an, der zur Repräsentierung irregulärer Zustände verwendet wird (not a number, -1.#IND).
NegativeInfinity
gibt den Wert minus unendlich an (-1.#INF).
PositiveInfinity
gibt den Wert unendlich an (1.#INF).
IsInfinity()
testet, ob der Wert unendlich enthält (egal mit welchem Vorzeichen).
IsNaN()
testet, ob es sich um einen ungültigen Wert handelt.
IsNegativeInfinity()
testet, ob der Wert negativ unendlich ist.
IsPositiveInfinity()
testet, ob der Wert positiv unendlich ist.
12.1.4
Arithmetische Funktionen
Arithmetische Funktionen sind Bestandteil der Klasse Math aus dem Namespace System. Diese Klasse liefert eine umfangreiche Anzahl mathematischer Methoden, wie z.B. Sinus, Cosinus oder Tangens, aber auch Konstanten wie z.B. PI oder die eulersche Zahl e. Alle
Sandini Bib
12 Arbeiten mit Datentypen
242
Funktionen der Klasse Math erwarten Parameter des Typs double und liefern die Ergebnisse auch als double-Wert zurück. Arithmetische Methoden und Konstanten der Klasse Math (aus System) Eulersche Zahl e (2.71828182845905)
PI
Kreisteilungszahl π
Abs( double d )
Absolutbetrag
Acos( double d ), Asin( double d ), Atan( double d )
Arcussinus, Arcuscosinus, Arcustangens
Atan2( double x, double y )
Arcustangens zu x/y
Cos( double d ), Sin( double d ), Tan( double d )
Sinus, Cosinus, Tangens
Cosh( double d ), Sinh( double d ), Tanh( double d )
hyperbolische Funktionen
Exp ( double d )
Exponentialfunktion (ed)
Log( double d )
natürlicher Logarithmus zur Basis e
Log10( double d )
Logarithmus zur Basis 10
Log( double d, double b )
Logarithmus zur Basis b
Pow( double x, double y )
berechnet xy, zum Quadrieren aber zu langsam
Sign( double d )
Signum-Funktion (liefert 1 bei positiven Zahlen, 0 bei 0, -1 bei negativen Zahlen)
Sqrt( double d )
Quadratwurzel
VORSICHT
E
Anders als in VB6 und vielen anderen Programmiersprachen führen ungültige Berechnungen nicht zu einem Fehler. Math.Sqrt(-1) oder Math.Log(-1) liefern als Ergebnis den Double-Wert -1.#IND. Dieser Wert entspricht dem Zustand not a number, der mit Double.IsNaN(x) festgestellt werden kann.
12.1.5
Zahlen runden und andere Funktionen
Math.Floor() entspricht im Verhalten dem Abschneiden des Nachkommaanteils – diese Methode rundet zur nächsten ganzen Zahl ab. Math.Ceiling() funktioniert so ähnlich wie Floor(), rundet aber immer auf. Beachten Sie, dass die Werte als double-Werte zurückgeliefert werden; um sie in int-Werte zu verwandeln, ist zumindest ein Casting notwendig.
Sandini Bib
243
Zahlen
Die einzige Funktion, die wirklich im Sinne des Sprachgebrauchs rundet, ist Math.Round(). Hier wird bei einem Nachkommaanteil kleiner 0,5 abgerundet, bei einem Nachkommaanteil größer 0,5 dagegen aufgerundet. Das Verhalten entspricht allerdings nicht ganz der Schulmathematik, denn wenn der Nachkommaanteil genau 0,5 ist, rundet Round() zur nächsten geraden Zahl. Der Wert 1,5 wird demnach zu 2 gerundet, der Wert 2,5 allerdings auch. Der Vorteil dieser Methode besteht darin, dass die Summe der Fehler, die beim Runden vieler gleich verteilter Zahlen entsteht, gegen 0 geht. Sonstige Methoden der Klasse Math (aus System) Ceiling( x )
rundet zur nächstgrößeren ganzen Zahl auf: Ceiling(0.1) liefert 1. Ceiling(-2.9) liefert -2. Ceiling(3) liefert unverändert 3.
Floor( x )
rundet zur nächstkleineren ganzen Zahl ab: Floor(0.1) liefert 0. Floor(-2.1) liefert -3. Floor(3) liefert unverändert 3.
IEEERemainder( x, y )
liefert den Rest einer ganzzahligen Division, also x Mod y; genau genommen wird zuerst Q=x/y berechnet, wobei Q zur nächsten ganzen Zahl auf- oder abgerundet wird; IEEERemainder liefert dann x-Q*y als Ergebnis. Math.IEEERemainder(12, 5) liefert 2. Math.IEEERemainder(-12, 5) liefert -2. Math.IEEERemainder(12, -5) liefert 2. Math.IEEERemainder(-12, -5) liefert -2. Math.IEEERemainder(12.1, 5) liefert 2.1 Math.IEEERemainder(12, 5.1) liefert 1.8
= 12.1 - 2 * 5. = 12 - 2 * 5.1.
Min( x ,y ), Max( x, y )
liefert die kleinere bzw. größere von zwei Zahlen. Es ist nicht möglich, mehr als zwei Argumente zu übergeben.
Round( x, n )
rundet auf die angegebene Stellenanzahl: Round(3.14159, 3) liefert 3.142.
12.1.6
Zufallszahlen
.NET stellt für Zufallszahlen die Klasse Random aus dem Namespace System zur Verfügung. Zu beachten ist, dass es sich bei dieser Art Zufallszahlen wie auch in anderen Programmiersprachen durchaus üblich um so genannte Pseudo-Zufallszahlen handelt. Der Konstruktor der Random-Klasse ist überladen und erwartet in einer seiner Versionen einen Parameter vom Typ int. Dieser gibt den so genannten Seed an, einen Initialwert für den Zufallszahlengenerator. Gleicher Initialwert bedeutet auch gleiche aufeinander folgende Zahlenwerte, wie die folgende Methode beweist:
Sandini Bib
12 Arbeiten mit Datentypen
244 static void Main(string[] args) { Random rnd = new Random( 50 ); for ( int i = 0; i < 10; i++ ) Console.WriteLine( rnd.Next( 100 ) ); Console.ReadLine(); }
Ohne diesen Parameter wird Random mit einem von der Zeit abhängigen Wert initialisiert, sodass nicht die gleichen Zahlen geliefert werden sollten. Demnach liefert das obige Programm mit einer Änderung (die 50 in der zweiten Zeile muss weg) immer wieder zehn unterschiedliche Zufallszahlen zwischen 0 und 99. Methoden der Klasse Random (aus System) Next()
liefert eine int-Zufallszahl zwischen 0 (inklusive) und 2147483647 (exklusive).
Next( int n )
liefert eine int-Zufallszahl zwischen 0 (inklusive) und n (exklusive). n muss selbst eine int-Zahl größer 0 sein. Next(4) liefert also Zufallszahlen zwischen 0 und 3.
Next( int n1, int n2 )
liefert eine int-Zufallszahl zwischen n1 (inklusive) und n2 (exklusive). Next(7, 12) liefert also Zufallszahlen zwischen 7 und 11.
NextBytes( byte [] b )
füllt das byte-Array b mit Zufallsdaten.
NextDouble()
liefert eine double-Zufallszahl zwischen 0 (inklusive) und 1 (exklusive).
12.2
Strings
12.2.1
Grundlagen
Der Datentyp string ist ein sehr wichtiger Datentyp und der wohl am meisten verwendete im .NET Framework. Eigentlich handelt es sich dabei um eine Klasse, die sich aber nach außen hin wie ein Wertetyp verhält. Der Grund dafür ist die Größe, die ein String einnehmen kann, denn diese ist nur durch die Größe des vorhandenen Hauptspeichers begrenzt. Eine solche Menge an Daten auf dem Stack unterzubringen ist allerdings nicht sinnvoll. Aus diesem Grund wurde String als Klasse, als Verweistyp, implementiert und der Inhalt einer Zeichenkette wird auf dem verwalteten Heap abgelegt. Dadurch gibt es bei diesem Datentyp einige Dinge zu beachten, die auf den ersten Blick unverständlich sein könnten.
Sandini Bib
245
Strings
Deklaration Jedes einzelne Zeichen eines Strings wird durch den Datentyp char repräsentiert. Dennoch ist String anders als in C++ nicht als ein Array aus char zu bezeichnen – es handelt sich um einen ganz eigenen Datentyp. Die Initialisierung eines Strings geschieht, wie von den Wertetypen gewohnt, durch Zuweisung eines Werts. Strings werden immer in doppelten Anführungszeichen angegeben. string s = "Das ist ein String";
Einzelzeichen (die vom Typ char sind) werden hingegen in einfachen Anführungszeichen geschrieben: char c = 'c';
Die Zuweisung eines einzelnen Zeichens an einer Position des Strings ist nicht möglich. Die folgende Zeile liefert einen Fehler, der besagt, dass die einzelnen Buchstaben schreibgeschützt sind: s[5] = 'b';
// funktioniert nicht
Das Hinzufügen des Datentyps char zu einem String funktioniert wiederum. Grund dafür ist, dass implizit die Methode ToString() des Datentyps char verwendet wird, um den Datentyp zu konvertieren.
Initialisierung Die Initialisierung von Strings ist auf verschiedene Arten möglich. Einmal, wie schon oben zu sehen, indem die Zeichenkette einfach zugewiesen wird. Die Klasse String besitzt aber auch noch einen Konstruktor, der mehrfach überladen ist und weitere Initialisierungsmöglichkeiten anbietet. string s; char[] ca = {'H','a','l','l','o'}; s s s s
= = = =
String.Empty; new String("=", 20); new String(ca); new String(ca, 2, 3);
// // // //
Ein leerer String Liefert einen String mit 20 Gleichheitszeichen Ergibt "Hallo" Ergibt "llo"
Letztere Notation erfordert eine kurze Erklärung. Übergeben werden dem Konstruktor ein Array aus char-Werten sowie zwei Zahlen. Diese geben den Startindex und die Länge der Zeichen des Arrays an, das in den String kopiert werden soll. Indizes beginnen in C# ausnahmslos mit 0, daher bezeichnet der Index 2 das dritte Element des Arrays.
Sandini Bib
12 Arbeiten mit Datentypen
246
12.2.2
Verketten von Strings
Strings »addieren« Zur Verkettung von Strings kann der +-Operator verwendet werden, sowohl in der normalen Form als auch in der zusammengesetzten mit =. Der Operator – jedoch kann nicht zum Entfernen eines Zeichens verwendet werden. Anders als in Visual Basic gibt es keinen &-Operator, der auch Zahlenwerte berücksichtigt. Dazu wird die Methode ToString() verwendet: string s = "1 dividiert durch 2 ist " + (1d/2d).ToString();
Diese Zeile ergibt den String 1 dividiert durch 2 ist 0,5
Die Verwendung des Suffixes d für die Zahlen ist bei diesem Beispiel wichtig, da die Zahlen ansonsten als int-Werte interpretiert würden (nur Zahlen mit Nachkommastellen werden automatisch als double interpretiert). Bei der Division zweier int-Werte werden die Nachkommastellen aber abgeschnitten, wodurch das Ergebnis 0 wäre. Bei Zahlen ergibt sich eine Besonderheit in der Verkettung von Strings. Die folgende Zeile müsste, der Logik halber, eigentlich eine Fehlermeldung verursachen: string s = "Der Wert fünf entspricht der Ziffer " + 5;
Statt einer Fehlermeldung wird aber die Ziffer 5 als string einfach an s angefügt. Dieses Verhalten mutet sonderbar an, kann aber, wenn man es nicht weiß, zu Fehlern führen. In diesem Fall verhält sich C# ähnlich zu Java (dort ist das auch der Fall). Eine große Fehlerquelle ist beispielsweise folgende Situation. Das Ergebnis einer Berechnung soll auf der Konsole ausgegeben werden. Der Programmierer schreibt also: Console.WriteLine( "Ergebnis " + 4 + 5 );
Das verblüffende Ergebnis dieser Berechnung sehen Sie in Abbildung 12.2.
Abbildung 12.2: Das verblüffende Ergebnis von 4+5 ...
Das korrekte Ergebnis erhalten Sie nur, wenn Sie den zu berechnenden Ausdruck in Klammern setzen und die Methode ToString() verwenden: Console.WriteLine( "Ergebnis " + ( 4 + 5 ).ToString() );
Sandini Bib
247
HINWEIS
Strings
Die Klammern um den Ausdruck 4+5 sind sehr wichtig. Ansonsten würde sich ToString() nur auf den Wert 5 beziehen, und das Ergebnis wäre erneut 45 (statt 9).
Methoden für die Verkettung von Strings Die Klasse String bietet zwei Methoden, mit denen Strings verbunden bzw. auch erzeugt werden können. Beide Methoden sind statisch. Die erste Methode, Concat(), verbindet zwei Strings miteinander zu einem resultierenden String. Übergeben werden entweder Strings oder aber Objekte, wobei die String-Repräsentationen dieser Objekte zu einem neuen String verbunden werden. Hierzu wird für jedes Objekt ToString() aufgerufen. string s = String.Concat( "Hallo" ," Welt" );
// Liefert "Hallo Welt"
Die zweite Methode ist ein wenig interessanter. Mit Join() können Sie String-Arrays zu einem einzigen String zusammenfügen und angeben, durch welches Zeichen die Teilstrings voneinander getrennt werden sollen. Dabei kann nicht nur ein Einzelzeichen, sondern durchaus eine ganze Zeichenkette angegeben werden. string[] sa = { "Das", "ist", "ein", "String" }; string s = String.Join( " ", sa ); // Liefert "Das ist ein String"
12.2.3
Zugriff auf Zeichenketten
Zugriff auf Einzelzeichen Die Zuweisung eines einzelnen Zeichens innerhalb eines Strings ist nicht möglich, sehr wohl aber kann ein einzelnes Zeichen ausgelesen werden. Das funktioniert wie bei einem Array. Geben Sie hinter der String-Variablen in eckigen Klammern den Index des Zeichens an, das Sie ermitteln wollen (die Klasse System.String implementiert einen Indexer). Zurückgeliefert wird ein Wert des Typs char: string s = "Frank Eller"; char c = s[2];
In diesem Fall wird das Zeichen a zurückgeliefert. Beachten Sie bitte, dass die Zählung mit 0 beginnt, d.h. der obige Index 2 zeigt auf das dritte Zeichen des Strings. Die Länge eines Strings wird über die Eigenschaft Length ermittelt. Da beim Zugriff auf die einzelnen Zeichen der Index mit 0 beginnt, wird das letzte Zeichen eines Strings über s[s.Length-1] ermittelt.
Zugriff auf Teile einer Zeichenkette Die Methode Substring() ermöglicht es, einen Teil eines Strings zurückzuliefern. Die Methode erwartet den Startindex, ab dem der Teilstring beginnen soll, und optional auch eine
Sandini Bib
248
12 Arbeiten mit Datentypen
Längenangabe, mit der die Anzahl der zu kopierenden Zeichen bestimmt wird. Fehlt diese Angabe, werden alle Zeichen bis zum Ende des Strings zurückgeliefert. Ist die Angabe der Länge vorhanden, geht aber über die Länge des Strings hinaus, wird eine Exception ausgelöst, d.h. es erfolgt eine Fehlermeldung. Sie müssen also darauf achten, dass sich die Angaben auf Positionen innerhalb des Strings beschränken. string s1 = "Frank Eller, Michael Kofler – Visual C#"; string s2 = s1.Substring(0, 27 );
Das Ergebnis ist: Frank Eller, Michael Kofler
Zeichenketten auffüllen Mithilfe der Methoden PadLeft() bzw. PadRight() können Sie Strings auffüllen. PadLeft() richtet den vorhandenen String linksbündig aus und füllt ihn bis zur angegebenen Gesamtlänge mit dem angegebenen Zeichen auf. Als Standardzeichen wird das Leerzeichen verwendet. PadRight() tut im Prinzip das Gleiche, richtet den String aber rechtsbündig aus und füllt
die Leerzeichen vor dem eigentlichen String ein. Beide Methoden erwarten als Übergabeparameter die Gesamtlänge der resultierenden Zeichenkette und optional zusätzlich das Zeichen, das zum Auffüllen verwendet werden soll. string s1 = "Frank Eller"; string s2 = s.PadLeft( 20, '=' );
Das Ergebnis ist: Frank Eller=========
Mithilfe der Methode Insert() können Sie eine Zeichenkette innerhalb einer Zeichenkette einfügen. Die Methode erwartet als Parameter die Position, an der das Einfügen beginnen soll, und eine Zeichenkette, die eingefügt werden soll. Bei der Positionsangabe muss wieder darauf geachtet werden, dass der Index 0 das erste Zeichen der Zeichenkette liefert. string s = "Hallo, Frank"; string s2 = s.Insert( 5," Welt" );
ergibt den String Hallo Welt, Frank
Zeichen entfernen Zum Entfernen von Zeichen gibt es mehrere Methoden. Die Methode Remove() entfernt eine angegebene Anzahl Zeichen ab einem angegebenen Index aus dem String. Mithilfe dieser Methode können Sie Zeichen aus der Mitte des Strings entfernen, wobei allerdings nicht festgelegt werden kann, welche Zeichen entfernt werden.
Sandini Bib
249
Strings
»Intelligenter« sind hier die Methoden Trim(), TrimStart() und TrimEnd(). Sie arbeiten jedoch nur am Anfang bzw. am Ende eines Strings. Ohne Parameter werden alle so genannten Leerraumzeichen entfernt. Dabei arbeiten TrimStart() am Anfang des Strings, TrimEnd() an dessen Ende und Trim() auf beiden Seiten. Alle drei Methoden ermöglichen es auch, die Zeichen anzugeben, die entfernt werden sollen. Dazu wird ein Array aus char-Elementen übergeben, das die zu entfernenden Zeichen angibt. Die Reihenfolge der Zeichen innerhalb des Arrays ist dabei egal, es werden so lange Zeichen entfernt, bis das aktuelle Zeichen nicht mehr im Array enthalten ist. string s1 = "Hallo Welt"; string s2 = s1.Trim( new char[] { 'e', 'l', 't', 'W' } );
Das Ergebnis ist Hallo
Achten Sie hierbei auf die Groß-/Kleinschreibung. Zum Vergleich, ob ein Zeichen des Strings einem Zeichen aus dem Array entspricht, werden die Unicode-Werte verglichen, d.h. der Buchstabe D entspricht nicht dem Buchstaben d.
Der Hashcode Ein Hashcode ist eine halbwegs eindeutige numerische Repräsentation eines Objekts. Hashcodes haben die Eigenschaft, dass eine kleine Änderung an dem zugrunde liegenden Wert eine große Änderung in dem resultierenden Hashwert ergibt. Hashcodes sind eindeutig für den zugrunde liegenden Wert, nicht wie beispielsweise GUIDs (Global Unique Identifiers) weltweit eindeutig. Gleiche Werte ergeben daher auch den gleichen Hashcode. Das gilt auch für Hashcodes von Strings. Die Grundlage für einen String-Vergleich ist die enthaltene Zeichenkette (und nicht wie bei Referenztypen üblich die Referenz auf die Speicherstelle, die die Daten enthält). Strings mit gleicher Zeichenkette liefern daher auch den gleichen Hashcode, wie das folgende Beispiel zeigt: public static void Main(string[] args) { Console.WriteLine( "Hallo".GetHashCode() ); Console.WriteLine( "Hallo".GetHashCode() );
// Neuer String // Noch ein neuer String
}
Das Ergebnis dieses Minimalprogramms beweist, dass zwei Strings mit gleichem Inhalt auch den gleichen Hashcode liefern. Zusätzlich ist die Klasse String auch noch versiegelt (sealed), kann also nicht erweitert werden. Diese Lösung ist also etwas unglücklich. Das Ergebnis des Programms sehen Sie hier. 222838403 222838403
Sandini Bib
12 Arbeiten mit Datentypen
ACHTUNG
250
Der Hash-Wert eines Strings ist nicht eindeutig. Es kann durchaus vorkommen, dass zwei unterschiedliche Zeichenketten denselben Hash-Wert liefern, obwohl es sich um verschiedene Instanzen handelt. (Es wäre ja ein Wunder, wenn in einer 32-BitZahl der gesamte Inhalt einer beliebig langen Zeichenkette ausgedrückt werden könnte – und Wunder sind in der Informatik eher selten.) GetHashCode() liefert für eine bestimmte Zeichenkette immer wieder denselben Hash-Wert. Mit anderen Worten: wenn zwei String-Variablen denselben Inhalt
VERWEIS
haben, ist auch ihr Hash-Wert derselbe. Nur die umgekehrte Schlussfolgerung ist nicht zulässig. Interessante Hintergrundinformationen zu GetHashCode() finden Sie auch bei der Dokumentation zu System.Object.GetHashCode(): ms-help://MS.VSCC.v80/MS.MSDNQTR.v80.en/MS.MSDN.v80/MS.NETDEVFX.v20.en/cpref/html/ M_System_Object_GetHashCode.htm
Verbatim-Strings Der Backslash dient bekanntlich als einleitendes Element für Steuerzeichen, so genannte. Escape-Sequenzen (siehe auch Abschnitt 4.2.2 über den Datentyp Char ab Seite 85). Daher kommt es bei der Verwendung von Dateinamen zu dem Problem, dass ein einzelner Backslash als Einleitung zu einem Steuerzeichen gesehen wird, und nicht als Verzeichnistrenner. Die folgende Codezeilen würde zu einem Fehler führen: string fileName = "C:\Temp\anyFile.txt"; FileInfo fi = new FileInfo( filename );
Umgehen können Sie dieses Problem auf zwei Arten. Entweder Sie schreiben statt eines Backslashes zwei: string fileName = "C:\\Temp\\anyFile.txt"; FileInfo fi = new FileInfo( filename );
Oder aber Sie teilen dem Compiler mit, dass er den String als Verbatim, also genau so wie er geschrieben ist, interpretieren soll. Mit anderen Worten schalten Sie die Behandlung der Steuerzeichen aus. Das geschieht mit dem at-Zeichen (umgangssprachlich auch als »Klammeraffe« bezeichnet): string fileName = @"C:\Temp\anyFile.txt"; FileInfo fi = new FileInfo( filename );
Natürlich funktioniert das Ganze nicht nur in dieser Variante, sondern auch direkt: FileInfo fi = new FileInfo( @"C:\Temp\anyFile.txt" );
Ein kleiner Nebeneffekt dieser Vorgehensweise ist, dass auch Zeilenumbrüche und Leerzeichen mit in den String aufgenommen werden. Sie können das leicht ausprobieren, indem Sie folgende Codezeilen schreiben und dann auf der Console ausgeben:
Sandini Bib
251
Strings string textString = "Das ist ein mehrzeiliger String."; Console.WriteLine( textString );
12.2.4
Vergleiche von Zeichenketten
Die Klasse String bietet mehrere Möglichkeiten des Vergleichs zweier Strings. Die Methode CompareTo() vergleicht das aktuelle String-Objekt mit einem anderen, das der Methode als Parameter übergeben werden muss. Das Ergebnis ist ein Wert vom Typ int, der angibt, ob der zweite String kleiner, größer oder gleich dem String ist, dessen CompareTo()-Methode aufgerufen wurde.
HINWEIS
Ist der Ergebniswert größer 0, dann ist der aufrufende String größer als der übergebene, ist der Wert kleiner 0, ist er kleiner. Bei einem Ergebniswert gleich 0 sind beide Strings gleich. Bei der Kontrolle wird nicht die Länge des Strings verglichen, sondern die Positionen der Zeichen der Strings. Das bedeutet, dass beispielsweise Sonderzeichen einer Sprache immer größer sind als Zeichen im ASCII-Bereich (also von 0 bis 128). Die Methode CompareTo() stammt aus dem Interface IComparable und wird auch zur Sortierung von Zeichenketten verwendet, die sich in einer ArrayList oder einer vergleichbaren Liste befinden. Siehe hierzu auch Abschnitt 13.3.2 ab Seite 289.
Eine weitere Möglichkeit ist die Verwendung der statischen Methode Compare(). Diese Methode erwartet als Parameter zumindest zwei Strings, die verglichen werden sollen, ist aber auch in mehreren überladenen Versionen vorhanden. Unter anderem kann hier angegeben werden, ob Groß-/Kleinschreibung beachtet oder ignoriert bzw. ob kulturspezifische Einstellungen berücksichtigt werden sollen. Die ebenfalls statische Methode CompareOrdinal() berücksichtigt keine kulturspezifischen Einstellungen. Dafür ist es mit dieser Methode allerdings auch möglich, Teilzeichenfolgen miteinander zu vergleichen. CompareOrdinal() berücksichtigt immer Groß- und Kleinschreibung. Eine weitere Methode, die dafür verwendet werden kann, zu kontrollieren, ob zwei Strings einander entsprechen, ist die Methode Equals(). Sie liefert einen booleschen Wert zurück, der true ist, wenn die beiden Strings gleich sind. Obwohl Strings eigentlich Referenztypen sind, wird hierbei wirklich der Inhalt verglichen, nicht die Referenz. Die Verwendung der Operatoren < und > funktioniert in C# nicht. Der Operator == kann jedoch zum Vergleich auf Gleichheit verwendet werden. Groß-/Kleinschreibung wird unterschieden.
Sandini Bib
12 Arbeiten mit Datentypen
252
12.2.5
Die Klasse StringBuilder
Strings sind unveränderlich Die String-Klasse besitzt nicht nur die Eigenart, sich nach außen hin wie ein Wertetyp zu verhalten, sie besitzt auch eine weitere Eigenschaft, die nicht sofort sichtbar oder spürbar ist – es handelt sich nämlich um eine Klasse, die immutable ist, unveränderlich. Das bedeutet, wenn die Klasse initialisiert ist, dann kann der enthaltene Wert nicht mehr geändert werden. Sicherlich werden Sie jetzt sagen, dass das doch nicht sein kann – denn einige Seiten vorher wurde ja bereits gezeigt, dass die Zuweisung zu einem vorhandenen String durchaus möglich ist. Nach außen hin scheint es so, richtig. Intern jedoch passiert etwas völlig anderes. Da einem String nichts hinzugefügt werden kann, werden beide Strings genommen und in einen neuen String kopiert. Danach wird die Referenz dieses neuen Strings auf die ursprüngliche String-Variable umgelegt.
CD
Die Auswirkungen sind zwar nicht sofort spürbar, belasten aber die Performance eines Programms enorm. Ein kleines Beispiel soll das zeigen. Es werden in einer Schleife 20000 Strings zusammengefügt. Die benötigte Zeit wird gemessen und ausgegeben. Zur besseren Visualisierung wurde ein kleines Windows.Forms-Projekt erstellt. Sie finden das folgende Beispielprogramm auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_12\StringCopy.
Die langsame Kopiervariante, allein auf der Klasse String basierend, sieht folgendermaßen aus: private void btnSlow_Click( object sender, EventArgs e ) { // Langsam: Über Strings this.lblResult.Text = "Zusammenfügen läuft ..."; Application.DoEvents(); string s = String.Empty; Stopwatch watch = new Stopwatch(); watch.Start(); for ( int i = 0; i < 20000; i++ ) s = s + "Text"; watch.Stop(); this.lblResult.Text = "Fertig, benötigte Zeit: " + watch.Elapsed.ToString(); }
Sandini Bib
Strings
253
Der Screenshot nach Ausführung dieser Methode zeigt, dass es etwa 3 Sekunden gedauert hat, bis alle 20000 Strings zusammengefügt waren. Diese Zeit ist natürlich auch von der Geschwindigkeit des Rechners abhängig, gemessen wurde hier auf einem P4 mit 3,2 GHz und 2GB RAM.
Abbildung 12.3: Die Zeit zum Zusammenfügen von Strings auf herkömmliche Art und Weise
Das mag zwar recht schnell erscheinen, ist aber eigentlich ziemlich langsam (wie Sie auch gleich sehen werden). Der Grund für die verhältnismäßig lange Zeit ist, dass mit jedem Schleifendurchgang ein neues String-Objekt erzeugt wird. Im Verlauf der Schleife müssen daher 20000 neue Objekte im Speicher angelegt und 19999 Objekte entsorgt werden. Darüber hinaus müssen mit jedem Schleifendurchgang größere Zeichenketten von der alten in die neue Variable kopiert werden. Deswegen steigt der Zeitverbrauch dieses Programms mit der Schleifengröße nicht linear, sondern eher quadratisch an – ein nicht nur in der Informatik eher unbeliebter Umstand.
Zusammenfügen mit StringBuilder Es geht aber auch noch schneller – sogar sehr viel schneller. Dazu dient die Klasse StringBuilder, die Sie im Namespace System.Text finden. Sie wurde speziell dafür erstellt, Strings zusammenzufügen und damit zu arbeiten. Der Overhead des internen Kopierens fällt hier weg. Die Verwendung ist denkbar einfach. Ein StringBuilder-Objekt wird erzeugt, die gewünschten Strings mithilfe der Methode Append() hinzugefügt und der resultierende String mit ToString() zurückgeliefert. Um die Kapazität und die Größe der Gesamtzeichenkette kümmert sich StringBuilder automatisch. Dass StringBuilder wesentlich schneller ist als die vorherige Methode, beweist folgender Programmcode: private void btnQuick_Click( object sender, EventArgs e ) { // Schnell: Über StringBuilder this.lblResult.Text = "Zusammenfügen läuft ..."; Application.DoEvents();
Sandini Bib
254
12 Arbeiten mit Datentypen
StringBuilder sb = new StringBuilder(); Stopwatch watch = new Stopwatch(); watch.Start(); for ( int i = 0; i < 20000; i++ ) { sb.Append( "Text" ); } watch.Stop(); this.lblResult.Text = "Fertig, benötigte Zeit: " + watch.Elapsed.ToString(); }
Und nun das Resultat in Form eines Screenshots:
Abbildung 12.4: Zusammenfügen mit StringBuilder
Die Klasse StringBuilder ist also (genau lässt sich das nicht sagen) um etwa den Faktor 2000 schneller. Für Sie bedeutet das: Wann immer Sie viele Strings zusammenfügen müssen (das kann schon einmal vorkommen) sollten Sie die Klasse StringBuilder verwenden. Ein weiterer Vorteil der Klasse StringBuilder ist, dass hier auch Zeichen direkt festgelegt werden können (was in einem herkömmlichen String nicht möglich ist). Der folgende Zugriff ist also durchaus möglich: StringBuilder sb = new StringBuilder(); sb.Append( "Frank Eller" ); sb[5] = '_';
Die zu erzeugende Zeichenfolge kann dem Konstruktor auch direkt übergeben werden. Alle Möglichkeiten hier aufzulisten ist aus Platzgründen weder möglich noch notwendig. Die Verwendung der Klasse StringBuilder ist einfach und intuitiv, die Online-Hilfe tut das ihre dazu. Eine Syntaxzusammenfassung finden Sie im nächsten Abschnitt.
Sandini Bib
255
Strings
12.2.6
Unicode
Sehr wichtig im Zusammenhang mit Strings ist das Unicode-Format, das in C# (respektive .NET) für die Repräsentation von Zeichenketten verwendet wird. Sowohl der Datentyp string als auch der Datentyp char enthalten Zeichen in Unicode. Das Format selbst entstand aus dem Problem heraus, dass mit ASCII bzw. ANSI nicht alle Zeichen repräsentiert werden konnten, die es weltweit gibt. So musste der ANSIZeichensatz, den Windows verwendete, immer entsprechend der Ländereinstellung eingestellt werden, oder andersherum: Jedes Land hat seine eigenen Sonderzeichen. Dementsprechend schwierig war es auch, Anwendungen zu globalisieren. In ANSI bzw. ASCII wird für jedes darstellbare Zeichen ein Byte verwendet (im Falle von ASCII allerdings nur die ersten 7 Bits). Damit sind 255 (ASCII: 128) verschiedene Zeichen in einem Zeichensatz darstellbar. Unicode verwendet (mindestens) zwei Bytes pro Zeichen, womit ein Zeichensatz 65535 Zeichen darstellen kann. Das reicht für alle Sprachen der Welt aus – und es ist sogar noch Platz.
HINWEIS
Die Darstellung am Bildschirm ist natürlich wieder eine andere Sache und vom verwendeten System-Zeichensatz abhängig. Intern jedoch wird Unicode verwendet. Unicode wird auch im Quelltext unterstützt, jedoch werden die Dateien standardmäßig nicht in Unicode abgespeichert, sondern im ANSI-Format (Codepage 1252). Über den Menüpunkt DATEI|ERWEITERTE SPEICHEROPTIONEN lässt sich das Speicherverhalten ändern, das muss aber bei jeder neuen Datei geschehen. Wenn Sie das Verhalten global ändern wollen, müssen Sie die Template-Dateien des Visual Studios in Unicode abspeichern.
Unicode-Grundlagen Unicode beschreibt an sich nur die Zuordnung zwischen Zahlencodes und Zeichen. Beispielsweise ist der Buchstabe »A« dem Code 65 zugeordnet, das Eurozeichen »€« dem Code 8364. Unicode beschreibt allerdings nicht, wie die Codes tatsächlich gespeichert werden. Dazu gibt es wiederum mehrere Möglichkeiten, die als Unicode-Formate bezeichnet werden. Windows und .NET verwenden intern meist UTF-16 (Unicode Transformation Format, 16-Bit Encoding Form). Bei diesem Format werden einfach zwei aufeinander folgende Bytes verwendet, um ein Unicode-Zeichen zu speichern. Der größte Vorteil von UTF-16 besteht darin, dass alle Zeichen einheitlich behandelt werden. Der offensichtliche Nachteil ist, dass bei der Speicherung von Texten mit ausschließlich westeuropäischen Zeichen jedes zweite Byte den Wert 0 enthält, was natürlich eine Platzverschwendung ist. Innerhalb von UTF-16 gibt es nochmals zwei Varianten, je nachdem, in welcher Reihenfolge die Bytes pro Zeichen gespeichert werden. Auf Rechnern mit Intel-Prozessoren wird beispielsweise das niederwertige Byte zuerst gespeichert. Das bedeutet, dass das Zeichen A durch die Byte-Codes 65 und 0 abgebildet wird.
Sandini Bib
12 Arbeiten mit Datentypen
256
Eine Variante zu UTF-16 ist das vor allem unter Unix/Linux populäre UTF-8. Hier ist die Anzahl der Bytes vom Code abhängig. ASCII-Zeichen (Zeichencodes < 128) werden mit nur einem Byte gespeichert, andere Zeichen mit zwei bis vier Bytes. UTF-8 hat zwei Vorteile: Erstens ist ausgeschlossen, dass der Byte-Code 0 innerhalb einer Zeichenkette auftritt (das vermeidet eine Menge Probleme für die Programmiersprache C, die annimmt, dass Zeichenketten mit dem Zeichen 0 enden). Zweitens ist die Speicherung englischer Texte deutlich kompakter als bei UTF-16. .NET unterstützt auch UTF-8.
VORSICHT
Neben UTF-8 und UTF-16 gibt es einige weitere Kodierungsmöglichkeiten, die aber relativ selten genutzt werden, z.B. UTF-7 (verwendet bei jedem Byte nur die ersten sieben Bits) und UTF-32 (verwendet vier Bytes für jedes Zeichen für einen erweiterten 32-Bit-UnicodeZeichensatz). Unter Windows und speziell in der .NET-Dokumentation ist es üblich, UTF-16 einfach als Unicode zu bezeichnen. Das stiftet bisweilen Verwirrung, weil Unicode allein ja noch keine Informationen darüber gibt, wie die einzelnen Zeichencodes nun tatsächlich zu Bytes angeordnet werden.
Kodierung auflösen oder ändern Normalerweise brauchen Sie sich nicht allzu viele Gedanken darüber zu machen, wie Zeichenketten unter .NET intern codiert werden. Wirklich interessant wird es aber, wenn Sie Texte bzw. Textdateien aus verschiedenen Quellen und mit unterschiedlicher Kodierung verarbeiten sollen. In solchen Fällen helfen die Klassen des Namespace System.Text weiter. Sie finden dort unter anderem Klassen für die Formate ASCII, Unicode (UTF-16), UTF-8 und UTF-7. Die Methoden dieser Klassen ermöglichen unter anderem eine Konvertierung zwischen String-Zeichenketten und Byte-Feldern. Das folgende Programm verwendet ein Objekt der Klasse UnicodeEncoding, um die ByteCodes der Zeichenkette "ABC€" zu ermitteln. Die eigentliche Umwandlung der Zeichenkette in ein Byte-Feld erfolgt mithilfe der Methode GetBytes(). Da die Zeichen bekanntlich aus zwei Bytes bestehen, werden die Byte-Paare dargestellt. public void Main( string[] args ) { string s = "ABC€"; UnicodeEncoding uc = new UnicodeEncoding(); byte[] bytes = uc.GetBytes( s ); for ( int i=0; i < bytes.Length; i += 2 ) Console.Write( "{0} {1} ", bytes[i], bytes[i+1] ); Console.ReadLine(); }
Sandini Bib
257
Strings
Das Programm liefert folgendes Ergebnis: 65 0
66 0
67 0
172 32
VERWEIS
Die beiden letzten Codes ergeben mit 172+32*256=8364 den Unicode des Eurozeichens. Die Multiplikation mit 256 ist notwendig, weil es sich um das höherwertige Byte handelt. Eine Einführung in das Thema Zeichenketten-Kodierung finden Sie in Abschnitt 14.4.1 ab Seite 357. Die Online-Hilfe enthält Informationen unter ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.VisualStudio.v80.de/dv_fxwalkthrough/html/5db4c29041d5-41f5-a7e6-3ab0f4bfafae.htm
Hinweise zum Speichern von Text in Dateien finden Sie in Abschnitt 14.4.3 ab Seite 365. Das Lesen einer Textdatei wird in Abschnitt 14.4.2 ab Seite 358 beschrieben.
12.2.7
Syntaxzusammenfassung
Die Klasse String Die folgenden Tabellen liefern eine Übersicht über die Konstruktoren, Methoden und Eigenschaften der Klasse String. Für die Methoden gilt, dass nicht immer alle Überladungen angegeben sind. Oft liegen diese Methoden in zahlreichen Varianten vor; aus Platzgründen sei hier auf die Online-Hilfe verwiesen. Konstruktoren der Klasse String (aus System) new String( char[] c )
liefert einen neuen String, dessen Inhalt durch das Array c festgelegt wird.
new String( char c, int n )
liefert einen String. Der Inhalt besteht aus n Wiederholungen des Zeichens c.
new String( char[] c, int start, int length )
liefert einen String, dessen Inhalt durch das char-Array c festgelegt wird. start gibt an, ab welchem Zeichen das Array in den String kopiert werden soll, length die Anzahl der zu kopierenden Elemente.
Eigenschaften und Methoden der Klasse String (aus System) Empty
repräsentiert einen leeren String.
Length
liefert die Länge des Strings.
Clone()
liefert eine flache Kopie, d.h. kopiert nur den Verweis auf den aktuellen String.
CompareTo( string b )
vergleicht die aktuelle String-Instanz mit einem anderen String. Der Rückgabewert entspricht dem von Compare().
Sandini Bib
12 Arbeiten mit Datentypen
258
Eigenschaften und Methoden der Klasse String (aus System) CopyTo( int sourceIndex, char[] dest, int destIndex, int count )
kopiert count Zeichen von sourceIndex an in das Array dest an die Position destIndex. Mit dieser Methode ist es möglich, aus einem String oder Teilstring ein Array aus Unicode-Zeichen zu erzeugen.
EndsWith( string s )
kontrolliert, ob das Ende des aktuellen Strings mit dem String s übereinstimmt.
Equals( string s )
kontrolliert, ob der aktuelle String und der String s übereinstimmen. Verglichen wird der Wert.
GetEnumerator()
wird benötigt, um die Zeichen eines Strings mittels foreach durchlaufen zu können. Liefert ein Objekt, das das Interface IEnumerator implementiert.
GetHashCode()
liefert einen HashCode für die aktuelle String-Instanz.
IndexOf( string s )
gibt den Index des ersten Vorkommens des angegebenen Strings s (oder eines Zeichens) an.
IndexOfAny( char[] c )
gibt die Position des ersten Auftretens eines beliebigen Zeichens aus dem Array c an.
Insert( string s, int pos )
fügt einen String an der Position pos in den bestehenden String ein.
LastIndexOf( string s )
wie IndexOf(), aber von hinten
LastIndexOfAny( char[] c )
wie LastIndexOf(), aber von hinten
PadLeft( int count, char c )
richtet den String rechtsbündig aus und füllt vorne bis zu einer Gesamtlänge count mit dem Zeichen c oder einem Leerzeichens auf.
PadRight( int count, char c )
wie PadLeft(), richtet aber linksbündig aus und füllt rechts auf
Remove( int start, int count )
löscht count Zeichen ab der Position start aus dem aktuellen String.
Replace( char a, char b ) Replace( string a, string b )
ersetzt im aktuellen String jedes Vorkommen des Zeichens a durch das Zeichen b. Funktioniert auch mit Strings, dann werden alle Teilstrings ersetzt.
Split( char c, int count )
teilt den String in Teilstrings auf. c stellt das Zeichen dar, an dem getrennt wird, count ist die maximale Anzahl der Teilstrings. Diese werden in einem string-Array zurückgeliefert.
StartsWith( string s )
kontrolliert, ob der String mit dem String s beginnt.
Substring( int start, int count )
liefert einen Teilstring, der bei Position start beginnt und count Zeichen umfasst. Wird count nicht angegeben, umfasst der Teilstring den gesamten String ab der Position start.
Sandini Bib
259
Strings Eigenschaften und Methoden der Klasse String (aus System) ToCharArray()
liefert ein Array aus Unicode-Zeichen zurück, das die Zeichen des Strings enthält.
ToLower()
wandelt den String in Kleinbuchstaben um und liefert ihn zurück.
ToUpper()
Wandelt den String in Großbuchstaben um und liefert ihn zurück.
Trim()
entfernt alle Zeichen einer angegebenen Zeichenmenge oder alle Leerzeichen am Anfang und am Ende des Strings.
TrimStart ()
wie Trim(), aber nur am Anfang des Strings
TrimEnd()
wie Trim(), aber nur am Ende des Strings
Statische Methoden der Klasse String (aus System) Compare( string a, string b, [...] )
dient zum Vergleichen zweier übergebener Strings. Zurückgeliefert wird ein int-Wert, der kleiner 0 ist, wenn ab ist, und gleich 0, wenn die Strings identisch sind. Weiterhin kann angegeben werden, ob Groß-/Kleinschreibung bzw. kulturabhängige Informationen berücksichtigt werden sollen.
CompareOrdinal( string a string b )
vergleicht zwei Strings ohne Berücksichtigung von Groß-/Kleinschreibung oder kulturspezifischen Informationen. Teilstrings können ebenfalls verglichen werden. Der Rückgabewert entspricht dem von Compare().
Concat( string a, string b )
verkettet zwei oder mehrere Instanzen von string oder beliebiger Objekte. Im zweiten Fall wird die Methode ToString() der Objekte verwendet, um die String-Repräsentation zu erhalten.
Copy( string s )
liefert einen neuen String, der den gleichen Inhalt hat wie s.
Format( string s, object o )
formatiert Werte entsprechend des Formatstrings s. Format() wird von mehreren anderen Methoden ebenfalls zur Formatierung verwendet, z.B. Console.WriteLine() oder ToString().
Die Struktur Char Auch Char besitzt einige sehr interessante Methoden und Eigenschaften, die in den folgenden Tabellen aufgelistet sind. Methoden der Struktur Char (aus System) CompareTo( object o )
vergleicht den Wert des Zeichens mit dem übergebenen Objekt. Der zurückgelieferte Wert ist kleiner 0, gleich 0 oder größer 0.
Equals( object o )
vergleicht das Zeichen mit dem übergebenen Objekt und liefert true wenn beide identisch sind.
Sandini Bib
12 Arbeiten mit Datentypen
260
Methoden der Struktur Char (aus System) GetHashCode()
liefert den HashCode für das Zeichen.
ToString()
wandelt das Zeichen in einen String um.
Statische Methoden und Eigenschaften der Struktur Char (aus System) MinValue
Der kleinste Wert, den char annehmen kann. Dieser Wert liefert immer 0 (0x00).
MaxValue
Der größte Wert für char. Dieser Wert liefert immer 65535 (0xFFFF).
IsControl ( char c )
gibt an, ob c ein Steuerzeichen ist.
IsDigit ( char c )
gibt an, ob c eine Ziffer ist.
IsLetter ( char c )
gibt an, ob c ein Buchstabe des Alphabets ist.
IsLetterOrDigit ( char c )
entspricht IsLetter() || IsDigit().
IsLower ( char c )
gibt an, ob c ein Kleinbuchstabe ist.
IsUpper ( char c )
gibt an, ob c ein Großbuchstabe ist.
IsNumber ( char c )
gibt an, ob c eine Zahl (Dezimal oder Hexadezimal) ist.
IsPunctuation ( char c )
gibt an, ob c ein Satzzeichen ist.
IsSeparator ( char c )
gibt an, ob c ein Trennzeichen ist.
IsSurrogate ( char c )
gibt an, ob c ein Ersatzzeichen ist.
IsSymbol ( char c )
gibt an, ob c ein symbolisches Zeichen ist.
IsWhiteSpace ( char c )
gibt an, ob c als Whitespace interpretiert wird.
ToLower ( char c )
liefert das Zeichen als Kleinbuchstabe zurück.
ToUpper ( char c )
liefert das Zeichen als Großbuchstabe zurück.
Die Klasse StringBuilder Konstruktoren der Klasse StringBuilder (aus System.Text) new StringBuilder()
erzeugt eine neue Instanz von StringBuilder.
new StringBuilder( string s )
erzeugt eine neue Instanz von StringBuilder mit s als voreingestelltem String.
new StringBuilder( int cap )
erzeugt eine neue Instanz von StringBuilder mit der Kapazität cap.
Sandini Bib
261
Strings Konstruktoren der Klasse StringBuilder (aus System.Text) new StringBuilder( int cap, int max )
erzeugt eine neue Instanz von StringBuilder mit der Kapazität cap, die maximal bis max erhöht werden kann.
new StringBuilder( string s, int cap )
erzeugt eine neue Instanz von StringBuilder mit s als voreingestelltem String und der Kapazität cap.
new StringBuilder( string s, int start, int length, int cap )
erzeugt eine neue Instanz von StringBuilder. Der Teilstring von s, beginnend bei start und length Zeichen lang, wird voreingestellt. cap gibt die Kapazität des StringBuilder an.
Eigenschaften und Methoden der Klasse StringBuilder (aus System.Text) Capacity
steht für die maximale Anzahl Zeichen, die in dieser Instanz von StringBuilder enthalten sein können. Der Wert kann entweder im Konstruktor oder über die Eigenschaft festgelegt werden. StringBuilder vergrößert diese Eigenschaft automatisch, wenn weitere Strings hinzugefügt werden. Beim Verkleinern darf die Größe der Eigenschaft Length nicht unterschritten werden.
Chars
Der Indexer für die StringBuilder-Klasse. Liefert das Zeichen an der angegebenen Position zurück bzw. legt es fest.
Length
liefert die aktuelle Länge der enthaltenen Zeichenkette zurück.
MaxCapacity
liefert die maximale Kapazität der Instanz zurück.
Append ( string s )
fügt der enthaltenen Zeichenkette einen weiteren String s hinzu. Diese Methode ist vielfach überladen und funktioniert auch mit anderen Datentypen, wobei immer die Zeichenfolgeentsprechung des jeweiligen Datentyps angehängt wird.
AppendFormat ( string s, object o )
fügt der enthaltenen Zeichenkette einen formatierten String hinzu. Die Übergabeparameter entsprechen denen der Methode String.Format().
AppendLine ( string s );
Fügt den in s angegebenen String hinzu und führt einen Zeilenwechsel aus.
EnsureCapacity ( int cap )
stellt sicher, dass die Kapazität des StringBuilder mindestens dem Wert cap entspricht.
Insert ( int index, string s )
fügt einen String s an einer angegebenen Position index in den enthaltenen String ein.
Remove ( int start, int length )
entfernt length Zeichen beginnend ab dem Index start aus dem enthaltenen String.
Replace ( char a, char b )
ersetzt alle Vorkommen eines angegebenen Zeichens oder einer Zeichenfolge a durch b.
ToString()
liefert den enthaltenen String zurück.
Sandini Bib
12 Arbeiten mit Datentypen
262
12.3
Datum und Zeit
Datums- und Zeitangaben werden in .NET durch den Datentyp DateTime aus dem Namespace System repräsentiert, Zeitspannen durch den Datentyp TimeSpan, ebenfalls aus System. Beide werden in diesem Abschnitt vorgestellt.
HINWEIS
Vor allem DateTime liefert eine große Anzahl verschiedener Funktionen zur Manipulation von Datums- und Zeitangaben. Die interne Speicherung geschieht in Form eines longWerts, der angibt, wie viele so genannte Ticks seit dem 1.1.0001 vergangen sind. Ein Tick entspricht etwa 100ns. Falls Sie sich fragen, wie lange der Wertebereich von DateTime noch ausreicht ... Nun, sicher ist, dass er zumindest so lange ausreicht, wie das .NET Framework existiert. Der maximal darstellbare Datumswert, der durch die statische Eigenschaft MaxValue von DateTime repräsentiert wird, ist der 31.12.10000, 23:59 Uhr, 59,9999999 Sekunden. Also genau ein Tick vor Jahreswechsel.
12.3.1
Die Struktur DateTime
DateTime ist eine Struktur, damit ein Wertetyp. Die Initialisierung funktioniert allerdings ein wenig anders als bei anderen Strukturen. Die öffentlichen Eigenschaften, die DateTime
zur Verfügung stellt, sind schreibgeschützt und können somit nicht für eine Zuweisung verwendet werden. Damit bleibt nur der Weg über einen der zahlreichen Konstruktoren. DateTime steht sowohl für die Zeit als auch für das Datum und beide Werte können unabhängig voneinander verwendet werden. Bei der Initialisierung der DateTime-Struktur ist aber eine Datumsangabe immer notwendig – auch wenn sie später nicht verwendet wird. Es existiert keine Überladung des Konstruktors, die nur eine Zeit aufnimmt. DateTime dt = new DateTime( 2005, 09, 01 ); // 01.09.2005 DateTime dt = new DateTime( 2005, 09, 01, 0, 10, 0 ); // 01.09.2005, 0:10 Uhr
Was jedoch by Design existiert, ist ein parameterloser Konstruktor, der den DateTime-Wert auf den 01.01.0001, 0:00 Uhr einstellt. Dieser wird zwar von IntelliSense nicht angezeigt, wir wissen aber, dass jede Struktur einen parameterlosen Konstruktor besitzt und können ihn damit auch verwenden. DateTime dt = new DateTime();
// 1.1.0001, 0:00 Uhr
Oftmals ist es so, dass zur Initialisierung die aktuelle Zeit und das aktuelle Datum verwendet werden sollen. Für diesen Zweck gibt es zwei Möglichkeiten, nämlich die statischen Eigenschaften Now sowie UtcNow. Now liefert die aktuell am Rechner eingestellte Zeit und das aktuelle Datum, UtcNow ebenfalls das aktuelle Datum, aber Greenwich Mean Time (GMT). Diese Zeitangabe nennt man auch Universal time, coordinated. Sinn und Zweck dieser Zeitangabe ist es, für den internationalen Austausch von Programmen oder Daten eine einheitliche Zeitangabe zu verwenden. Nur so ist es möglich
Sandini Bib
263
Datum und Zeit
festzustellen, welche Datei wirklich aktuell ist. Wenn Sie in einem Team arbeiten, bei dem sich die Mitglieder in unterschiedlichen Zeitzonen befinden, kann das durchaus ein Problem darstellen. Stellen Sie sich doch einfach mal die Frage, welche Datei aktueller ist: Eine, die in München um 18:00 abgespeichert wurde oder eine, die in New York um 12:30 abgespeichert wurde? DateTime dt = DateTime.Now; // aktuelle Zeit DateTime dt = DateTime.UtcNow; // GMT-Zeit
Falls Sie nur das aktuelle Datum benötigen und die Zeit Sie nicht interessiert, können Sie auch die ebenfalls statische Eigenschaft Today verwenden, die eine DateTime-Struktur nur gefüllt mit dem aktuellen Datum zurückliefert. DateTime dt = DateTime.Today;
// aktuelles Datum
Ebenfalls möglich ist die Verwendung der statischen Methode Parse(), die einen String mit einer Datums-/Zeitangabe interpretiert. Die Datumsangabe ist hierbei optional, in diesem Fall wird das aktuelle Datum automatisch eingestellt. Datums- und Zeitangabe werden durch ein Leerzeichen getrennt, wenn beide angegeben werden. Das Datum muss im Format tt/mm/yyyy angegeben werden. DateTime dt = DateTime.Parse( "01:00:00" );
// 01:00 Uhr, aktuelles Datum
Die Aufzählung DateTimeKind ermöglicht es ab dem .NET Framework 2.0, bei der Initialisierung einer DateTime-Struktur anzugeben, wie die enthaltene Zeit interpretiert werden soll. DateTimeKind besitzt drei mögliche Einstellungen: DateTimeKind.Local (lokale Zeit), DateTimeKind.UTC (Universal Time) bzw. DateTimeKind.Unspecified (keines von beiden). Auf Berechnungen mittels DateTime-Strukturen hat diese Einstellung allerdings keinen Einfluss, nur auf die Umrechnung zwischen lokaler und globaler Zeit.
12.3.2
Die Struktur TimeSpan
Der Datentyp DateTime speichert lediglich Datum und Zeit – also einen exakten Punkt der Zeitlinie. Die andere Form der Zeitangabe, nämlich eine Zeitspanne, kann nicht durch DateTime ausgedrückt werden. Hierfür existiert in .NET die Struktur TimeSpan. Intern wird der Wert auch bei TimeSpan mithilfe des Datentyps long gespeichert, auch hier dienen Ticks als Basis. Damit ist die Zeitspanne, die insgesamt mit einem TimeSpan-Wert ausgedrückt werden kann, etwas größer als 29000 Jahre. Das sollte eigentlich für jedes Programm ausreichen. Eine neue Variable des Typs TimeSpan muss natürlich auch initialisiert werden. Der Standardkonstruktor belegt die Variable mit einer leeren Zeitspanne (also einer Zeitspanne von 0). Dieser Konstruktor ist standardmäßig in einer Struktur enthalten und wird daher von IntelliSense nicht angezeigt. Er existiert aber by Design (Sie erinnern sich: Ein struct besitzt immer einen parameterlosen Standardkonstruktor). Die folgende Codezeile ist also absolut zulässig: TimeSpan ts = new TimeSpan();
// 00:00:00
Sandini Bib
12 Arbeiten mit Datentypen
264
Weitere Konstruktoren dienen dazu, Zeitspannen direkt festzulegen, von einem Tick bis zu Stunden, Minuten, Sekunden oder Tagen. Wochen, Monate oder Jahre können allerdings nicht eingestellt werden. Für eine Zeitspanne von einer Woche stellen Sie einfach sieben Tage ein. TimeSpan ts = new TimeSpan( 1 ); // 1 Tick (100 ns) TimeSpan ts = new TimeSpan( 2, 40, 0 ) // 2 Stunden, 40 Minuten TimeSpan ts = new TimeSpan( 7, 0, 0, 0 ); // Eine Woche (7 Tage)
Sie müssen eine TimeSpan-Variable nicht zwangsläufig initialisieren, Sie können ihr mithilfe einiger statischer Methoden auch direkt einen Wert zuweisen. Alle diese Methoden beginnen mit From. TimeSpan ts; ts = TimeSpan.FromTicks( 5 ); ts = TimeSpan.FromMilliSeconds( 20 ); ts = TimeSpan.FromSeconds( 15 ); ts = TimeSpan.FromMinutes( 50 ); ts = TimeSpan.FromHours( 10 ); ts = TimeSpan.FromDays( 21 );
// // // // // //
500 Nanosekunden (5 Ticks) 20 Millisekunden 15 Sekunden 50 Minuten 10 Stunden 21 Tage oder 3 Wochen
Ebenfalls elegant, aber etwas langsamer als diese Methoden ist die Methode Parse(). Auch sie ist statisch und interpretiert eine Zeichenkette als TimeSpan-Wert. Der Aufbau dieser Zeichenkette sieht folgendermaßen aus: [-][t.]hh:mm:ss[.bb]
Dabei steht t für Tage, hh für Stunden, mm für Minuten, ss für Sekunden und bb für Millisekunden. Diese können natürlich auch mit mehr als zwei Stellen angegeben werden. Wichtig ist auch das Minuszeichen, wodurch eine negative Zeitspanne angegeben werden kann. Führende und nachfolgende Leerzeichen werden ignoriert. TimeSpan ts; ts = TimeSpan.Parse( "03:00:00" ); // 3 Stunden ts = Timespan.Parse( "7.04:00:00" ); // 7 Tage, 4 Stunden ts = TimeSpan.Parse( "07:30:00" ); // 7,5 Stunden
Zum Auslesen des Inhalts bietet TimeSpan wie auch DateTime eine Menge an Eigenschaften. Beispielsweise können Sie mit der Eigenschaft Hours die Anzahl der eingestellten Stunden, mit Minutes die Anzahl der eingestellten Minuten abrufen. Interessant in diesem Zusammenhang sind auch die Eigenschaften TotalSeconds, TotalMinutes usw. Diese liefern ebenfalls die Zeitspanne zurück, allerdings nur in Minuten, Stunden, Sekunden, je nachdem, welche der Methoden gewählt wird. Ein kleines Beispiel macht das deutlich: TimeSpan ts = TimeSpan.Parse( "00:03:12" ); int i = ts.Seconds; int i = ts.Minutes; double d = ts.TotalSeconds; double d = ts.TotalMinutes;
// // // //
Enthält Enthält Liefert Liefert
12 3 192 – 3 Minuten (180 s) und 12 Sekunden 3,2 – 12 Sekunden sind 1/5 Minute
Sandini Bib
Datum und Zeit
12.3.3
265
Arbeiten mit Datum und Zeit
Detailinformationen Die verschiedenen Eigenschaften von DateTime liefern Detailangaben zum eingestellten Datum bzw. zur eingestellten Zeit. So können Sie beispielsweise mit den Eigenschaften Year, Month und Day das eingestellte Jahr, den Monat oder den Tag ermitteln. Wenn Sie wissen möchten, um welchen Wochentag es sich bei dem Datum handelt, verwenden Sie einfach DayOfWeek. Zurückgegeben wird die dem Tag entsprechende Konstante der Aufzählung WeekDays. Umgewandelt in einen Zahlenwert entspricht 0 dem Sonntag und 6 dem Samstag. WeekDays ist ebenfalls in System deklariert. Die Eigenschaft DayOfYear liefert einen Zahlenwert, der angibt, um welchen Tag des Jahres es sich handelt. Der Wert liegt zwischen 1 und 366 und beginnt somit ausnahmsweise nicht bei 0. Die Methode IsLeapYear() liefert einen booleschen Wert zurück, der angibt, ob es sich bei dem der Methode übergebenen Jahr um ein Schaltjahr handelt. Das wurde leider nicht durch eine Eigenschaft (oder eine zusätzliche Eigenschaft) gelöst. IsLeapYear() ist statisch, die Jahreszahl muss übergeben werden. Auch für die enthaltene Zeitangabe gibt es vergleichbare Eigenschaften, die genauere Informationen liefern. Hour, Minute und Second liefern wie der Name schon sagt Stunde, Minute und Sekunde. Über Millisecond können Sie auf die Millisekunden zugreifen, über Ticks sogar auf die Anzahl der vergangenen 100-ns-Abschnitte. Letztere allerdings stehen für Datum und Zeit, d.h. hier wird nicht allein die Zeitangabe berücksichtigt, sondern auch das Datum.
Rechnen mit DateTime und TimeSpan Eigentlich ist es recht einfach möglich, mit Datums- bzw. Zeitwerten zu rechnen. Die Operatoren + und - funktionieren auch mit DateTime, allerdings muss hier berücksichtigt werden, dass bei der Subtraktion zweier Datumswerte ein TimeSpan-Wert herauskommt (zwangsläufig – wenn zwei Zeitpunkte voneinander abgezogen werden, muss eine Zeitspanne dabei rauskommen). Das bedeutet auch, dass Sie, um ein Datum zu erhalten, einen TimeSpan-Wert subtrahieren müssen. Die Addition zweier DateTime-Werte funktioniert ebenfalls nicht. Zum Addieren muss immer ein TimeSpan-Wert herhalten, d.h. zu einem Datum kann nur eine Zeitspanne addiert werden. Das Resultat ist dann wieder ein Datum. Die folgenden Codezeilen zeigen die Zusammenhänge bei den Operatoren + und -. Wenn eine Zeitspanne länger als ein Tag ist, wird selbstverständlich auch das Datum berücksichtigt. Um den Monatswechsel bzw. einen Jahreswechsel kümmert sich .NET automatisch.
Sandini Bib
12 Arbeiten mit Datentypen
266 DateTime DateTime DateTime TimeSpan TimeSpan
dt1 dt2 dt3 ts1 ts2
= = = = =
DateTime.Parse("01:00:00"); DateTime.Parse("00:40:00"); new DateTime(2003,2,25); new TimeSpan(0,0,10,0,0); new TimeSpan(7,0,0,0)
// // // // //
01:00 Uhr 0:40 Uhr 25. Februar 10 Minuten 7 Tage (eine Woche)
DateTime dtResult; TimeSpan tsResult; dtResult tsResult tsResult dtResult
= = = =
dt1+ts1; dt1-dt2; dt2-dt1; dt3+ts2;
// // // //
ergibt ergibt ergibt ergibt
01:10 Uhr 20 Minuten –20 Minuten den 4. März
Die Zeitspanne kann natürlich auch negativ sein. Die Addition eines negativen TimeSpanWerts entspricht der Subtraktion des gleichen positiven TimeSpan-Werts. Die Subtraktion und Addition von Zeitwerten funktioniert auch über verschiedene Methoden von DateTime. Subtract() beispielsweise subtrahiert die angegebene Dauer vom aktuellen DateTime-Wert. Add() fügt eine Zeitspanne hinzu. Hier gelten die gleichen Einschränkungen wie bei den Operatoren + und -, Zeitspannen können addiert und subtrahiert, ein Datum von einem anderen Datum nur subtrahiert werden. Weitere Methoden, die wiederum einen DateTime-Wert zurückliefern, sind AddDays(), AddMonths(), AddYears() oder auch für Zeiten AddMinutes(), AddHours(), usw.
12.3.4
Zeitmessungen - die Klasse Stopwatch
DateTime und TimeSpan können natürlich auch für Zeitmessungen herangezogen werden, die allerdings in ihrer Genauigkeit begrenzt sind – die kleinste mögliche Einheit ist ein Tick, also ca. 100 Nanosekunden. Die Vorgehensweise ist simpel. Am Beginn der zu messenden Operation wird die aktuelle Zeit gespeichert, am Ende erneut. Der Unterschied zwischen der Startzeit und der Endzeit entspricht dann der abgelaufenen Zeit: DateTime startTime = DateTime.Now; // zu messender Vorgang DateTime endtime = DateTime.Now; TimeSpan timeElapsed = startTime.Subtract( endTime );
Wesentlich genauer lässt sich die Zeit mithilfe der Klasse Stopwatch messen. Diese verwendet (falls vorhanden) einen hochauflösenden Performance-Timer zur Zeitmessung und ist somit wesentlich genauer. Beim Start der Zeitmessung erstellen Sie eine neue Instanz von Stopwatch und starten die Zeitmessung über Start(). Am Ende der Zeitmessung stoppen Sie über die Methode Stop() den Timer und ermitteln die abgelaufene Zeit über eine der Eigenschaften Elapsed, ElapsedMilliSeconds oder ElapsedTicks. Das folgende Beispiel zeigt, wie Stopwatch angewendet werden kann:
Sandini Bib
267
Datum und Zeit Stopwatch watch = new StopWatch(); watch.Start(); // zu messender Vorgang watch.Stop(); string elapsedTime = watch.Elapsed.toString();
12.3.5
Syntaxzusammenfassung
Die Struktur DateTime Konstruktoren und Initialisierung der Struktur DateTime (aus System) new DateTime( int y, int m, int d )
initialisiert eine DateTime-Instanz mit dem angegebenen Datum. Achten Sie darauf, dass entgegen der in Deutschland gewohnten Schreibweise hier das Jahr zuerst steht, der Tag an letzter Stelle.
New DateTime( int y, int m, int d, int h, int mm, int s )
initialisiert eine DateTime-Instanz mit dem angegebenen Datum und der angegebenen Zeit.
FromFileTime ( long ft )
wandelt die Dateizeit n in eine DateTime-Instanz um. (n enthält die Anzahl an Ticks seit dem 1.1.1601 UTC.)
Parse ( string s )
versucht, die Zeichenkette s entsprechend der geltenden Ländereinstellung als Datum/Uhrzeit zu interpretieren, und liefert als Ergebnis eine DateTime-Instanz.
UTCNow
liefert eine DateTime-Instanz mit dem aktuellen Datum und der aktuellen GMT-Zeit (Greenwich Mean Time).
Now
liefert eine DateTime-Instanz mit dem aktuellen Datum und der aktuellen Systemzeit.
Eigenschaften der Struktur DateTime (aus System) Date
liefert den Datumsanteil der DateTime-Instanz
Year
liefert das Jahr (1 bis 9999).
Month
liefert den Monat (1 bis 12).
Day
liefert den Monatstag (1 bis 31).
DayOfWeek
liefert den Wochentag als Konstante der WeekDays-Aufzählung. Interpretiert als Zahl steht 0 für den Sonntag, 6 für den Samstag.
DayOfYear
liefert den Tag im Jahr (1 bis 366).
Sandini Bib
12 Arbeiten mit Datentypen
268
Eigenschaften der Struktur DateTime (aus System) TimeOfDay
liefert die seit 0:00 Uhr des aktuellen Tages vergangene Zeitspanne in Form eines TimeSpan-Werts.
Hour
liefert die Stunde (0 bis 23).
Minute
liefert die Minute (0 bis 59).
Second
liefert die Sekunde (0 bis 59).
Millisecond
liefert den Millisekundenanteil der Zeit (0 bis 999).
Ticks
liefert die interne Darstellung der Zeit als long-Zahl (je 100 ns).
Methoden der Struktur DateTime (aus System) Add( TimeSpan ts )
liefert aktuelles Datum plus angegebene Zeispanne.
AddTicks( long n ) AddMilliSeconds( double d ) AddSeconds( double d ) AddMinutes( double d ) AddHours( double d ) AddDays( double d ) AddMonths( int m ) AddYears( int y )
liefert eine neue DateTime-Instanz, die aktuelles Datum bzw. aktuelle Zeit plus die angegebene Zeispanne repräsentiert.
Compare( DateTime dt1, DateTime dt2 )
vergleicht zwei DateTime-Werte. Liefert 1 bei dt1>dt2, -1 bei dt1
CompareTo( DateTime dt )
vergleicht dt mit der aktuellen DateTime-Instanz. Der zurückgegebene Wert ist der gleiche wie bei Compare().
DaysInMonth( int y, int m )
liefert die Anzahl der Tage des Monats m im Jahr y. Diese Methode ist statisch.
IsLeapYear( int y )
gibt an, ob y ein Schaltjahr ist. Diese Methode ist statisch.
Subtract( TimeSpan ts )
liefert aktuelles Datum/Zeit minus der angegebenen Zeitspanne.
Subtract( DateTime dt )
liefert eine Zeitspanne in Form einer TimeSpan-Instanz, die den Zeitunterschied zwischen der aktuellen DateTime-Instanz und dt angibt.
ToUniversalTime()
wandelt die aktuelle Zeit in GMT-Zeit um.
Die Struktur TimeSpan Konstruktoren und Initialisierung der Struktur TimeSpan (aus System) new TimeSpan( long ticks )
liefert eine TimeSpan-Instanz, die die angegebene Anzahl Ticks repräsentiert.
Sandini Bib
269
Datum und Zeit Konstruktoren und Initialisierung der Struktur TimeSpan (aus System) new TimeSpan( int h, int m, int s )
liefert eine TimeSpan-Instanz, die die angegebene Anzahl Stunden (h), Minuten (m) und Sekunden (s) repräsentiert.
new TimeSpan( int d, int h, int m, int s )
liefert eine TimeSpan-Instanz, die die angegebene Anzahl Tage (d), Stunden (h), Minuten (m) und Sekunden (s) repräsentiert.
FromTicks( long l ) FromMilliSeconds( double d ) FromSeconds( double d ) FromMinutes( double d ) FromHours( double d ) FromDays( double d )
liefert eine TimeSpan-Instanz mit der entsprechenden Zeitspanne. Alle diese Methoden sind statisch.
Parse( "[d.]hh:mm:ss[.bb]" )
liefert eine Zeitspanne der angegebenen Zeit (bestehend aus Tagen, Stunden, Minuten, Sekunden und Sekundenbruchteilen).
Eigenschaften der Struktur TimeSpan (aus System) MilliSeconds Seconds Minutes Hours Days
Diese Eigenschaften geben den Anteil der Sekunden, Minuten, Stunden etc. der Zeitspanne an. Die gesamte gespeicherte Zeitspanne ergibt sich aus der Summe dieser Zeiten (d.h. MilliSeconds reicht von 0-999, Seconds und Minutes reichen von 0-59, Hours von 0-23).
Ticks TotalMilliSeconds TotalSeconds TotalMinutes TotalHours TotalDays
drückt die gesamte Zeitspanne in Sekunden, Minuten, Stunden etc. aus. Ticks liefert das Ergebnis als Long-Zahl, alle anderen Methoden liefern Double-Werte.
Konstanten der Struktur TimeSpan (aus System) MaxValue
stellt den Wert für die maximale Zeitspanne dar. Entspricht Int64.MaxValue.
MinValue
stellt den Wert für die minimale Zeitspanne dar. Entspricht Int64.MinValue.
TicksPerDay
gibt die Anzahl Ticks pro Tag an (864 Milliarden).
TicksPerHour
gibt die Anzahl Ticks pro Stunde an (36 Milliarden).
TicksPerMinute
gibt die Anzahl Ticks pro Minute an (600 Millionen).
TicksPerSecond
gibt die Anzahl Ticks pro Sekunde an (10 Millionen).
TicksPerMillisecond
gibt die Anzahl Ticks pro Millisekunde an (10000).
Zero
stellt den TimeSpan-Wert für 0 dar.
Sandini Bib
12 Arbeiten mit Datentypen
270
Methoden der Struktur TimeSpan (aus System) Add( TimeSpan ts )
addiert ts zur aktuellen Zeitspanne.
Compare( ts1, ts2 )
vergleicht zwei TimeSpan-Werte. Liefert 1 bei ts1>ts2, -1 bei ts1
CompareTo( TimeSpan ts )
vergleicht ts mit der aktuellen TimeSpan-Instanz. Der zurückgegebene Wert ist der gleiche wie bei Compare().
Duration()
liefert den Absolutbetrag der aktuellen Zeitspanne.
Negate()
liefert die negative Entsprechung des TimeSpan-Werts.
Subtract( TimeSpan ts )
subtrahiert ts von der aktuellen Zeitspanne.
Die Klasse Stopwatch Eigenschaften der Klasse Stopwatch (aus System.Diagnostics) Elapsed
liefert die abgelaufene Zeit als TimeSpan-Wert.
ElapsedTicks
liefert die abgelaufene Zeit in Ticks.
ElapsedMilliseconds
liefert die abgelaufene Zeit in Millisekunden.
IsRunning
liefert einen booleschen Wert, der angibt, ob die Zeitmessung derzeit läuft.
static IsHighResolution
liefert einen booleschen Wert, der angibt, ob die Zeitmessung mittels eines High-Resolution-Counters durchgeführt wird.
static Frequency
liefert die Frequenz der Zeitmessung in Ticks pro Sekunde.
Methoden der Klasse Stopwatch (aus System.Diagnostics) Start()
Startet die Zeitmessung einer StopWatch-Instanz.
Stop()
Stoppt die Zeitmessung einer StopWatch-Instanz.
Reset()
Stoppt eine evtl. laufende Zeitmessung und setzt den Wert der abgelaufenen Zeit auf 0 zurück.
static StartNew()
Statische Methode. Liefert eine Instanz der Klasse StopWatch und startet gleichzeitig die Zeitmessung.
Sandini Bib
271
Formatierungsmethoden in .NET
12.4
Formatierungsmethoden in .NET
12.4.1
Grundlagen
Eigentlich könnte man diesen Abschnitt auch an anderer Stelle im Buch unterbringen, wenn man so möchte bei jedem Datentyp. Es geht hierbei um die statische Methode Format() der Klasse String, die dazu dient, Zeichenketten oder Werte beliebiger Art zu formatieren. Es gibt im .NET Framework weitere Methoden, die die Methode Format() implizit verwenden. Unter anderem sind dies sämtliche ToString()-Methoden der Wertetypen oder auch die Methode Console.WriteLine().
Grundlegende Formatierungen Die grundsätzliche Syntax der Format()-Methode sieht folgendermaßen aus: String.Format("Formatstring", Ausdruck1 [, Ausdruck2 [, Ausdruck3]]);
Der Formatstring besteht aus dem herkömmlichen String und einem Platzhalter, den Sie ja bereits von Console.WriteLine() kennen. Bisher jedoch haben wir in diesem Platzhalter lediglich eine Nummer angegeben, die angibt, welcher der nachfolgenden Werte hier eingesetzt werden soll. Diese Positionsnummer wird nun durch Doppelpunkt von der eigentlichen Formatierung getrennt: string s; s = String.Format( "{0:D}", DateTime.Now ); s = String.Format( "{0:f2}", 1.53543 ); s = String.Format( "{0:x4}", 300 );
// Ergibt z.B. "Freitag, 6. Januar 2005" // Ergibt "1,54" // Ergibt "0124"
Das D steht hierbei für die Formatierung nach dem langen Datum, f2 für eine Festkommazahl mit zwei Nachkommastellen und x für hexadezimale Darstellung. Bei der Angabe der Nachkommastellen führt die Format()-Methode automatisch eine Rundung durch.
Landesspezifische Einstellungen Sie können in der Mehode Format()festlegen, welche Kultur (respektive Sprache) für die Formatierung verwendet werden soll. Wollen Sie beispielsweise das Datum in französischer Sprache ausgeben, genügt es, ein entsprechendes CultureInfo-Objekt zu erstellen, das die Kultur angibt. Zur Verwendung der Klasse CultureInfo müssen Sie den Namespace System.Globalization einbinden. CultureInfo ci = new CultureInfo( "fr-FR" ); string s = String.Format( ci, "{0:D}", DateTime.Now ); // "mercredi 11 janvier 2006" String.Format() erwartet hier das Interface IFormatProvider, das beispielsweise von CultureInfo implementiert wird. Sie können natürlich auch eine eigene Klasse zusammenstellen,
Sandini Bib
12 Arbeiten mit Datentypen
272
die die entsprechenden Formatierungsoptionen zur Verfügung stellt. Dazu müssen Sie lediglich das Interface IFormatProvider implementieren. Weitere Klassen, die IFormatProvider implementieren, sind NumberFormatInfo und DateTimeFormatInfo, ebenfalls aus System.Globalization. Zur Speicherung von Daten ist es nützlich, diese auch in einem landesunabhängigen Format zur Verfügung zu stellen. Das ermöglicht es, diese Daten in jedem Land zu verwenden, ohne auf Besonderheiten Rücksicht nehmen zu müssen. Um dies zu ermöglichen, müssen Sie eine unabhängige Kultur zur Verfügung stellen. Diese erhalten Sie über die statische Methode InvariantCulture() der CultureInfo-Klasse:
VERWEIS
CultureInfo ci = CultureInfo.InvariantCulture; s = String.Format( ci, "{0:D}", DateTime.Now );
// Ergibt: "Wednesday, 11 January 2006"
Die Klasse CultureInfo ist im Namespace System.Globalization deklariert und dient vor allem der Lokalisierung von Anwendungen bzw. der Abfrage auf die aktuellen Landeseinstellungen. An dieser Stelle soll die Klasse einfach verwendet werden. Mehr über Lokalisierung und das Übersetzen von Programmen erfahren Sie in Kapitel 23 ab Seite 865.
Formatierungen mit ToString() Bei der Verwendung der Methode ToString() erübrigt sich die Übergabe von Werten, denn diese Methode wird ja von dem zu formatierenden Wert selbst aufgerufen. Der alternativ zu übergebende Formatstring unterscheidet sich in seinen Optionen aber nicht von dem aus String.Format(), wobei natürlich die Formatierung nach Datum bei einem int-Wert keinen Sinn macht. Hier muss also darauf geachtet werden, welcher Wert formatiert werden soll. ToString() erwartet demnach nicht bei allen Datentypen mehrere Parameter, es darf jedoch immer ein Formatstring angegeben werden.
12.4.2
Zahlen formatieren
Dieser Abschnitt stellt eine Menge Codes zur Formatierung von Zahlen vor. Diese Codes können folgendermaßen angewendet werden (hier für den Formatierungscode e, der Zahlen in wissenschaftlicher Notation darstellt): String s; s = Math.PI.ToString( "e3" ); s = String.Format( "{0:e3}", Math.PI );
// s == "3,142e+000" // s == "3,142e+000"
Die Formate c bis p können für alle numerischen Datentypen verwendet werden. Bei einigen Formaten kann durch eine nachgestellte Zahl die Anzahl der Stellen hinter dem Komma angegeben werden. Standardmäßig werden üblicherweise zwei Nachkommastellen verwendet. Die folgende Tabelle zeigt, wie die double-Zahlen 123456789 und -0,0000123 formatiert werden (bei deutscher Ländereinstellung und mit € als Währungssymbol).
Sandini Bib
273
Formatierungsmethoden in .NET Vordefinierte Formatcodes zur Formatierung von Zahlen (String.Format()) c
123.456.789,00 € / -0,00 €
Währungsformat (Currency)
c3
123.456.789,000 € / -0,000 €
Währungsformat mit drei Nachkommastellen
e
1,234568e+008 / -1,230000e-006
wissenschaftliches Format (Exponential)
e3
1,235e+008 / 1,230e-005
wissenschaftliches Format mit drei Nachkommastellen
E
1,234568E+008 / -1,230000E-006
wie oben, aber Exponentialschreibweise mit E statt mit e
f
123456789,00 / -0,00
Festkommaformat (Fixed-point)
f3
123456789,000 / -0,000
Festkommaformat mit drei Nachkommastellen
g
123456789 / -1,23e-06
allgemeines Format, möglichst kompakte Darstellung von Zahlen (General)
n
123.456.789,00 / -0,00
Format mit Tausendertrennung (Number)
n3
123.456.789,000 / -0,000
Format mit Tausendertrennung mit drei Nachkommastellen
p
12,345,678,900,00% / -0,00%
Prozentzahlen (Achtung: der Wert 1 wird als 100 % dargestellt!)
p3
12,345,678,900,00% / -0,001%
Prozentzahlen mit drei Nachkommastellen
r
123456789 / -1,23E-06
Format, das ein verlustfreies Wiedereinlesen der Daten ermöglicht (RoundTrip). Allerdings ist auch dieses Format von der Landeseinstellung abhängig und daher für internationale Anwendungen ungeeignet! Das Format ist ausschließlich für float- und double-Zahlen gedacht.
Die Formate d und x können ausschließlich für Ganzzahlenformate verwendet werden (nicht aber für float, Double oder decimal). Bei beiden Formaten kann die gewünschte Stellenanzahl angegeben werden – in diesem Fall werden entsprechend viele Nullen eingefügt. Die folgende Tabelle zeigt, wie die Integer-Zahlen i1=123456789 und i2=-123 formatiert werden. Vordefinierte Formatcodes für Integerzahlen (Byte, Short, Integer, Long) d
123456789 / -123
dezimales Format
d5
123456789 / -00123
dezimales Format mit fünf Stellen
x
75bcd15 / ffffff85
hexadezimales Format
Sandini Bib
12 Arbeiten mit Datentypen
274
Einzelcodes zur individuellen Formatierung von Zahlen Wenn Ihnen die vordefinierten Formate nicht ausreichen, können Sie die Formatzeichenkette auch ganz selbst bilden. .NET sieht dazu eine Menge Codes vor, die sowohl für Fließkomma- als auch für Integerzahlen verwendet werden dürfen. Einzelcodes zur Formatierung von Zahlen (String.Format()) 0
Platzhalter für eine Zahl bzw. für eine Stelle; nichtsignifikante Nullen werden durch das Zeichen 0 dargestellt.
#
Platzhalter für eine Zahl bzw. für eine Stelle; nichtsignifikante Nullen werden durch Leerzeichen ersetzt.
.
Dezimalpunkt (das tatsächlich verwendete Zeichen hängt von der Landeseinstellung ab!)
,
Tausendertrennung (wie oben); das Zeichen bewirkt gleichzeitig eine Division durch 1000, so dass die Zahl 1234567890 mit dem Formatcode "#,," zu "1235" wird.
%
Prozentzeichen (wie oben); das Zeichen bewirkt gleichzeitig eine Multiplikation mit 100, so dass die Zahl 0,753 mit dem Formatcode "0%" zu "75%" wird.
E+0 e+0
Exponentialdarstellung mit den Zeichen E oder e; das Vorzeichen des Exponenten wird immer angezeigt (1E+3 oder 1E-3); die Anzahl der Nullen bestimmt die Stellenanzahl des Exponenten (d.h., das Format e+000 führt zu "1e+003")
E-0 e-0
wie oben, ein positives Vorzeichen des Exponenten wird aber nicht angezeigt (1E3 oder 1E-3).
fp;fn;f0
ermöglicht die Angabe von drei unterschiedlichen Formaten für positive Zahlen, negative Zahlen und für 0; Achtung, bei negativen Zahlen wird das Vorzeichen entfernt (d.h., die Zahl -1 wird durch das Format 0;0 als 1 dargestellt).
Die folgenden Zeilen liefern einige Beispiele für die Anwendung der obigen Codes bei deutscher Landeseinstellung. Um den Code möglichst kompakt zu halten, wurde die unübliche (aber durchaus zulässige) Schreibweise zahl.ToString() gewählt. string s; s = 123.456.ToString("0"); s = 123.456.ToString("0.00"); s = 123.456.ToString("0000.0000");
// s = "123" // s = "123,46" // s = "0123,4560"
s s s s
// // // //
= = = =
123.456.ToString("#"); 0.0123.ToString("#"); 0.0123.ToString("#.###"); 0.0123.ToString("0.###");
s s s s
= = = =
"123" "" ",012" "0,012"
Sandini Bib
275
Formatierungsmethoden in .NET s = 1234.ToString("#,#,#"); s = 123456789.ToString("#,#,#"); s = 1234.ToString("0,000,000");
// s = "1.234" // s = "123.456.789" // s = "0.001.234"
s = 0.0123.ToString("0%"); s = 0.0123.ToString("0.00%");
// s = "1%" // s = "1,23%"
s = 123456789.ToString("0e+00"); s = 123456789.ToString("000e+00"); s = 123456789.ToString("0000E-00");
// s = "1e+08" // s = "123e+06" // s = "1235E05"
s =-1.ToString("0;(0);0");
// s = "(1)"
Mit dem letzten Beispiel wird erreicht, dass positive Zahlen und 0 normal dargestellt werden, negative Zahlen dagegen in Klammern und ohne Vorzeichen, wie dies bisweilen im englischen Sprachraum üblich ist.
Text und Sonderzeichen in der Formatzeichenkette In der Formatzeichenkette dürfen auch beliebige andere Zeichen enthalten sein. Diese werden einfach unverändert angezeigt. s = 123.ToString("abc#efg")
' s = "abc123efg"
Etwas komplizierter wird es, wenn Sie Formatierungszeichen als Text anzeigen möchten (z.B. das Zeichen #). Dazu gibt es zwei Schreibweisen: Sie können dem betreffenden Zeichen das Zeichen \ (Backslash) voranstellen oder Sie können die Sonderzeichen zwischen Apostrophe stellen. Auch hierfür zwei Beispiele: s = 123.ToString("#") s = 123.ToString("'###' # '###'")
// s = "#123" // s = "### 123 ###"
Landesunabhängige Formatierung von Zahlen Unabhängig davon, welche Formatcodes Sie verwenden, wird bei der Formatierung von Zahlen immer auch die Landeseinstellung berücksichtigt (z.B. bei der Auswahl der Zeichen zur Dezimal- und zur Tausendertrennung). Wenn Sie das vermeiden möchten, übergeben Sie an die Formatmethode ein zusätzliches CultureInfo-Objekt mit den InvariantCulture-Einstellungen. string s; double x = 1d/7d; CultureInfo ci = CultureInfo.InvariantCulture; s = x.ToString( "r", ci ) // s = "0.14285714285714285" s = String.Format( ci, "{0:r}", x ) // s = "0.14285714285714285"
Dezimaltrennzeichen und Tausendertrennzeichen feststellen Wenn Sie wissen möchten, welche Zeichen am aktuellen Computer zur Dezimal- bzw. zur Tausendertrennung eingestellt sind, können Sie den folgenden Code zu Hilfe nehmen.
Sandini Bib
12 Arbeiten mit Datentypen
276 char decimalPoint; char groupSeparator; decimalPoint = ( String.Format( "{0:0.0}", 0 ) )[1]; groupSeparator = ( String.Format( "{0:0,000}", 1000 ) )[1];
Einfacher ist es allerdings, die aktuell eingestellte Kultur zu verwenden. Diese besitzt eine Eigenschaft NumberFormat, über deren Eigenschaften Sie alle benötigten Informationen ermitteln können. Im Gegensatz zu oben gezeigtem Code müssen Sie dazu allerdings die aktuelle Kultur feststellen, was über den aktuellen Thread funktioniert. Dazu müssen Sie den Namespace System.Threading einbinden. Ebenfalls benötigt (für das CultureInfo-Objekt) wird der Namespace System.Globalization. string decimalPoint; string groupSeparator; CultureInfo ci = Thread.CurrentThread.CurrentCulture; groupSeparator = ci.NumberFormat.NumberGroupSeparator; decimalPoint = ci.NumberFormat.NumberDecimalSeparator;
Formatcodes testen Um die verschiedenen Formatcodes rasch auszuprobieren, können Sie ein Programm nach dem folgenden Muster verwenden. public void Main(string[] args) { double x1 = 123456789d; double x2 = -0.00000123 string[] myformat = { "c", "e", "f", "g", "n", "p", "r" } for ( int i = 0; i < myFormat.Length; i++ ) { string formStr = myFormat[i] + ": {0:" + myFormat[i] + "} / {1:" + myFormat[i] + "}"; Console.WriteLine( formStr, x1, x2 ); } }
CD
Mehr Flexibilität bietet ein kleines Windows-Programm, in dem Sie in zwei Textfeldern eine Fließkommazahl und eine Formatzeichenkette eingeben können. Das Programm wertet Ihre Eingaben aus und liefert eine Ergebniszeichenkette. Auf einen Abdruck des gesamten Codes wird hier verzichtet, gezeigt wird lediglich die relevante Methode zur Auswertung der Formatzeichenkette und Ermittlung des Ergebnisses. Sie finden den gesamten Quelltext des Programms auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_12\FormatChecker.
Sandini Bib
Formatierungsmethoden in .NET
277
private void btnTest_Click( object sender, EventArgs e ) { double numberToFormat; bool isOk = double.TryParse( this.txtDoubleValue.Text, NumberStyles.Float, Thread.CurrentThread.CurrentCulture, out numberToFormat ); string formatString = this.txtFormatString.Text.Trim(); if ( isOk ) { lblResult.Text = String.Format( "{0:" + formatString + "}", numberToFormat ); } else { MessageBox.Show( "Die eigegebene Zahl muss vom Typ double sein", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error ); } }
Im Programm auf der CD ist das Ganze noch ein wenig erweitert worden. Hier wird der Formatstring zusätzlich noch in die Zwischenablage kopiert und außerdem im DebugFenster ausgegeben. Sie können den Code selbstverständlich anpassen. Abbildung 12.5 zeigt das Programm zur Laufzeit.
HINWEIS
Abbildung 12.5: Programm zum Testen von Formatcodes
Beachten Sie bei der Eingabe der Werte, dass als Dezimaltrennzeichen beim doubleWert das Komma eingegeben werden muss, im Formatstring aber der Punkt. Das hat damit zu tun, dass die landesspezifischen Einstellungen zur Auswertung, ob es sich wirklich um einen double-Wert handelt, verwendet werden. Falls beim Formatstring das Komma eingegeben wird, wird eine Exception ausgelöst, die in diesem Programm nicht abgefangen wird (da Exceptions noch nicht besprochen wurden).
Sandini Bib
12 Arbeiten mit Datentypen
278
12.4.3
Datum und Zeit formatieren
Die folgende Tabelle zählt die vordefinierten Codes zur Formatierung von Datum und Zeit auf (zusammen mit Beispielen, die für die deutsche Ländereinstellung gelten). Die Codes können folgendermaßen angewendet werden (hier für den Code F): s s s s
= = = =
DateTime.Now.ToString( "F" ); String.Format( "{0:F}", Now ); DateTime.Now.ToString( "HH:mm:ss" ); String.Format( "{0:HH:mm:ss}", DateTime.Now );
Formatcodes für Datum und Uhrzeit (String.Format()) d
03.12.2001
Datum kurz (Date)
D
Montag, 3. Dezember 2001
Datum lang
f
Montag, 3. Dezember 2001 21:42
Datum lang plus Zeit (Full)
F
Montag, 3. Dezember 2001 21:42:06
Datum lang plus Zeit lang
g
03.12.2001 21:42
Datum kurz plus Zeit kurz (General)
G
03.12.2001 21:42:06
Datum kurz plus Zeit lang
m
03 Dezember
Tag und Monat (Month)
r
Mon, 03 Dec 2001 21:42:06 GMT
Datum gemäß RFC1123 (Internet-Standard)
s
2001-12-03T21:42:06
Datum gemäß ISO-8601-Standard (Sortable)
t
21:42
Zeit kurz (Time)
T
21:42:06
Zeit lang
u
2001-12-03 21:42:06Z
wie s, aber laut Dokumentation universal time statt lokaler Zeit
U
Montag, 3. Dezember 2001 20:42:06
Variante zu u
y
Dezember 2001
Monat und Jahr (Year)
Einige der folgenden Zeichen repräsentieren für sich allein ein komplettes Datumsformat (siehe Tabelle oben). Erst in Kombination mit anderen Formatierungszeichen werden sie als Einzelcodes interpretiert. Einzelcodes für Uhrzeit (String.Format()) f bis fffffff
123
Sekundenbruchteile (ein bis sieben Stellen, also von Zehntelsekunden bis 100 ns)
ss
59
Sekunden (00-59)
m
59
Minute (0-59) ohne vorangestellte 0
Sandini Bib
Formatierungsmethoden in .NET
279
Einzelcodes für Uhrzeit (String.Format()) mm
59
Minute (00-59) mit vorangestellter 0
h
12
Stunde (1-12) ohne vorangestellte 0
hh
12
Stunde (1-12) mit vorangestellter 0
H
23
Stunde (0-23) ohne vorangestellte 0
HH
23
Stunde (0-23) mit vorangestellter 0
t
A
A oder P entsprechend AM oder PM (nur wenn AM/PM in der Ländereinstellung vorgesehen ist)
tt
AM
AM oder PM (nur bei entsprechender Ländereinstellung)
z
+1
Zeitzone (relativ zu GMT) ohne vorangestellte 0
zz
+01
Zeitzone mit vorangestellter 0
zzz
+01:00
Zeitzone vierstellig
Einzelcodes für Datum (String.Format) ddd
Mon
Abkürzung für Monatstag
dddd
Montag
Monatstag voll ausgeschrieben
d
31
Monatstag (1-31) ohne vorangestellte 0
dd
31
Monatstag (01-31) mit vorangestellter 0
M
12
Monat (1-12) ohne vorangestellte 0
MM
12
Monat (1-12) mit vorangestellter 0
MMM
Dez
Abkürzung für den Monatsnamen
MMMM
Dezember
Monatsname voll ausgeschrieben
y
1
Jahr zweistellig ohne vorangestellte 0
yy
01
Jahr zweistellig mit vorangestellter 0
yyyy
2001
Jahr vierstellig
gg
n. Chr.
Zeitperiode oder Zeitära (nur, wenn dies für den eingestellten Kalender vorgesehen ist!)
Sandini Bib
Sandini Bib
13 Collections Collections, auch als Listen bezeichnet, werden häufig zur Organisation von Daten verwendet. Das .NET Framework selbst verwendet eine nicht unerhebliche Anzahl von Collections für spezielle Fälle, wie z.B. die Inhalte einer ListBox oder einer ComboBox. Die Standardlisten des .NET Frameworks arbeiten fast ausschließlich mit dem Datentyp object, sind demnach also nicht typsicher. Bei der Entnahme der Werte muss eine Typ-
umwandlung in den korrekten Typ vorgenommen werden. Die Erstellung eigener, typsicherer Listen ist zwar auch mit den Standard-Collectionklassen möglich, Generics vereinfachen diese Angelegenheit jedoch deutlich. Die Verwendung der Standard-Collections wie ArrayList oder Hashtable ist allerdings nach wie vor notwendig, weshalb sie ausführlich beschrieben werden (die Verwendung generischer Collections geschieht eher intuitiv). Generische Datentypen sind generell nicht CLScompliant. Das bedeutet, wann immer Sie einen Datentyp öffentlich zur Verfügung stellen, der auch aus anderen Programmiersprachen heraus verwendet werden soll (beispielsweise im Falle einer Plugin-Schnittstelle), müssen Sie im öffentlichen Teil des Datentyps vollständig auf Generics verzichten. Aus diesem Grund sind die »herkömmlichen« Listenklassen nach wie vor wichtig.
13.1
Grundlagen
Collections dienen dazu, größere Mengen von Objekten bzw. Daten zu verarbeiten. Der Zugriff auf die einzelnen Elemente geschieht in der Regel über einen Index oder, z.B. bei einer Hashtable, über einen Schlüsselwert. Eine ganz bestimmte, aber leider auch in vielen Belangen recht eingeschränkte Form einer Collection stellen Arrays dar, die in Abschnitt 4.5 ab Seite 98 besprochen wurden. Die Nachteile eines Arrays sind seine Größenunveränderlichkeit, was das Hinzufügen von Daten erschwert, und die Tatsache, dass die Größe des Arrays bereits bei der Initialisierung bekannt sein muss. Änderungen der Arraygröße führen daher zwangsläufig zu einer umfangreichen Kopieraktion, bei der zunächst ein neues, größeres Array erzeugt wird, die Werte des Ursprungsarrays hineinkopiert werden und erst dann ein neuer Wert hinzugefügt werden kann. Die Tatsache, dass C# anders als Visual Basic kein Kommando der Art ReDim Preserve besitzt, ist in diesem Fall keineswegs als Nachteil zu werten. ReDim Preserve ist enorm langsam, weil dieses Kommando nämlich im Prinzip nur oben beschriebene Kopieraktion bewirkt. Die Listenklassen des .NET Frameworks verfolgen einen anderen Ansatz. Diese Listen sind dynamisch erweiterbar, besitzen keine festgelegte Größe und erlauben einen schnellen Zugriff auf die enthaltenen Daten. Mithilfe von Generics kann eine Liste auch automatisch typsicher gestaltet werden, was in der Vorgängerversion noch nicht möglich war. Dort war der Datentyp object und damit universell. Der Programmierer musste immer
Sandini Bib
282
13 Collections
wissen, welcher Datentyp in einer Liste enthalten war und dementsprechend ein Casting durchführen. Sollte eine Liste typsicher gestaltet werden, musste dafür eine eigene Listenklasse gestaltet werden – für jeden gewünschten Datentyp getrennt. Ein enormer Programmieraufwand und keineswegs performant. Dennoch sind diese Listen nach wie vor Bestandteil dieses Kapitels und sollten auch Bestandteil Ihres Repertoires sein. Generische Listenklassen haben einen enormen Nachteil, wenn es darum geht, dass eine in C# geschriebene DLL in einer anderen Sprache verwendet werden soll: Sie sind nicht CLS-compliant, d.h. auch eine DLL, die eine generische Liste veröffentlicht, kann nicht von allen Sprachen genutzt werden. JScript.NET beispielsweise unterstützt keine Generics und könnte daher mit Ihrer DLL nichts anfangen. Sobald Sie also sicherstellen müssen, dass Ihre DLLs universell verwendbar sind, müssen Sie auf die bisherigen Listenklassen zurückgreifen.
13.2
Die Listenklassen aus System.Collections
13.2.1
Übersicht
Die folgenden Tabellen geben Ihnen einen Überblick über die verschiedenen Listenklassen aus den Namespaces System.Collections und System.Collections.Specialized. Listen aus System.Collections ArrayList
ArrayList ist die universellste Liste im .NET Framework. Sie nimmt Objekte jeder Art auf und ermöglicht den wahlfreien Zugriff.
BitArray
BitArray
Hashtable
Eine Hashtable speichert Schlüssel-/Wertepaare. Der Zugriff geschieht über den Schlüssel, ein Zugriff über einen Index ist aus Implementierungstechnischen Gründen nicht möglich.
Queue
Die Klasse Queue repräsentiert einen FIFO (First In/FirstOut)-Speicher, ähnlich einer Warteschlange am Kiosk. Der Wert, der als Erstes eingefügt wurde, wird auch als Erstes ausgegeben.
SortedList
SortedList
Stack
Ein Stack repräsentiert einen LIFO (Last In/First Out)-Speicher, ähnlich eines Stapels. Der zuletzt hinzugefügte Wert wird als Erstes wieder entnommen.
speichert ein Array aus Bitwerten. Diese sind als boolesche Werte dargestellt. Der Zugriff auf einzelne Bits, z.B. zum Ablegen von Programmoptionen, ist damit sehr einfach.
stellt eine Kombination aus Array und Hashtable dar. Der Zugriff ist sowohl über den Schlüssel als auch über einen Index möglich. Sortiert ist die Liste immer nach dem Schlüssel.
Sandini Bib
Die Listenklassen aus System.Collections
283
Listen aus System.Collections.Specialized CollectionsUtil
CollectionsUtil erstellt Auflistungen von Zeichenfolgen, in denen die Groß/Kleinschreibung nicht berücksichtigt wird.
HybridDictionary
HybridDictionary
ListDictionary
Implementiert ebenfalls IDictionary, funktioniert aber wie eine einfach verkettete Liste. Effizient für bis zu 10 Elemente.
StringCollection
Eine spezielle Collection für Zeichenketten.
implementiert das Interface IDictionary zur Speicherung von Schlüssel-/Wertpaaren. Ab einer bestimmten Anzahl an Elementen wechselt diese Listenart automatisch zur Hashtable.
ArrayList Diese Klasse weist ähnliche Eigenschaften wie ein normales Array auf. Der wesentliche Unterschied besteht darin, dass die Größe einer ArrayList dynamisch ist und sich den Gegebenheiten anpasst. Elemente können mittels der Methoden Add(), AddRange() und Insert() angehängt bzw. eingefügt werden. Add() hängt neue Elemente hinten an die Liste an, Insert() fügt sie an einer vorgegebenen Position ein. AddRange() dient zum Hinzufügen mehrerer Elemente auf einen Schlag, wobei diese in Form einer Liste, die das Interface ICollection implementiert, zur Verfügung stehen müssen. Außer den Listen implementiert auch die Klasse Array dieses Interface, d.h. beliebige Arrays können ebenfalls hinzugefügt werden. Die Eigenschaft Count liefert die Anzahl der Elemente in der ArrayList. Die Methoden Remove() bzw. RemoveAt() entfernen Objekte aus der ArrayList. Remove() arbeitet direkt mit einem Objekt, RemoveAt() entfernt das Element mit dem angegebenen Index. Die Methode Clear() löscht alle Elemente der ArrayList auf einen Schlag. Falls Sie die Elemente in Form eines Arrays weiter auswerten wollen, können Sie dazu die Methode CopyTo() verwenden.
BitArray Die Klasse BitArray ermöglicht eine effiziente Speicherung von Bitwerten. Die Größe des BitArray-Objekts wird über den Konstruktor festgelegt, der mehrfach überladen ist. Sie können beispielsweise ein Array aus booleschen Werten zur Initialisierung verwenden (wobei die Werte übernommen werden) oder auch ein Array aus byte-Werten, bei dem jedes Byte für acht Bit-Werte steht. Der Zugriff auf die einzelnen Werte erfolgt über einen Index, alle Werte sind vom Typ bool. Alternativ können Sie zum Zugriff auch die Methoden Get() und Set() verwenden. Die Methoden And(), Or() und Not() führen eine entsprechende Verknüpfung mit den ent-
haltenen Werten durch.
Sandini Bib
284
13 Collections
Hashtable Eine Hashtable steht für eine Liste aus Schlüssel-/Wertepaaren. Die Schlüssel werden nicht als Objekte gespeichert, sondern in Form so genannter Hashcodes. Ein Hashcode ist ein Wert, der aus den Daten eines Objekts ermittelt wird. Sinn und Zweck eines Hashcodes ist es, dass er für gleiche Objekte auch gleich ist. Zur Erläuterung soll der Datentyp string dienen. Wenn die folgende Abfrage zweier Strings strA und strB true liefert: bool test = strA.Equals(strB);
müssen auch die Hash-Werte der beiden Strings gleich sein. Ermittelt wird der Hash-Wert über die Methode GetHashCode(), die bereits von der Klasse Object implementiert wird und so auch in selbst definierten Klassen zur Verfügung steht. Leider ist diese Implementierung höflich ausgedrückt »verbesserungswürdig«. Sie liefert nämlich lediglich eine durchlaufende Nummer, was schlicht bedeutet, dass im Falle eigener Klassen kein wirklicher Hash-Wert erzeugt wird (und somit die Hash-Werte zweier gleicher Objekte niemals auch gleich sein können). Sie sollten also eine eigene Implementierung dieser Methode in Erwägung ziehen. Falls Sie für alle enthaltenen Elemente die gleiche Implementierung zum Ermitteln des Hash-Werts bereitstellen wollen, können Sie auch eine entsprechende Klasse erstellen, die das Interface IHashCodeProvider implementiert. Ein Objekt dieser Klasse übergeben Sie dem Konstruktor, der fortan die dort enthaltene Implementierung von GetHashCode() verwendet. Der Zugriff auf einen Wert erfolgt über einen Schlüsselwert, bei dem es sich um ein beliebiges Objekt handeln kann. Alternativ kann auch ein Zugriff über eine Schleife erfolgen, indem auf das Interface IEnumerator zugegriffen wird. Die Eigenschaften Keys und Values liefern eine Collection, die alle Schlüssel bzw. alle Werte beinhaltet. Die Methode Add() fügt der Hashtable ein Element hinzu, Remove() entfernt das Element mit dem angegebenen Schlüssel. Über die Methoden ContainsKey() bzw. ContainsValue() können Sie feststellen, ob ein bestimmter Schlüssel bzw. Wert in der Hashtable enthalten ist.
Queue Eine Queue ist eine »Warteschlange«, und diese Klasse funktioniert auch so. Das zuerst eingefügte Element wird auch zuerst entnommen. Solche Konstruktionen nennt man auch FIFO-Speicher (First In/First Out). Auch die Klasse Queue arbeitet mit Elementen vom Typ object. Die Methode Enqueue() fügt ein Objekt hinzu, Dequeue() holt sich das nächste Element aus der Warteschlange. Die Eigenschaft Count liefert die Anzahl der enthaltenen Elemente. Mithilfe der Methode Peek() können Sie ermitteln, welches Element als Nächstes entnommen wird, ohne dieses aus dem Queue-Objekt zu entfernen.
Stack Die Klasse Stack funktioniert ähnlich wie eine Queue, stellt aber einen LIFO-Speicher (Last In/First Out) dar. Das zuletzt hinzugefügte Element wird als Erstes entnommen. Man
Sandini Bib
Die Listenklassen aus System.Collections
285
kann sich das Ganze wie eine Röhre vorstellen, in die Kugeln hineingeworfen und wieder entnommen werden. Die Methode Push() fügt ein Element hinzu, Pop() entfernt ein Element und Peek() steht auch hier wieder dafür, nachzuschauen, welches Element als Nächstes entnommen wird. Die Eigenschaft Count liefert die Anzahl der Elemente.
SortedList Die SortedList stellt wie angesprochen eine Verbindung zwischen Array und Hashtable dar. Das Grundverhalten entspricht dem der Hashtable, d.h. beim Zugriff über den Index wird der Wert des Schlüssels verwendet, um auf das gewünschte Element zuzugreifen. Nach dem Schlüssel sind alle enthaltenen Elemente auch sortiert. Einen Zugriff ähnlich zu einem Array ermöglichen die Methoden GetByIndex() und SetByIndex(). Damit wären die am häufigsten verwendeten Listen kurz angesprochen. Die verschiedenen Programmiertechniken zeigen Ihnen genauer, wie Sie mit Listen umgehen und wie Sie eigene Listen erstellen können.
13.2.2
Übersicht über die verwendeten Interfaces
Da die einzelnen Listen gemeinsame Merkmale haben, wurden diese in verschiedenen Interfaces zusammengefasst. Jede der Collection-Klassen implementiert eines oder mehrere dieser Interfaces, um die gewünschte Funktionalität zur Verfügung zu stellen. Die folgende Tabelle zeigt eine Übersicht über die implementierten Interfaces aller Listenklassen. In diese Übersicht wurde die Klasse Array ebenfalls mit eingefügt, um Ihnen den Gesamtüberblick zu verschaffen. IEnumerable
ICollection
IList
IDictionary
Array
+
+
+
ArrayList
+
+
+
StringCollection
+
+
+
Hashtable
+
+
+
ListDictionary
+
+
+
HybridDictionary
+
+
+
StringDictionary
+
NameValueCollection
+
+
SortedList
+
+
+
Sandini Bib
286
13 Collections
Die folgende Auflistung gibt Ihnen einen Überblick über die Funktion der Interfaces und die Methoden bzw. Eigenschaften, deren Implementierung sie erzwingen. f Das Interface IEnumerable ermöglicht es, dass alle Elemente der Liste mittels einer foreach-Schleife durchlaufen werden können. IEnumerable erzwingt die Implementierung der Methode GetEnumerator(), die wiederum ein Objekt zurückliefert, das die Schnittstelle IEnumerator implementiert. IEnumerator wiederum erzwingt die Implementierung der Methoden MoveNext() und Reset() sowie der Eigenschaft Current. Eigentlich ist es also dieses Interface, das das
Durchlaufen der Elemente ermöglicht. Alle Standard-Collections implementieren diese Interfaces, können also mittels foreach durchlaufen werden. In .NET 2.0 gibt es für die Implementierung der IEnumerator-Schnittstelle eine neue Möglichkeit durch das Schlüsselwort yield. Mehr darüber erfahren Sie in Abschnitt 13.4.2 ab Seite 302. f Die Schnittstelle ICollection erzwingt die Implementierung der Eigenschaften Count, IsSynchronized und SyncRoot sowie der Methode CopyTo(). Jede Collection, die dieses Interface implementiert, verfügt also in der Regel über die Möglichkeit, Daten in ein Array zu kopieren und die Anzahl der Elemente zurückzuliefern. Es verwundert nicht, dass alle Standard-Collections dieses Interface implementieren. f Die Schnittstelle IList stellt Methoden zur Verwaltung einer Liste zur Verfügung. Unter anderem sind dies Add(), Clear(), Contains(), IndexOf(), Insert(), Remove() und RemoveAt(). Weiterhin ist in IList der Indexer einer Liste definiert (also die Eigenschaft, die dann ausgewertet wird, wenn Sie über den Index auf eine Liste zugreifen). Da IList lediglich die Verwaltung einfacher Listen wie z.B. ArrayList ermöglicht, ist dieses Interface nicht bei allen Listen implementiert. f Das Interface IDictionary ist das Gegenstück zu IList zur Verwaltung von Listen, die aus Datenpaaren bestehen (wie z.B. Hashtable). Der entscheidende Unterschied besteht in der Art des Zugriffs, der nun über einen Schlüssel und nicht über einen Index erfolgt.
13.3
Grundlegende Programmiertechniken
In diesem Abschnitt erhalten Sie Informationen über grundsätzliche Vorgehensweisen im Umgang mit den einzelnen Listenklassen. Dabei werden nicht die enthaltenen Methoden aufgelistet, sondern vielmehr konkrete Beispiele geboten, die zeigen, wie Sie die Listenklassen in .NET anwenden können.
13.3.1
Listenelemente löschen
Alle Listen stellen die Methode Clear() zur Verfügung (sie stammt aus dem Interface IList bzw. aus IDictionary), um die Elemente der Liste zu löschen. Häufig ist es allerdings der Fall, dass nicht alle Elemente gelöscht oder die Elemente vor ihrer Löschung noch bearbei-
Sandini Bib
Grundlegende Programmiertechniken
287
tet werden sollen. Im letzteren Fall können Sie selbstverständlich zuerst die Liste mithilfe einer Schleife durchlaufen, mit den Elementen arbeiten und sie dann löschen. Im ersten Fall, wenn nur bestimmte Elemente gelöscht werden sollen, müssen Sie das Löschen direkt in der Schleife erledigen. Die folgende kleine Methode löscht Elemente einer ArrayList, die mit int-Werten gefüllt ist. Elemente, deren Wert größer als 10 ist, werden gelöscht. private static void DeleteItems( ArrayList arl ) { int i = 0; // Zählvariable while ( i < arl.Count ) { if ( (int)arl[i] > 10 ) arl.RemoveAt( i ); else i++; }
CD
}
Sie finden den gesamten Quelltext des Programms auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_13\DeleteList.
Abbildung 13.1 zeigt einen Screenshot, an dem Sie sehen, dass wirklich nur die Elemente gelöscht wurden, deren Wert größer als 10 war.
Abbildung 13.1: Das Programm DeleteList im Einsatz
Die Verwendung einer foreach-Schleife ist hier nicht möglich, denn die Methoden der Schnittstelle IEnumerator funktionieren nur dann, wenn die Liste nicht während des Schleifendurchlaufs verändert wird. Aber genau das tun wir, indem wir Elemente löschen. Eine allgemeinere Lösung, die für alle Listenformen gilt, die die Schnittstelle IList implementieren, wäre die folgende:
Sandini Bib
288
13 Collections
private static void DeleteItems( IList lst ) { int i = 0; while( i < lst.Count ) { if ( (int)lst[i] > 10 ) lst.RemoveAt( i ); else i++; } }
Hier wird das Interface IList wie ein Objekt verwendet, obwohl es eigentlich nicht instanziert werden kann. Allerdings implementiert das übergebene Objekt (in diesem Falle wäre das beispielsweise eine ArrayList) auch das Interface. Indem wir den Übergabeparameter als Interface deklarieren, legen wir fest, dass wir auch nur die vom Interface vorgegebenen Methoden und Eigenschaften verwenden können. Und da IList alle Methoden und Eigenschaften zur Verwaltung der Liste beinhaltet, funktioniert der Code mit jeder Liste, die IList implementiert.
Dictionary-Elemente löschen Im Falle eines Dictionary, also einer Schlüssel-/Werteliste, liegt der Fall ein wenig anders. Hier ist es sinnvoll, zunächst alle Schlüssel zu ermitteln, diese dann zu durchlaufen und jedes Element zu kontrollieren bzw. zu löschen. Auch hier ist wieder ein Interface für die Verwaltung zuständig, IDictionary, also verwenden wir es auch. Das Programmfragment löscht wiederum Elemente, deren Wert größer ist als 10: private static void DeleteItems( IDictionary dict ) { object[] keys = new object[dict.Count]; dict.Keys.CopyTo( keys, 0 ); for ( int i = 0; i < keys.Length; i++ ) { if ( (int)dict[keys[i]] > 10 ) dict.Remove( keys[i] ); }
CD
}
Sie finden den gesamten Quelltext des Programms auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_13\DeleteDict.
Das Ergebnis einer solchen Operation, basierend auf einer Hashtable, zeigt Abbildung 13.2. Sowohl Schlüssel als auch Werte bestehen aus int-Werten, um die Programmierung etwas zu vereinfachen.
Sandini Bib
Grundlegende Programmiertechniken
289
Abbildung 13.2: Das Ergebnis beim Löschen aller Werte größer 10 aus einer Hashtable
13.3.2
Sortieren von Listen
Zum Sortieren von Listen verwenden Sie fast immer die Klasse ArrayList. Sowohl die Elemente, die sich in einer ArrayList selbst befinden, lassen sich sortieren, als auch alle Listen, die die Schnittstelle IList implementieren. Letztere jedoch nur, indem man einen ArrayList-Wrapper um die Liste legt und dann die Methoden der ArrayList zum Sortieren verwendet.
Einfaches Sortieren einer ArrayList Die Methode der Klasse ArrayList, die für das Sortieren zuständig ist, heißt Sort(). Bei herkömmlicher Anwendung müssen die in der ArrayList enthaltenen Elemente das Interface IComparable implementieren, dessen Methode CompareTo() für den Vergleich verwendet wird.
CD
Das folgende kleine Beispielprogramm sortiert den Inhalt einer ArrayList. Für dieses Beispiel wurden Werte vom Typ string verwendet. string implementiert IComparable, die Sortierung erfolgt nach dem Alphabet. Sie finden den gesamten Quelltext des Programms auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_13\SimpleSort.
static void Main( string[] args ) { ArrayList arl = new ArrayList(); // Werte hinzufügen arl.AddRange( new string[] { "Frank Eller", "Michael Kofler", "Holger Schwichtenberg", "Tobias Ulm",
Sandini Bib
290
13 Collections "Andreas Rauch", "Hannes Preishuber", "Klaus Aschenbrenner", "Jürgen Bayer", "Andreas Barchfeld", "Joachim Fuchs" } );
// Unsortierte Ausgabe Console.WriteLine( "Unsortierte ArrayList:" ); Console.WriteLine( "======================" ); foreach ( string s in arl ) Console.WriteLine( s ); Console.WriteLine( "\r\n" ); // Sortierte Ausgabe arl.Sort(); Console.WriteLine( "Sortierte ArrayList:" ); Console.WriteLine( "====================" ); foreach ( string s in arl ) Console.WriteLine( s ); Console.ReadLine(); }
Abbildung 13.3 zeigt einen Screenshot des Programms.
Abbildung 13.3: Sortieren einer ArrayList mit Strings
Sandini Bib
Grundlegende Programmiertechniken
291
Angepasste Sortierung Das Sortieren einer ArrayList funktioniert grundsätzlich auf die gleiche Weise wie bei einem Array. Eine ArrayList implementiert die gleichen Interfaces wie auch die Klasse Array, die die Basis aller Arrays in C# darstellt. Auch die Klasse ArrayList besitzt demnach eine Methode Sort(), die ohne Parameter nur dann funktioniert, wenn die enthaltenen Elemente das Interface IComparable (und damit die Methode CompareTo()) implementieren. Wahlweise kann dieser Methode auch ein Objekt übergeben werden, das das Interface IComparer implementiert. Dieses erzwingt die Implementierung der Methode Compare(), die von der ArrayList dann zur Sortierung der Elemente herangezogen wird. Ein Beispiel für die angepasste Sortierung, bei der Sie volle Kontrolle über den Sortieralgorithmus haben, finden Sie in Kapitel 9.2.2 auf Seite 209. Dieses Beispiel verwendet ein Array, das Sie leicht durch eine ArrayList ersetzen können. Beachten Sie bitte, dass dann aber die Methode Sort() der ArrayList zum Einsatz kommen muss, bei der es sich um eine Instanzmethode handelt.
Sortieren über die Schnittstelle IList Zahlreiche Klassen des .NET Frameworks implementieren irgendwie geartete Listen. Nicht immer wird dabei eine ArrayList verwendet, häufig handelt es sich um typisierte Listen, die allerdings auch das Interface IList implementieren. Dieses Interface bietet ebenfalls die Möglichkeit, eine Liste zu sortieren, auch dann, wenn die eigentliche Listenklasse keine Sortiermöglichkeit zur Verfügung stellt. Es muss dabei ein Umweg gegangen werden. Dieser Umweg führt über einen Wrapper, also eine Schicht um die Liste, bei dem es sich um eine ArrayList handelt. Die Klasse ArrayList besitzt eine statische Methode Adapter(), die es ermöglicht, die Methoden der ArrayList auf jede Liste anzuwenden, die das Interface IList implementiert. Auch dafür finden Sie selbstverständlich ein Beispiel in diesem Buch. Aus Platzgründen wurde darauf verzichtet, es doppelt abzudrucken. Sie finden eines in Verbindung mit dem Steuerelement ListBox in Kapitel 18.6.1 ab Seite 545 und ein weiteres im Zusammenhang mit dem Steuerelement TreeView ab Seite 584.
Sortieren von Listen mit Schlüssel-/Wertepaaren Das Sortieren eines Arrays oder einer ArrayList bzw. einer gleichartigen Liste ist, wie Sie sehen, relativ trivial. Schnell können Sie eigene Sortieralgorithmen implementieren. Intern wird dabei übrigens ein QuickSort-Algorithmus verwendet. Etwas komplizierter wird es bei Listen, die aus Schlüssel-/Wertepaaren bestehen. Die einzige Liste, die eine Sortiermöglichkeit bietet, ist die Klasse SortedList. Auch der Umweg über einen ArrayList-Adapter ist nicht ohne weiteres möglich, da diese Listenklassen nicht IList implementieren. Der Grund dafür, dass keine Sortierung möglich ist, liegt darin, dass das Interface IDictionary keinen durchlaufenden Index liefert, mit dem auf die Objekte zu-
Sandini Bib
292
13 Collections
gegriffen werden kann. Die Klasse SortedList als eine Art »Hybridklasse« bietet jedoch sowohl den Zugriff über einen Index als auch über den Schlüssel. Die Reihenfolge der Elemente in einem SortedList-Objekt wird jedes Mal reorganisiert, wenn Sie ein neues Element hinzufügen oder ein Element löschen. Sortiert wird allerdings nicht nach dem Wert, sondern nach dem Schlüssel-Objekt. Wie bei den anderen Sortiermethoden auch kommt hier die Methode CompareTo() zum Einsatz. Falls Sie lieber eine angepasste Sortierung mittels eines eigenen Sortierobjekts bevorzugen, das das Interface IComparer implementiert, können Sie dieses Objekt dem Konstruktor der SortedList übergeben. Ein Beispiel hierzu zeigt genauer, wie das Ganze funktioniert. In diesem Fall greifen wir auf die Farben zurück, die das System kennt. Alle diese Farben sind über die Aufzählung KnownColor verfügbar. Das eigentliche Color-Objekt wird dann über die statische Methode FromKnownColor() der Klasse Color erzeugt. Zunächst werden im Beispiel alle verfügbaren Farben ermittelt. Der Name der Farbe und die Farbe selbst werden dann in eine SortedList eingefügt, wobei der Name der Farbe als Schlüssel fungiert. Da es sich dabei um einen String handelt, der IComparable implementiert, können wir auch danach sortieren.
CD
Um das Ganze etwas schöner darstellen zu können, wurde in diesem Fall eine Windows.Forms-Applikation erstellt. Für die Verwendung von Farben ist es Voraussetzung, dass der Namespace System.Drawing eingebunden ist. Sie finden den gesamten Quelltext des Programms auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_13\CustomSort.
Zunächst die Methode, mit der die Farben ermittelt und der SortedList hinzugefügt werden. An dieser Methode gibt es eigentlich nichts Besonderes. Die Namen der Farben werden aus der Aufzählung KnownColor ermittelt und zusammen mit der Farbe selbst einer SortedList hinzugefügt. private SortedList GetColors() { // Ermittelt die Farben und liefert eine sortierte Liste zurück SortedList result = new SortedList(); foreach ( string s in Enum.GetNames( typeof( KnownColor ) ) ) { KnownColor knownColor = (KnownColor)Enum.Parse( typeof( KnownColor ), s, true ); Color color = Color.FromKnownColor( knownColor ); result.Add( s, color ); } return result; }
Sandini Bib
Grundlegende Programmiertechniken
293
Wenn das Formular geladen wird, wird ein Ereignis namens Load ausgeführt. Dieses Ereignis nutzen wir, um die Farbliste auf den Bildschirm zu bringen. An dieser Stelle werden, obwohl dieses Vorgehen noch nicht angesprochen wurde, Steuerlemente (Label) dynamisch erzeugt und einem Panel hinzugefügt. Das Panel dient auf dem Formular der besseren Darstellung und um einen gewissen Kontrast zum Einheitsgrau darzustellen. Die Eigenschaft AutoScroll des Panels wurde auf true eingestellt, sodass die einzelnen Labels einfach untereinander angeordnet werden können. Wenn Labels aus dem sichtbaren Bereich herausfallen, stellt das Panel automatisch eine Scrollbar dar, mit der man die nicht sichtbaren Elemente erreichen kann. Der Code ist im Prinzip denkbar simpel. Da es sich ja bei den Steuerelementen auch nur um Klassen handelt, wird ein neues Objekt des Typs Label erzeugt. Diesem werden seine Eigenschaften so zugewiesen, wie es gewünscht ist. Dabei wird als Vordergrundfarbe die Farbe verwendet, die in der SortedList gespeichert ist. Dann wird das Label der Eigenschaft Controls des Panels, bei der es sich um eine Collection handelt, hinzugefügt. Da die SortedList sowohl die Schlüssel als auch die Werte in Form des Datentyps object speichert, muss vor der Zuweisung erst ein Casting durchgeführt werden. private void Form1_Load( object sender, EventArgs e ) { // Ermitteln der Farben und Auswertung SortedList colorList = GetColors(); int x = 5; int y = 5; // Labels dem Panel hinzufügen for ( int i = 0; i < colorList.Count; i++ ) { Label lbl = new Label(); lbl.Left = x; lbl.Top = y; lbl.AutoSize = true; lbl.Text = (string)colorList.GetKey( i ); lbl.ForeColor = (Color)colorList.GetByIndex( i ); lbl.BackColor = Color.White; pnlShow.Controls.Add( lbl ); y += 15; } }
Einen Screenshot des Programms zur Laufzeit zeigt Abbildung 13.4.
Sandini Bib
294
13 Collections
Abbildung 13.4: Die Farben, angezeigt in dynamisch erzeugten Labels und alphabetisch sortiert
13.3.3
Suchen in einer ArrayList
Zur effizienten Suche bietet die Klasse ArrayList die Methode BinarySearch(), die den Vorteil hat dass sie sehr schnell ist. Damit sie verwendet werden kann muss die ArrayList allerdings sortiert sein, und das nach dem gleichen Kriterium, nach dem auch die Suche erfolgt. Bei der ersten Art der Suche wird BinarySearch() lediglich das zu suchende Objekt übergeben. Die Methode verwendet dann zur Suche die Methode CompareTo() der in der Liste enthaltenen Objekte, weshalb diese auch entsprechend der CompareTo()-Methode sortiert sein muss. Haben Sie bei der Sortierung der ArrayList auf eine IComparer-Implementierung zurückgegriffen, sollten Sie diese auf bei BinarySearch() verwenden. Das Objekt, das IComparer implementiert, wird BinarySearch() zusätzlich zum zu suchenden Objekt übergeben. Falls Sie die Suche in einer unsortierten ArrayList durchführen oder mit der falschen Vergleichsfunktion (z.B. mittels einer IComparer-Implementierung, obwohl die Liste über die Mehode CompareTo() der Objekte sortiert wurde), kann es zu fehlerhaften Ergebnissen kommen.
Sandini Bib
Grundlegende Programmiertechniken
13.3.4
295
Queue und Stack verwenden
Zwei etwas speziellere Listen werden durch die Klassen Queue und Stack zur Verfügung gestellt. Der Unterschied besteht hauptsächlich in der Art, wie Daten entnommen und gespeichert werden. Dennoch handelt es sich um vollwertige Listen. Wie leicht die Verwaltung solcher Listen ist, zeigt das folgende Programm. Mit nur wenigen Codezeilen können Elemente einem Stack oder einer Queue hinzugefügt und entfernt werden. Über die Methode Peek() der Klassen Stack und Queue wird außerdem angezeigt, welches Element als Nächstes entfernt werden wird. Beim Entfernen wird das zuletzt entfernte Element angezeigt. Aus Bedienbarkeitsgründen handelt es sich auch hierbei wieder um eine Windows.FormsApplikation. Das Hauptformular enthält ein Steuerelement TabControl mit zwei Seiten, deren Aufbau identisch ist. Eine Seite dient der Anzeige der Werte aus dem Stack-Objekt, die andere entsprechend der Anzeige der Werte des Queue-Objekts.
CD
Benötigt werden jeweils eine Textbox, zwei Labels zur Anzeige des zuletzt entnommenen und nächsten Elements, zwei Buttons und eine Listbox zur Anzeige der Gesamtliste. Die Buttons dienen einmal dem Hinzufügen eines Werts und dem Entfernen. Sie finden den gesamten Quelltext des Programms auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_13\StackQueue.
Der Quellcode sollte nicht allzu schwer verständlich sein. Die Namen der Steuerelemente wurden so gewählt, dass sie der jeweiligen Funktion sofort zugeordnet werden können. Zunächst werden jeweils ein Stack- und ein Queue-Objekt benötigt. Diese werden als Felder des Hauptformulars angelegt. // Felder Stack theStack = new Stack(); Queue theQueue = new Queue();
Im Programm wurden die einzelnen Arbeitsschritte sauber in Methoden aufgeteilt. Die ersten Methoden dienen der Anzeige sämtlicher im Stack/in der Queue enthaltenen Elemente in der jeweiligen ListBox. private void ShowQueueElements() { // Queue-Elemente in Listbox anzeigen this.lstQueueResult.Items.Clear(); if ( theQueue.Count > 0 ) this.lstQueueResult.Items.AddRange( this.theQueue.ToArray() ); }
Sandini Bib
296
13 Collections
private void ShowStackElements() { // Stack-Elemente in Listbox anzeigen this.lstStackResult.Items.Clear(); if ( theStack.Count > 0 ) this.lstStackResult.Items.AddRange( this.theStack.ToArray() ); }
Die nächsten beiden Methoden dienen der Anzeige des jeweils nächsten Elements, das entnommen werden kann. Dazu wird die Methode Peek() verwendet, die beide Klassen zur Verfügung stellen. Kann kein Element mehr entfernt werden (d.h. Stack/Queue sind leer) wird eine entsprechende Ausgabe generiert. private void ShowNextQueueElement() { // Nächstes Element, das if ( this.theQueue.Count this.lblQueueNext.Text else this.lblQueueNext.Text
aus Queue entfernt wird, anzeigen > 0 ) = (string)this.theQueue.Peek(); = "";
} private void ShowNextStackElement() { // Nächstes Element, das aus dem Stack entfernt wird, anzeigen if ( theStack.Count > 0 ) this.lblStackNext.Text = (string)theStack.Peek(); else this.lblStackNext.Text = "<Stack ist leer>"; }
Nun fehlen noch die Methoden zum Hinzufügen bzw. Entfernen von Werten. Hier zeigt sich nun auch der Vorteil einer ordentlichen Aufteilung der Funktionalität in Methoden. Nach dem Entnehmen eines Werts müssen die Anzeigen in der jeweiligen ListBox sowie im Feld für den nächsten verfügbaren Wert aktualisiert werden. Das kann nun durch einen einfachen Methodenaufruf geschehen. private void AddQueueValue( string s ) { // Wert in Queue ablegen this.theQueue.Enqueue( s ); ShowQueueElements(); ShowNextQueueElement(); }
Sandini Bib
Grundlegende Programmiertechniken
297
private void GetQueueValue() { // Wert aus Queue holen if ( this.theQueue.Count > 0 ) this.lblQueueGet.Text = (string)this.theQueue.Dequeue(); ShowQueueElements(); ShowNextQueueElement(); } private void AddStackValue( string s ) { // Element zu Stack hinzu und in Listbox anzeigen this.theStack.Push( s ); ShowStackElements(); ShowNextStackElement(); } private void GetStackValue() { // Element entfernen if ( this.theStack.Count > 0 ) this.lblStackGet.Text = (string)this.theStack.Pop(); ShowStackElements(); ShowNextStackElement(); }
Die Click-Ereignisbehandlungsroutinen der beiden Buttons zum Entnehmen des jeweiligen Werts enthalten ebenfalls lediglich einen einzigen Funktionsaufruf. private void btnQueueGet_Click( object sender, EventArgs e ) { GetQueueValue(); } private void btnStackGet_Click( object sender, EventArgs e ) { GetStackValue(); }
In den Ereignisbehandlungsroutinen der Buttons zum Hinzufügen ist ein wenig mehr Code vorhanden. Hier wird nach erfolgtem Hinzufügen der Inhalt der jeweiligen TextBox gelöscht und der Fokus auf diese zurückgesetzt. Das geschieht über die Methode Focus() des Steuerelements TextBox. private void btnQueueAdd_Click( object sender, EventArgs e ) { // Hinzufügen zur Queue if ( !this.txtQueueValue.Text.Equals( String.Empty ) ) AddQueueValue( this.txtQueueValue.Text );
Sandini Bib
298
13 Collections
txtQueueValue.Text = String.Empty; txtQueueValue.Focus(); } private void btnStackAdd_Click( object sender, EventArgs e ) { // Hinzufügen if ( !this.txtStackValue.Text.Equals( String.Empty ) ) AddStackValue( this.txtStackValue.Text ); txtStackValue.Text = String.Empty; txtStackValue.Focus(); }
Abbildung 13.5 zeigt einen Screenshot des laufenden Programms. Gezeigt wird die StackAnsicht. Durch die Anzeigen wird klar, welches Element als Nächstes entnommen wird. Gefüllt wird die Listbox immer von oben.
Abbildung 13.5: Ein Stack im Einsatz
13.3.5
Datenaustausch zwischen Listen
Im letzten Beispiel wurden bereits Elemente aus einer Liste in eine andere Liste kopiert. Grundsätzlich würde es natürlich keinen Sinn machen, wenn alle Elemente einer Liste nur in dieser verwendbar wären. Deshalb gibt es auch Methoden, die für den Datentransfer zuständig sind.
Sandini Bib
Grundlegende Programmiertechniken
299
Die Methode CopyTo() CopyTo() ist in allen Listen verfügbar, die die Schnittstelle ICollection implementieren. Die-
se Methode kopiert alle in der Liste enthaltenen Elemente in ein Array. Da der Datentyp der Listenelemente in der Regel nicht automatisch in den Datentyp des Arrays konvertiert werden kann, wird es sich dabei normalerweise um ein Array des Typs object handeln. Die Methode erwartet als Übergabeparameter einmal das Array selbst und den Index, ab dem die Elemente eingefügt werden sollen. Das Array muss selbstverständlich initialisiert sein. In der Regel funktioniert das folgendermaßen: ArrayList arl = new ArrayList(); // Hinzufügen mehrerer Elemente object[] arr = new object[arl.Count]; arl.CopyTo(arr, 0);
Über die Eigenschaft Count der Liste kann normalerweise die Anzahl der Elemente ermittelt werden. Beachten Sie, dass Arrays grundsätzlich mit dem Index 0 beginnen.
Die Methode ToArray() Die Klasse ArrayList implementiert weiterhin eine Methode ToArray(), mit der es möglich ist, Elemente in ein beliebigesArray zu überführen. Bei dieser Variante kann auch der Datentyp des Arrays angegeben werden. In diesem Fall wird allerdings eine Instanz der Klasse Array zurückgeliefert. Wird kein Datentyp angegeben, handelt es sich bei dem zurückgelieferten Wert um ein object-Array. Der Unterschied zu CopyTo() ist, dass hierbei kein Array initialisiert werden muss. Die folgende Zuweisung ist demnach möglich: ArrayList arl = new ArrayList(); // Hinzufügen mehrerer Elemente object[] arr = arl.ToArray();
Die zweite Variante, bei der die Klasse Array benutzt wird, sieht folgendermaßen aus: ArrayList arl = new ArrayList(); arl.AddRange( new string[] {"a","b","c","d" } ); Array arr = arl.ToArray( typeof(System.String) );
In der Folge müssen dann die Methoden der Klasse Array angewendet werden, z.B. GetValue() zum Ermitteln eines Werts oder SetValue() zum Setzen eines Werts. Diese Variante wird vermutlich nicht sehr häufig verwendet werden.
Die Methode AddRange() AddRange(), ebenfalls bereits im letzten Beispiel verwendet, dient dazu, mehrere Listenele-
mente auf einen Schlag zu einer Liste hinzuzufügen. Der Übergabeparameter muss ent-
Sandini Bib
300
13 Collections
weder ein Array vom Typ object sein oder aber die Schnittstelle ICollection implementieren. Auf diese Weise kann eine Liste sehr schnell gefüllt werden. Arrays implementieren ICollection ebenso wie die meisten anderen Listenklassen.
13.4
Eigene Listenklassen erstellen
Nicht immer ist es wünschenswert, Listen zu verwenden, die den Datentyp object verwenden. Immerhin ist es so möglich, jeden beliebigen anderen Datentyp der Liste hinzuzufügen. Bei eigenen Listen kann es von Vorteil sein, diesen Datentyp zu beschränken, d.h. eine streng typisierte Liste zu verwenden. Im Namespace System.Collections existieren zwei Klassen, die als Basis für eigene Listen zur Verfügung stehen, CollectionBase für einfache Listen und DictionaryBase für Listen mit Schlüssel-/Wertepaaren. Sie müssen nicht von diesen Klassen ableiten, es empfiehlt sich aber. CollectionBase beispielsweise implementiert bereits die Interfaces IList, ICollection und IEnumerable. Weiterhin wird über die (geschützte) Eigenschaft InnerList eine ArrayList zur Verfügung gestellt, die zum internen Speichern der enthaltenen Objekte dient. Typisiert muss die eigene Liste ja bekanntlich nur nach außen hin sein. Die Online-Hilfe empfiehlt auch, die Klasse CollectionBase (bzw. bei Schlüssel-/Wertepaaren die Klasse DictionaryBase) als Ausgangspunkt für eigene Listen zu verwenden. Zwei weitere Basisklassen für Aufzählungen stehen zur Verfügung, die allerdings nicht so häufig Anwendung finden dürften: f ReadOnlyCollectionBase erleichtert die Programmierung von Read-Only-Aufzählungen auf der Basis des Interface IList. f NameObjectCollectionBase dient zum Erzeugen einer Listenklasse, bei der der Elementzugriff wahlweise über einen Index oder über einen String-Schlüssel erfolgt. Intern werden die Schlüsseleinträge sortiert (wie bei der Klasse SortedList). Die Programmierung einer eigenen Aufzählungsklasse ist zwar nicht sonderlich kompliziert, ein wenig Schreibarbeit ist es aber schon. Insbesondere müssen alle Methoden für den Elementzugriff neu implementiert werden, um die Verwendung der korrekten Datentypen sicher zu stellen. Glücklicherweise erfolgt die interne Speicherung der Daten über eine ArrayList, sodass die Implementierung eines Enumerators oder einer Sortiermethode trivial ist – die Klasse ArrayList stellt diese Funktionalität ja bereits zur Verfügung.
13.4.1
Eine neue Art von Eigenschaft: der Indexer
Wir gehen in diesem Fall von einer einfachen Werteliste aus, also von der Klasse CollectionBase. Für den Zugriff auf die einzelnen Elemente einer Liste stellt C# eine besondere Konstruktion zur Verfügung, den so genannten Indexer. Dabei handelt es sich um eine spezielle Eigenschaft, die man als »Standard-Eigenschaft« bezeichnen könnte. Sicherlich haben Sie schon bemerkt, dass Sie beispielsweise bei Arrays oder bei Collections lediglich die Indexnummer des gewünschten Elements benötigen. Diese wird in eckigen
Sandini Bib
Eigene Listenklassen erstellen
301
Klammern hinter dem Namen des Objekts angegeben, worauf das entsprechende Element zurückgeliefert wird. In der Klasse selbst handelt es sich dabei um eine spezielle Eigenschaft. Statt eines Eigenschaftsnamens wird allerdings hier das reservierte Wort this verwendet. Auch die Syntax unterscheidet sich ein wenig, denn irgendwo muss ja auch der Index herkommen, der zur Ermittlung des Elements benötigt wird. Die Syntax eines Indexers stellt sich wie folgt dar: public DataType this[int index] { get { ... } set { ... } } DataType bezeichnet den Datentyp, der zurückgeliefert werden soll. Angenommen, Ihre Liste besteht aus Objekten des Typs BookInfo, die Buchinformationen enthalten (im nach-
folgenden Beispiel werden wir eine solche Klasse verwenden). In der Regel sind Ihre Elemente in der Eigenschaft InnerList gespeichert. Was Sie also im Indexer tun müssen ist, das angeforderte Element aus der InnerList zu entnehmen und zurückzuliefern. Dabei sollten Sie immer darauf achten, dass der Index sich innerhalb des erlaubten Bereichs befindet. Das folgende Programmfragment stellt nur ein kleines Beispiel dar. Die ausführliche Verwendung eines Indexers sehen Sie in der Beispielapplikation. public class TestList : CollectionBase { // Deklarationen // Indexer public BookInfo this[int index] { get { if ( index > -1 && index < this.InnerList.Count) return (BookInfo)InnerList[index]; } } // Weitere Deklarationen }
Der Indexer bei diesem Beispiel ist Readonly, d.h. das Element kann nicht direkt zugewiesen werden. Selbstverständlich können Sie auch eine Zuweisung programmieren, falls Ihre Liste das unterstützen soll. Wenn der Index außerhalb des gültigen Bereichs ist, sollte außerdem eine Exception ausgelöst werden. Auch die Standard-Listenklassen des .NET Frameworks gehen so vor. Die passende Exception findet sich im Namespace System und trägt den Namen IndexOutOfRangeException. Entsprechend angepasst sieht obiger Indexer dann folgendermaßen aus:
Sandini Bib
302
13 Collections
// Indexer public BookInfo this[int index] { get { if (index < 0 || index >InnerList.Count ) throw new IndexOutOfRangeException( "Index außerhalb des gültigen Bereichs" ); else return (BookInfo)InnerList[index]; } }
Indexer mit Namen Nicht alle Programmiersprachen unterstützen die Art und Weise, wie in C# auf die Elemente zugegriffen wird. Teilweise greifen diese Sprachen über eine Standardeigenschaft, die einen Namen trägt, auf die Elemente zu. Damit eine Klasse auch mit diesen Sprachen kompatibel ist, müssen Sie dem Indexer einen Namen geben. Zuständig hierfür ist das Attribut IndexerName aus dem Namespace System.Runtime.CompilerServices. Versehen mit einem Namen sieht der Indexer dann folgendermaßen aus: // Indexer [System.Runtime.CompilerServices.IndexerName( "Item" )] public BookInfo this[int index] { get { if ( index < 0 || index > InnerList.Count ) throw new IndexOutOfRangeException( "Index außerhalb des gültigen Bereichs" ); else return (BookInfo)InnerList[index]; } }
Der Zugriff in C# erfolgt wie gewohnt, auch andere Sprachen, die einen solchen Zugriff ermöglichen, funktionieren so. Sollte eine Sprache aber eine benamte Eigenschaft benötigen, kann Item als Bezeichner verwendet werden.
13.4.2
Implementierung der foreach-Schleife
Collections können üblicherweise mittels foreach durchlaufen werden. Diese Funktionalität muss jedoch für eigene Listenklassen implementiert werden. Zuständig für foreach sind die Interfaces IEnumerable bzw. IEnumerator. Die Listenklasse muss diese Interfaces implementieren, wobei IEnumerable lediglich die Implementierung einer Methode namens GetEnumerator() erzwingt, die ihrerseits das Interface IEnumerator (bzw. ein Objekt, das dieses Interface implementiert) zurückliefert. Unter .NET 1.1 war die Implementierung der Interfaces aufwändig. Es musste mit einem Zeiger auf die aktuelle Position gearbeitet werden, mittels der Methode MoveNext() wurde
Sandini Bib
Eigene Listenklassen erstellen
303
dieser dann auf die nächste Position gesetzt, mittels Reset() auf die Position 0. Doch .NET 2.0 bringt Erleichterung durch ein neues Schlüsselwort namens yield. Alles, was eine Collectionklasse implementieren muss, um mittels foreach durchlaufen werden zu können, ist die Methode GetEnumerator(). In dieser wiederum kommt yield zum Einsatz: public IEnumerator GetEnumerator() { for ( int i = 0; i < this.Count; i++ ) yield return this[i]; } yield bewirkt, dass bei einem erneuten Aufruf der Methode nicht an den Anfang gesprungen wird, sondern vielmehr hinter die durch yield festgelegte Position. Also in diesem Fall in die Schleife hinein, die danach weiter durchlaufen wird. Die Laufvariable der Schleife wird zwischengespeichert. Damit ist es sehr einfach, Werte für foreach zurückzuliefern.
13.4.3
Beispielprogramm Bücherliste
Unsere eigene Liste soll eine Bücherliste darstellen. Die enthaltenen Objekte sollen nur vom Typ BookInfo sein, einer Klasse, die wir ebenfalls selbst erstellen und die Buchinformationen beinhaltet. Enthalten sollen sein der Titel des Buchs, die Autoren, die ISBN und der Preis. Falls Sie möchten, können Sie auch noch Informationen über den jeweiligen Verlag, die Seitenzahl, Farbe des Buchs oder Ähnliches einfügen. Die grundsätzliche Funktionsweise bleibt die gleiche. Bei der Planung der Liste legen wir fest, dass die Liste sortierbar sein soll. Die Methode
CD
Sort() ist nicht durch ein Interface vorgegeben, wir müssen sie selbst implementieren. Das sollte allerdings leicht fallen, da die interne Liste ja eine ArrayList ist, die durchaus sortiert werden kann. Die Klasse BookInfo soll hierzu das Interface IComparable und somit eine Methode CompareTo() erhalten.
Sie finden den gesamten Quelltext des Programms auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_13\BookListExample.
Die BookInfo-Klasse für die Daten Der Quellcode für die BookInfo-Klasse ist nicht weiter schwierig und sollte leicht verständlich sein. using System; using System.Collections; using System.Text;
Sandini Bib
304
13 Collections
namespace BookListExample { public class BookInfo : IComparable { private private private private
string title; string author; string isbn; float price;
public string Title { get { return this.title; } set { this.title = value; } } public string Author { get { return this.author; } set { this.author = value; } } public string ISBN { get { return this.isbn; } set { this.isbn = value; } } public float Price { get { return this.price; } set { this.price = value; } } private int InternalCompare( BookInfo bi ) { // Interner Vergleich // Sortierkriterien: Titel und Autor string firstValue = this.title + " " + this.author; string secondValue = bi.Title + " " + bi.Author; return firstValue.CompareTo( secondValue ); } public override string ToString() { // Liefert den Titel des Buchs zurück return this.title; }
Sandini Bib
Eigene Listenklassen erstellen
305
public int CompareTo( object o ) { // Vergleicht zwei BookInfo-Objekte BookInfo bi = ( o as BookInfo ); if ( bi == null ) throw new ArgumentException( "Das übergebene Objekt muss vom Typ BookInfo sein" ); else return InternalCompare( bi ); } public BookInfo( string ti, string au, string isbn ) { // Alle Daten außer Preis this.title = ti; this.author = au; this.isbn = isbn; this.price = 0.00f; } public BookInfo( string ti, string au, string isbn, float pr ) : this( ti, au, isbn ) { // Alle Daten vorhanden - anderer Konstruktor wird automatisch aufgerufen this.price = pr; } } }
Die Listenklasse Die Liste wird der Einfachheit halber von CollectionBase abgeleitet. Das hat einen großen Vorteil, denn Sie müssen sich praktisch um nichts kümmern. Die Liste enthält automatisch eine ArrayList (in Form der InnerList), die direkt verwendbar ist. Wenn die Liste nicht typisiert sein sollte, bräuchten Sie fast nichts zu programmieren. Wegen der Typisierung müssen die benötigten Methoden neu implementiert werden, mit dem Datentyp BookInfo als Übergabeparameter. Dass alles letztendlich doch in einer ArrayList landet, sieht der Benutzer der Klasse ja nicht und es kann ihm auch egal sein. Ein Eingriff ist lediglich beim Indexer nötig (der nicht den Datentyp object, sondern den Datentyp BookInfo zurückliefern soll) und bei der Methode ToArray(). Da diese immer gleich implementiert ist und ein Array aus Objekten liefert, wurde dieses Verhalten so belassen. Es existiert allerdings auch eine Methode ToBookArray(), die ein Array aus BookInfo-Objekten zurückliefert. Im Übrigen sollte der Quelltext nicht schwer verständlich sein. Sie werden sehen, dass der Aufwand für eine eigene typisierte Liste nicht weiter groß ist.
Sandini Bib
306
13 Collections
using System; using System.Collections; using System.Text; namespace BookListExample { public class BookList : CollectionBase { [System.Runtime.CompilerServices.IndexerName( "Item" )] public BookInfo this[int index] { get { return (BookInfo)this.InnerList[index]; } } public int Add( BookInfo bi ) { // Fügt ein BookInfo-Objekt hinzu return this.InnerList.Add( bi ); } public bool Contains( BookInfo bi ) { // Prüft, ob ein Objekt enthalten ist return this.InnerList.Contains( bi ); } public int IndexOf( BookInfo bi ) { // Liefert den Index eines Objekts wenn dieses in der Liste enthalten ist return this.InnerList.IndexOf( bi ); } public void Insert( int index, BookInfo bi ) { // Fügt ein Element an angegebener Stelle ein this.InnerList.Insert( index, bi ); } public void Remove( BookInfo bi ) { //Entfernt ein Objekt aus der Liste this.InnerList.Remove( bi ); } public void Sort() { this.InnerList.Sort(); } public void Sort( IComparer comp ) { this.InnerList.Sort( comp ); }
Sandini Bib
Eigene Listenklassen erstellen public void Sort( int index, int count, IComparer comp ) { this.InnerList.Sort( index, count, comp ); } public BookInfo[] ToBookArray() { // Liefert ein Array aus BookInfo-Objekten if ( this.InnerList.Count > 0 ) { BookInfo[] books = new BookInfo[this.InnerList.Count]; for ( int i = 0; i < this.InnerList.Count; i++ ) books[i] = (BookInfo)this.InnerList[i]; return books; } return null; } public object[] ToArray() { // Liefert ein object-Array return this.InnerList.ToArray(); } public BookList() { } public BookList( BookInfo[] books ) { this.InnerList.AddRange( books ); } } }
Abbildung 13.6 zeigt ein Programm, das die BookList-Klasse verwendet, im Einsatz.
Abbildung 13.6: Ein Programm mit der Bücherliste im Einsatz
307
Sandini Bib
VERWEIS
308
13 Collections
Bei der Verwendung von CollectionBase werden einige Sachen von der abgeleiteten Klasse geerbt. Unter anderem die Eigenschaft Count (die somit ohne explizite Implementierung vorhanden ist) wie auch die Methode GetEnumerator(). Daher sind diese im Beispiel nicht mehr implementiert.
13.5
Syntaxzusammenfassung
13.5.1
Interfaces
Die Interfaces IEnumerable und IEnumerator Zur Anwendung der Interfaces IEnumerable und IEnumerator formulieren Sie einfach eine foreach-Schleife für das Aufzählungsobjekt. Mit den Interna dieser Schnittstellen müssen Sie sich nur dann beschäftigen, wenn Sie selbst Klassen programmieren möchten, die diese Schnittstelle realisieren und ein bestimmtes Objekt zurückliefern sollen. Interface IEnumerable (aus System.Collections) GetEnumerator()
liefert ein Objekt, das das Interface IEnumerator implementiert.
Interface IEnumerator (aus System.Collections) Current
verweist auf das aktuelle Objekt (Typ object).
MoveNext()
geht zum nächsten Objekt. Liefert false, wenn das Ende der Aufzählung erreicht ist.
Reset()
geht zum ersten Element der Liste.
Interface IDictionaryEnumerator (aus System.Collections) Entry
verweist auf das aktuelle Objekt (Typ DictionaryEntry).
Key
verweist auf den aktuellen Schlüssel (object).
Value
verweist auf die aktuellen Daten (object).
Sandini Bib
Syntaxzusammenfassung
309
ICollection-Schnittstelle Interface ICollection (aus System.Collections) CopyTo(Array, index)
kopiert die Elemente der Liste in ein eindimensionales Array des Typs object. Mit dem Füllen des Arrays wird ab Index index begonnen.
Count
liefert die Anzahl der Elemente in der Liste.
IsSynchronized
gibt an, ob die Aufzählung synchronisiert ist. (Das bedeutet, dass sichergestellt ist, dass kein anderer Thread die Daten ändert.) IsSynchronized liefert bei fast allen Collection-Objekten False.
SyncRoot
liefert ein Objekt, das zur Synchronisierung der Liste verwendet werden kann.
IList- und IDictionary-Schnittstelle Beachten Sie, dass sämtliche Methoden der Interfaces IList und IDictionary optional sind. Sie können sich also nicht darauf verlassen, dass Sie bei einer Klasse mit diesen Schnittstellen tatsächlich eine bestimmte Methode ausführen können. Im Regelfall geben IsFixedSize und IsReadOnly Auskunft über die Merkmale der Klasse, aber selbst diese beiden Eigenschaften sind optional. Interface IList Add( object o )
fügt ein Objekt ein. Add() liefert auch den Index des eingefügten Objekts zurück.
Clear()
löscht alle Elemente der Liste.
Contains(object o)
prüft, ob das Objekt bereits in der Liste enthalten ist.
IndexOf( object o )
ermittelt den Index des Objekts. Liefert -1, wenn das Objekt nicht enthalten ist.
IsFixedSize
gibt an, ob die Anzahl der Elemente der Liste unveränderlich ist. In diesem Fall stehen Add() und Remove()/RemoveAt() nicht zur Verfügung.
IsReadOnly
gibt an, ob einzelne Elemente der Liste verändert werden dürfen.
RemoveAt(int index)
entfernt das Objekt an der Indexposition index aus der Liste.
Remove(object o)
entfernt das angegebene Objekt aus der Liste.
Sandini Bib
310
13 Collections
Interface IDictionary Clear() Contains( object o ) IsFixedSize IsReadOnly Remove( object o )
funktionieren wie bei IList.
Add( object key, object data )
fügt das Objekt data mit dem Schlüssel key in die Aufzählung ein.
GetEnumerator()
liefert ein Objekt mit IDictionaryEnumerator-Schnittstelle. In foreach-Schleifen werden DictionaryEntry-Objekte durchlaufen.
Keys
liefert alle Schlüssel in Form einer Aufzählung des Typs ICollection.
Values
liefert alle Daten in Form einer Aufzählung des Typs ICollection.
13.5.2
Klassen
Die folgende Tabelle gibt einen Überblick über die wichtigsten Klassen und ihre Merkmale. Schlüssel
Daten
Datentyp
eindeutig
sortieren
Array
Integer
+
beliebig
ArrayList
Integer
+
Object
+
StringCollection
Integer
+
String
+
Hashtable
Object
+
Object
+
ListDictionary
Object
+
Object
+
HybridDictionary
Object
+
Object
+
StringDictionary
String
+
String
+
NameValueCollection
String
String
+
einfügen
Object
+ +
+
CollectionBase
Integer
+
beliebig
ReadOnlyCollectionBase
Integer
+
beliebig
DictionaryBase
beliebig
+
beliebig
+
String/int
+
beliebig
+
NameObjectCollectionBase
+
sortieren
+
Object/int
SortedList
+
Datentyp
+
(+)
Sandini Bib
Syntaxzusammenfassung
311
ArrayList Die Klasse ArrayList realisiert die Schnittstellen IEnumerable, ICollection und IList. Die folgende Tabelle beschreibt nur solche Eigenschaften bzw. Methoden, die nicht ohnedies durch die Schnittstellen vorgegeben sind. Im Konstruktor kann optional auch die voraussichtliche Anzahl der Aufzählungselemente angegeben werden. Klasse ArrayList (aus System.Collections) BinarySearch()
ermöglicht eine effiziente Suche nach Elementen, wenn die Liste vorher sortiert wird.
Capacity
gibt an, wie viele Elemente die Liste enthalten kann, bevor sie automatisch vergrößert wird.
Count
gibt an, wie viele Elemente die Liste tatsächlich enthält.
GetRange()
liefert eine Teilliste.
InsertRange()
fügt mehrere Elemente auf einen Schlag in die Liste ein.
LastIndex()
sucht das letzte Element der Liste, das ein bestimmtes Objekt enthält bzw. darauf verweist.
RemoveRange()
entfernt mehrere Elemente gleichzeitig.
Repeat()
liefert ein ArrayList-Objekt, das ein angegebenes Objekt mehrfach enthält.
Reverse()
dreht die Reihenfolge der Elemente der Liste um.
Sort()
sortiert die Elemente (optional unter Angabe eines Objekts, das das Interface IComparer implementiert).
Synchronized()
liefert eine synchronisierte Version des ArrayList-Objekts.
ToArray()
kopiert die Elemente in ein Feld.
TrimToSize()
gibt nicht benötigten Speicher für weitere Elemente frei.
Hashtable Die Klasse Hashtable realisiert die Schnittstellen IEnumerable, ICollection und IDictionary. Darüber hinaus gibt es nur zwei wichtige Methoden. Klasse Hashtable (aus System.Collections) Contains()
testet, ob das angegebene Objekt in der Aufzählung gespeichert ist.
ContainsKey()
testet, ob der angegebene Schlüssel bereits in Verwendung ist.
Sandini Bib
312
13 Collections
SortedList Die Klasse SortedList unterstützt unter anderem die Schnittstellen IEnumerable, ICollection und IDictionary. Die folgende Tabelle beschreibt die wichtigsten Methoden. Klasse SortedList (aus System.Collections) GetByIndex(int index)
liefert das Objekt zur angegebenen Indexnummer.
GetKey(int index)
liefert den Schlüssel zur Indexnummer.
GetKeyList()
liefert eine Liste mit allen Schlüsseln.
GetValueList()
liefert eine Liste mit allen Datenelementen.
IndexOfKey( object o )
liefert die Indexnummer zum angegebenen Schlüssel.
IndexOfValue( object o )
liefert die Indexnummer des ersten Elements, das das angegebene Objekt enthält.
RemoveAt( int index )
entfernt das Objekt mit der angegebenen Indexnummer.
13.6
Generische Listenklassen
Die generischen Listenklassen ersetzen ihre nicht-generischen Pendants in vielen Fällen. Dazu sind sie automatisch auch noch typsicher, was will man also mehr. Die Verwendung der Klassen gestaltet sich in den meisten Fällen intuitiv. Die folgende Tabelle führt die Klassen aus System.Collections.Generic auf. Generische Listenklassen List
Generische Standardliste. Ersatz für die ArrayList-Klasse.
Stack
Ein generischer Stack
Queue
Eine generische Queue
Dictionary
Eine generische Schlüssel-/Wert-Liste, der Ersatz unter anderem für die Hashtable.
LinkedList
Eine generische doppelt verkettete Liste
SortedDictionary
Ein generisches Dictionary, sortiert nach dem Key.
SortedList
Eine generische Schlüssel-/Wert-Liste, sortiert nach dem Key.
Aus der oberen Tabelle lässt sich erkennen, dass SortedDictionary und SortedList offenbar die gleiche Art der Implementierung besitzen. In der Tat repräsentieren beide intern einen binären Suchbaum, durch den die Elemente schnell gefunden werden können. Sie unterscheiden sich jedoch in einigen Punkten:
Sandini Bib
Generische Listenklassen
313
f SortedDictionary benötigt mehr Speicher als SortedList. f SortedList ist schneller gefüllt, wenn alle Elemente auf einen Schlag und vorsortiert eingefügt werden. f SortedDictionary ist schneller beim Einfügen und Entnehmen, wenn die Werte noch nicht sortiert sind.
13.6.1
Verwendung generischer Listenklassen
Die Verwendung einer generischen Listenklasse gestaltet sich recht einfach. Sie müssen lediglich angeben, welcher Datentyp verwendet werden soll. Im Falle der Bücherliste würde eine Deklaration also folgendermaßen aussehen: List bookList = new List();
Damit wäre die Liste bereits typsicher. Wenn Sie eigene Listenklassen mit eigener Funktionalität implementieren möchten, haben Sie auch diese Möglichkeit. Es existiert allerdings keine Basisklasse wie z.B. CollectionBase. Stattdessen implementieren Sie innerhalb Ihrer Listenklasse einfach ein Feld vom Typ List, das die Elemente aufnimmt, und machen die eigene Listenklasse ebenfalls generisch. Oder Sie leiten direkt von List ab, auch das ist möglich.
CD
Das aufzunehmende Element sollte dann auch (wenn schon Generics, dann auch richtig) statt des Interfaces IComparable das Interface IComparable implementieren (natürlich unter Angabe des korrekten Typs). Das folgende Beispiel zeigt den Aufbau einer solchen Listenklasse und der dazugehörigen Elemente. Sie finden den gesamten Quelltext des Programms auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_13\GenericBookListExample.
Die Klasse BookInfo mit generischem IComparable-Interface Die Klasse BookInfo entspricht fast exakt der Klasse BookInfo des vorangegangenen Beispiels, lediglich die Implementierung von IComparable ist verschieden. Aus diesem Grund wird nicht mehr die gesamte Klasse abgedruckt, sondern nur noch die relevanten Bestandteile. public class BookInfo : IComparable { // Felder, Eigenschaften und Methode entsprechen der Klasse BookInfo aus dem // Beispiel BookListExample public int CompareTo( BookInfo other ) { string firstValue = this.title + " " + this.author;
Sandini Bib
314
13 Collections string secondValue = other.Title + " " + other.Author; return firstValue.CompareTo( secondValue );
} }
Die eigene generische Listenklasse Natürlich macht eine eigene generische Listenklasse nur dann Sinn, wenn sie mehr Funktionalität bietet als die vom Framework angebotenen generischen Klassen. Die eigene generische Listenklasse soll aus diesem Grund zusätzlich zur Standardfunktionalität auch noch die enthaltenen Objekte je eins nach oben oder nach unten bewegen können. Die Implenentierung einer solchen Liste ist denkbar einfach. Es wird schlicht von List abgeleitet und die zusätzlichen Funktionen werden implementiert. Damit wäre dann eine generische Klasse mit allen Features in kürzester Zeit fertig. Hier das Listing der Klasse ExpandedList: public class ExpandedList :List
{
// Eins nach oben public void MoveUp( int index ) { if ( (index == 0) || index >= this.Count return; this.Reverse( index - 1, 2 ); }
)
// Eins nach unten public void MoveDown( int index ) { if ( index > this.Count - 2 ) return; this.Reverse( index, 2 ); } }
Im Hauptformular wird nun die ExpandedList statt der im vorangegangenen Beispiel eingesetzten BookList verwendet. Die Funktionalität wird um zwei Buttons erweitert, mit denen die Elemente nach oben bzw unten verschoben werden können. Hier der Code für die zwei Buttons – ansonsten ändert sich nichts. private void BtnUp_Click( object sender, EventArgs e ) { // Nach oben int currentIndex = this.lstBooks.SelectedIndex; if ( currentIndex > 0 ) { this.books.MoveUp( currentIndex ); ShowBooks(); } }
Sandini Bib
Generische Listenklassen
315
private void btnDown_Click( object sender, EventArgs e ) { // Nach unten int currentIndex = this.lstBooks.SelectedIndex; if ( currentIndex < this.lstBooks.Items.Count - 1 ) { this.books.MoveDown( currentIndex ); ShowBooks(); } }
Einen Screenshot der Anwendung zur Laufzeit zeigt Abbildung 13.7.
Abbildung 13.7: Die generische Buchliste im Einsatz. Das Programm hat nun zwei Buttons mehr für das Auf und Ab der Bücher in der Liste.
13.6.2
Geschwindigkeitsvergleich Generics zu normal
CD
Einer der größten Vorteile generischer Listenklassen ist die Geschwindigkeit. Diese ist im Vergleich zu den herkömmlichen Listen wesentlich höher. Ein kleines Beispiel kann das belegen. Sie finden den gesamten Quelltext des Programms auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_13\GenericPerformance.
Das Beispiel vergleicht die Geschwindigkeit mehrerer Listen. Zum Einen kommt die ArrayList zum Einsatz, weiterhin eine typsichere Liste auf Basis von CollectionBase und schließlich die generische List. Bei allen Listen wird mit Wertetypen und Referenztypen gearbeitet (denn auch da gibt es Geschwindigkeitsunterschiede). Der verwendete Wertetyp ist int, als Referenztyp wird string verwendet. Für beide wurde eine eigene typsichere Collection auf Basis von CollectionBase implementiert.
Zunächst wird dafür gesorgt, dass der gesamte Code auch durch den Jitter gelaufen ist. Dazu wird der Test einmal ohne Zeitmessung durchlaufen. Danach folgen 1000 Testdurchläufe, bei denen dann die Zeit gemessen wird. Ein Testdurchlauf sieht folgendermaßen aus (Am Beispiel der StringObjectCollection):
Sandini Bib
316
13 Collections
StringObjectCollection list = new StringObjectCollection(); for ( int i = 0; i < 10000; i++ ) { list.Add( "StringObject" ); string s = list[0]; list.RemoveAt( 0 ); }
Im Beispiel wurden die Tests mittels anonymer Methoden durchgeführt. Das erfolgte hier aber lediglich zu Demonstrationszwecken und verfolgt keinen tieferen Sinn. Der Quelltext wird nicht abgedruckt; die Methoden entsprechen alle dem gezeigten Beispiel und werden immer 1000-mal durchlaufen. Mit jeder Liste werden somit insgesamt 20000000 Operationen durchgeführt. Abbildung 13.8 zeigt das Ergebnis des Tests, das für sich sprechen dürfte. Vor allem die Performance der auf CollectionBase basierenden Listen ist eigentlich erschreckend.
Abbildung 13.8: Der Performance-Test. CollectionBase hat eindeutig verloren.
Sandini Bib
14 Dateien und Verzeichnisse Im Mittelpunkt dieses Kapitels stehen die Klassen des Namensraums System.IO. Mit ihnen können Sie komfortabel den Verzeichnisbaum durchlaufen, Verzeichnisse erstellen und löschen sowie Dateien in verschiedenen Formaten (binär, Unicode-Text, ASCII-Text) lesen und verändern. Das Kapitel geht auch auf einige Besonderheiten der Dateiein- und -ausgabe ein, etwa auf asynchrone Dateioperationen, auf die Speicherung (Serialisierung) von Objekten und auf die Überwachung des Dateisystems.
14.1
Grundlagen
Der Namespace System.IO stellt Klassen für den Zugriff auf das Dateisystem des Rechners zur Verfügung. Visual Basic-Programmierer kennen vor allem noch die vor dem .NET Framework propagierte Lösung, das File System Object (FSO). Die Klassen des Namensraums System.IO können hier als der legitime Nachfolger betrachtet werden. Ihre Leistungsfähigkeit und die Anzahl der sich ergebenden Möglichkeiten ist allerdings ungleich höher. Bei System.IO handelt es sich übrigens um einen aufgeteilten Namespace, ein Teil der Klassen ist in der Bibliothek mscorlib.dll deklariert, ein anderer Teil in der Bibliothek System.dll. Beide sind standardmäßig als Verweis in jedem neuen Projekt vorhanden, der Namespace System.IO ist jedoch nicht in neuen Projekten eingebunden. Sie müssen die entsprechende using-Anweisung manuell einfügen.
HINWEIS
In den Beispielen dieses Kapitels wird davon ausgegangen, dass System.IO mittels using bei allen Programmen eingebunden ist und die enthaltenen Klassen somit ohne vollständige Qualifizierung verwendet werden können. Die meisten der in diesem Kapitel vorgestellten Beispielprogramme sind WindowsProgramme, die zwangsläufig auch auf einige Steuerelemente zurückgreifen. In einem derart umfangreichen Buch lassen sich solche Dinge nicht immer vermeiden. Es werden jedoch keine speziellen Funktionalitäten dieser Steuerelemente verwendet, sodass die Anwendung derselben und das Verständnis nicht leiden sollten. Genaueres zu den einzelnen Standard-Steuerelementen erfahren Sie in Kapitel 18 ab Seite 501.
14.1.1
Streams
Die Basis aller Dateizugriffe bilden so genannte Streams. Dabei handelt es sich im Prinzip lediglich um Datenströme, in denen die einzelnen Daten unabhängig von letztendlich verwendeten Format als Bytes vorliegen. Bei einem solchen Zugriff auf unterster Ebene (der über die Klasse FileStream möglich ist) handelt es sich um den flexibelsten, aber auch komplexesten Zugriff.
Sandini Bib
318
14 Dateien und Verzeichnisse
Viele Klassen aus System.IO verwenden intern ebenfalls einen Stream, auch wenn das nicht gleich deutlich wird (der Name dieser Klassen suggeriert es allerdings). Hier verbirgt sich dann auch eine häufige Fehlerursache. Streams sind in der Regel entweder mit irgendeiner Stelle im Speicher oder mit einer Datei verbunden (denn irgendwo müssen die Daten ja herkommen, bzw. hingelangen). Dass eine Klasse Daten in einen Stream schreibt, bedeutet jedoch nicht, dass diese Daten auch schon in die Datei geschrieben werden. Das geschieht erst, wenn die Datei explizit geschlossen wird (über die Methode Close()) bzw. wenn das Kommando Flush() ausgeführt wird. Sollten Sie also Daten geschrieben haben (in einen Stream) und der Strom fällt plötzlich aus (das soll es schon gegeben haben), sind die Daten nicht auf der Festplatte gelandet. Eine Lösung ist es, die Eigenschaft AutoFlush auf true zu setzen. Das bewirkt, dass der Stream alle in ihn geschriebenen Daten gleich an die verbundene Datei weitergibt. Nicht jede Klasse, die einen Stream verwendet, stellt die Eigenschaft AutoFlush bzw. die Methode Flush() zur Verfügung. Wenn sie nicht vorhanden sind, wird in der Regel sofort in die Datei geschrieben. Wenn sie vorhanden sind, sollten Sie AutoFlush in jedem Fall auf true setzen, um sicher zu gehen, dass die Daten auch direkt in der Datei landen. In jedem Fall aber gilt: Wenn Sie eine Datei geöffnet haben, schließen Sie Sie auch wieder – warten Sie nicht, bis die Garbage Collection das für Sie erledigt.
14.1.2
Klassen von System.IO
Die folgenden Tabellen geben Ihnen einen Überblick über die Klassen des Namensraums System.IO und über den Einsatzzweck. In den folgenden Abschnitten werden die wichtigsten Klassen dann detaillierter behandelt. Klassen für den Dateizugriff File, FileInfo
Diese Klassen dienen der Ermittlung von Informationen und der Arbeit mit Dateien. Während File ausschließlich statische Methoden zur Verfügung stellt, muss von FileInfo eine Instanz erzeugt werden.
Directory, DirectoryInfo
Wie File/FileInfo, allerdings für Verzeichnisse. Auch hier enthält Directory ausschließlich statische Methoden, während von DirectoryInfo eine Instanz erzeugt werden muss.
Path
Die Klasse Path enthält ausschließlich statische Methoden für die Arbeit mit Dateipfaden. Der Name einer Datei ist Bestandteil des Pfads. Mithilfe von Path können beispielsweise die Endung einer Datei extrahiert oder der Dateiname vom Pfadanteil getrennt werden.
FileStream
Diese Klasse ermöglicht den Low-Level-Zugriff auf Dateien. Sie können damit einzelne Bytes lesen oder schreiben. Die Position, an der gelesen bzw. geschrieben werden soll, ist frei veränderbar. Die Lese- und Schreiboperationen können synchron oder asynchron erfolgen.
StreamReader
Mit dieser Klasse können Sie Textdateien komfortabel lesen. Das Format der zu lesenden Datei kann angegeben werden. Der Zugriff ist rein sequenziell, es ist nicht möglich, die Leseposition unmittelbar zu beeinflussen.
Sandini Bib
Grundlagen
319
Klassen für den Dateizugriff StreamWriter
Diese Klasse ist das Gegenstück von StreamReader und ermöglicht das Schreiben von Textdateien. Es gilt auch hier ein rein sequenzieller Zugriff und UTF-8 als Standard für das Dateiformat, wenn nicht anders angegeben.
BinaryReader
dient zum Lesen von byte-Werten bzw. von binären Dateien. Wie die gelesenen Werte interpretiert werden, hängt von der Methode ab, mit der sie gelesen werden. ReadInt32() beispielsweise liest einen int-Wert aus dem Stream und erhöht die Position des Dateizeigers um vier Bytes. Natürlich können die Bytes auch einzeln gelesen werden.
BinaryWriter
BinaryWriter ist das Gegenstück zum BinaryReader. Hiermit können binäre Dateien geschrieben werden. Die gleichen Methoden, die bei BinaryReader als Read-Methoden implementiert sind, bietet BinaryWriter als Write-Methoden.
MemoryStream
MemoryStream funktioniert grundsätzlich wie FileStream, ist aber nicht mit einer Datei verknüpft, sondern speichert die Daten im Hauptspeicher des Rechners.
BufferedStream
BufferedStream
dient dem Einbau eines Datenpuffers. Grundsätzlich liest und schreibt er in jeweils einen anderen Stream, durch die Zwischenspeicherung der Daten können wiederholte Lese-/Schreibvorgänge allerdings durch Zwischenschalten eines BufferedStream optimiert werden.
Basisklassen FileSystemInfo
dient als Basisklasse für FileInfo und DirectoryInfo und ist als abstract deklariert.
TextReader
TextReader
TextWriter
Als Gegenstück zu TextReader ist TextWriter die Basisimplementierung für Klassen, die Texte in eine Datei schreiben sollen.
Stream
Stream ist die Basisklasse für Dateioperationen. Grundsätzlich stellt ein Stream lediglich eine Menge von Byte-Daten dar. Diese Klasse bildet die Grundlage aller Dateizugriffe. Alle Klassen, die auf Stream enden, sind von ihr abgeleitet.
dient als Basis für Klassen, die Zeichen aus Dateien lesen sollen. Es handelt sich dabei um die grundlegende Implementierung. Auch diese Klasse ist als abstract deklariert.
Weitere Klassen StringReader
wie StreamReader, nur dass hier direkt aus einer Zeichenkette (einem String) gelesen wird.
StringWriter
wie StreamWriter, nur dass hier direkt in eine Zeichenkette und nicht in eine Datei (bzw. einen Stream) geschrieben wird.
FileSystemWatcher
Diese Klasse dient der Überwachung des Dateisystems. Sie kann ein Verzeichnis und alle diesem Verzeichnis untergeordneten Verzeichnisse überwachen und löst Ereignisse bei bestimmten Vorgängen aus, z.B. wenn eine Datei in dem Verzeichnis erstellt, gelöscht oder umbenannt wurde.
Sandini Bib
320
14 Dateien und Verzeichnisse
Vor allem beim Schreiben in Dateien und dem Lesen aus Dateien ist zu beachten, dass die grundlegende Klasse (bzw. das grundlegende Objekt), das den eigentlichen Schreibvorgang durchführt, ein Stream-Objekt ist. D.h. dass beispielsweise Klassen wie StreamReader oder StreamWriter lediglich in einen Stream schreiben (bzw. aus einem Stream lesen), nicht direkt in die Datei oder auf den Datenträger. Das erledigt der Stream selbst, teilweise direkt, teilweise erst durch Aufruf einer der Methoden Flush() bzw. Close().
14.2
Verzeichnisse und Dateien
In diesem Abschnitt dreht sich alles um den Zugriff auf das Dateisystem selbst, d.h. um die Ermittlung von Dateinamen, das Kopieren, Verschieben oder Löschen von Dateien, das Ermitteln der Datei-Attribute usw. Im Mittelpunkt stehen dabei vier Klassen: f File und FileInfo zum Bearbeiten von Dateien (löschen, kopieren, umbenennen) und zum Ermitteln von Informationen über eine Datei f Directory und DirectoryInfo zum Bearbeiten von Verzeichnissen und Ermitteln von Informationen über Verzeichnisse Wie bereits in der Übersicht angesprochen leisten diese Klassen weitgehend das gleiche, lediglich einmal als instanzierbare Klasse und einmal mithilfe statischer Elemente. Diese Zweiteilung macht durchaus Sinn. Es wäre ein Overhead, wenn zum Kopieren einer Datei erst einmal ein Objekt mit Bezug auf diese erzeugt werden müsste – stattdessen kann die statische Methode Copy() der Klasse File verwendet werden, um das Kopieren durchzuführen. Andererseits ist es notwendig, Objekte zur Verfügung stellen zu können, die Dateien repräsentieren, z.B. wenn ein Verzeichnis durchsucht wird. In diesem Fall wird eine instanzierbare Klasse benötigt. Welche der Methoden Sie letztendlich anwenden, ist Ihnen überlassen und vom Kontext abhängig. In diesem Abschnitt werden Sie einige Vorgehensweisen kennen lernen und erfahren, welche Art des Zugriffs die Autoren bevorzugen.
14.2.1
Datei- und Verzeichnisinformationen
Um Informationen über ein bekanntes Verzeichnis bzw. eine bekannte Datei zu ermitteln, wie z.B. das Datum der Erstellung oder auch die Größe einer Datei, genügen die Fähigkeiten der Klassen File bzw. Directory. Da der Name der Datei/des Verzeichnisses in der Regel bekannt ist, kann problemlos darauf zugegriffen werden.
Kontrolle, ob die Datei/das Verzeichnis existiert Bevor es an die Informationsermittlung geht, sollte zunächst kontrolliert werden, ob das Verzeichnis bzw. die Datei überhaupt existiert. Dazu dient die Methode Exists() der Klassen Directory und File: bool fileExists = File.Exists( "c:\\Text.txt" ); bool dirExists = Directory.Exists( "c:\\windows" );
Sandini Bib
Verzeichnisse und Dateien
321
Die Groß-/Kleinschreibung von Verzeichnisnamen ist dabei unerheblich, darauf achtet Windows bekanntlich nicht (im Gegensatz zu Unix, Linux oder Mac). Wichtig aber ist, dass das zu kontrollierende Verzeichnis ohne Backslash am Ende angegeben wird. Die beiden obigen Zeilen könnten auch folgendermaßen geschrieben werden: bool fileExists = File.Exists( @"c:\Text.txt" ); bool dirExists = Directory.Exists( @"c:\windows" );
HINWEIS
Der Unterschied besteht in der Behandlung des Backslash-Zeichens. Wie schon auf Seite 250 erläutert dient der Backslash dazu, Steuerzeichen in Strings einzuleiten, also als Escape-Character. Das ist bei einer Pfadangabe natürlich nicht erwünscht. Um dieses Verhalten zu verhindern gibt es zwei Möglichkeiten. Entweder, der Backslash wird doppelt geschrieben oder der String wird als Verbatim-String betrachtet, indem das @-Zeichen davor geschrieben wird. Das schaltet die Behandlung des Backslashs als Escape-Character aus. Wenn Sie den Dateinamen bzw. einen Verzeichnispfad in einer Textbox eingeben, gilt das mit den Backslashes natürlich nicht. Es wäre auch sehr unangenehm, wenn in jedem Programm die Backslashes doppelt angegeben werden müssten. C# erkennt den Backslash dann als normales Zeichen.
Alternativ funktioniert das natürlich auch mit einem DirectoryInfo- bzw. FileInfo-Objekt: FileInfo fi = new bool fileExists = // bzw. FileInfo fi = new bool fileExists =
FileInfo( @"c:\test.txt" ); fi.Exists; FileInfo( "c:\\test.txt" ); fi.Exists;
HINWEIS
Das neue FileInfo-Objekt wird in jedem Fall erzeugt, ob die Datei existiert oder nicht. Bevor Sie damit arbeiten, sollten Sie immer kontrollieren, ob die Datei wirklich existiert. Die bevorzugte Methode ist hier natürlich die statische Methode Exists() der Klassen File bzw. Directory. Falls das Verzeichnis oder die Datei nicht existiert, wurde dann nämlich nicht unnötigerweise ein Objekt erzeugt.
Zugriff auf die Eigenschaften einer Datei Zum Auslesen aller Eigenschaften einer Datei bietet sich das FileInfo-Objekt an, da es mehr Informationen liefert als File. Beispielsweise bietet diese Klasse keine Methode zum Ermitteln der Dateigröße. Bei anderen Informationen, z.B. der Ermittlung der Attribute, kann auch File verwendet werden. Oft ist es jedoch so, dass bei einem FileInfo-Objekt weniger Schreibarbeit anfällt. Die folgende Methode liefert einige Eigenschaften einer Datei in Form eines string-Arrays zurück. Übergeben wird nur der Dateiname.
Sandini Bib
322
14 Dateien und Verzeichnisse
public string[] GetFileInfo(string fileName) { // Dateiinformationen ermitteln string[] result = new string[4]; if (File.Exists(fileName)) { FileInfo fi = new FileInfo(fileName); result[0] = "Name: "+fi.FullName; result[1] = "Größe: "+fi.Length; result[2] = "Letzter Zugriff: "+fi.LastAccessTime.ToShortDateString(); result[3] = "Letzter Schreibzugriff: "+fi.LastWriteTime.ToShortDateString(); } return result; }
ACHTUNG
Das Ganze funktioniert natürlich auch mit einem DirectoryInfo-Objekt. Die Informationen über eine Datei werden bei der Erzeugung des FileInfo-Objekts eingelesen und danach nicht mehr verändert. Daher kann es vorkommen, dass die Attribute einer Datei sich ändern, dass die Datei umbenannt wird und sich ihre Größe ändert (z.B. wenn Sie eine Word-Datei überprüfen und Word im Hintergrund eine automatische Sicherung durchführt). Sie sollten daher, wenn das FileInfo-Objekt länger existiert, vor jedem Zugriff die Methode Refresh() ausführen. Das bringt das FileInfo-Objekt wieder auf den neuesten Stand.
Zugriff auf Attribute einer Datei/eines Verzeichnisses Die Attribute einer Datei bzw. eines Verzeichnisses sind als Bitfeld in der Eigenschaft Attributes des FileInfo-/DirectoryInfo-Objekts abgelegt, der zugrunde liegende Datentyp ist FileAttributes aus dem Namespace System.IO. Die Kontrolle dieser Attribute kann daher nicht auf herkömmliche Art geschehen, da mehrere Werte der Aufzählung gleichzeitig zugewiesen sein können. Sie müssen die Kontrolle auf ein bestimmtes Attribut daher mit einer und-Verknüpfung durchführen: FileInfo fi = new FileInfo( fileName ); if ( ( fi.Attributes & FileAttributes.Compressed ) == FileAttributes.Compressed ) { // Attribut "Compressed" ist gesetzt } else { // Attribut "Compressed" ist nicht gesetzt }
Die Methode ToString() der Eigenschaft Attributes liefert eine Zeichenkette, in der die gesetzten Attribute hintereinander gereiht sind: Console.WriteLine( fi.Attributes.ToString() );
Sandini Bib
Verzeichnisse und Dateien
14.2.2
323
Ermittlung von Dateien in einem Verzeichnis
Oftmals ist der direkte Zugriff auf eine Datei nicht das, was Sie benötigen. Eine häufige Anwendung ist beispielsweise das Auflisten aller Dateien in einem Verzeichnis, das Auflisten der Unterverzeichnisse oder sogar die Suche nach Dateien in allen Unterverzeichnissen eines Verzeichnisses.
Dateien eines Verzeichnisses ermitteln Die Methode GetFiles() der Klasse DirectoryInfo liefert alle enthaltenen Dateien in Form eines Arrays aus FileInfo-Objekten zurück. Ohne Parameter aufgerufen liefert diese Methode alle enthaltenen Dateien zurück, Sie können aber auch ein Suchmuster angeben. Dieses folgt den gleichen Regeln wie die Suche in Windows: DirectoryInfo di = new DirectoryInfo( "C:\\windows" ); FileInfo[] fi1 = di.GetFiles( "*.txt" ); // liefert alle Dateien mit der Endung txt fileInfo[] fi2 = di.GetFiles( "w*" ); // liefert alle Dateien, die mit "w" beginnen
Neu hinzugekommen ist ein Parameter, mit dem Sie angeben können, ob auch die Unterverzeichnisse in die Suche mit eingebunden werden sollen. Das erleichtert das Auffinden von Dateien enorm. In der Version 1.1 des .NET Frameworks musste hierzu noch eine üblicherweise rekursiv ausgeführte Methode verwendet werden. Das folgende Beispielprogramm liefert eine Liste aller Dateien eines vorgegebenen Verzeichnisses zurück, falls gewünscht inklusive der Dateien in den Unterverzeichnissen. Das Ergebnis wird in einer generischen Liste vom Typ FileInfo gespeichert.
CD
Eine solche Liste macht nur Sinn, wenn sie auch sortierbar ist. Da weder FileInfo noch seine Basisklasse FileSystemInfo das Interface IComparable implementieren, müssen Sie auf eigene Funktionalität zurückgreifen. Die Klasse FileComparer implementiert den benötigten Vergleichsalgorithmus. Schon jetzt sehen Sie, wie häufig generische Bestandteile des .NET Frameworks Einsatz finden können. Auch in FileComparer wird das generische Interface Comparer an Stelle seinen nicht-generischen Pendants verwendet. Die Art der Sortierung wird durch eine Enumeration namens SortingType angegeben. Damit kann nicht nur nach dem Namen, sondern nach verschiedenen gewünschten Kriterien sortiert werden. Das gesamte Bespielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\ListFiles.
Zunächst die Deklaration der Aufzählung SortingType, deren Werte für die Art der Sortierung stehen. Sortiert werden kann auf- und absteigend nach Dateiname, Länge und Dateityp. Die Auswahl erfolgt später über eine Combobox, in die der Einfachheit halber die Namen der SortingType-Konstanten eingetragen werden.
Sandini Bib
324
14 Dateien und Verzeichnisse
public enum SortingType { ByNameAscending, ByLengthAscending, ByTypeAscending, ByNameDescending, ByLengthDescending, ByTypeDescending }
Die eigentlich für die Sortierung zuständige Klasse FileSorter implementiert das Interface IComparer und somit die Methode Compare(). Der generische Typ T wird durch den realen Typ FileInfo ersetzt. Wie sortiert wird, wird durch eine Instanzvariable sortType festgelegt, die im Konstruktor initialisiert wird. public class FileSorter : IComparer { private SortingType sortType; public int Compare( FileInfo x, FileInfo y ) { // Vergleichsmethode string ext1 = Path.GetExtension( x.FullName ); string ext2 = Path.GetExtension( y.FullName ); switch ( this.sortType ) { case SortingType.ByNameAscending: return x.Name.CompareTo( y.Name ); case SortingType.ByLengthAscending: return ( x.Length < y.Length ) ? -1 : 1; case SortingType.ByTypeAscending: return ext1.CompareTo( ext2 ); case SortingType.ByNameDescending: return -( x.Name.CompareTo( y.Name ) ); case SortingType.ByLengthDescending: return ( x.Length > y.Length ) ? -1 : 1; case SortingType.ByTypeDescending: return -( ext1.CompareTo( ext2 ) ); } return 0; } public FileSorter( SortingType sortType ) { this.sortType = sortType; } }
Sandini Bib
Verzeichnisse und Dateien
325
Eine Anmerkung zu der Art und Weise des Vergleichs: Wenn Strings verglichen werden, wird einfach die CompareTo()-Methode von String verwendet. Diese sortiert grundsätzlich aufsteigend. Da der zurückgelieferte Wert ein Zahlenwert ist, kann durch einfache Negation des Rückgabewerts eine absteigende Sortierreihenfolge realisiert werden. Nun fehlen noch die Methoden des Hauptformulars. Aus Platzgründen werden wieder nur die wichtigsten Methoden abgedruckt. Die folgende Funktionalität ist implementiert: f Die Art der Sortierung wird über die ComboBox cbxSort festgelegt. Ihr Ihnalt wird ausgewertet und ein entsprechendes FileSorter-Objekt erzeugt, das der Sortierroutine übergeben wird. f Über eine Checkbox namens chkIncludeSubFolders kann angegeben werden, ob die Unterverzeichnisse in das Ergebnis mit einbezogen werden sollen. f In der TextBox txtPattern kann ein Suchmuster nach altbekannter Art angegeben werden. Die einzelnen Suchpatterns müssen durch Semikola getrennt sein und werden automatisch gesplittet. f Die Anzeige der ermittelten Dateien erfolgt in der ListBox lbxResult. Das Suchmuster muss zunächst aufgesplittet werden, denn nach jedem Muster wird einzeln gesucht. Das erledigt eine kleine Methode. private string[] SplitPatterns() { string patterns = this.txtPattern.Text; return patterns.Split( ';' ); }
Die eigentliche Suche wird in der Methode GetFiles() durchgeführt. Der Quelltext sollte nicht viele Erklärungen benötigen. private void GetFiles() { // Dateien ermitteln string folder = this.txtSource.Text; if ( this.txtSource.Text.Equals( String.Empty ) || !Directory.Exists( folder ) ) return; this.lblFileCount.Text = "-"; this.Cursor = Cursors.WaitCursor; string[] patterns = SplitPatterns(); // Einstellungen übernehmen SortingType sorting = (SortingType)Enum.Parse( typeof( SortingType ), this.cbxSortType.Text, true ); FileSorter sorter = new FileSorter( sorting ); SearchOption option = this.chkIncludeSubFolders.Checked ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
Sandini Bib
326
14 Dateien und Verzeichnisse
DirectoryInfo folderInfo = new DirectoryInfo( folder ); List fileList = new List(); foreach ( string pattern in patterns ) { FileInfo[] files = folderInfo.GetFiles( pattern, option ); foreach ( FileInfo file in files ) fileList.Add( file ); } // Sortieren fileList.Sort( sorter ); lstFiles.Items.AddRange( fileList.ToArray() ); this.lblFileCount.Text = String.Format( "{0} Dateien gefunden", fileList.Count ); this.Cursor = Cursors.Default; }
Das Label lblFileCount zeigt nocht die Anzahl der gefundenen Dateien an. Der Parameter SearchOption gibt an, ob die Unterverzeichnisse mit einbezogen werden sollen oder nicht. Leider wurde dieser nicht als boolescher Parameter implementiert (möglicherweise werden in Zukunft also weitere Möglichkeiten hinzukommen – ich denke dabei vor allem an Windows Vista). Abbildung 14.1 zeigt einen Screenshot des Programms mit den gefundenen Dateien.
Abbildung 14.1: In den bisher von mir geschriebenen Büchern befinden sich 4655 Grafiken … und dabei bin ich mit diesem noch gar nicht fertig …
Sandini Bib
Verzeichnisse und Dateien
14.2.3
327
Manipulation von Dateien und Verzeichnissen
Der Zugriff auf die Informationen einer Datei ist natürlich nicht alles. Dateien müssen verschoben, gelöscht oder erstellt werden, ebenso wie Verzeichnisse. Sowohl die Klassen File/Directory als auch FileInfo/DirectoryInfo bieten für alle diese Vorgänge Methoden an. Für welche Sie sich entscheiden, hängt wieder vom Kontext ab. Wenn es nur um das reine Kopieren einer Datei oder das Erstellen eines Verzeichnisses geht, sind die entsprechenden Methoden von File/Directory günstiger. Beim Erstellen einer Datei oder eines Verzeichnisses liefern diese direkt ein entsprechendes FileInfo- bzw. DirectoryInfo-Objekt zurück. Existiert aber bereits ein FileInfo-Objekt, beispielsweise wenn eine Datei kopiert werden soll, kann dieses auch gleich verwendet werden.
Dateien und Verzeichnisse erstellen Um ein Verzeichnis zu erstellen genügt ein Aufruf der Methode CreateDirectory() der Klasse Directory. Unterverzeichnisse werden automatisch mit erstellt. Backslashes müssen wie auch bei den anderen Dateinamen doppelt angegeben werden, mittlerweile sollte diese Eigenart von C# bekannt sein. DirectoryInfo di = Directory.CreateDirectory( "C:\\Verzeichnis1\\Verzeichnis2" );
Wenn Sie das zurückgelieferte DirectoryInfo-Objekt nicht benötigen, können Sie es im Nirvana verschwinden lassen, indem Sie keine Zuweisung durchführen. Directory.CreateDirectory( "C:\\Verzeichnis1\\Verzeichnis2" );
Das Erzeugen einer Datei ist genauso einfach, jedoch gibt es hier gleich mehrere Möglichkeiten. Die erste Möglichkeit ist die Erzeugung mithilfe der Methode Create() der Klasse File. Diese liefert ein FileStream-Objekt zurück, sodass Sie gleich mit der Datei arbeiten können. Wollen Sie eine Textdatei erzeugen, verwenden Sie File.CreateText(). In diesem Fall wird ein StreamWriter-Objekt zurückgeliefert, das Ihnen ermöglicht, direkt in die Datei zu schreiben. Die Methode Open() ermöglicht ebenfalls das Erstellen einer Datei. Open() erwartet als Parameter nicht nur den Dateinamen, sondern auch einen Dateimodus in Form einer Konstante der Aufzählung FileMode. Auch hier gibt es Konstanten, die eine automatische Erstellung der Datei ermöglichen und sie gleichzeitig öffnen. Zurückgeliefert wird ein FileStream-Objekt. FileStream fs = File.Create( "C:\\Testfile.dat" ); StreamWriter sw = File.Create( "C:\\Testfile.txt" ); FileStream fs = File.Open( "C:\\testFile.dat", FileMode.OpenOrCreate );
Wenn Sie die Datei nur erstellen und später damit arbeiten wollen, sollten Sie sie durch einen Aufruf von Close() sofort nach der Erzeugung wieder schließen. Das funktioniert auf zwei Arten. Sie können das zurückgelieferte Objekt verwenden oder Close() direkt an den Aufruf der Create()-Methode anhängen (da diese ein FileStream-Objekt zurückliefert, repräsentiert sie auch ein solches – was auch IntelliSense beweist, wenn Sie den Punkt eingeben. Dann werden nämlich die Member von FileStream angezeigt).
Sandini Bib
328
14 Dateien und Verzeichnisse
FileStream fs = File.Create( "C:\\Testfile.dat" ); fs.Close();
VERWEIS
// oder mit weniger Schreibaufwand: File.Create( "C:\\Testfile.dat" ).Close();
Die Methoden Create() und Open() öffnen bzw. erstellen und öffnen eine Datei im exklusiven Zugriffsmodus. Bei Create() ist das immer so, bei Open() lässt es sich beeinflussen. Der exklusive Modus bedeutet, dass es einem anderen Programm nicht möglich ist, auf die Datei zuzugreifen, weder lesend noch schreibend. CreateText() ist da etwas liberaler und lässt einen parallelen Lesezugriff zu, nicht aber einen Schreibzugriff. Wenn Sie explizit angeben möchten, ob andere Programme auf die geöffnete oder neu erstellte Datei zugreifen dürfen, müssen Sie zum Öffnen/Erstellen die Methode Open() mit allen vier möglichen Parametern verwenden: File.Open( string fileName, FileMode mode, FileAccess access, FileShare share )
Genaue Informationen zu den Open()-Parametern finden Sie in Abschnitt 14.5.1 ab Seite 376 im Zusammenhang mit der Klasse FileStream. Möchten Sie eine Textdatei nur zum Lesen öffnen (die dazu allerdings existieren muss), können Sie auch die Methode OpenText() der Klasse File verwenden. Diese Methode liefert ein StreamReader-Objekt zurück. Im Falle von binären Dateien verwenden Sie hierzu OpenRead(), zurückgeliefert wird in diesem Fall ein FileStream-Objekt. Sollten Sie einen Text gleich komplett einlesen wollen, ist der Umweg über einen StreamReader ab .NET 2.0 nicht mehr nötig. Die Klasse File bietet bereits entsprechende Methoden zum Lesen und auch Schreiben von Textdateien (ReadAllText() bzw. WriteAllText()).
Dateien kopieren, verschieben, umbenennen und löschen Zum Kopieren einer Datei können Sie entweder die Methode Copy() der Klasse File oder die Methode CopyTo() der Klasse FileInfo verwenden. Entsprechend verschieben die beiden Methoden Move() bzw. MoveTo() die Datei an einen anderen Ort. Das Zielverzeichnis muss beim Kopier-/Verschiebevorgang existieren und es muss immer der komplette Dateiname angegeben werden. Wenn Sie nur ein Verzeichnis angeben, erhalten Sie eine IOException. Wenn Sie kein Verzeichnis oder ein relatives Verzeichnis angeben (also nicht den kompletten Pfad inkl. Laufwerksbezeichnung), wird das aktuelle Arbeitsverzeichnis als Basis genommen. Der relative Pfad bezieht sich dann auf dieses Verzeichnis. In der Regel handelt es sich dabei um das Verzeichnis, aus dem das Programm gestartet wurde. Darauf können Sie sich aber nicht verlassen, da es sich um unterschiedliche Einstellungen handelt. Das aktuelle Arbeitsverzeichnis erhalten Sie über Directory.GetCurrentDirectory() bzw. die Eigenschaft CurrentDirectory der Klasse DirectoryInfo.
Sandini Bib
Verzeichnisse und Dateien
329
Die Methoden Move() bzw. MoveTo() dienen übrigens auch zum Umbenennen einer Datei. Geben Sie dazu einfach einen anderen Dateinamen, aber das gleiche Verzeichnis an.
Verzeichnisse kopieren, verschieben, umbenennen und löschen Für Verzeichnisse existiert zwar eine Möglichkeit, sie zu verschieben und auch sie zu löschen, allerdings unverständlicherweise keine Möglichkeit, sie zu kopieren. Da fragt man sich, warum das so ist. Das Verschieben, Umbenennen oder Löschen funktioniert analog zu den entsprechenden Methoden für Dateien, eine Kopierfunktion ist aber nicht vorgesehen.
CD
Die im Folgenden vorgestellte Methode dient genau dazu – ein Verzeichnis zu kopieren. Das Zielverzeichnis muss nicht existieren. Beachten Sie, dass hierbei sämtliche Dateien und die gesamte Verzeichnisstruktur unterhalb des Quellverzeichnisses in das Zielverzeichnis kopiert werden. Die übergeordneten Verzeichnisse sind nicht von Belang. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\CopyDirectories.
Die Methode erwartet als Parameter ein DirectoryInfo-Objekt, das das Quellverzeichnis respräsentiert, und das Zielverzeichnis in Form eines Strings. Die Methode selbst ist wieder eine rekursive Methode. Das Quellverzeichnis wird durchlaufen, alle Dateien werden kopiert, danach wird ein neuer Ziel-String zusammengebaut und die Unterverzeichnisse werden kopiert. Bei Erfolg liefert die Methode true zurück. private bool CopyFolder( DirectoryInfo source, string dest ) { // Kopieren eines gesamten Verzeichnisses bool result = true; this.Cursor = Cursors.WaitCursor; if ( !Directory.Exists( dest ) ) Directory.CreateDirectory( dest ); // Kopieren der Dateien foreach ( FileInfo fi in source.GetFiles() ) { // Jede Datei kopieren string destFile = dest + @"\" + fi.Name; result = result && ( fi.CopyTo( destFile, false ) != null ); } // Unterverzeichnisse bearbeiten foreach ( DirectoryInfo subDir in source.GetDirectories() ) { string destTmp = subDir.FullName.Replace( source.FullName, dest ); result = result && CopyFolder( subDir, destTmp ); }
Sandini Bib
330
14 Dateien und Verzeichnisse
this.Cursor = Cursors.Default; return result; }
Dateien und Verzeichnisse in den Papierkorb verschieben Die Methode Delete() einer der Klassen File oder FileInfo löschen eine Datei unwiderruflich, ohne den Umweg über den Papierkorb. Das ist natürlich nicht immer erwünscht. Um einer zu löschenden Datei doch noch den Weg in den Papierkorb zu zeigen, müssen wir auf das Windows-API zurückgreifen, genauer gesagt auf die Funktion SHFileOperation(). Dazu wird eine Technik namens P/Invoke (Platform Invoke) verwendet, die noch nicht besprochen wurde. Genaueres über die Funktionsweise dieser Technik erfahren Sie in Abschnitt 24.2 ab Seite 878. Grundlegend geht es einfach darum, dass mittels P/Invoke die Funktionen des WindowsAPI aufgerufen werden können. Die Datentypen des API stimmen leider nicht immer mit den vom .NET Framework bereitgestellten Datentypen überein, daher muss eine Art Konvertierung stattfinden. Die verwalteten Datentypen müssen in einen unverwalteten Datentyp überführt werden. Diesen Vorgang bezeichnet man auch als Marshalling. Verwendet wird hierzu das Attribut MarshalAs. Da eine Betriebssystemfunktion aufgerufen wird, erhalten Sie auch einen Dialog, mit dem Sie vor dem eigentlichen Löschen nochmals abbrechen können. Außerdem müssen Sie, damit das Beispiel funktioniert, den Namespace System.Runtime.InteropServices einbinden. Die Funktionsdeklaration sieht folgendermaßen aus: [DllImport("shell32.dll")] public static extern int SHFileOperation( [MarshalAs(UnmanagedType.Struct)] ref SHFILEOPSTRUCT lpFileOp);
Angegeben wird hierbei, dass es sich um eine externe Funktion der DLL shell32.dll handelt, mit Namen SHFileOperation() und einem Übergabeparameter vom Typ SHFILEOPSTRUCT, der als Referenz übergeben wird. Zurückgeliefert wird ein int-Wert. Die hier angegebene Funktion ist nicht nur für das Löschen, sondern allgemein für mehrere Operationen zuständig, z.B. Kopieren von Dateien und Verzeichnissen, Verschieben von Dateien und Verzeichnissen usw. Die für die jeweilige Operation benötigten Parameter werden mittels einer Struktur übergeben, die folgenden Aufbau hat: [StructLayout(LayoutKind.Sequential, Pack=1)] public struct SHFILEOPSTRUCT { public int hWnd = 0; public int wFunc = 0 ; public string pFrom = ""; public string pTo = ""; public short fFlags = 0; public bool fAborted = false; public int hNameMaps = 0; public string sProgress = ""; }
Sandini Bib
Verzeichnisse und Dateien
331
Das Attribut StructLayout ist notwendig, um dem Compiler mitzuteilen, dass er an der Reihenfolge der Strukturelemente nichts ändern soll. Dieses Attribut ist ebenfalls in System.Runtime.InteropServices deklariert. Die eigentliche Funktion zum Löschen besteht im Prinzip nur aus dem Zuweisen der Werte an die Struktur und dem Aufruf der Methode SHFileOperation(). Zwei Konstanten sind dazu noch notwendig, die als geschützte Member des Formulars deklariert sind: protected const int FO_DELETE = 0x0003; protected const int FOF_ALLOWUNDO = 0x0040;
Die eigentliche Methode sieht dann folgendermaßen aus: private void RecycleBin(params string[] files) { string charDoubleZero = ""+(char)0+(char)0; string charZero = ""+(char)0; SHFILEOPSTRUCT shf = new SHFILEOPSTRUCT(); shf.hWnd = 0; shf.wFunc = FO_DELETE; shf.pFrom = String.Join(charZero,files)+charDoubleZero; shf.fFlags = FOF_ALLOWUNDO; SHFileOperation(ref shf); }
CD
Das Zusammenfügen der zu löschenden Dateien mit einem Nullbyte als Trenner ist in der Funktionsweise der Funktion begründet, die mehrere Dateien oder Verzeichnisse eben mit einem Nullbyte als Trennzeichen erwartet. Abgeschlossen wird der gesamte String dann mit einem doppelten Nullbyte. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\Papierkorb.
Zugriffsrechte von Dateien und Verzeichnissen unter NTFS Die Zugriffsrechte auf einem Laufwerk mit dem Dateisystem NTFS (also Windows 2000 oder Windows XP) können wesentlich genauer eingestellt werden, als das bei dem von Windows98 oder ME verwendeten FAT32-Dateisystem der Fall ist. Die dazu erforderlichen Klassen und Methoden befinden sich in den Namespaces System.Security und System.Security.Permissions. Da das Festlegen von Zugriffsrechten aber eher für Administratoren interessant ist, verweise ich hier auf die Online-Hilfe.
Sandini Bib
332
14.2.4
14 Dateien und Verzeichnisse
Verzeichnisse, Dateien und Laufwerke ermitteln
Das aktuelle Verzeichnis ermitteln Das aktuelle Verzeichnis erhalten Sie über die Klasse Environment, die mehrere nützliche statische Methoden und Eigenschaften zur Verfügung stellt. In diesem Fall handelt es sich um die Eigenschaft CurrentDirectory: string currDir = Environment.CurrentDirectory;
Ein anderer Weg ist der über ein DirectoryInfo-Objekt, das einfach mit einem Punkt initialisiert wird. Der Punkt gibt immer das aktuelle Verzeichnis, zwei Punkte das übergeordnete Verzeichnis an (alles noch wie unter MS-DOS): string currDir = new DirectoryInfo( "." ).FullName;
Eine weitere Möglichkeit bietet die Klasse Directory mit der Methode GetCurrentDirectory(): string currDir = Directory.GetCurrentDirectory();
Zum Ändern des aktuellen Verzeichnisses können Sie analog die Methode SetCurrentDirectory() verwenden. Diese Methode kommt sowohl mit lokalen Verzeichnissen als auch mit Netzwerkpfaden zurecht. Directory.SetCurrentDirectory( @"c:\test" );
Ermitteln des Programmverzeichnisses Das Verzeichnis, in dem sich die ausführbare Datei des Programms befindet, stimmt nicht zwangsläufig mit dem aktuellen Verzeichnis, das auch als Arbeitsverzeichnis bezeichnet wird, überein. Häufig ist es so, aber nicht immer. Zur Ermittlung des Programmverzeichnisses bieten sich mehrere Wege an. Falls es sich bei Ihrer Applikation um eine Windows-Applikation handelt, führt der einfachste Weg über das Application-Objekt. Dabei handelt es sich um ein so genanntes Intrinsic Object, also ein Objekt, das auch ohne Instanzierung ständig vorhanden ist. Die Klasse Application ist im Namespace System.Windows.Forms deklariert. Die Eigenschaft Application.StartupPath ruft den Pfad für die Datei ab, die das aktuelle Programm gestartet hat. Das sollte normalerweise zum gewüschten Ergebnis führen. string startupPath = Application.StartupPath;
Sollte Ihre Anwendung allerdings aus einer anderen Anwendung heraus aufgerufen werden, kann es sein, dass mit dieser Eigenschaft deren Startverzeichnis zurückgeliefert wird. Sicher gehen können Sie, indem Sie die Eigenschaft ExeName verwenden, aus der Sie allerdings noch den Pfad extrahieren müssen. ExeName enthält sowohl den kompletten Pfad als auch den Namen der ausführbaren Datei. string startPath = Path.GetDirectoryName( Application.ExeName );
Sandini Bib
Verzeichnisse und Dateien
333
Eine weitere Möglichkeit besteht in der Verwendung des Namespace System.Reflection. Dieser soll hier nur angerissen werden – Reflection ist ein recht umfangreiches Thema, mit dieser Technologie können Informationen über Datentypen ermittelt, Methoden aufgerufen oder sogar ganze Programme zur Laufzeit geschrieben werden. An dieser Stelle interessiert uns aber nur die ausführende Assembly, die wir über Assembly.GetExecutingAssembly() ermitteln können. Die Eigenschaft Location liefert den Speicherort. // Namespace System.Reflection muss eingebunden sein string startPath = Assembly.GetExecutingAssembly().Location;
Diese Vorgehensweise funktioniert auch bei Konsolenanwendungen.
Ermitteln des temporären Verzeichnisses Wie beim Programmverzeichnis gibt es auch für das temporäre Verzeichnis mehrere Möglichkeiten der Ermittlung. Eine Möglichkeit besteht über die statische Methode GetTempPath() der Klasse Path. string tempDir = Path.GetTempPath();
Eine weitere Möglichkeit ist die Auswertung der entsprechenden Umgebungsvariable über die Methode GetEnvironmentVariable() der Klasse Environment. string tempDir = Environment.GetEnvironmentVariable( "Temp" );
Den Namen einer temporären Datei können Sie auf folgende Weise ermitteln: string tmpFile = Path.GetTempFileName();
Dabei wird sowohl der Name zurückgeliefert als auch eine entsprechende Datei mit einer Größe von 0 Bytes auf dem Datenträger erzeugt.
Ermittlung spezieller Verzeichnisse Spezielle Verzeichnisse, z.B. das Systemverzeichnis oder auch das Autostart-Verzeichnis des aktuellen Benutzers, können ebenfalls relativ leicht ermittelt werden. Die Klasse Environment stellt hierfür die Methode GetFolderPath() zur Verfügung, die als Parameter einen Wert vom Typ Environment.SpecialFolder erwartet. Die Aufzählung SpecialFolder ist also innerhalb der Klasse Environment deklariert. Man fragt sich, warum das so ist, aber es wird sich schon jemand etwas dabei gedacht haben. Eine Referenz über die Einträge von Environment.SpecialFolder finden Sie in der Syntaxzusammenfassung am Ende des Abschnitts. Eine Übersicht über die verschiedenen zurückgelieferten Werte gibt folgender kurzer Code: Environment.SpecialFolder es; foreach ( string s in Enum.GetNames( typeof( Environment.SpecialFolder ) ) ) { es = (Environment.SpecialFolder)Enum.Parse( typeof( Environment.SpecialFolder ), s ); Console.WriteLine( "{0} : {1}", s, Environment.GetFolderPath( es ) ); }
Sandini Bib
334
14 Dateien und Verzeichnisse
Das Windows-Verzeichnis ist in dieser Auflistung nicht enthalten, wohl aber das Windows-Systemverzeichnis. Das ist umso verwunderlicher, weil das Systemverzeichnis einfacher über die Eigenschaft SystemDirectory in Environment ermittelt werden kann. Für die Ermittlung des Windows-Verzeichnisses muss entweder wieder die Methode GetEnvironmentVariable() von Environment herhalten oder Sie erzeugen ein neues DirectoryInfoObjekt mit dem Systemverzeichnis als Ziel und ermitteln das Windows-Verzeichnis über die Eigenschaft Parent: string winDir = Environment.GetEnvironmentVariable( "windir" ); string winDir = new DirectoryInfo( Environment.SystemDirectory ).Parent.FullName;
Verfügbare Laufwerke ermitteln
CD
Mit .NET 2.0 kommt endlich eine Klasse, die auch die Ermittlung von Laufwerken und Laufwerksinformationen erlaubt. Bislang musste dazu auf Betriebssystemfunktionen zurückgegriffen werden. Die neue Klasse DriveInfo liefert alle Informationen sozusagen »auf Knopfdruck«. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\ListDrives.
Über die statische Methode GetDrives() von DriveInfo können alle angeschlossenen Laufwerke ermittelt werden. Die Methode liefert ein Array aus DriveInfo-Objekten zurück. Weitergehende Informationen erhalten Sie allerdings nur, wenn das entsprechende Laufwerk auch bereit ist. Diese Information liefert die Eigenschaft IsReady. Die folgende Methode listet alle verfügbaren Laufwerke, falls möglich mit Detaildaten, auf. private void ListDrives() { // Laufwerke ermitteln DriveInfo[] drives = DriveInfo.GetDrives(); StringBuilder builder = new StringBuilder(); builder.AppendLine( "Laufwerke des Systems" ); builder.AppendLine( "---------------------" ); builder.AppendLine(); foreach ( DriveInfo drive if ( drive.IsReady ) { builder.AppendFormat( builder.AppendFormat( builder.AppendFormat( builder.AppendFormat( builder.AppendFormat( builder.AppendLine();
in drives ) { "Laufwerk: "Freier Platz: "Gesamtgröße: "Dateiformat: "Laufwerkstyp:
{0}\r\n", drive.Name ); {0} Bytes\r\n", drive.TotalFreeSpace ); {0} Bytes\r\n", drive.TotalSize ); {0}\r\n", drive.DriveFormat ); {0}\r\n", drive.DriveType );
Sandini Bib
Verzeichnisse und Dateien } else { builder.AppendFormat( "Laufwerk: builder.AppendLine(); }
335
{0}: nicht bereit\r\n", drive.Name );
} this.txtDrives.Text = builder.ToString(); }
Abbildung 14.2 zeigt das Programm zur Laufzeit auf dem Entwicklungsrechner.
Abbildung 14.2: Alle verfügbaren Laufwerke des Entwicklungsrechners
14.2.5
Datei- und Verzeichnisnamen bearbeiten
Die Klasse Path haben wir bereits kurz verwendet. Der Name dieser Klasse, die in System.IO deklariert ist, suggeriert, dass sie etwas mit dem Dateipfad zu tun hat. Sie enthält allerdings auch einige Methoden zur Bearbeitung von Dateinamen, denn der Dateiname ist laut Konvention Bestandteil des Pfads. Anhand eines Beispielpfades wird gezeigt, welche Informationen die einzelnen Methoden zurückliefern: string string string string string string
basePath = @"d:\Buecher\CSharp\cs.doc"; filename1 = Path.GetFileName( basePath ); // "cs.doc" fileName2 = Path.GetFileNameWithoutExtension( basePath ); // "cs" driveName = Path.GetPathRoot( basePath ); // "d:\" dirName = Path.GetDirectoryName( basePath) ; // "d:\Buecher\CSharp" extension = Path.GetExtension( basePath ); // ".doc"
Sandini Bib
336
14 Dateien und Verzeichnisse
Die Methode GetFullPath() ist sinnvoll, wenn Sie nur eine relative Pfadangabe oder nur den Dateinamen zur Verfügung haben, aber den gesamten Pfad benötigen. Ob eine Pfadangabe komplett ist, können Sie mit der Methode IsPathRooted() kontrollieren. Das Vorhandensein des angegebenen Pfads oder Dateinamens wird dabei allerdings nicht kontrolliert. Ausgangspunkt der Pfadermittlung ist das aktuelle Arbeitsverzeichnis (es wird also nicht auf der Festplatte nach dem Dateinamen gesucht). bool isRooted = Path.IsPathRooted( basePath );
Eine weitere nützliche Methode ist ChangeExtension(). Sie ändert die Endung eines Dateinamens, allerdings nur des Strings, der den Namen enthält. Um die Datei auch physikalisch auf der Festplatte umzubenennen, müssen Sie File.Move() oder die Methode MoveTo() von FileInfo verwenden, zum Erstellen einer Sicherheitskopie entsprechend File.Copy() oder CopyTo(). string basePath = @"d:\Buchprojekte\CSharp\cs.doc"; File.Copy(basePath, Path.ChangeExtension( basePath, ".bak" );
Weiterhin verfügt Path über einige Eigenschaften z.B. zur Angabe der LaufwerksTrennzeichen (VolumeSeparatorChar), der Pfadtrenner (PathSeparator) oder ungültiger Zeichen für Dateinamen bzw. Pfade (InvalidPathChars). Eine komplette Übersicht finden Sie in der Syntaxzusammenfassung am Ende des Abschnitts.
14.2.6
Beispielanwendung Backup
Backups sind wichtig, vor allem, wenn man ein Buch schreibt. Nicht auszudenken, wenn beispielsweise bei 1000 geschriebenen Seiten plötzlich eine defekte Festplatte das Weiterschreiben verhindert bzw. alle Daten verloren wären. Das darf natürlich nicht passieren. Eine Möglichkeit, die recht schnell zu realisieren ist, ist das automatische Kopieren der Word-Datei unter einem anderen Namen in ein bestimmtes Verzeichnis einer anderen Festplatte oder sogar zu einem anderen Computer. Da im Falle dieses Buchs aber auch zahlreiche Beispielprogramme gefährdet sind (die ich beim besten Willen nicht alle hätte neu programmieren wollen), wurde hier eine andere Strategie verwendet. Es wird das komplette Verzeichnis, in dem sich das Buchprojekt befindet, in ein anderes Verzeichnis auf einer anderen Festplatte kopiert. Das geschieht täglich. Somit sind alle Grafiken und alle Programme gesichert und auf dem neuesten Stand. Das Zielverzeichnis bekommt als Zusatz zum Namen das aktuelle Datum und die aktuelle Zeit, sodass es kein Problem ist, auch mehrmals am Tag ein Backup durchzuführen. Natürlich handelt es sich um ein Windows-Programm, um auch den nötigen Komfort bereit zu stellen. Die Routine zum Kopieren ganzer Verzeichnisse wurde bereits auf Seite 329 vorgestellt. Sie wird natürlich auch in diesem Beispielprojekt Verwendung finden, wozu auch das Rad neu erfinden. Die Oberfläche des Programms besteht aus zwei Textboxen, die die Verzeichnisse beinhalten, einmal das Quellverzeichnis und einmal das Zielverzeichnis. Die Verzeichnisse werden über einen Dialog ausgewählt. Weiterhin sind noch ein paar Labels für diverse Anzeigen und eine Progressbar enthalten, mit der der Verlauf des Kopiervorgangs dargestellt werden soll. Das Hauptformular der Anwendung sehen Sie in Abbildung 14.3.
Sandini Bib
Verzeichnisse und Dateien
337
Abbildung 14.3: Das Hauptformular zur Entwurfszeit
Das Fenster soll im »normalen« Zustand kleiner dargestellt werden, erst wenn kopiert wird, wird das Fenster »aufgeklappt« und die gesamte Oberfläche kommt zum Vorschein. Die Höhe des Formulars wird daher beim Start der Aplplikation (genauer gesagt im Ereignis Form_Load()) auf 190 Pixel eingestellt. Die Gesamthöhe beträgt 330 Pixel. Auf sie wird das Formular während des Backups vergrößert. Die Anzeige des Fortschritts erledigt ein ProgressBar-Steuerelement. Obwohl noch nicht vorgestellt ist es sehr leicht zu handhaben und sollte keine Probleme verursachen. Mehr Informationen zu diesem Steuerelement finden Sie in Abschnitt 18.8.3 ab Seite 601.
CD
Beachten Sie bitte auch, dass das Programm nur rudimentär gegen Fehler abgesichert ist. Um kritische Dateien tagesweise zu sichern, ist es aber allemal gut geeignet. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\Backup.
Deklaration der Ereignisbestandteile Der Kopiervorgang wird in einer eigenen Klasse durchgeführt. Für jede Datei, die kopiert wurde, soll dazu ein Ereignis ausgelöst werden. Da hier auch der Name der kopierten Datei übergeben werden soll, muss eine eigene EventArgs-Klasse erstellt werden. public class FileCopyEventArgs : EventArgs { private FileInfo copiedFile; // Die kopierte Datei private string destination; // Das Ziel des Kopiervorgangs private long fileSize;
Sandini Bib
338
14 Dateien und Verzeichnisse
public FileInfo CopiedFile { get { return this.copiedFile; } } public string Destination { get { return this.destination; } } public long FileSize { get { return this.fileSize; } } public FileCopyEventArgs( FileInfo copiedFile, string dest ) { this.copiedFile = copiedFile; this.fileSize = copiedFile.Length; this.destination = dest; } }
Im Programm wird die Eigenschaft Destination nicht verwendet, lediglich CopiedFile und FileSize finden Verwendung. Der Delegate für das Ereignis ist schnell deklariert. Das Ereignis soll FileCopy heißen, entsprechend der Konvention für Ereignisnamen ergibt sich damit folgende Delegate-Deklaration: public delegate void FileCopyEventHandler( object sender, FileCopyEventArgs e );
Die Klasse für das eigentliche Backup – der BackupMaker Für den eigentlichen Kopiervorgang wurde eine eigene Klasse erstellt. Dem Konstruktor werden Quell- und Zielverzeichnis angegeben. Unterhalb des Zielverzeichnisses wird automatisch ein neues Verzeichnis angelegt, das auch Datum und Zeit beinhaltet. So können Kopiervorgänge immer in das gleiche Verzeichnis erfolgen und auch mehrfach am Tag durchgeführt werden. Zunächst die Deklarationen des Events, der Instanzvariablen und der Eigenschaften. public class BackupMaker { public event FileCopyEventHandler FileCopy; private DirectoryInfo sourceInfo; private string source; private string destination; public string Source { get { return this.source; } set {
Sandini Bib
Verzeichnisse und Dateien
339
if ( Directory.Exists( value ) ) { this.sourceInfo = new DirectoryInfo( value ); this.source = value; } else { this.source = String.Empty; this.sourceInfo = null; } } } public string Destination { get { return this.destination; } set { this.destination = value; } }
Vor allem die Eigenschaft Source ist interessant. Hier wird beim Zuweisen gleich kontrolliert, ob das Quellverzeichnis auch wirklich existiert, und wenn nicht, wird später auch keine Kopieraktion gestartet. Der Aufruf des Ereignisses geschieht nach Konvention über die Methode OnFileCopy(): private void OnFileCopy( FileCopyEventArgs e ) { if ( this.FileCopy != null ) FileCopy( this, e ); }
Die eigentliche Kopiermethode entspricht fast der Methode CopyFolder() von Seite 329, mit dem Unterschied, dass hier zusätzlich das Ereignis FileCopy ausgelöst wird. Die Methode ist in diesem Fall als private deklariert. Der Benutzer soll keine Parameter übergeben müssen, da alle benötigten Bestandteile bereits als Eigenschaft deklariert sind. private void DoBackup( DirectoryInfo src, string dest ) { // Wird nur aufgerufen wenn beide Verzeichnisse // real sind, d.h. einen Wert enthalten if ( !Directory.Exists( dest ) ) Directory.CreateDirectory( dest ); // Kopieren der Dateien - rekursiv, da Verzeichnisse // erstellt werden müssen foreach ( FileInfo fi in src.GetFiles() ) { // Jede Datei kopieren string destFile = dest + @"\" + fi.Name; fi.CopyTo( destFile, false ); // Event aufrufen - Datei wurde kopiert FileCopyEventArgs e = new FileCopyEventArgs( fi, dest ); OnFileCopy( e ); }
Sandini Bib
340
14 Dateien und Verzeichnisse
// Unterverzeichnisse bearbeiten, rekursiv aufrufen foreach ( DirectoryInfo subDir in src.GetDirectories() ) { string destTmp = subDir.FullName.Replace( src.FullName, dest ); DoBackup( subDir, destTmp ); } }
Eine weitere Methode zählt die Anzahl der Dateien. Diese Zahl wird später benötigt, um einerseits den Maximalwert für die Fortschrittsanzeige einzustellen und um die Anzahl der kopierten Dateien im entsprechenden Label des Hauptformulars anzuzeigen. Glücklicherweise beinhaltet DirectoryInfo jetzt die Möglichkeit, mittels GetFiles() gleich alle Dateien inklusive aller Unterverzeichnisse zu ermitteln, sodass die Methode CountFiles() schnell implementiert ist: public int CountFiles() { // Zählen der Dateien if ( this.sourceInfo != null ) return sourceInfo.GetFiles( "*.*", SearchOption.AllDirectories ).Length; else return 0; }
Damit ist die Klasse fast fertig. Es fehlen lediglich der Konstruktor sowie natürlich eine Möglichkeit, das Backup zu starten. Für dieses Beispiel soll es lediglich einen parametrisierten Konstruktor geben, auch wenn es ebenso gut möglich wäre, die benötigten Werte über die Eigenschaften einzustellen. Die Methode DoBackup() stößt den Kopiervorgang an. public void DoBackup() { // Das eigentliche Backup if ( this.sourceInfo != null && !this.destination.Equals( String.Empty ) ) { this.Destination += @"\Backup_"; this.Destination += String.Format( "{0:vyyyy.MM.dd-HH.mm}", DateTime.Now ); DoBackup( this.sourceInfo, this.destination ); } } public BackupMaker( string source, string dest ) { // Initialisiert die Felder mit den Werten this.Source = source; this.Destination = dest; }
Der Konstruktor erwartet zwei string-Parameter, einmal für das Quellverzeichnis und einmal für das Zielverzeichnis. Die Zuweisung der Werte innerhalb des Konstruktors geschieht an die Eigenschaften, nicht direkt an die Instanzvariablen. Das hat den Vorteil, dass in der set-Methode der Eigenschaft Source gleich schon ein DirectoryInfo-Objekt erzeugt wird.
Sandini Bib
Verzeichnisse und Dateien
341
Wie angesprochen fehlt hier die Fehlerbehandlung. So könnte man eine Fehlermeldung ausgeben, wenn das Quellverzeichnis nicht existiert oder die Eigenschaft für das Zielverzeichnis leer ist. Das begrenzte Platzangebot eines Buchs ist wie so oft der Grund dafür. Sie dürfen aber gerne selbst Hand anlegen.
Die Methoden des Hauptformulars Bzgl. des Hauptformulars werden nur die relevanten Methoden gezeigt. Wichtig ist zunächst die Methode, mit der wir auf das FileCopy-Ereignis reagieren wollen. Sie muss natürlich die gleiche Signatur aufweisen wie der dazu passende Delegate. private void FrmMain_FileCopy( object sender, FileCopyEventArgs e ) { this.bytesCopied += e.FileSize; this.lblFileNames.Text = e.CopiedFile.FullName; this.lblBytes.Text = String.Format( "{0} Bytes", bytesCopied ); this.prgCopyProgress.PerformStep(); this.lblFileNames.Invalidate(); this.lblBytes.Invalidate(); this.prgCopyProgress.Invalidate(); Application.DoEvents(); }
Die Anweisung Application.DoEvents() tut nichts anderes, als es Windows zu ermöglichen, die noch anstehenden Nachrichten zu verarbeiten. So können wir sicher sein, dass die Anzeigeelemente korrekt aktualisiert werden und dass es der Anwender auch sieht. Die Methode PerformStep() der ProgressBar erhöht den angezeigten Wert um den Betrag, der in der Eigenschaft Step dieses Steuerelements festgelegt ist. Ansonsten kümmern wir uns hier nur noch um die korrekte Anzeige des Dateinamens. Als Nächstes sehen wir uns das Ereignis Load des Formulars an. Wir wollen das Formular ja kleiner anzeigen, als es zur Entwurfszeit ist. Im Load-Ereignis legen wir daher die Initialhöhe fest. In unserem Fall hat sich ein Wert von 190 hierfür als ideal erwiesen, in Ihrem Beispiel kann es sein, dass Sie diesen Wert ein wenig anpassen müssen. private void FrmMain_Load(object sender, System.EventArgs e) { this.Height = 190; }
Die eigentliche Hauptfunktion des Programms wird beim Klick auf den Button btnBackup erledigt, der das Backup durchführt. Der Quelltext sollte nicht allzu schwer verständlich sein. Hauptsächlich werden hier Einstellungen durchgeführt, eine Instanz der BackupKlasse erzeugt, die Ereignisbehandlungsroutine mit dem Ereignis verbunden und das Backup gestartet.
Sandini Bib
342
14 Dateien und Verzeichnisse
private void BtnBackup_Click( object sender, EventArgs e ) { if ( Directory.Exists( txtSource.Text ) ) { // Einstellungen this.Height = 330; // Unsichtbare Steuerelemente zeigen this.lblFileCount.Text = String.Empty; this.lblStatus.Text = "Dateien zählen ..."; this.bytesCopied = 0; Application.DoEvents(); // Nachrichtenbearbeitung für Windows // Backup-Objekt erzeugen BackupMaker backupMaker = new BackupMaker( this.txtSource.Text, this.txtDestination.Text ); // Dateien zählen, anzeigen und Progressbar einstellen int fileCount = backupMaker.CountFiles(); prgCopyProgress.Minimum = 0; prgCopyProgress.Maximum = fileCount; prgCopyProgress.Value = 0; lblFileCount.Text = String.Format( "{0} Dateien", fileCount ); // Event anhängen backupMaker.FileCopy += new FileCopyEventHandler( this.FrmMain_FileCopy ); // Backup durchführen this.lblStatus.Text = "Backup wird durchgeführt ..."; Application.DoEvents(); // Backup backupMaker.DoBackup(); // Höhe Zurücksetzen this.Height = 190; } }
Das war auch eigentlich schon alles (bis auf ein bisschen Code zum Auswählen der Verzeichnisse, der hier nicht abgedruckt ist). Abbildung 14.4 zeigt das Programm zur Laufzeit bei der Sicherung der (zu diesem Zeitpunkt) ca. 480 MB Buchdaten.
Sandini Bib
Verzeichnisse und Dateien
343
Abbildung 14.4: Das Backup-Programm im Einsatz
14.2.7
Beispielanwendung Synchronisieren
Ein weiteres nützliches Beispiel, das mit Dateien und Verzeichnissen im Zusammenhang steht, ist das Synchronisieren zweier Verzeichnisse. Wir haben das Beispiel deshalb gewählt, weil es häufig zum Einsatz kam. Als Autor arbeitet man nicht immer auf dem lokalen Rechner, sondern recht häufig auch auf dem Notebook. Es wurde also eine einfache Möglichkeit gesucht, die Dateien von einem Rechner auf den anderen zu schaufeln und die geänderten Dateien später wieder zurückschreiben zu können. Was liegt näher, als ein kleines C#-Programm für diesen Zweck zu schreiben. Die einfachste Form der Synchronisation besteht darin, einfach den gesamten Verzeichnisbaum vom Ursprungsort zum Zielort zu kopieren. Wenn sich aber nur einige wenige Dateien in einem riesigen Verzeichnisbaum geändert haben, ist diese Vorgehensweise ineffizient. Der folgende Algorithmus kopiert daher nur alle neuen bzw. veränderten Dateien von einem Quell- in ein Zielverzeichnis. Wenn das Zielverzeichnis leer ist, werden alle Dateien und Verzeichnisse kopiert. Damit kann der Algorithmus also auch dazu verwendet werden, um einen ganzen Verzeichnisbaum einfach zu kopieren. Beachten Sie bitte die folgenden Einschränkungen des Programms: f Die Synchronisierung erfolgt nur einseitig. Wenn sich manche Dateien im Quellverzeichnis, andere im Zielverzeichnis verändert haben, wäre eine Synchronisierung in beide Richtungen erforderlich. f Zur Feststellung, ob sich eine Datei verändert hat, wird ausschließlich das Datum der letzten Änderung verwendet. Das bedeutet, dass das Programm nur dann zuverlässig funktioniert, wenn das Zeitsystem im Quell- und im Zielverzeichnis übereinstimmt. (Wenn sich Quell- und Zielverzeichnis auf unterschiedlichen Rechnern befinden, sollte das unbedingt kontrolliert werden.)
Sandini Bib
344
14 Dateien und Verzeichnisse
f Dateien, die im Quellverzeichnis gelöscht wurden, werden im Zielverzeichnis aus Sicherheitsgründen nicht ebenfalls gelöscht. Das führt allerdings dazu, dass auf einem Rechner gelöschte Dateien nach zwei Synchronisationsvorgängen (einmal in die eine, dann in die andere Richtung) wieder auftauchen.
HINWEIS
f Das Programm ist in keiner Weise gegen mögliche Fehler abgesichert. Was die Kontrolle der Zeit betrifft, so können Sie auch die UTC-Zeit kontrollieren. Dann muss die Zeit auf beiden Rechnern zwar immer noch übereinstimmen, allerdings nur auf die auf dem Rechner eingestellte Zeitzone bezogen. Damit können dann auch Dateien synchronisiert werden, die sich auf Rechnern unterschiedlicher Zeitzone befinden, da die UTC-Zeit dann für beide gleich ist.
Die Klasse zum Synchronisieren
CD
Selbstverständlich wurde nach Manier der objektorientierten Programmierung wieder eine Klasse erstellt, die die Hauptarbeit durchführt. Diese ähnelt aufgrund der Ähnlichkeit der Funktionalität auch der im vorigen Beispiel vorgestellten Backup-Klasse. Diesmal allerdings werden die Dateien, die kopiert werden, gezählt und die Anzahl zurückgeliefert. Damit ist eine Kontrolle möglich – wenn Sie nur zwei Dateien geändert haben, und das Programm möchte fünf Dateien kopieren, kann etwas nicht stimmen ... Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\SyncDirectories.
Abgedruckt wird an dieser Stelle nur der Quelltext der Klasse Synchronizer, die für die Hauptarbeit zuständig ist. Gestartet wird die Synchronisation durch den Aufruf der Methode DoSynchronize(), beim Erzeugen des Objekts müssen Quell- und Zielverzeichnis angegeben werden. Über die Eigenschaft IsReadyForSynchronisation kann kontrolliert werden, ob die Synchronisation möglich ist (in dem Fall ist IsReadyForSynchronisation true). public class Synchronizer { private DirectoryInfo firstfolderInfo; private string firstFolder; private string secondFolder; public string FirstFolder { get { return this.firstFolder; } set { if ( Directory.Exists( value ) ) { this.firstfolderInfo = new DirectoryInfo( value ); this.firstFolder = value; } else {
Sandini Bib
Verzeichnisse und Dateien
345
this.firstFolder = ""; this.firstfolderInfo = null; } } } public string SecondFolder { get { return this.secondFolder; } set { this.secondFolder = value; } } public bool IsReadyForSynchronisation { get { return ( firstfolderInfo != null ) && ( !this.SecondFolder.Equals( String.Empty ) ); } } private int Synchronize() { // Zähler für Dateianzahl int synchronizedFiles = 0; // Verzeichnis erstellen wenn nicht vorhanden if ( !Directory.Exists( this.secondFolder ) ) Directory.CreateDirectory( this.secondFolder ); // Kontrollieren + Kopieren der Dateien foreach ( FileInfo file in this.firstFolder.GetFiles() ) { // Jede Datei prüfen string secondFile = Path.Combine( this.secondFolder, file.Name ); // Kopieren wenn Bedingungen erfüllt if ( !File.Exists( secondFile ) || ( file.LastWriteTime > File.GetLastWriteTime( secondFile ) ) ) { file.CopyTo( secondFile, true ); synchronizedFiles++; } } // Unterverzeichnisse bearbeiten foreach ( DirectoryInfo subDir in this.firstFolder.GetDirectories() ) { string destTmp = subDir.FullName.Replace( this.firstFolder.FullName, this.secondFolder ); synchronizedFiles += DoSync( subDir, destTmp ); } return synchronizedFiles; }
Sandini Bib
346
14 Dateien und Verzeichnisse
public int DoSynchronize() { // Synchronisiert die angegebenen Verzeichnisse if ( this.firstfolderInfo != null && this.secondFolder != "" ) return Synchronize(); return 0; } public Synchronizer( string source, string destination ) { // Initialisiert die Felder mit den Werten this.FirstFolder = source; this.SecondFolder = destination; } }
14.2.8
Syntaxzusammenfassung
Die Klasse FileSystemInfo Die Klassen DirectoryInfo und FileInfo sind beide von der Klasse FileSystemInfo abgeleitet. Deswegen gibt es eine Menge gemeinsamer Eigenschaften und Methoden, die in der folgenden Tabelle aufgelistet werden. Eigenschaften und Methoden der Klasse FileSystemInfo (aus System.IO) Attributes
gibt die Datei- bzw. Verzeichnisattribute an. Der Wert setzt sich aus der Kombination von Elementen der Bitfeld-Aufzählung FileAttributes zusammen.
CreationTime
gibt den Zeitpunkt an, zu dem das Verzeichnis bzw. die Datei erzeugt wurde. Diese Eigenschaft liefert einen Wert vom Typ DateTime zurück.
Exists
gibt an, ob das enthaltene Verzeichnis bzw. die Datei existiert.
Extension
liefert die Dateiendung inklusive des Punkts zurück. Wenn die Datei Test.exe heißt, wird .exe zurückgeliefert.
FullName
liefert den voll qualifizierten Dateinamen zurück, d.h. inkl. des gesamten Pfads.
LastAccessTime
liefert den Zeitpunkt des letzten Lesezugriffs zurück.
LastWriteTime
liefert den Zeitpunkt der letzten Veränderung bzw. des letzten Schreibzugriffs zurück.
Name
gibt den Datei- oder Verzeichnisnamen ohne Unterverzeichnisse an.
Delete()
löscht die Datei oder das Verzeichnis.
Refresh()
aktualisiert die Daten über die Datei bzw. das Verzeichnis.
Sandini Bib
Verzeichnisse und Dateien
347
Die Klasse FileInfo Die folgende Tabelle gibt Ihnen eine Übersicht über die spezifischen Eigenschaften und Methoden der Klasse FileInfo. Eigenschaften und Methoden der Klasse FileInfo (aus System.IO) liefert ein DirectoryInfo-Objekt des Verzeichnisses, in dem sich die Datei befindet.
DirectoryName
liefert eine Zeichenkette mit dem Verzeichnis, in dem sich die Datei befindet.
Length
liefert die Größe der Datei in Bytes.
AppendText()
liefert ein StreamWriter-Objekt, um am Ende der Datei Text hinzuzufügen.
CopyTo( string destFile, bool overwrite )
kopiert die aktuelle Datei in eine neue Datei. Wenn der Parameter overwrite nicht angegeben wird, ist Überschreiben nicht erlaubt.
Create()
erzeugt bzw. überschreibt die Datei und liefert ein FileStreamObjekt zum Schreiben der Datei.
CreateText()
wie Create(), liefert aber ein StreamWriter-Objekt zum Schreiben der Textdatei.
MoveTo( string destFile )
wie CopyTo(), allerdings wird die Quelldatei außerdem gelöscht. Die Zieldatei darf nicht existieren.
Open( FileMode mode, [FileAccess access [,FileShare share]] )
öffnet die Datei und liefert ein FileStream-Objekt zur weiteren Bearbeitung. mode gibt an, wie die Datei geöffnet wird, access gibt den Zugriffsmodus an (Lesen, Schreiben usw.), share gibt an, ob ein Mehrfachzugriff zulässig ist.
OpenText()
öffnet die Datei zum Lesen mit UTF-8-Kodierung und liefert ein StreamReader-Objekt zurück.
OpenRead()
öffnet die Datei mit Lesezugriff und liefert ein FileStreamObjekt zurück.
OpenWrite()
öffnet die Datei nur zum Schreiben und liefert ein FileStreamObjekt zurück.
VERWEIS
Directory
Eine Referenz der Aufzählungen IO.FileMode, IO.FileAccess und IO.FileSharing finden Sie in der Syntaxzusammenfassung zum FileStream-Objekt in Abschnitt 14.5.9 ab Seite 402.
Sandini Bib
348
14 Dateien und Verzeichnisse
Die Klasse DirectoryInfo Die folgende Tabelle gibt Ihnen eine Übersicht über die spezifischen Eigenschaften und Methoden der Klasse DirectoryInfo. Eigenschaften und Methoden der Klasse DirectoryInfo (aus System.IO) Parent
liefert das übergeordnete Verzeichnis als DirectoryInfo-Objekt.
Root
liefert das Wurzelverzeichnis (z.B. C:\ oder \\mars\data) als DirectoryInfo-Objekt.
GetDirectories() GetDirectories( string pattern )
liefert alle Unterverzeichnisse in Form eines Arrays aus DirectoryInfo-Objekten. Mit dem Parameter pattern kann eine Maske angegeben werden, es werden dann nur die Verzeichnisse zurückgeliefert, die dieser Maske entsprechen.
GetFiles() GetFiles( string pattern ) GetFiles( string pattern, SearchOption option )
liefert alle Dateien in Form eines Arrays aus FileInfo-Objekten. Auch hier kann wahlweise eine Maske angegeben werden sowie ein Parameter, der angibt, ob Unterverzeichnisse durchsucht werden sollen.
GetFileSystemInfos() GetFileSystemInfos( string pattern )
liefert alle Unterverzeichnisse und Dateien in Form eines Arrays aus FileSystemInfo-Objekten. Auch hier ist die Angabe einer Maske möglich.
Create()
erzeugt das Verzeichnis. Wenn das Verzeichnis bereits existiert, bleibt es unverändert.
CreateSubdirectory( string name )
erzeugt ein Unterverzeichnis zum aktuellen Verzeichnis und liefert ein neues DirectoryInfo-Objekt zur weiteren Bearbeitung. Der angegebene Pfad kann auch relativ sein und wird dann als relativ zum aktuellen Pfad, den das DirectoryInfo-Objekt repräsentiert, betrachtet.
MoveTo( string dirName )
verschiebt das Verzeichnis samt Inhalt in ein anderes Verzeichnis.
Die Klasse File Die Klasse File bietet fast die gleiche Funktionalität wie die Klasse FileInfo, allerdings handelt es sich hier ausschließlich um statische Methoden. Methoden der Klasse File (aus System.IO) AppendText( string fileName )
liefert ein StreamWriter-Objekt, mit dessen Hilfe Sie an die bereits vorhandene Datei Text anfügen können.
Create( string fileName )
erzeugt bzw. überschreibt die angegebene Datei und liefert ein FileStream-Objekt zur weiteren Bearbeitung.
Sandini Bib
Verzeichnisse und Dateien
349
Methoden der Klasse File (aus System.IO) CreateText( string fileName )
wie Create(), liefert aber ein StreamWriter-Objekt zum Schreiben der Datei.
Open( FileMode mode, [FileAccess access [,FileShare share]] )
öffnet die Datei mit dem Namen fileName und liefert ein FileStream-Objekt zur weiteren Bearbeitung. mode gibt an, wie die Datei geöffnet wird, access gibt den Zugriffsmodus an (Lesen, Schreiben usw.), share gibt an, ob ein Mehrfachzugriff zulässig ist.
OpenRead( string fileName )
öffnet die vorhandene Datei und liefert ein FileStream-Objekt zum Lesen der Daten.
OpenText( string fileName )
öffnet die vorhandene Textdatei und liefert ein StreamReaderObjekt zum Lesen der Daten.
OpenWrite( string fileName )
öffnet die vorhandene Datei und liefert ein FileStream-Objekt zum Lesen und Schreiben von Daten.
Copy( string sourceFile, string destFile [, bool overwrite] )
kopiert sourceFile nach destFile. Der optionale Parameter overwrite gibt an, ob die Zieldatei überschrieben werden darf oder nicht.
Delete( string fileName )
löscht die Datei fileName.
Exists( string fileName )
testet, ob die Datei fileName existiert.
Move( string sourceFile, string destFile )
verschiebt sourceFile nach destFile. Diese Methode kann auch zum Umbenennen einer Datei verwendet werden (indem die Pfadangabe gleich ist).
Encrypt()
Diese und die folgenden Methoden sind neu in .NET 2.0. Encrypt() verschlüsselt eine angegebene Datei. Nur der Benutzer, der sie verschlüsselt hat, kann sie auch wieder entschlüsseln.
Decrypt()
Das Gegenstück zu Encrypt(). Diese Methode entschlüsselt eine Datei wieder.
ReadAllText()
Die Methode ReadAllText() liest den gesamten Inhalt einer Textdatei und liefert ihn als String zurück.
ReadAllLines()
Die Methode ReadAllLines() liest den gesamten Inhalt einer Textdatei, allerdings zeilenweise, und liefert die Zeilen in Form eines String-Arrays zurück.
ReadAllBytes()
Die Methode ReadAllBytes() liest den Inhalt einer binären Datei und liefert ein byte-Array zurück.
WriteAllText()
Das Gegenstück zu ReadAllText()
WriteAllLines()
Das Gegenstück zu ReadAllLines()
WriteAllBytes()
Das Gegenstück zu ReadAllBytes()
Sandini Bib
350
14 Dateien und Verzeichnisse
Die Klasse Directory Methoden der Klasse Directory (aus System.IO) CreateDirectory( string dirName )
erzeugt das Verzeichnis name und liefert ein DirectoryInfoObjekt.
Delete( string dirName ) Delete( string dirName, bool subDirs )
löscht das Verzeichnis dirName. Das Verzeichnis muss leer sein. Mit dem Parameter subDirs kann angegeben werden, ob auch die Unterverzeichnisse gelöscht werden sollen (die dann auch leer sein müssen).
Exists( string dirName )
kontrolliert, ob das Verzeichnis dirName existiert.
GetCreationTime( string dirName )
liefert Datum und Zeit, zu der dieses Verzeichnis erstellt wurde.
GetCurrentDirectory()
liefert das aktuelle Arbeitsverzeichnis. Dieses Verzeichnis muss nicht mit dem Programmverzeichnis übereinstimmen.
GetDirectories( string dirName) Getdirectories( string dirName, string pattern )
liefert alle Unterverzeichnisse des Verzeichnisses dirName. Mit dem Parameter pattern kann ein Suchmuster angegeben werden.
GetDirectoryRoot( string dirName )
liefert die Information über Datenträger und/oder Stammverzeichnis des Verzeichnisses dirName.
GetFiles( string dirName ) GetFiles( string dirName, string pattern )
liefert alle Dateien im Verzeichnis dirName in Form eines Arrays aus DirectoryInfo-Objekten. Mit dem Parameter pattern kann ein Suchmuster angegeben werden.
GetFileSystemEntries (string dirName ) GetFileSystemEntries( string dirName string pattern )
liefert alle Dateien und Verzeichnisse in Form eines Arrays aus FileSystemInfo-Objekten. Über den Parameter pattern kann falls gewünscht ein Suchmuster angegeben werden.
GetLastAccessTime( string dirName )
liefert Datum und Uhrzeit des letzten Zugriffs auf das Verzeichnis dirName als DateTime-Wert.
GetLastWriteTime( string dirName )
liefert Datum und Uhrzeit des letzten Schreibvorgangs im Verzeichnis dirName als DateTime-Wert.
GetLogicalDrives()
liefert die logischen Laufwerke des Computers.
GetParent( string dirName )
liefert das übergeordnete Verzeichnis als DirectoryInfoObjekt.
Move( string source, string dest )
verschiebt den Inhalt des Verzeichnisses source nach dest.
SetCreationTime( string dirName )
legt den Zeitpunkt der Erzeugung des Verzeichnisses dirneu fest.
Name SetLastAccessTime( string dirname )
legt den Zeitpunkt des letzten Zugriffs auf das Verzeichnis dirName neu fest.
Sandini Bib
Verzeichnisse und Dateien
351
Methoden der Klasse Directory (aus System.IO) SetLastWriteTime( string dirName )
legt den Zeitpunkt des letzten Schreibzugriffs im Verzeichnis dirName neu fest.
SetCurrentDirectory( string dirName )
legt das aktuelle Arbeitsverzeichnis auf dirName fest.
Die Klasse DriveInfo Methoden und Eigenschaften der Klasse DriveInfo (aus System.IO) AvailableFreeSpace
liefert den freien Speicherplatz für den aktuellen Benutzer.
DriveFormat
liefert das Dateiformat des Laufwerks als String.
DriveType
liefert den Typ des Laufwerks (Fest, Removable usw.). Zurückgegeben wird ein Wert vom Typ DriveType.
IsReady
ist true, wenn das Laufwerk bereit ist (im Falle von CD-ROM-Laufwerken also, wenn eine CD eingelegt und lesbar ist).
Name
liefert den Namen des Laufwerks als String.
RootDirectory
liefert das Basisverzeichnis des Laufwerks als String.
TotalFreeSpace
liefert den gesamten freien Speicherplatz des Laufwerks.
TotalSize
liefert die Gesamtgröße des Laufwerks.
VolumeLabel
liefert den vom Benutzer angegebenen Volume-Namen.
GetDrives()
Statische Methode. Liefert alle angeschlossenen Laufwerke in Form eines Arrays aus DriveInfo-Objekten zurück.
Die Klasse Path Die Methoden der Klasse Path dienen in der Hauptsache der Bearbeitung von Datei- und Verzeichnisnamen. Eigenschaften und Methoden der Klasse Path (aus System.IO) GetTempPath()
liefert den Namen des temporären Verzeichnisses.
GetTempFileName()
liefert den Namen einer temporären Datei, die zu diesem Zweck als leere Datei erzeugt wird.
ChangeExtension( string s, string ext )
ändert die Kennung des Dateinamens.
GetDirectoryName( string s )
liefert das Laufwerk und das Verzeichnis.
GetExtension( string s )
liefert die Dateikennung (z.B. .bmp).
Sandini Bib
352
14 Dateien und Verzeichnisse
Eigenschaften und Methoden der Klasse Path (aus System.IO) GetFileName( string s )
liefert den Dateinamen ohne Verzeichnisse.
GetFileNameWithoutExtension( string s )
wie GetFileName(), es wird aber auch die Dateikennung entfernt.
GetFullPath( string s )
verbindet einen ohne Verzeichnis angegebenen Dateinamen mit dem aktuellen Verzeichnis.
GetPathRoot( string s )
liefert das Laufwerk (z.B. C:\ oder \\mars\data).
HasExtension( string s )
testet, ob der Dateiname eine Kennung hat.
DirectorySeparatorChar
liefert das Zeichen zur Trennung von Verzeichnissen: Windows: »\« Apple: »:« Unix: »/«
AltDirectorySeparatorChar
gibt eine (nur für den Namespace System.IO) zulässige Alternative zu DirectorySeparatorChar an: Windows: »/« Apple: »/« Unix: »\«
InvalidPathChars
liefert ein char-Array mit Zeichen, die nicht in Dateinamen vorkommen dürfen. Unter Windows sind das die Zeichen <, >, |, das Hochkomma sowie 0-Codes.
PathSeparator
liefert das Zeichen zur Trennung mehrerer Namen voneinander. Unter Windows ist das das Semikolon »;«
VolumeSeparatorChar
liefert das Zeichen zur Angabe von Laufwerken. Windows: »:« Apple: »:« Unix: »/«
Datei- und Verzeichnisattribute Die folgende Tabelle fasst die möglichen Verzeichnis- und Dateiattribute zusammen. Beachten Sie bitte, dass manche Attribute nur zur Verfügung stehen, wenn ein NTFS-Dateisystem vorliegt (also nur bei Windows NT, Windows 2000 oder Windows XP). Bitfeld FileAttributes (aus System.IO) Archive
Die Datei wurde durch ein Backup-Programm archiviert und seither nicht mehr verändert.
Compressed
Die Datei ist komprimiert.
Device
Es handelt sich nicht um eine normale Datei, sondern um ein so genanntes Device, das den direkten Zugriff auf die Hardware ermöglicht.
Directory
Es handelt sich nicht um eine Datei, sondern um ein Verzeichnis.
Encrypted
Die Datei ist verschlüsselt.
Hidden
Die Datei ist versteckt.
Sandini Bib
Verzeichnisse und Dateien
353
Bitfeld FileAttributes (aus System.IO) Normal
Es handelt sich um eine gewöhnliche Datei.
NotContentIndexed
Die Datei ist nicht Teil eines Index.
Offline
Die Datei ist nur über ein Netzwerk/Internet zugänglich, momentan besteht aber keine Verbindung.
Readonly
Die Datei darf nicht verändert werden.
ReparsePoint
Die Datei enthält zusätzliche, benutzerspezifische Informationen.
SparseFile
Die Datei besteht überwiegend aus 0-Bytes.
System
Es handelt sich um eine Systemdatei.
Temporary
Es handelt sich um eine temporäre Datei.
Spezielle Verzeichnisse ermitteln Klasse Path (aus System.IO) GetTempPath()
liefert den Namen des temporären Verzeichnisses.
GetTempFileName()
liefert den Namen einer temporären Datei, die zu diesem Zweck als leere Datei erzeugt wird.
Klasse Environment (aus System) CurrentDir
liefert das aktuelle Verzeichnis.
GetEnvironmentVariable( var )
liefert das temporäre Verzeichnis (var="temp") bzw. das WindowsVerzeichnis (var="windir").
GetFolderPath( Environment.SpecialFolder f )
liefert ein spezielles Verzeichnis entsprechend dem Wert von f.
GetLogicalDrives()
liefert ein String-Array mit den Namen aller lokalen Laufwerke (z.B. "C:\").
Aufzählung Environment.SpecialFolder (aus System) ApplicationData
persönliche Anwendungsdaten
CommonApplicationData
allgemeine Anwendungsdaten
CommonProgramFiles
gemeinsame Dateien (C:\Programme\Gemeinsame Dateien)
Cookies
Cookies-Verzeichnis
DesktopDirectory
Desktop-Verzeichnis
Sandini Bib
354
14 Dateien und Verzeichnisse
Aufzählung Environment.SpecialFolder (aus System) Favorites
Favoriten des Internet Explorers
History
Verlauf des Internet Explorers
InternetCache
Cache des Internet Explorers
LocalApplicationData
lokale Anwendungsdaten
Personal
persönliche Dateien (C:\Dokumente und Einstellungen\user\Eigene Dateien)
ProgramFiles
Installationsverzeichnis für Programme
Programs
Verzeichnis des Startmenüs PROGRAMME
Recent
zuletzt genutzte Dokumente
SendTo
Verzeichnis der SENDEN-AN-Programme
StartMenu
Verzeichnis des Startmenüs
Startup
Autostart-Verzeichnis
System
Windows-Systemverzeichnis
Templates
Vorlagenverzeichnis
14.3
Dialoge für Verzeichnisse und Dateien
VERWEIS
Die Auswahl einer Datei zum Laden oder zum Speichern geschieht in WindowsProgrammen selbstverständlich über Dialoge. In diesem Abschnitt werden Sie die Dialoge zum Laden und Speichern von Dateien kennen lernen. OpenFileDialog und SaveFileDialog gehören zu einer ganzen Gruppe so genannter Standarddialoge, die alle im Namespace System.Windows.Forms definiert sind. Allge-
meine Informationen zu diesen Standarddialogen finden Sie in Abschnitt 20.2 ab Seite 667.
Selbstverständlich werden die Dateien durch diese Dialoge nicht geladen oder gespeichert. Sie ermöglichen lediglich die Auswahl des Dateinamens, der dann weiterverwendet werden kann.
14.3.1
Der Dialog zum Öffnen einer Datei
Zum Auswählen einer oder mehrerer Dateien, die geöffnet werden sollen, ist der Dialog OpenFileDialog zuständig. Über die Eigenschaft MultiSelect können Sie festlegen, ob mehrere Dateien ausgewählt werden können oder nicht. Nach erfolgreicher Ausführung des Dia-
Sandini Bib
Dialoge für Verzeichnisse und Dateien
355
logs finden Sie den Namen der ausgewählten Datei in der Eigenschaft FileName, im Falle einer Mehrfachauswahl in der Eigenschaft FileNames. FileNames ist dabei ein Array aus Strings. Ausgeführt wird der Dialog über die Methode ShowDialog(). Diese Methode liefert einen Wert des Typs DialogResult zurück, der angibt, wie der Dialog beendet wurde. Der folgende Codeausschnitt zeigt, wie dieser Wert überprüft werden kann. public void button1_Click( object sender, EventArgs e ) { if ( openFileDialog1.ShowDialog() == DialogResult.OK ) { // OK wurde geklickt } else { // Cancel wurde geklickt } }
Das Aussehen des Dialogs kennen Sie – es handelt sich nicht etwa um einen Spezialdialog, der nur unter .NET verfügbar wäre, sondern um den Standarddialog zum Öffnen von Dateien aus Windows.
14.3.2
Der Dialog zum Speichern einer Datei
Der Dialog SaveFileDialog ist vom Aussehen her identisch mit OpenFileDialog, da er aber zum Speichern vorgesehen ist, verhält er sich ein wenig anders. So können Sie beispielsweise mit der Eigenschaft OverWritePrompt angeben, dass der Dialog eine Warnmeldung anzeigen soll, wenn eine existierende Datei als zu speichernde Datei ausgewählt wird. Das würde natürlich bei einem OpenFileDialog keinen Sinn machen. Den eingegebenen oder ausgewählten Dateinamen können Sie nach Beenden des Dialogs der Eigenschaft FileName entnehmen. Um das Speichern oder Laden müssen Sie sich allerdings selbst kümmern. Ab dem nächsten Abschnitt erfahren Sie darüber etwas mehr.
14.3.3
Der Dialog zur Verzeichnisauswahl
In .NET 1.0 noch nicht vorhanden (außer über einen Trick) gab es diesen Dialog unter .NET 1.1 bereits, allerdings fehlerhaft. In .NET 2.0 funktioniert er nun endlich. Sie finden ihn unter dem Namen FolderBrowserDialog bei den übrigen Standarddialogen in der Toolbox. Das Aussehen dieses Dialogs, bei dem es sich ebenfalls um einen Standarddialog aus Windows handelt, sollte Ihnen bekannt sein. Aufgerufen wird der FolderBrowserDialog wie die anderen Dialoge auch. Die Eigenschaft SelectedPath enthält den vom Benutzer ausgewählten Pfad (sofern dieser mit OK bestätigt hat) und über die Eigenschaft ShowNewFolderButton können Sie festlegen, ob ein Button zum Anlegen eines neuen Verzeichnisses angezeigt werden soll.
Sandini Bib
356
14.3.4
14 Dateien und Verzeichnisse
Syntaxzusammenfassung
An dieser Stelle finden Sie wie immer die Zusammenfassung der Eigenschaften und Methoden der vorgestellten Klassen.
Die Klassen OpenFileDialog und SaveFileDialog Die folgende Tabelle zeigt die wichtigsten Eigenschaften und Methoden der Klassen SaveFileDialog und OpenFileDialog.
Methoden und Eigenschaften der Klassen OpenFileDialog und SaveFileDialog AddExtension
gibt an, ob dem ausgewählten Dateinamen automatisch eine Dateinamenserweiterung angefügt werden soll, wenn der Benutzer keine angibt. Die standardmäßig verwendete Erweiterung wird in DefaultExt festgelegt.
CheckFileExists
Wenn CheckFileExists auf true gesetzt ist, wird eine Warnmeldung angezeigt, wenn der Benutzer eine Datei auswählt, die nicht existiert. Für einen SaveFileDialog ist der Standardwert dieser Eigenschaft false, für einen OpenFileDialog true.
CheckPathExists
Wenn CheckPathExists auf true gesetzt ist, wird eine Warnmeldung angezeigt, wenn ein nicht vorhandener Pfad eingegeben wird. Der Standardwert ist true.
FileName
enthält den vollständigen Dateinamen, wenn es sich nicht um einen MultiselectDialog handelt.
FileNames
enthält ein String-Array mit allen ausgewählten Dateinamen, wenn MultiSelect auf true gesetzt ist.
Filter
enthält einen oder mehrere Filter für die Dateitypen. Angezeigter Text und Filtermuster werden durch das Pipe-Symbol getrennt. Eine typische FilterAngabe sieht folgendermaßen aus: Textdateien|*.txt|Bitmaps|*.bmp|Alle Dateien|*.*
InitialDirectory
gibt das Startverzeichnis für den Dialog an.
MultiSelect
gibt an, ob mehrere Dateien ausgewählt werden dürfen.
Title
gibt an, welcher Text im Fenstertitel des Dialogs angezeigt werden soll.
ValidateNames
gibt an, ob nur gültige Windows-Dateinamen akzeptiert werden sollen. Der Standardwert für diese Eigenschaft ist true.
DefaultExt
gibt die Standarderweiterung an, die dem angegebenen Dateinamen hinzugefügt wird, wenn der Benutzer keine Erweiterung angibt.
FilterIndex
enthält den Index des Filters, der beim Anzeigen des Dialogs ausgewählt sein soll.
RestoreDirectory
gibt an, ob der Dialog wieder zum Initialverzeichnis wechselt, wenn er geschlossen wird, oder nicht.
ShowHelp
gibt an, ob ein HILFE-Button im Dialog angezeigt werden soll.
Sandini Bib
Textdateien
357
Methoden und Eigenschaften der Klassen OpenFileDialog und SaveFileDialog OverwritePrompt
Nur im SaveFileDialog. Der Wert dieser Eigenschaft bestimmt, ob eine Sicherheitsabfrage durchgeführt werden soll, wenn die angegebene Datei bereits existiert.
ShowDialog()
zeigt den Dialog an. Zurückgeliefert wird ein Wert des Typs DialogResult, der angibt, durch welchen Button der Dialog beendet wurde.
14.4
Textdateien
Textdateien werden vermutlich mit am häufigsten geschrieben oder gelesen werden. Das .NET Framework bietet speziell hierfür zwei Klassen an, nämlich StreamReader zum Lesen und StreamWriter zum Schreiben. Beide Klassen arbeiten sequenziell, arbeiten die Dateien also von vorn nach hinten ab (wie man den Positionszeiger innerhalb der Datei trotzdem direkt verändern kann, erfahren Sie ab Seite 364). Zusätzlich wurde auch in der Klasse File eine entsprechende Funktionalität implementiert, mit der Textdateien schnell gelesen werden können. Doch bevor es an die Beschreibung der Klassen StreamReader bzw. StreamWriter geht noch einige Informationen über die verschiedenen Formate von Textdateien.
14.4.1
Kodierung von Textdateien
Wenn Sie mit Windows in Westeuropa arbeiten, sind Sie normalerweise daran gewöhnt, dass die Zeichen in Textdateien mit dem ANSI-Zeichensatz (Codeseite 1252) kodiert sind. Diese Dateien werden oftmals auch als ASCII-Dateien bezeichnet, was genau genommen aber falsch ist: ASCII definiert nur die Zeichen mit Codes von 0-127. ANSI lässt dagegen Codes bis 255 zu und ermöglicht so auch die Darstellung vieler Sonderzeichen in Europa verbreiteter Sprachen (inklusive ä, ö, ü und ß). Andere Sprachen haben aber andere Codeseiten, d.h. Sonderzeichen werden anders dargestellt, und die Sonderzeichen, die bei uns gelten, sind dort möglicherweise nicht verfügbar. Die korrekte Übertragung eines Texts kann eigentlich nur dann gewährleistet werden, wenn ausschließlich die Zeichen des ASCII-Zeichensatzes verwendet werden. Die Zeichen mit den Codes 0-127 entsprechen in ANSI wie übrigens auch in Unicode den Zeichen des ASCII-Zeichensatzes, also des englischen Zeichensatzes. Diese Probleme werden durch Unicode vermieden, weshalb es unter .NET auch als Standard gilt (unverständlich ist nur, warum das Visual Studio seine Dateien standardmäßig in ANSI speichert). Dennoch gibt es auch hier Unterschiede, denn es existieren verschiedene Unicode-Formate. Was in .NET als Unicode bezeichnet wird, ist in der Regel die UTF-8Kodierung. Daneben gibt es noch UTF-7, UTF-16 oder UTF-32 – Sie sehen also, das Wort Unicode allein sagt noch nichts aus. Standard für das Speichern und Laden von Textdateien ist UTF-8. Dabei handelt es sich um eine »gemischte« Kodierung. ASCII-Zeichen (Codes 0-127) werden mit einem Byte pro Zeichen gespeichert, alle anderen Zeichen mit zwei bis vier Byte. Sollen andere Formate
Sandini Bib
358
14 Dateien und Verzeichnisse
verwendet werden, muss das gewünschte Format explizit angegeben werden. StreamReader und StreamWriter können mit allen gängigen Formaten umgehen.
Wie kann die Kodierung einer Textdatei erkannt werden? Bei Textdateien mit einer Kodierung mit einem Byte pro Zeichen gibt es in der Regel keine Kennzeichnung der Kodierung. Damit also ein Austausch von Textdateien zwischen verschiedenen Personen oder Programmen gelingt, muss der Empfänger wissen, welche Kodierung der Sender verwendet hat. Etwas besser sieht es bei Unicode-Dateien aus: Dort ist für einige Kodierungsvarianten eine Kennzeichnung durch die ersten Bytes vorgesehen. Genau genommen dient diese Kennzeichnung nur dazu, um die Byte-Reihenfolge anzugeben (Byte Order Mark, kurz BOM). Diese kann bei UTF-16- und UTF-32-Dateien je nach Prozessorarchitektur variieren. Bei UTF-8-Dateien ist eine derartige Kennzeichnung dagegen nicht erforderlich, weil die Bytereihenfolge unabhängig von der Prozessorarchitektur ist. Dennoch sind auch für UTF8-Dateien drei Kennzeichnungsbytes vorgesehen, die aber optional sind. (In der Praxis werden Sie überwiegend auf UTF-8-Dateien ohne Kennzeichnung stoßen. Beachten Sie auch, dass es nicht üblich ist, einzelne Zeichenketten durch ein BOM zu kennzeichnen – etwa wenn Sie Unicode-Zeichenketten in einer Tabelle einer Datenbank speichern.) Die folgende Tabelle gibt die hexadezimalen Codes der ersten Bytes für die wichtigsten Unicode-Varianten an: BOM
Unicode-Typ
FF FE
Unicode UTF-16
FE FF
Unicode UTF-16 Big-Endian (umgekehrte Byte-Reihenfolge)
00 00 FE FF
Unicode UTF-32
FF FE 00 00
Unicode UTF-32 Big-Endian (umgekehrte Byte-Reihenfolge)
EF BB BF
Unicode UTF-8 (optionale Kennzeichnung, fehlt oft!)
14.4.2
Textdateien lesen (mit StreamReader)
Textdateien öffnen Es gibt mehrere Möglichkeiten, eine Textdatei zu öffnen. Die folgenden Zeilen zeigen einige dieser Möglichkeiten, wobei das Ergebnis in jedem Fall ein StreamReader-Objekt ist. StreamReader sr = StreamReader sr = StreamReader sr = FileInfo fi = new StreamReader sr =
new StreamReader( @"c:\test\test1.txt" ); new StreamReader( aStream ); File.OpenText( @"c:\test\test2.txt" ); FileInfo( @"c:\text\Text3.txt" ); fi.OpenText();
Sandini Bib
Textdateien
359
Bei allen Varianten werden die ersten Bytes der Datei ausgewertet, um die UnicodeKodierung zu erkennen. Dateien, die als UTF-8- und UTF-16-Dateien gekennzeichnet sind, werden korrekt behandelt. Alle anderen Dateien werden so verarbeitet, als wären sie gemäß UTF-8 codiert. Wenn die Datei in einer anderen Kodierung vorliegt, führt das natürlich zu fehlerhaften Ergebnissen. Die zweite Variante verwendet zum Öffnen ein beliebiges Stream-Objekt, also eine Instanz einer von Stream abgeleiteten Klasse. Das kann auch ein MemoryStream sein, wodurch Sie komplette Texte im Speicher des Rechners zwischenlagern können.
CD
Das folgende Beispielprogramm liest eine Textdatei ohne Rücksicht auf die Kodierung. Damit wird im Falle von ANSI-Dateien UTF-8 angenommen, was zur Folge hat, dass die deutschen Umlaute nicht korrekt angezeigt werden. Abgedruckt ist wieder nur die relevante Methode des Programms. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\ReadTextFile.
private void btnLoad_Click( object sender, EventArgs e ) { if ( this.dlgOpen.ShowDialog() == DialogResult.OK ) { this.txtFileName.Text = this.dlgOpen.FileName; StreamReader reader = new StreamReader( this.txtFileName.Text, true ); this.txtText.Text = reader.ReadToEnd(); this.lblResult.Text = "StreamReader meldet: " + reader.CurrentEncoding; reader.Close(); } }
Die Methode ReadToEnd() der Klasse StreamReader liest bis zum Ende der Datei und liefert den gesamten Inhalt in einem String zurück. Da es sich um den ersten Lesevorgang handelt, wird die gesamte Datei gelesen und der Eigenschaft Text der TextBox zugewiesen. Die Textbox ist mehrzeilig (Eigenschaft MultiLine auf true). Die Eigenschaft CurrentEncoding liefert die ermittelte Kodierung zurück. Die angegebene Textdatei ist im Ansi-Format gespeichert. StreamReader meldet allerdings UTF-8, da dies als Standard gilt und ANSI-Dateien keine BOM-Markierung beinhalten. In Abbildung 14.5 sehen Sie, dass die Sonderzeichen (Umlaute) in der Tat verschluckt werden. CurrentEncoding funktioniert übrigens erst nachdem aus der Datei gelesen wurde, da die Kodierung (UTF-8 oder UTF-16) erst nach dem Lesen ermittelt werden kann. Schließlich sollten Sie nie vergessen, die Datei nach getaner Arbeit mit einem Aufruf von Close() zu schließen. Zwar erledigt das auch die Garbage-Collection – aber Sie wissen
nicht, wann sie das tut (möglicherweise erst bei Programmende oder, bei einem Absturz des Programms beispielsweise, nie).
Sandini Bib
360
14 Dateien und Verzeichnisse
Abbildung 14.5: Eine gelesene Textdatei im ANSI-Format, die fälschlicherweise als UTF-8 interpretiert wird
Kodierung zum Öffnen anpassen Der Konstruktor der StreamReader-Klasse ermöglicht die Angabe der Kodierung in Form einer Instanz der Klasse Encoding aus dem Namespace System.Text. Diese Angabe legt fest, mit welcher Kodierung der StreamReader arbeiten soll (die automatische Erkennung, die bei ANSI- oder ASCII-Dateien ohnehin nicht zuverlässig funktioniert, wird damit ausgeschaltet). Mithilfe der Methode GetEncoding() der Klasse Encoding können Sie die Kodierung auf eine bestimmte Codeseite festlegen. ANSI-Latin-1, wie in Westeuropa verwendet, hat die Codepage 1252. // System.Text per using einbinden – nicht vergessen Encoding enc = Encoding.GetEncoding( 1252 ); StreamReader sr = new StreamReader( @"c:\Test.txt", enc );
Die Encoding-Klasse besitzt einige statische Eigenschaften, die entsprechende EncodingInstanzen zurückliefern. Encoding.Default liefert beispielsweise die aktuelle WindowsCodepage. Encoding.ASCII Encoding.Default Encoding.UTF7 Encoding.UTF8 Encoding.Unicode Encoding.BigEndianUnicode
// // // // // //
US-ASCII (7 Bit) Windows-Default-Codepage UTF-7 UTF-8 UTF-16 UTF-16 Big Endian
Für alle anderen Kodierungen muss die Methode GetEncoding() verwendet werden. Übergeben wird entweder die Nummer der gewünschten Codeseite oder der entsprechende Windows-interne Name (z.B. »Windows-1252«). Die folgenden Beispiele zeigen verschiedene Einstellungen. Encoding enc = Encoding.GetEncoding( 850 ) // OEM Multilingual Latin 1 (DOS) Encoding enc = Encoding.GetEncoding( 1252 ) // ANSI Latin-1 Encoding enc = Encoding.GetEncoding( 28591 ) // ISO 8859-1 Latin-1
Sandini Bib
Textdateien
361
Eine Liste aller auf dem Rechner installierten Codeseiten finden Sie in der Systemsteuerung im Dialog LÄNDEREINSTELLUNGEN|ALLGEMEIN|ERWEITERT. Abbildung 14.6 zeigt das gleiche Programm jetzt mit Angabe der korrekten Kodierung. Wie Sie sehen, werden die Sonderzeichen korrekt dargestellt. Verwendet wurde die Standard-Codepage des Windows-Systems (Encoding.Default).
Abbildung 14.6: Darstellung des gleichen Texts, geladen mit korrekter Codepage
Inhalt der Datei lesen Über die Methode ReadToEnd() der Klasse StreamReader kann eine Datei komplett gelesen werden. Diese Methode haben wir bereits im letzten Beispiel verwendet. Der Lesevorgang kann allerdings auch zeilenweise durchgeführt werden. Die Methode ReadLine() der Klasse StreamReader liest eine Zeile aus dem Stream und liefert sie in Form eines Strings zurück. Ist das Ende der Datei erreicht, wird der Wert null zurückgeliefert. Damit kann eine Datei auf einfache Art und Weise gelesen und jede Zeile kontrolliert werden: string s = String.Empty; StreamReader sr = new StreamReader( @"C:\Test.txt", Encoding.Default ); do { s = sr.ReadLine(); if ( s != null ) { // Tu etwas } } while ( s != null );
Die Methode Peek() kann helfen, diesen Code noch zu vereinfachen. Sie »schaut« auf das Zeichen an der nächsten zu lesenden Position und liefert dessen Code zurück, ohne jedoch die Leseposition zu verändern. Ist das Dateiende erreicht, wird –1 zurückgeliefert. Unter Verwendung von Peek() spart man sich die if-Abfrage:
Sandini Bib
362
14 Dateien und Verzeichnisse
string s = String.Empty; StreamReader sr = new StreamReader( @"C:\Test.txt", Encoding.Default ); while ( sr.Peek() > -1 ) { s = sr.ReadLine(); //Tu etwas }
Auch einzelne Zeichen können gelesen werden. Dazu dient die Methode Read(), die allerdings keinen Wert des Typs char zurückliefert (wie vermutet werden könnte), sondern einen int-Wert mit dem Code des gelesenen Zeichens. Hier ist also Umwandlungsarbeit angesagt. Am Ende der Datei liefert Read() den Wert –1. Auch hier hilft Peek() wieder, zu ermitteln, ob das zu lesende Zeichen wirklich eines ist oder ob das Ende der Datei bereits erreicht wurde. string s = String.Empty; StreamReader sr = new StreamReader( @"C:\Test.txt", Encoding.Default ); while( sr.Peek() > -1 ) { char c = (char)sr.Read(); // Tu etwas }
In einer überladenen Version kann Read() auch dazu verwendet werden, ein Array aus char-Werten zu füllen. Übergeben werden hierbei einmal das Array, der Startindex und die Anzahl der zu lesenden Zeichen. Bei dem Startindex handelt es sich natürlich um den Startindex des Arrays, nicht um die Position innerhalb der Datei. Sollten Sie bei dieser Variante eine zu große Anzahl zu lesender Zeichen angeben, hat das lediglich zur Folge, dass weniger Zeichen in das Array eingetragen werden. Read() liefert dabei nicht etwa einen Zeichencode zurück, sondern die Anzahl der gelesenen Zeichen.
Beispielprogramm – Textdatei partiell lesen Das folgende Beispielprogramm zeigt den Inhalt einer Textdatei partiell an. Der Anwender kann festlegen, von welcher Position an gelesen werden soll und auch wie viele Zeichen angezeigt werden sollen. Da mit den bisher vorgestellten Mitteln kein direktes Setzen des Dateizeigers möglich ist, werden Start- und Endposition angegeben und es werden alle Zeichen bis zum Startindex gelesen und verworfen. Erst dann wird die gewünschte Anzahl Zeichen eingelesen. Die Maximalanzahl zu lesender Zeichen kann in diesem Fall leicht über die Dateigröße ermittelt werden, da wir eine ANSI-Datei verwenden (damit entspricht ein Zeichen auch einem Byte, d.h. die Dateigröße der Anzahl der Zeichen). Bei Unicode ist das nicht zwangsläufig der Fall. Die Anzahl der Zeichen bzw. die Größe der Datei wird in ein Label geschrieben und ist damit ständig verfügbar. Wieder werden nur die relevanten Methoden des Programms gezeigt.
Sandini Bib
CD
Textdateien
363
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\ReadToArray.
private string ReadPartial() { // Werte ermitteln int readFrom = Int32.Parse( txtFrom.Text ); int readTo = Int32.Parse( txtTo.Text ); // From muss kleiner sein als To if ( readTo < readFrom ) { int a = readFrom; readFrom = readTo; readTo = readFrom; txtFrom.Text = readFrom.ToString(); txtTo.Text = readTo.ToString(); } // Nicht weiter lesen als Dateilänge if ( readTo > Int32.Parse( this.lblCharCount.Text ) ) readTo = Int32.Parse( this.lblCharCount.Text ); // Anzahl zu lesender Zeichen int readCount = readTo - readFrom; char[] buffer = new char[readCount]; StreamReader sr = new StreamReader( txtFileName.Text, Encoding.Default ); // Lesen und Verwerfen for ( int i = 1; i < readFrom; i++ ) sr.Read(); // Zeichen lesen sr.Read( buffer, 0, readCount ); sr.Close(); // Zurückliefern return new String( buffer ); }
Einen Screenshot des Programms zur Laufzeit sehen Sie in Abbildung 14.7.
Sandini Bib
364
14 Dateien und Verzeichnisse
Abbildung 14.7: Partielles Lesen einer Textdatei
Positionszeiger in Textdateien bewegen Auf herkömmlichem Weg ist es zwar nicht möglich, den Positionszeiger innerhalb einer Textdatei zu bewegen, die Klasse StreamReader stellt dafür aber ein Hilfsmittel zur Verfügung. Da sie von Stream abgeleitet ist, liest sie auch aus einem solchen (um genau zu sein handelt es sich um einen FileStream). Da ein FileStream einfach nur Bytes repräsentiert und einen wahlfreien Zugriff zulässt (man kann also den Positionszeiger verschieben), ist dies auch bei Textdateien möglich. Auch hier gilt allerdings für Unicode-Dateien, dass es nur mit Einschränkungen empfohlen ist, so vorzugehen, solange Sie nicht genau wissen, wie viele Bytes die einzelnen Zeichen beanspruchen. Beim Standardformat UTF-8 ist die Anzahl der Bytes pro Zeichen bekanntlich variabel, weshalb der folgende Algorithmus eigentlich nur für ASCII- bzw. ANSI-Dateien relevant ist. Das sind aber immer noch die am häufigsten genutzten. Auf den zugrunde liegenden Stream kann über die Eigenschaft BaseStream der Klasse StreamReader zugegriffen werden. Diese Eigenschaft liefert ein FileStream-Objekt zurück, das dazu verwendet werden kann, den Positionszeiger zu setzen. Der StreamReader liest dann an der angegebenen Stelle weiter. Im vorherigen Beispielprogramm muss eigentlich nur der Teil geändert werden, an dem die Zeichen gelesen und verworfen werden. An die Stelle des Lesens tritt nun das direkte Setzen des Positionszeigers: // Position setzen sr.BaseStream.Seek( readFrom, SeekOrigin.Begin );
Sandini Bib
Textdateien
365
Die Methode Seek() erwartet einen Offset und den Startwert. Dieser wird als Wert der Aufzählung SeekOrigin angegeben, es kann sich dabei um den Anfang der Datei, das Ende oder die aktuelle Position handeln.
14.4.3
Textdateien schreiben (StreamWriter)
StreamWriter ist das Gegenstück zum StreamReader. Mithilfe dieser Klasse können neue Textdateien erstellt, beschrieben und Text an vorhandene Dateien angehängt werden. Der Standard für die Textkodierung ist auch bei dieser Klasse UTF-8, wobei diese Kodierung im Konstruktor wie gehabt durch Verwendung der Klasse Encoding geändert werden kann.
Obwohl UTF-8 die Verwendung der BOM ermöglicht, wird diese standardmäßig nicht geschrieben. Um das Schreiben dieser drei Bytes zu veranlassen, müssen Sie einen StreamWriter explizit mit der Angabe Encoding.UTF8 erzeugen. UTF-16-Dateien werden in jedem Fall gekennzeichnet.
Textdateien erzeugen und öffnen Es gibt zahlreiche Möglichkeiten, eine neue Textdatei zu erzeugen. Auch der Konstruktor der StreamWriter-Klasse ist vielfach überschrieben. Um eine nicht vorhandene Datei zu erzeugen, genügt es, den gewünschten Dateinamen anzugeben. StreamWriter sw = new StreamWriter( @"c:\test\test1.txt" );
alternativ können auch die folgenden Codezeilen verwendet werden: FileStram aStream StreamWriter sw = StreamWriter sw = StreamWriter sw =
= new FileStream( @"c:\test.txt" ); new StreamWriter( aStream ); File.CreateText( @"c:\test\test2.txt" ); new FileInfo( @"c:\test\test3.txt" ).CreateText();
Weitere überladene Versionen des Konstruktors erlauben die Angabe eines booleschen Parameters, mit dem angegeben wird, ob die Datei zum Anhängen geöffnet werden soll oder nicht. Auch die Klasse FileInfo besitzt eine Methode, AppendText(), die einen StreamReader zurückliefert, der zum Anhängen weiteren Texts geeignet ist. StreamWriter sw = new StreamWriter( @"c:\test\test1.txt", true ); StreamWriter sw = File.AppendText( @"c:\test\test2.txt" ); StreamWriter sw = new FileInfo( "c:\test\test3.txt" ).AppendText();
Andere Kodierung als UTF-8 verwenden Der dritte Parameter des StreamWriter-Konstruktors gibt die Kodierung an. Standard ist UTF-8, über eine Instanz der Encoding-Klasse können Sie aber eine andere Kodierung festlegen. StreamWriter sw = new StreamWriter( @"c:\test.txt", false, Encoding.Default ); // neu StreamWriter sw = new StreamWriter( @"c:\text.txt", true, Encoding.ASCII ) // Anhängen
Sandini Bib
HINWEIS
366
14 Dateien und Verzeichnisse
Intern verwendet C# zur Darstellung von Zeichenketten immer Unicode. Beim Speichern einer Zeichenkette in einer Textdatei werden alle Zeichen entsprechend der gewählten Kodierung in Bytecodes umgewandelt. Wenn ein Unicode-Zeichen in der Kodierung nicht vorgesehen ist (z.B. ein deutsches Sonderzeichen bei der Kodierung Encoding.ASCII), wird es beim Speichern automatisch durch ein Fragezeichen ersetzt (oder durch einen speziellen Code für unbekannte Zeichen, sofern ein derartiger Code im Zeichensatz vorgesehen ist).
Kennzeichnung von Unicode-Dateien StreamWriter verwendet zwar per Default UTF-8, kennzeichnet neue Dateien aber nicht mit den drei BOM-Bytes (EF BB BF). Wenn Sie das möchten, erzeugen Sie die neue Datei unter Angabe von Encoding.UFT8: StreamWriter sw = new StreamWriter( @"c:\test.txt", False, Encoding.UTF8 )
UTF-16-Dateien (also Dateien auf der Basis von Encoding.Unicode oder Encoding.BigEndianUnicode) werden in jedem Fall durch die zwei BOM-Bytes FF FE bzw. FE FF gekennzeichnet.
Textdateien schreiben Analog zum Lesen besitzt StreamWriter zwei Methoden zum Schreiben von Text in eine Datei. Diese Methoden ähneln den gleichnamigen Methoden der Klasse Console. Write() schreibt einen String in die Datei, WriteLine() einen String mit einem Zeilenende-Zeichen. Unter Windows besteht dies aus zwei Zeichen, nämlich (char)10 und (char)13. WriteLine() fügt allerdings immer die Zeichen an, die für das betreffende Betriebssystem relevant sind und die Sie über die Eigenschaft Environment.NewLine ermitteln können. Sowohl bei Write() als auch bei WriteLine() gibt es zahlreiche Syntaxvarianten, dank derer alle elementaren .NET-Datentypen direkt als Parameter übergeben werden können. Standardmäßig werden bei der Umwandlung in Zeichenketten die aktuellen Ländereinstellungen berücksichtigt. // schreibt "0,3333333333333333" in die Datei sw.WriteLine( 1/3 ); Write() bzw. WriteLine() berücksichtigen auch die bekannten Formatstrings, mit denen wir
schon gearbeitet haben. Damit ist auch die folgende Angabe möglich: sw.WriteLine( "Das heutige Datum ist: {0}", DateTime.Now );
Um die Ländereinstellungen zu berücksichtigen, müssen Sie allerdings auf die Methode Format() der Klasse String zurückgreifen. WriteLine() bietet keine Möglichkeit, Kulturinformationen mit anzugeben. // schreibt z.B. " giovedì 5 gennaio 2006" in die Datei CultureInfo ci = new CultureInfo( "it-IT" ); // italienisch sw.WriteLine( String.Format( ci, "{0:D}", DateTime.Now ) );
Sandini Bib
Textdateien
367
Die Daten werden allerdings unter Umständen nicht sofort physikalisch auf die Festplatte geschrieben. Das geschieht in der Regel erst beim Schließen der Datei. Um die Daten direkt auf die Festplatte zu verbannen, können Sie entweder die Methode Flush() verwenden oder aber die Eigenschaft AutoFlush des StreamWriter-Objekts auf true setzen.
14.4.4
Beispielprogramm – Textdatei erstellen und lesen
Im folgenden Beispielprogramm wird ein StreamWriter-Objekt verwendet, um eingegebenen Text in einer Textdatei zu speichern. Das Programm selbst ist eigentlich schon ein kleiner Texteditor, die Datei zum Speichern kann über einen SaveFileDialog angegeben werden. Zum Laden des Texts wird ein StreamReader-Objekt verwendet. Da es sich eigentlich um einen Texteditor handelt, dieser aber wirklich nur elementare Funktionalität zur Verfügung stellt, heißt er PicoEditor. Das Formular besteht aus drei Buttons, einem zum Laden, einem zum Speichern und einem zum Beenden des Programms. Der zu speichernde Text entstammt einer TextBox, deren Eigenschaft Multiline auf true gesetzt ist. Über die Eigenschaft Lines der TextBox kann in diesem Fall ein Array aus Strings ausgelesen werden, das die Zeilen in der Textbox enthält.
CD
Voraussetzung für das Programm ist, dass die Namensräume System.IO und System.Text eingebunden werden. Letzterer wegen des verwendeten Encoding-Objekts. Gezeigt werden wie immer nur die relevanten Methoden des Programms. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\StreamPicoEditor.
private void BtnLoad_Click( object sender, EventArgs e ) { if ( this.dlgOpen.ShowDialog() == DialogResult.OK ) { StreamReader sr = new StreamReader( dlgOpen.FileName, Encoding.UTF8 ); txtText.Text = sr.ReadToEnd(); sr.Close(); } } private void BtnSave_Click( object sender, EventArgs e ) { if ( this.dlgSave.ShowDialog() == DialogResult.OK ) { StreamWriter sw = new StreamWriter( dlgSave.FileName, false, Encoding.UTF8 ); foreach ( string s in txtText.Lines ) sw.WriteLine( s ); sw.Close(); } }
Sandini Bib
368
14 Dateien und Verzeichnisse
Der Quellcode sollte eigentlich keiner weiteren Erklärung bedürfen. Wichtig ist, dass beim Schreiben und beim Lesen der Datei das gleiche Encoding-Objekt verwendet wird, sonst ergeben sich (zwangsläufig) Darstellungsprobleme. Sie können das leicht testen, indem Sie in einer der Methoden z.B. Encoding.ASCII angeben. Abbildung 14.8 zeigt das Programm zur Laufzeit. Es ist übrigens ganz nebenbei auch möglich, Text aus der Zwischenablage einzufügen. Die TextBox bietet diese Funktionalität automatisch, über das Kontextmenü oder über die aus anderen Programmen bekannten Tastenkombinationen (Strg)+(X), (Strg)+(C) bzw. (Strg)+(V). Auch eine RückgängigFunktionalität (Undo())ist standardmäßig schon dabei.
Abbildung 14.8: Der Minimaleditor im Einsatz
Wenn Sie ein Programm zur hexadezimalen Darstellung von Dateien besitzen, können Sie sich Byte für Byte ansehen, wie die Datei intern aussieht. In Abbildung 14.9 sehen Sie deutlich die drei Kennzeichnungsbytes.
Abbildung 14.9: Ansicht des gespeicherten Texts mit einem Hex-Editor
Sandini Bib
HINWEIS
Textdateien
369
Vergessen Sie auf keinen Fall das Schließen des StreamWriter- bzw. StreamReaderObjekts über die Methode Close(). Zwar verliert die Variable sw am Ende der Methode ihre Gültigkeit, das StreamWriter-Objekt wird aber erst bei der nächsten Garbage Collection tatsächlich aus dem Speicher entfernt (und Sie wissen nicht, wann diese stattfindet). Bis dahin gilt die Datei als offen. Beim Versuch, die Datei erneut zu öffnen, würde daher ein Fehler auftreten!
14.4.5
Beispielprogramm – Textkodierung ändern
CD
Sie können die Klassen StreamReader und StreamWriter natürlich auch dazu verwenden, die Kodierung einer Textdatei explizit zu ändern. Dazu verwenden Sie ein StreamReader-Objekt um die Datei zu lesen und ein StreamWriter-Objekt mit anderer Kodierung um die Datei zu speichern. Das folgende kleine Konsolenprogramm demonstriert die Vorgehensweise anhand einer Konvertierung von UTF-8 nach ANSI. Das Programm arbeitet mit Kommandozeilenargumenten, die über args ermittelt werden. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\UTF2Ansi.
static void Main( string[] args ) { if ( args.Length < 2 ) { Console.WriteLine( "Bitte geben Sie zwei Dateinamen an" ); } else { // Dateinamen ermitteln string fileName1 = args[0]; string fileName2 = args[1]; StreamReader sr = new StreamReader( fileName1, Encoding.UTF8 ); StreamWriter sw = new StreamWriter( fileName2, false, Encoding.Default ); sw.Write( sr.ReadToEnd() ); sr.Close(); sw.Close(); } }
Die Anweisung sw.Write( sr.ReadToEnd() ) ist zwar für den Programmierer praktisch, aber in der Ausführung nicht unbedingt optimal, denn die zu konvertierende Datei wird vollständig in den Arbeitsspeicher geladen. Das funktioniert nur bei kleinen Dateien gut, bei großen Dateien muss die Datei dagegen stückweise gelesen und geschrieben werden. Die folgenden Zeilen zeigen dafür einen entsprechenden Algorithmus, der ein char-Array als Pufferspeicher verwendet. Mit Read() werden maximal 65536 Zeichen in dieses Array gelesen. Mit Write() werden anschließend die tatsächlich gelesenen Zeichen gespeichert.
Sandini Bib
CD
370
14 Dateien und Verzeichnisse
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_10\UTF2Ansi2.
static void Main( string[] args ) { if ( args.Length < 2 ) { Console.WriteLine( "Bitte geben Sie zwei Dateinamen an" ); } else { // Dateinamen ermitteln string fileName1 = args[0]; string fileName2 = args[1]; // Puffergröße festlegen und Puffer bereitstellen int bufSize = 65535; char[] buffer = new Char[bufSize]; // Variable für tatsächlich gelesene Zeichen int bytesRead = 0; StreamReader reader = new StreamReader( fileName1, Encoding.UTF8 ); StreamWriter writer = new StreamWriter( fileName2, false, Encoding.Default ); do { bytesRead = reader.Read( buffer, 0, bufSize ); writer.Write( buffer, 0, bytesRead ); } while ( bytesRead == bufSize ); reader.Close(); writer.Close(); } }
14.4.6
Zeichenketten lesen und schreiben (StringReader und StringWriter)
Zu den in den vorigen Abschnitten vorgestellten Klassen StreamReader und StreamWriter gibt es zwei analoge Klassen StringReader und StringWriter. Der wesentliche Unterschied besteht darin, dass Sie mit diesen Klassen nicht Dateien, sondern Zeichenketten lesen bzw. schreiben können. Beispielsweise können Sie sie dazu verwenden, um Daten, die Sie sonst in einer temporären Datei zwischenspeichern würden, in einer string-Variablen zwischenzuspeichern. Der offensichtliche Vorteil besteht darin, dass dazu kein Festplattenzugriff erforderlich ist – die Vorgehensweise ist daher (zumindest bei kleinen Datenmengen) deutlich effizienter. Gleichzeitig sind nur minimale Änderungen am Code erforderlich, weil die meisten Eigenschaften und Methoden äquivalent sind.
Sandini Bib
Textdateien
371
StringReader Mit den folgenden zwei Zeilen wird zuerst eine Zeichenkette initialisiert und auf deren Basis dann das StringReader-Objekt sr erzeugt. Aus diesem Objekt können Sie nun wie bei einem StreamReader-Objekt mit den Methoden Read(), ReadLine() oder ReadToEnd() einzelne Zeichen, Zeilen oder den gesamten Inhalt lesen. Beachten Sie, dass das StringReader-Objekt statisch ist. Eine nachträgliche Veränderung der Variablen s hat keinerlei Einfluss mehr auf dieses Objekt! string s = "abcdefg" + Environment.NewLine + "zweite Zeile" + Environment.NewLine; StringReader sr = new StringReader( s ); Console.WriteLine( sr.ReadLine() );
StringWriter Einem StringWriter-Objekt liegt intern ein StringBuilder-Objekt zugrunde. Insofern bietet die Klasse StringWriter einfach eine weitere Möglichkeit, Zeichenketten sehr viel effizienter als durch s += "abc" zusammenzusetzen. Dazu verwenden Sie in diesem Fall die Methoden Write() oder WriteLine(). StringWriter sw = new StringWriter(); sw.WriteLine( "erste Zeile" ); sw.WriteLine( "zweite Zeile" );
Sie können vom StringWriter-Objekt jederzeit mit den Methoden GetStringBuilder() oder ToString() ein StringBuilder-Objekt oder eine gewöhnliche Zeichenkette ableiten. Console.Write( sw.ToString() );
Wie bei einem StreamWriter-Objekt können Sie das Schreiben mit Close() abschließen. Das bedeutet, dass weitere Veränderungen nicht mehr möglich sind. Der Inhalt der StringWriter-Objekts bleibt aber erhalten, d.h. GetStringBuilder() und ToString() funktionieren weiterhin. Wenn Sie den Inhalt des StringWriter()-Objekts dagegen löschen möchten, müssen Sie die Methode Dispose() ausführen.
14.4.7
Syntaxzusammenfassung
Die folgenden Tabellen fassen wieder die einzelnen Methoden und Eigenschaften der in den vohergehenden Abschnitten behandelten Klassen zusammen.
Die Klasse Encoding Die Klasse Encoding bietet eine Reihe nützlicher Methoden, nicht nur in Bezug auf eine Codepage, sondern auch für die Umwandlung eines Strings in ein Byte-Array oder zur Ermittlung der BOM. Die folgende Tabelle listet die wichtigsten Eigenschaften und Methoden auf. Beachten Sie bitte, dass die Methoden oft mehrfach überladen sind und hier nicht alle Versionen beschrieben werden.
Sandini Bib
372
14 Dateien und Verzeichnisse
Methoden und Eigenschaften der Klasse Encoding (aus System.Text) ASCII UTF7 UTF8 Unicode
statische Eigenschaften der Klasse Encoding, die ein ensprechendes Kodierungsobjekt zurückliefern.
WindowsCodepage
liefert die Codepage des Windows-Systems, die der aktuellen Encoding-Instanz entspricht.
GetBytes( string s )
liefert ein byte-Array, das den angegebenen String repräsentiert. Diese Methode ist mehrfach überladen und arbeitet unter anderem auch mit einem char-Array oder Teilen eines char-Arrays. Umwandlungen in byte-Arrays sind vor allem bei kryptografischen Methoden wichtig.
GetEncoding( int codepage ) GetEncoding( string name )
statische Methode. Liefert ein Encoding-Objekt, das die angegebene Codepage repräsentiert. Diese kann entweder über eine Nummer oder über einen Namen angegeben werden.
GetPreamble()
liefert die Bytes am Anfang eines Streams, die in der Regel die BOM beinhalten.
GetByteCount( string s ) GetByteCount( char[] c )
liefert die Anzahl der Bytes, die bei Verwendung der aktuellen Encoding-Instanz benötigt werden, um den angegebenen String oder das angegebene char-Array in ein Byte-Array umzuwandeln.
GetCharCount( byte[] b )
liefert die Anzahl der Zeichen, die bei Verwendung der aktuellen Encoding-Instanz beim Dekodieren des angegebenen byte-Arrays erzeugt werden.
EncodingName
liefert die Beschreibung der Kodierung des aktuellen EncodingObjekts im Klartext.
GetChars( byte[] b )
dekodiert ein byte-Array in ein char-Array.
Textdateien lesen Erzeugen eines Objekts vom Typ StreamReader (aus System.IO) new StreamReader( string filename )
Konstruktor. Öffnet die Textdatei, die in fileName angegeben ist. fileName enthält auch den Pfad zur Datei. Ist dieser ein relativer Pfad, wird vom aktuellen Arbeitsverzeichnis ausgegangen.
new StreamReader( string fileName, Encoding enc )
wie oben, aber unter Anwendung einer bestimmten Kodierung
new StreamReader( string fileName, Encoding enc, bool detectBOM );
wie oben, allerdings mit der Angabe, ob die BOM automatisch erkannt werden soll oder nicht
Sandini Bib
Textdateien
373
Erzeugen eines Objekts vom Typ StreamReader (aus System.IO) new StreamReader( Stream reader )
Konstruktor. Erzeugt einen StreamReader aus einem beliebigen Stream-Objekt.
File.OpenText( string filename )
öffnet die in fileName angegebene Textdatei und liefert ein StreamReader-Objekt, das sofort verwendet werden kann.
FileInfo fi = new FileInfo( string filename ); fi.OpenText();
öffnet ebenfalls eine Textdatei, wobei hier eine Instanz der FileInfo-Klasse verwendet wird. Auch hier wird ein StreamReader-Objekt zurückgeliefert.
Eigenschaften und Methoden der Klasse StreamReader (aus System.IO) CurrentEncoding
liefert ein Encoding-Objekt, das die aktuelle Kodierung angibt. Standard ist UTF-8.
BaseStream
liefert den zugrunde liegenden Stream zurück, der dazu verwendet werden kann, die Position des Dateizeigers direkt zu manipulieren oder zu ermitteln.
Close()
schließt die Datei, die durch den aktuellen StreamReader repräsentiert wird.
Peek()
liefert den Code des nächsten zu lesenden Zeichens. Das Zeichen wird nicht gelesen und der Dateizeiger nicht bewegt. Am Ende der Datei wird –1 geliefert.
Read()
liest das nächste Zeichen und liefert dessen Code zurück. Am Ende der Datei wird –1 zurückgeliefert.
Read( char[] c, int start, int count )
liest eine angegebene Anzahl Zeichen in das char-Array c. start bezeichnet den Index innerhalb des Arrays, ab dem die Zeichen eingefügt werden. count gibt die maximale Anzahl der zu lesenden Zeichen an. Die Methode liefert die Anzahl der tatsächlich gelesenen Zeichen zurück.
ReadLine()
liefert die nächste Zeile bzw. null, wenn das Dateiende erreicht ist.
ReadToEnd()
liefert den gesamten Text bis zum Ende der Datei.
Textdateien schreiben Erzeugen eines Objekts vom Typ StreamWriter (aus System.IO) new StreamWriter( string fileName )
Konstruktor. Erzeugt eine neue Textdatei mit dem Namen fileName. fileName enthält auch den Pfad zur Datei. Ist dieser ein relativer Pfad, wird vom aktuellen Arbeitsverzeichnis ausgegangen.
Sandini Bib
374
14 Dateien und Verzeichnisse
Erzeugen eines Objekts vom Typ StreamWriter (aus System.IO) new StreamWriter( string fileName, bool append, Encoding enc )
wie oben, aber unter Anwendung einer bestimmten Kodierung und mit der Angabe, ob die Datei neu erzeugt oder Text an die Datei angehängt werden soll
new StreamWriter( string fileName, bool append )
wie oben, mit Standardkodierung und möglichem Anhängen von Text
new StreamWriter( Stream st )
erzeugt einen neuen StreamWriter auf Basis des angegebenen Stream-Objekts.
File.AppendText( string filename )
liefert einen StreamWriter zurück, an die angegebene Datei werden Daten angehängt. Es kann auch die gleichnamige Instanzmethode der FileInfo-Klasse verwendet werden.
File.CreateText( string filename )
erzeugt eine neue Textdatei. Es kann auch die gleichnamige Instanzmethode der FileInfo-Klasse verwendet werden.
Eigenschaften und Methoden der Klasse StreamWriter (aus System.IO) AutoFlush
Diese Eigenschaft gibt an, ob der Text, der in den Stream geschrieben wird, auch sofort physikalisch auf die Festplatte geschrieben wird. Ist AutoFlush false, werden diese Daten zwischengepuffert, bis entweder Flush() oder Close() aufgerufen werden.
BaseStream
liefert den zugrunde liegenden Stream zurück, der dazu verwendet werden kann, die Position des Dateizeigers direkt zu manipulieren oder zu ermitteln.
Encoding
ruft die aktuelle Kodierung ab, mit der der StreamWriter arbeitet. Standard ist UTF-8-Kodierung. Diese Eigenschaft ist read-only.
NewLine
ruft die Zeichenfolge für den Zeilenwechsel ab bzw. verändert sie. Unter Windows ist dies in der Regel die Zeichenfolge \r\n (Wagenrücklauf und Linefeed).
Close()
schließt die Datei und schreibt die im Puffer befindlichen Daten auf die Festplatte.
Flush()
schreibt die im Puffer befindlichen Daten auf die Festplatte.
WriteLine( object o )
wie die entsprechende Methode Write(), allerdings mit Zeilenvorschub
Write( object o)
schreibt den Inhalt von o in die Textdatei (als Text). Diese Methode ist vielfach überladen, es können alle Datentypen geschrieben werden.
Write( char[] c, int start, int count)
speichert count Zeichen beginnend bei der Position start aus dem charArray c in der Datei. Die Position start betrifft das Array, nicht den Dateizeiger.
WriteLine( char[] c, int start, int count )
wie die entsprechende Methode Write(), allerdings mit Zeilenvorschub
Sandini Bib
Binäre Dateien
14.5
375
Binäre Dateien
Das Schreiben und Lesen von Textdateien gestaltet sich weitgehend problemlos, die Klassen StreamWriter und StreamReader erledigen den größten Teil der Arbeit (z.B. die Umwandlung von Datentypen) automatisch. Weiterhin hat eine solche Datei natürlich den Vorteil, dass sie praktisch mit Bordmitteln gelesen werden kann (die Editoren von Windows 2000 und Windows XP beherrschen das Unicode-Format). Bei Binärdateien werden Daten dagegen in ihrer internen Darstellung gespeichert. Beispielsweise wird eine Fließkommazahl vom Typ double nicht in der Form 123.456789012345 gespeichert, sondern durch die hexadezimalen Codes 69 4C FB 07 3C DD 5E 40. Das hat Vor- und Nachteile: Auf der einen Seite sind Binärdateien meist deutlich kleiner als Textdateien, außerdem ist das Lesen und Schreiben der Daten effizienter, weil keine Umwandlungen erforderlich sind. Auf der anderen Seite können derartige Dateien nur mit einem Programm gelesen werden, das den exakten Aufbau der enthaltenen Daten kennt. Zum Lesen und Schreiben von Binärdateien bietet der Namespace System.IO grundsätzlich zwei Möglichkeiten: f Die Klasse FileStream ermöglicht einen Dateizugriff auf unterster Ebene. Mit Objekten dieses Typs können einzelne oder ganze Gruppen von Bytes gelesen und geschrieben werden. FileStream arbeitet mit Bytes, die Interpretation der Daten muss innerhalb des Progamms geschehen. Die FileStream-Klasse bietet eine fast vollständige Kontrolle über den Lese- bzw. Schreibprozess: Sie können die Position des Dateizeigers frei verändern und so die Daten in beliebiger Reihenfolge lesen oder schreiben. Sie können auch in der gleichen Datei lesen und schreiben und weiterhin zwischen synchronem und asynchronem Zugriff wählen (siehe Abschnitt ). f Die beiden Klassen BinaryReader und BinaryWriter ermöglichen es, die meisten Basisdatentypen unmittelbar zu lesen und zu schreiben. Beispielsweise können Sie damit eine double-Variable speichern, ohne sich Gedanken über die interne Darstellung dieser Variablen zu machen. Dieser Vorteil wird allerdings durch andere Einschränkungen gegenüber der FileStream-Klasse erkauft. Der Unterschied zur Klasse StreamWriter besteht in der Darstellung des gespeicherten Werts in der Datei. Daneben beschreibt dieser Abschnitt noch zwei weitere Klassen, die ebenfalls von Stream abgeleitet sind und sich häufig als nützliche Helfer erweisen: f Die Klasse MemoryStream bietet im Wesentlichen dieselben Methoden und Eigenschaften wie FileStream, der Datenstrom wird aber im Arbeitsspeicher verwaltet. MemoryStream eignet sich daher vor allem als Ersatz für temporäre Dateien. Sollten die Daten dennoch gespeichert werden müssen, so ist dies problemlos möglich, indem der MemoryStream einfach durch einen FileStream geschickt wird. f BufferedStream ist eine Hilfsklasse zur Optimierung von Schreib- und Lesezugriffen. Die Klasse hilft dabei, die Zugriffszeit bei wiederholten Lese- bzw. Schreibzugriffen durch die Verwendung eines größeren Zwischenspeichers zu optimieren.
Sandini Bib
376
14 Dateien und Verzeichnisse
14.5.1
Die Klasse FileStream
Im Falle binärer Dateien ist FileStream wohl die am häufigsten eingesetzte Klasse, da sie recht universell verwendbar ist. Wie der Name bereits sagt, ist ein FileStream immer an eine Datei gekoppelt.
Erzeugen eines FileStream-Objekts Der Konstruktor der Klasse FileStream steht in mehreren Varianten zur Verfügung. Die Angabe des Dateinamens oder (falls vorhanden) eines Dateihandles ist obligatorisch. Weiterhin kann noch angegeben werden, in welchem Modus die Datei geöffnet werden soll, welche Freigabeberechtigung gelten soll bzw. wie die Lese- und Schreibberechtigungen gesetzt werden sollen. Die folgenden Konstruktoren geben eine kleine Übersicht über die Möglichkeiten der Erstellung eines FileStream-Objekts: FileStream FileStream FileStream FileStream FileStream
fs fs fs fs fs
= = = = =
new new new new new
FileStream( FileStream( FileStream( FileStream( FileStream(
filename ); fileName, mode ); fileName, mode, access ); fileName, mode, access, sharing ); fileName, mode, access, sharing, buffersize );
mode ist ein Wert des Typs FileMode. Die Konstanten dieser Aufzählung geben an, auf welche Weise die Datei geöffnet werden soll (z.B. ob sie neu erstellt, zum Lesen oder zum Schreiben geöffnet werden soll). access ist vom Typ FileAccess, wobei es sich ebenfalls um eine Aufzählung handelt. Sie gibt den Zugriffsmodus an, also Lesen, Schreiben oder Lesen und Schreiben.
Der Unterschied zwischen diesen beiden Parametern ist auf den ersten Blick nicht erkennbar, er wird aber sofort deutlich, wenn Sie sich kurz in die Online-Hilfe begeben. Während mit der FileAccess-Konstante der Benutzerzugriff gesteuert wird, kann über mode auch angegeben werden, dass die Datei geöffnet werden soll, wenn sie existiert, bzw. neu erstellt, wenn sie nicht existiert. Der Parameter sharing vom Typ FileShare regelt den Zugriff anderer Benutzer auf die Datei. Beispielsweise kann hiermit angegeben werden, dass die Datei zwar zum Lesen weiterhin durch andere Programme geöffnet werden kann, nicht jedoch zum Schreiben. buffersize schließlich gibt die Größe des Schreib-/Lesepuffers an. Leider finden sich hier-
VERWEIS
zu keine Werte in der Dokumentation, in der Regel ist der Standardpuffer allerdings ausreichend, wie Versuche ergeben haben. FileStream-Objekte können auch mit den Methoden Create(), Open(), OpenRead() und OpenWrite() der Klasse File bzw. eines Objekts des Typs FileInfo erzeugt werden.
Eine Syntaxzusammenfassung dieser Methoden finden Sie in Abschnitt 14.2.8 ab Seite 346.
Sandini Bib
Binäre Dateien
377
Methoden zum Lesen und Schreiben von Daten Die Klasse FileStream kennt nur vier verschiedene Methoden zum Lesen und Schreiben: Read(), ReadByte(), Write() und WriteByte(). ReadByte() dient, wie der Name suggeriert zum Lesen eines einzelnen Bytes, wonach der Dateizeiger um ein Byte weitergesetzt wird. Obwohl dieser Wert nur aus 8 Bit besteht und damit nicht größer werden kann als 255 wird dennoch ein Wert des Typs int zurückgeliefert. Falls das Ende der Datei erreicht ist, beträgt dieser Wert –1. Read() wiederum ermöglicht das Einlesen mehrerer Bytes, die in einem Byte-Array abge-
legt werden. Zurückgeliefert wird in diesem Fall die Anzahl der gelesenen Zeichen oder null, wenn das Dateiende erreicht bzw. überschritten wurde. Hier verbirgt sich auch eine potenzielle Fehlerquelle. Read() erwartet als Parameter das Byte-Array, den Startindex in
diesem Array und die Anzahl der zu lesenden Zeichen. Ist dieser letzte Wert größer als die noch in der Datei vorhandenen Bytes, wird null zurückgeliefert und es tritt eine Exception auf, was natürlich nicht im Sinne des Programmierers ist. Warum FileStream sich so verhält, ist nicht ganz verständlich, man kann diesen Fehler aber relativ leicht umgehen. Im ersten Beispielprogramm dieses Abschnitts werden wir Ihnen zeigen, wie Sie das Auslesen der Daten sicher gestalten können. Analog zu den Lesemethoden funktionieren auch die Methoden WriteByte() bzw. Write(). Während WriteByte() für das Schreiben eines einzelnen Bytes zuständig ist, schreibt Write() ein Array aus Bytes oder Teile davon in den Stream. Auch hier kann wieder ein Startindex und die Menge der zu schreibenden Bytes angegeben werden. Write() und WriteByte() liefern keinen Wert zurück. Bevor Sie allerdings losschreiben oder -lesen können, sollten Sie kontrollieren, ob das für diese FileStream-Instanz überhaupt erlaubt ist. Dazu dienen die Eigenschaften CanRead und CanWrite, die einen entsprechenden booleschen Wert zurückliefern.
Ändern der Position im Stream Die Schreib- und Lesemethoden ändern die aktuelle Position des Dateizeigers automatisch, wenn gelesen wird. Falls Sie jedoch aus einem bestimmten Bereich der Datei Bytes lesen wollen, müssen Sie diesen manuell verändern. Die Eigenschaft Position vom Typ long liefert die aktuelle Position innerhalb der Datei und ermöglicht auch deren Änderung durch Angabe des aktuellen Werts, den der Dateizeiger einnehmen soll. Eine weitere Möglichkeit der Positionsänderung besteht durch die Methode Seek(), die eine Änderung relativ zu einer bestimmten Position, nämlich zur aktuellen Position, zum Dateibeginn oder dem Dateiende ermöglicht. Die Ausgangsposition wird durch eine Konstante der Aufzählung SeekOrigin festgelegt. SeekOrigin.Begin bezeichnet den Anfang der Datei, SeekOrigin.End das Ende und SeekOrigin.Current die aktuelle Position des Dateizeigers. Wenn Sie also den Dateizeiger um 10 Bytes von der aktuellen Position aus in Richtung Dateiende bewegen wollen, verwenden Sie folgende Codezeile: fs.Seek( 10, SeekOrigin.Current );
Sandini Bib
378
14 Dateien und Verzeichnisse
Auch die Bewegung zum Anfang der Datei ist möglich, indem einfach ein negativer Wert angegeben wird: fs.Seek( -10, SeekOrigin.Current );
HINWEIS
Damit Sie bei diesen Bewegungen oder bei der absoluten Festlegung der Dateizeigerposition nicht über das Ziel hinausschießen, benötigen Sie natürlich noch die Länge der Datei. Diese wird durch die Eigenschaft Length angegeben. Length dient nicht nur zum Ermitteln, sondern auch zum Festlegen der Größe des
Streams. Sie können also durch Angabe eines kleineren Werts eine Datei »abschneiden«. Die Klassen File bzw. FileInfo bieten hierzu leider keine Methode (was eigentlich intuitiver gewesen wäre).
Schließen der Datei Wie auch bei Textdateien sollten Sie ein Kommando nicht vergessen, nämlich Close() zum Schließen der Datei. Ansonsten ist der Zugriff, je nach den Angaben beim Öffnen der Datei, aus anderen Applikationen (oder auch nur aus einer anderen Methode) nicht möglich. Auch hier werden die Daten letztendlich erst dann komplett auf die Festplatte geschrieben, wenn die Datei geschlossen oder explizit die Methode Flush() aufgerufen wird. Die Eigenschaft AutoFlush zum Automatisieren dieses Vorgangs steht dementsprechend ebenfalls zur Verfügung.
14.5.2
Beispielprogramm – Dateien zerteilen
Als Beispielanwendung für die Klasse FileStream soll ein kleines Utility dienen, das Dateien in kleine Häppchen zerteilt. Die Größe dieser Teildateien kann angegeben werden, was in diesem Fall über eine Combobox geschieht. Wie bei den vorherigen Programmen handelt es sich wiederum um eine Windows.Forms-Applikation. Der Aufbau der Oberfläche gestaltet sich verhältnismäßig einfach. Es befinden sich darauf ein paar Labels, ein ComboBox-Steuerelement zur Auswahl der Dateigröße, zwei Textboxen für Quell- und Zieldateiname und ein paar Buttons. Abbildung 14.10 zeigt den Aufbau. Die eigentliche Funktionalität ist in einer Klasse namens FileSplit implementiert. Die Teildateien erhalten die gleiche Endung wie die Quelldatei und werden durchnummeriert. Das Programm zerteilt die Dateien natürlich nicht nur, sondern setzt sie auch wieder zusammen. Die Hauptfunktionalität der Anwendung ist in einer Klasse zusammengefasst. Über den Konstruktor werden die Werte initialisiert. Benötigt werden dabei der Quelldateiname, der Zieldateiname und die Größe der Zieldateien zum Zerteilen. Wird eine Datei zusammengesetzt oder soll standardmäßig eine Größe von 1440 kB angenommen werden, ist die Angabe des letzten Parameters nicht erforderlich.
Sandini Bib
Binäre Dateien
379
CD
Abbildung 14.10: Der Aufbau der Oberfläche für das Programm
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\FileSplitter.
Felder und Eigenschaften der Klasse FileSplit Über zwei Eigenschaften CanSplit und CanConcat wird kontrolliert, ob das Zerteilen der Dateien bzw. das Zusammensetzen möglich ist. Selbstverständlich sind auch Quell- und Zieldateiname sowie gewünschte Dateigröße für die Teildateien als Eigenschaften implementiert. Hier zunächst die Deklaration der Felder und Eigenschaften der Klasse FileSplit: public class FileSplit { private string sourceFile; private string destFile; private int fileSize; // Quelldateiname public string SourceFile { get { return this.sourceFile; } set { this.sourceFile = value; } } // Zieldateiname public string DestFile { get { return this.destFile; } set { this.destFile = value; } }
Sandini Bib
380
14 Dateien und Verzeichnisse
// Größe der Zieldateien public int FileSize { get { return this.fileSize; } set { this.fileSize = value; } } // Kann zerteilt werden? public bool CanSplit { get { bool result = true; result &= File.Exists( this.sourceFile ); result &= !this.destFile.Equals( String.Empty ); result &= ( this.fileSize > 0 && this.fileSize <= 1440 ); return result; } } // Kann zusammengesetzt werden? public bool CanConcat { get { bool result = true; result &= File.Exists( this.sourceFile ); result &= !this.destFile.Equals( String.Empty ); return result; } }
Die Information, ob zusammengesetzt oder zerteilt werden kann, wird einfach über das Vorhandensein der jeweils notwendigen Daten ermittelt. Ansonsten enthalten die Deklarationen keine weiteren Besonderheiten. Die beiden Konstruktoren sind ebenfalls sehr leicht verständlich. Standardwert für die Zieldateigröße sind 1440 kB, also Diskettengröße. Ein parameterloser Konstruktor existiert nicht. public FileSplit( string src, string dst, int siz ) { this.destFile = dst; this.sourceFile = src; this.fileSize = siz; } public FileSplit( string src, string dst ) : this( src, dst, 1440 ) { } }
Sandini Bib
Binäre Dateien
381
Zerteilen der Datei Die erste interessante Methode der Klasse FileSplit ist die zum Zerteilen der Datei. Festgelegt werden müssen die Puffergröße, die durchlaufende Nummer für die Zieldateien, eine Variable für die tatsächlich gelesenen Bytes und eine Variable für die zu lesenden Bytes. Letztere beiden könnten auch in der Schleife, die das eigentliche Lesen und Schreiben durchführt, deklariert werden. public bool SplitFile() { bool result = false; // Ergebnis int rwBuffer = this.fileSize * 1024; // Zieldateigröße int fileCounter = 0; // Durchlaufende Nummer für Datei int bytesRead = 0; // Tatsächlich gelesene Bytes int bytesToRead = rwBuffer; // Zu lesende Bytes
Damit die Namen der Zieldateien zusammengesetzt werden können, müssen sie zunächst in Einzelteile zerlegt werden: // Zieldateiname und Pfad zwischenspeichern string destExt = Path.GetExtension( this.destFile ); string destination = Path.GetFileNameWithoutExtension( this.destFile ); string destPath = Path.GetDirectoryName( this.destFile );
Aus diesen Bestandteilen wird später der korrekte Dateiname erstellt. Der FileStream zum Lesen kann sofort erzeugt werden, der zum Schreiben hat noch etwas Zeit. Die Variable wird aber dennoch bereits deklariert. // Streams festlegen FileStream srcStream = new FileStream( this.sourceFile, FileMode.Open, FileAccess.Read, FileShare.None ); FileStream dstStream;
Der Puffer zum Lesen, also das byte-Array, wird auf die vorher festgelegte Größe gesetzt: //Puffer festlegen byte[] fileBytes = new byte[rwBuffer];
Nun kommt der eigentlich interessante Teil der Methode, nämlich das Zerteilen der Datei. Um möglichen Fehlern vorzubeugen wurde die Schleife in einen try-catch-Block gepackt. Gleich am Anfang der Schleife erfolgt eine Kontrolle, die sicherstellt, dass nicht über das Dateiende hinausgelesen werden kann. Die aktuelle Position der Quelldatei bestimmt, wie viele Bytes noch gelesen werden können. Ist diese Anzahl kleiner als die Puffergröße, würde es zum Fehler kommen. Aus diesem Grund wird die Anzahl der zu lesenden Bytes in diesem Fall auf die noch vorhandene Anzahl Bytes gesetzt. Damit kann es nicht mehr passieren, dass über das Ende der Datei hinausgelesen wird. Das Casting zum Datentyp int ist notwendig, weil sowohl die Eigenschaft Length des FileStream als auch die Eigenschaft Position vom Datentyp long sind. Die Anzahl zu lesender Bytes wird aber mittels eines int-Werts angegeben. Durch die Festlegung auf maximal 1,4 MB (1440 kBytes) wissen wir aber, dass der Wert beim Casting nicht verfälscht wird.
Sandini Bib
382
14 Dateien und Verzeichnisse
Nach der Kontrolle und dem Festlegen der noch zu lesenden Bytes erfolgt die Ermittlung des Zieldateinamens, die keine Verständnisprobleme aufwerfen dürfte. Für den ermittelten Dateinamen wird ein Stream erzeugt und die gelesenen Bytes werden hineingeschrieben. Danach kann der Stream wieder geschlossen und der Dateizähler erhöht werden. Die Schleife läuft so lange, wie die Anzahl der gelesenen Bytes der Puffergröße entspricht (in diesem Fall kann davon ausgegangen werden, dass noch Bytes in der Quelldatei »übrig« sind). Nach der Schleife kann die Operation als geglückt betrachtet werden, die Variable result wird demnach auf true gesetzt. Sollte ein Fehler auftreten, wird dieser über eine MessageBox angezeigt, der finally-Block sorgt dafür, dass auch in diesem Fall der FileStream für die Quelldatei geschlossen wird. try { do { if ( ( srcStream.Length - srcStream.Position ) < rwBuffer ) bytesToRead = (int)( srcStream.Length - srcStream.Position ); bytesRead = srcStream.Read( fileBytes, 0, bytesToRead ); // Neuen Dateinamen festlegen string currDestFile = destPath + @"\" + destination; // Nummerierung erst ab 1 if ( fileCounter > 0 ) currDestFile += fileCounter.ToString(); currDestFile += destExt; // Datei schreiben dstStream = new FileStream( currDestFile, FileMode.Create, FileAccess.ReadWrite, FileShare.None ); dstStream.Write( fileBytes, 0, bytesRead ); dstStream.Close(); // Zähler erhöhen fileCounter++; } while ( bytesRead == rwBuffer ); // Operation erfolgreich result = true; } catch ( Exception ex ) { MessageBox.Show( ex.Message, "Fehler" ); throw; } finally { srcStream.Close(); } return result; }
Sandini Bib
Binäre Dateien
383
Dateien zusammensetzen Ein Programm, das Dateien in kleine Einzelstücke auftrennt, wäre natürlich nicht komplett, wenn die Dateistückchen nicht auch wieder zu einer kompletten Datei zusammengesetzt werden könnten. Die Methode ConcatFile() tut genau das, indem sie den umgekehrten Weg geht. Diesmal dient die Eigenschaft SourceFile zur Angabe der gesplitteten Dateien, DestFile zur Angabe der Zieldatei. Da jetzt alles andersherum läuft, muss auch nicht mehr kontrolliert werden, ob genügend Bytes vorhanden sind – die Anzahl zu lesender Bytes ergibt sich automatisch aus der Größe der jeweiligen Quelldatei. Auf Basis der Methode SplitFile() sollte ConcatFile() daher leicht verständlich sein und wird deshalb am Stück abgedruckt. public bool ConcatFile() { bool result = false; // Größe erste Datei als Puffer festlegen // Die maximale Größe ist die eines int --> Casting möglich int rwBuffer = (int)( new FileInfo( this.sourceFile ).Length ); // Durchlaufende Nummer für Datei int fileCounter = 0; // Tatsächlich gelesene Bytes int bytesRead = 0; // Zu lesende Bytes (abhängig von Datei) int bytesToRead; // Quelldateiname und Pfad zwischenspeichern string srcExt = Path.GetExtension( this.sourceFile ); string source = Path.GetFileNameWithoutExtension( this.sourceFile ); string srcPath = Path.GetDirectoryName( this.sourceFile ); // Streams festlegen FileStream dstStream = new FileStream( this.destFile, FileMode.Create, FileAccess.ReadWrite, FileShare.None ); FileStream srcStream; // Puffer festlegen byte[] fileBytes = new byte[rwBuffer]; try { do { // Neuen Dateinamen festlegen string currSrcFile = srcPath + @"\" + source;
Sandini Bib
384
14 Dateien und Verzeichnisse // Nummerierung erst ab 1 if ( fileCounter > 0 ) currSrcFile += fileCounter.ToString(); currSrcFile += srcExt; // Stream erzeugen srcStream = new FileStream( currSrcFile, FileMode.Open, FileAccess.Read, FileShare.None ); // Lesen bytesToRead = (int)srcStream.Length; bytesRead = srcStream.Read( fileBytes, 0, bytesToRead ); srcStream.Close(); fileCounter++; // Schreiben dstStream.Write( fileBytes, 0, bytesRead ); } while ( bytesToRead == rwBuffer ); // Operation erfolgreich result = true; } catch ( Exception ex ) { MessageBox.Show( ex.Message, "Fehler" ); throw; } finally { dstStream.Close(); } return result;
}
Methoden des Hauptformulars Die Auswahl der Quell- und Zieldateien geschieht über entsprechende Dialoge des Typs OpenFileDialog bzw. SaveFileDialog. Auch die Methoden zum Splitten und Zusammensetzen der Dateien sind nicht weiter kompliziert: private void BtnSource_Click( object sender, EventArgs e ) { if ( this.dlgOpen.ShowDialog() == DialogResult.OK ) this.txtSource.Text = this.dlgOpen.FileName; } private void BtnDestination_Click( object sender, EventArgs e ) { if ( this.dlgSave.ShowDialog() == DialogResult.OK ) this.txtDestination.Text = this.dlgSave.FileName; }
Sandini Bib
Binäre Dateien
385
private void BtnSplit_Click( object sender, EventArgs e ) { FileSplit split = new FileSplit( this.txtSource.Text, this.txtDestination.Text, Int32.Parse( cbxFileSize.Text ) ); if ( split.CanSplit ) { this.Cursor = Cursors.WaitCursor; split.SplitFile(); this.Cursor = Cursors.Default; } } private void BtnConcat_Click( object sender, EventArgs e ) { FileSplit split = new FileSplit( this.txtSource.Text, this.txtDestination.Text ); if ( split.CanConcat ) { this.Cursor = Cursors.WaitCursor; split.ConcatFile(); this.Cursor = Cursors.Default; } }
14.5.3
Gleichzeitiger Zugriff auf eine Datei
Der FileShare-Parameter
CD
Soll der Zugriff auf eine Datei von zwei Seiten aus erfolgen, d.h. in der Regel von zwei Programmen gleichzeitig, kann dieses Verhalten durch den Parameter vom Typ FileShare angegeben werden. Laut Dokumentation legt FileShare allerdings fest, welche nachfolgenden Dateizugriffe erlaubt sein sollen. Das ist so nicht korrekt, wie folgendes kleines Beispiel zeigt. Es wird hier versucht, eine Datei zu öffnen, innerhalb des gleichen Programms aber von zwei verschiedenen FileStream-Objekten. Das entspricht der Simulation eines Zugriffs von zwei verschiedenen Stellen. Ein Panel gibt den Erfolg der Operation an, es wird grün gefärbt, wenn der Zugriff erfolgreich war, und rot, wenn nicht. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\SharedAccess.
private void BtnCreateFile_Click( object sender, EventArgs e ) { // Binäre Datei mit 10 Bytes erzeugen string fileName = Path.GetTempFileName(); FileStream firstStream = new FileStream( fileName, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite );
Sandini Bib
386
14 Dateien und Verzeichnisse
for ( int i = 0; i < 10; i++ ) { firstStream.WriteByte( (byte)i ); } try { int x; // Datei lesen FileStream secondStream = new FileStream( fileName, FileMode.Open, FileAccess.Read, FileShare.Read ); for ( int i = 0; i < 10; i++ ) { x = secondStream.ReadByte(); } secondStream.Close(); this.pnlResult.BackColor = Color.Green; } catch { this.pnlResult.BackColor = Color.Red; } finally { firstStream.Close(); } }
Folgt man der Dokumentation, sollte das Ergebnis dieser Routine eigentlich sein, dass das Panel pnlResult grün eingefärbt wird. Dem ist aber nicht so, der Fehler tritt bereits beim Erzeugen des zweiten FileStream auf. Grund für die Fehlermeldung ist der Wert FileShare.Read, der nicht nur für nachfolgende, sondern auch für vorherige Zugriffe gilt – und mit firstStream wurde ja geschrieben. Die Änderung auf FileStream fs2 = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
liefert das gewünschte Ergebnis. Sollten Sie dieses Feature verwenden, d.h. eine Datei öffnen wollen, ohne dass anderen Programmen der Zugriff darauf verwehrt wird, müssen Sie auf die korrekte Angabe des FileShare-Parameters achten.
Sperren von Bits Zum Sperren von Bits dienen die Methoden Lock() bzw. Unlock() der Klasse FileStream. Damit kann der Zugriff auf bestimmte Stellen der Datei relativ genau festgelegt werden, d.h. der Zugriff auf bestimmte Bits kann gesperrt werden. Die Syntax der Methoden ist nicht weiter kompliziert, es werden der Offset und die Anzahl der zu sperrenden Bits übergeben. Das Verhalten der Datei nach dem Sperren allerdings ist nicht wie erwartet. Sofern nur ein Teil der Datei gesperrt wurde, sollte man meinen, dass der restliche Teil noch gelesen werden kann. Die folgende Routine, die sich ebenfalls im Programm SharedAccess befindet, sollte demnach wieder ein grünes Panel ergeben.
Sandini Bib
Binäre Dateien
387
private void BtnLockBits_Click( object sender, EventArgs e ) { string fileName = Path.GetTempFileName(); // Datei schreiben FileStream firstStream = new FileStream( fileName, FileMode.OpenOrCreate, FileAccess.Write ); for ( int i = 0; i < 10; i++ ) { firstStream.WriteByte( (byte)i ); } firstStream.Close(); // Zugriff auf Datei try { FileStream secondStream = new FileStream( fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite ); // Sperren secondStream.Lock( 5, 2 ); FileStream thirdStream = new FileStream( fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite ); // Erstes Byte (nicht gesperrt) lesen thirdStream.ReadByte(); thirdStream.Close(); // Sperre entfernen secondStream.Unlock( 5, 2 ); secondStream.Close(); pnlResult.BackColor = Color.Green; } catch ( Exception ex ) { MessageBox.Show( ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Stop ); pnlResult.BackColor = Color.Red; } }
Das Ergebnis jedoch ist die Ausgabe der Fehlermeldung aus Abbildung 14.11. Offensichtlich ist es so, dass zwar das teilweise Sperren einer Datei möglich ist, von anderer Seite aber in keinem Fall auf diese Datei zugegriffen werden kann. Lock() schützt demnach nur vor Veränderungen durch den aktuellen FileStream.
Sandini Bib
388
14 Dateien und Verzeichnisse
Abbildung 14.11: Die Fehlermeldung beim Versuch, eine (teilweise) gesperrte Datei zu öffnen
14.5.4
Die Klasse BufferedStream
Die Klasse BufferedStream dient dazu, die Effizienz beim Umgang mit anderen StreamObjekten zu steigern. Dazu werden Schreibvorgänge zuerst im Arbeitsspeicher zwischengespeichert, bevor sie tatsächlich durchgeführt werden. Bei Lesevorgängen werden entsprechend schon im Voraus größere Datenmengen gelesen, die dann sofort zur Verfügung stehen. Die Anwendung eines BufferedStream empfiehlt sich laut Dokumentation dann, wenn sehr viele Lese- oder Schreibvorgänge hintereinander durchgeführt werden, es aber nur selten zu einem Wechsel zwischen Lese- und Schreibvorgängen kommt. Ein Objekt des Typs BufferedStream wird erzeugt, indem im Konstruktor zumindest das bestehende Stream-Objekt angegeben wird, dessen Schreib-/Lesevorgänge beschleunigt werden sollen. Optional kann auch die Größe des Datenpuffers angegeben werden, die der BufferedStream verwenden soll. Diese Vorgehensweise bietet sich natürlich besonders bei FileStream-Objekten an. Im Falle eines MemoryStream gibt es nicht viel zu beschleunigen, da die Daten ja ohnehin schon im Speicher sind. Anschließend verwenden Sie die Read()- bzw. Write()-Methoden des BufferedStream-Objekts statt derer des Basis-Streams. Die Verwendung des BufferedStream-Objekts erfordert also fast keine Veränderung an bereits bestehendem Code, noch dazu, wo dieses gleich mitgeschlossen wird, wenn das zugrunde liegende Stream-Objekt geschlossen wird. FileStream fs = new FileStream( fileName, FileMode.Create ); BufferedStream bs = new BufferedStream( fs ); bs.Write(...); fs.Close(); // BufferedStream wird mitgeschlossen
Beispiel Natürlich werden auch bei FileStream-Objekten Lese- und Schreibvorgänge zwischengespeichert, ansonsten wäre der Zugriff auf Dateien unerträglich langsam. Insofern war nicht ganz klar, welche Größenordnungen bzgl. der Beschleunigung des Zugriffs zu erwarten waren. Je nach Puffergröße sind die Unterschiede in der Tat nicht als relevant zu bezeichnen. Das folgende Programm erzeugt zwei temporäre Dateien. Die Größe kann wahlweise auf 10 oder 100 MB festgelegt werden. Die erste Datei wird auf der Basis eines FileStreamObjekts erstellt, die zweite unter Zuhilfenahme eines BufferedStream-Objekts. In beiden Fäl-
Sandini Bib
Binäre Dateien
389
len werden die Daten Byte für Byte mit WriteByte() gespeichert. Abbildung 14.12 zeigt die Oberfläche des Programms.
Abbildung 14.12: Die Oberfläche des Programms mit Auswahlmöglichkeiten für Dateigröße und die Art des FileStream.
CD
In der Anwendung können Sie zum Vergleich auswählen, wie groß die Dateien sein sollen und auch ob der FileStream gepuffert sein soll (er hat ja einen eigenen Puffer) oder nicht. Im Falle ungepufferter FileStreams ergibt sich in der Tat ein großer Unterschied, der einen zunächst an die zukünftig ausschließliche Verwendung eines BufferedStream-Objekts denken lässt. Mit entsprechendem Puffer relativiert sich das Ganze dann aber schnell. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\BufferedStream.
Im Hauptformular werden zwei Felder definiert, die die Dateigröße und den temporären Dateinamen aufnehmen. Diesen Feldern werden mittels einer Methode ihre Werte zugewiesen. private void SetValues() { this.fileSize = this.rbTenMB.Checked ? 10485760 : 104857600; this.fileName = Path.GetTempFileName(); }
Die Methode GetFileStream() liefert abhängig von den Einstellungen einen gepufferten oder ungepufferten FileStream zurück. private FileStream GetFileStream() { // Basiswerte setzen SetValues();
Sandini Bib
390
14 Dateien und Verzeichnisse
// Zurückliefern return this.rbNotBuffered.Checked ? new FileStream( this.fileName, FileMode.Create ) : new FileStream( this.fileName, FileMode.Create, FileAccess.Write, FileShare.None, this.fileSize ); }
Diese beiden Methoden werden dann aus den eigentlichen Ereignisbehandlungsroutinen, die das Schreiben der Datei und die Zeitmessung erledigen, aufgerufen. Zunächst die Methode zum Schreiben einer Datei mittels FileStream. Für die Zeitmessung wird die Klasse Stopwatch herangezogen. private void BtnFileStream_Click( object sender, EventArgs e ) { // Holen des korrekten FileStream FileStream stream = GetFileStream(); // Zeitmessung starten Stopwatch watch = new Stopwatch(); watch.Start(); // Schreiben for ( int i = 0; i < fileSize; i++ ) stream.WriteByte( (byte)( i % 16 ) ); watch.Stop(); this.lblFileStreamResult.Text = watch.Elapsed.ToString(); // Temporäre Datei löschen stream.Close(); File.Delete( this.fileName ); }
Die Methode für den gepufferten Stream sieht fast genauso aus. Auch dort wird ein FileStream erzeugt, der dann aber in den BufferedStream »eingehängt« wird. Beim Schließen des FileStream-Objekts wird auch der dazugehörige BufferedStream geschlossen. private void BtnBufferedStream_Click( object sender, EventArgs e ) { // Basiswerte setzen SetValues(); FileStream stream = new FileStream( this.fileName, FileMode.Create ); BufferedStream buffered = new BufferedStream( stream, this.fileSize );
Sandini Bib
Binäre Dateien
391
// Zeitmessung starten Stopwatch watch = new Stopwatch(); watch.Start(); // Schreiben for ( int i = 0; i < fileSize; i++ ) buffered.WriteByte( (byte)( i % 16 ) ); watch.Stop(); this.lblBufferedResult.Text = watch.Elapsed.ToString(); // Temporäre Datei löschen stream.Close(); // BufferedStream wird mit geschlossen File.Delete( this.fileName ); }
Abbildung 14.13 zeigt das Ergebnis mit einem deutlichen Vorsprung für die BufferedStream-Variante bei einem ungepufferten FileStream. Auch wenn diese Zeiten recht klein sind, zeigt sich hier doch ein enormer Geschwindigkeitszuwachs (bei einer 100-MB-Datei).
Abbildung 14.13: Geschwindigkeitsunterschied zwischen FileStream und BufferedStream
Fast ebenso überraschend ist das Ergebnis, wenn auch dem FileStream eine Puffergröße zugestanden wird. Das Ergebnis in Abbildung 14.14 spricht für sich.
Sandini Bib
392
14 Dateien und Verzeichnisse
Abbildung 14.14: Geschwindigkeitszuwachs unter Angabe der Puffergröße
14.5.5
MemoryStream (Streams im Arbeitsspeicher)
Die Klasse MemoryStream verwaltet einen Stream im Arbeitsspeicher. Da MemoryStream ebenfalls von Stream abgeleitet ist, stehen die meisten von FileStream bekannten Eigenschaften und Methoden auch für MemoryStream-Objekte zur Verfügung. Im einfachsten Fall, d.h. wenn das Objekt mit new MemoryStream() erzeugt wurde, erfolgt die Verwaltung des von dem Objekt benötigten Speichers automatisch, dieser wird stets an die Erfordernisse angepasst. Die zahlreichen Varianten des Konstruktors ermöglichen es aber auch, in der Größe oder im Inhalt unveränderliche MemoryStreams auf der Basis von byteArrays zu erzeugen. MemoryStream ms = new MemoryStream() ms.WriteByte(...)
Mit WriteTo() können Sie den Inhalt eines MemoryStream-Objekts in ein anderes Stream-Objekt übertragen. Wenn Sie dabei als Parameter beispielsweise ein FileStream-Objekt angeben, können Sie ein MemoryStream-Objekt in einer Datei speichern. Oftmals werden MemoryStreamObjekte auch dann benutzt, wenn Daten zwischengespeichert werden müssen und keine temporäre Datei verwendet werden soll. Mit den folgenden Zeilen wird zuerst ein MemoryStream-Objekt erzeugt, um darin zehn Bytes zu speichern. Anschließend wird der Inhalt des Objekts in einer temporären Datei gespeichert. MemoryStream ms = new MemoryStream(); for( int i = 0; i < 10; i++ ) ms.WriteByte( (byte)i ); FileStream stream = new FileStream( Path.GetTempFileName(), FileMode.Create ); ms.WriteTo( stream );
Sandini Bib
Binäre Dateien
393
Wenn Sie das gesamte MemoryStream-Objekt in einer anderen Form weiterbearbeiten möchten, können Sie es auch mit der Methode ToArray() in ein byte-Array kopieren.
14.5.6
Dateien komprimieren
Die IO-Routinen des .NET Frameworks haben Zuwachs bekommen. Im Namespace System.IO.Compression finden sich zwei Klassen, die zunächst einmal großes vermuten lassen: DeflateStream sowie GZipStream. Leider sind beide sehr inkonsequent umgesetzt. f Mit beiden Streams lässt sich immer nur eine Datei komprimieren bzw. dekomprimieren f Der Dateiname einer Datei, die komprimiert ist, lässt sich nicht ermitteln f Obwohl DeflateStream eigentlich dem ZIP-Algorithmus entspricht, ließen sich komprimierte Dateien nicht öffnen. Die Meldung war stets »Das Archiv ist beschädigt«. Dennoch war es möglich, die Dateien fehlerfrei wieder auszupacken.
CD
An dieser Stelle hätte ich etwas mehr erwartet, zumal es mit der SharpZipLib bereits eine Implementierung für ZIP-Dateien gibt. Die Möglichkeit, eine einzige Datei zu komprimieren, ist, um es höflich auszudrücken, »unbefriedigend«. Dennoch sollen Sie erfahren, wie es funktioniert. Auch hierfür existiert ein kleines Beispielprogramm. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\ZipperExample.
Es lässt sich also immer nur eine Datei komprimieren bzw. dekomprimieren. Beide Klassen arbeiten auf die gleiche Weise, das Beispiel kann also durch einfache Änderung der verwendeten Klasse umgebaut werden. Gezeigt werden hier nur die Methoden zum Komprimieren und Entkomprimieren, die in eine eigene Klasse ausgelagert wurden. Zunächst soll eine Datei zusammengepresst werden.
Komprimieren einer Datei Gleich welche Aktion, zunächst benötigen wir die Daten, die komprimiert bzw. dekomprimiert werden sollen. Im Falle des Komprimierens ist das sehr einfach. Sie erinnern sich sicherlich an die Methode ReadAllBytes() der Klasse File. Diese findet hier Verwendung und liest alle Bytes der Quelldatei in ein byte-Array ein. Danach werden ein Ausgabestream (FileStream) sowie ein DeflateStream (zum Komprimieren) erzeugt. Der DeflateStream benötigt als ersten Parameter den Stream, in den er schreiben soll. Danach wird der gesamte Pufferinhalt einfach in den DeflateStream geschrieben. Dieser komprimiert dann während des Schreibens. Zum Schluss wird der FileStream geschlossen (was auch den Kompressionsstream schließt). Das folgende Listing zeigt die Methode.
Sandini Bib
394
14 Dateien und Verzeichnisse
public void Compress( string source, string destination ) { // Datei einlesen. // Sehr einfach über File.ReadBytes() byte[] buffer = File.ReadAllBytes( source ); // Ausgabestream erzeugen und an DeflateStream hängen FileStream outStream = new FileStream( destination, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None ); DeflateStream zipStream = new DeflateStream( outStream, CompressionMode.Compress, true ); // Schreiben zipStream.Write( buffer, 0, buffer.Length ); // OutStream schließen, DeflateStream wird mit geschlossen outStream.Close(); }
Dekomprimieren Beim Dekomprimieren gibt es ein Problem: Es ist nicht möglich, die dekomprimierte Größe der gepackten Datei zu ermitteln. Damit ein Puffer bereitgestellt werden kann, muss aber irgend ein Wert existieren. Diesem Wert kann man sich nur annähern, oder man kann einfach mal versuchen, mit dem Maximalwert zu arbeiten (also Int32.MaxValue). Dieser Versuch führte dazu, dass .NET mir zum ersten Mal die Meldung überbrachte, mein Speicher sei voll … naja, 2GB RAM reichen eben nicht für alles. Als Annäherungswert wird angenommen, dass die Datei auf 1/20 komprimiert wurde, d.h. die Dateigröße mal 20 ist der zur Verfügung stehende Puffer. Nun tritt ein zweites Problem auf: während des Lesens werden die Daten ja entpackt, und die entpackte Größe ist nicht bekannt – sie muss gemessen werden, d.h. es muss schrittweise gelesen werden. Die folgende Routine liest immer 100 Bytes aus dem Stream. Und da es sich bei diesem um einen Entkomprimierungsstream handelt, handelt es sich auch um 100 dekomprimierte Bytes. public int ReadAllBytes( Stream stream, byte[] buffer ) { // Liest alle Bytes aus dem Stream int offset = 0; int totalCount = 0; while ( true ) { int bytesRead = stream.Read( buffer, offset, 100 ); if ( bytesRead == 0 ) break;
Sandini Bib
Binäre Dateien
395
offset += bytesRead; totalCount += bytesRead; } return totalCount; }
Der Puffer wird gefüllt und die Gesamtanzahl der entnommenen Bytes zurückgeliefert. Das ist wichtig, da nur diese Anzahl auch auf der Festplatte landen darf. Danach werden sie noch geschrieben und alle erzeugten Streams geschlossen. public void Decompress( string source, string destination ) { FileStream inStream = new FileStream( source, FileMode.Open, FileAccess.Read, FileShare.None ); // Keine Möglichkeit, die ungepackte Größe zu ermitteln byte[] buffer = new byte[inStream.Length * 20]; // Entpacker-Stream erzeugen DeflateStream zipStream = new DeflateStream( inStream, CompressionMode.Decompress ); int numberOfUnpackedBytes = ReadAllBytes( zipStream, buffer ); FileStream outStream = new FileStream( destination, FileMode.Create, FileAccess.Write, FileShare.None ); outStream.Write( buffer, 0, numberOfUnpackedBytes ); inStream.Close(); zipStream.Close(); outStream.Close(); }
Alles in allem muss man aber sagen, dass die Implementierung sehr unbefriedigend ist. Hinzu kommt noch, dass beispielsweise die Eigenschaft CanSeek des komprimierten Streams true zurückliefert, ein Zugriff auf Position oder Seek() aber eine Exception auslöst. Insgesamt lässt sich zu diesem Feature nur sagen: Entweder richtig oder gar nicht – aber nicht so.
14.5.7
BinaryReader und -Writer (Variablen binär speichern)
Wenn Sie mithilfe eines FileStream Daten schreiben oder lesen, müssen Sie sich selbst um die Repräsentation innerhalb des Programms bzw. um die Umwandlung in Bytes kümmern. Die Klassen BinaryReader und BinaryWriter nehmen Ihnen diese Arbeit ab und ermöglichen es, beliebige Werte binär in eine Datei zu schreiben. Die Umwandlung in Bytes findet automatisch statt.
Sandini Bib
396
14 Dateien und Verzeichnisse
BinaryReader und BinaryWriter bieten Methoden zum Lesen bzw. Schreiben für jeden primi-
tiven Datentyp des .NET Frameworks, allerdings nicht für zusammengesetzte Datentypen. Der Typ DateTime beispielsweise wird nicht direkt unterstützt. Trotzdem lässt sich ein solcher Wert natürlich speichern, da die Eigenschaft Ticks bekanntlich das gesamte Datum und die Zeit in Form eines long-Werts (Int64) bereitstellt. Dieser kann durchaus geschrieben und später wieder eingelesen werden. Im Beispielprogramm wird die genaue Vorgehensweise gezeigt. Während die Klasse BinaryReader übrigens für jeden Datentyp eine eigene Methode zur Verfügung stellt (zwangsläufig, denn es ändert sich ja nur der Rückgabewert, wodurch ein Überladen nicht möglich ist), bietet BinaryWriter nur eine (allerdings 17fach überladene) Methode Write(), mit der die Daten geschrieben werden.
Erstellen von BinaryReader und BinaryWriter BinaryReader und BinaryWriter sind nicht von Stream abgeleitet, dennoch arbeiten sie mit einem solchen. Der Stream muss vorher erzeugt und an den Konstruktor übergeben werden. Dabei kann es sich um jedes Objekt handeln, dessen Klasse von Stream abgeleitet ist. Für BinaryReader muss dieser Stream verständlicherweise Lesevorgänge unterstützen, für BinaryWriter Schreibvorgänge. Die folgenden Codezeilen zeigen die Erstellung eines BinaryReader-Objekts mithilfe eines FileStream: FileStream fs = new FileStream( @"C:\Text.dat", FileMode.Open, FileAccess.Read ); BinaryReader br = new BinaryReader( fs );
Eine weitere Möglichkeit, wenn z.B. Zeichen oder ganze Strings geschrieben werden sollen, ist die zusätzliche Verwendung eines Werts vom Typ Encoding: FileStream fs = new FileStream( @"C:\Text.dat", FileMode.Open, FileAccess.Read ); BinaryReader br = new BinaryReader( fs, Encoding.Default );
Achten Sie bei der Verwendung von Encoding darauf, dass Sie den Namespace System.Text einbinden. Das Erstellen eines BinaryWriter funktioniert analog. Dieser bietet auch scheinbar ein paar Funktionen mehr, z.B. wie FileStream eine Methode Seek(), mit der die Position zum Schreiben innerhalb des Streams festgelegt werden kann. Mit dem BinaryReader funktioniert dies nicht auf Anhieb, zumindest nicht direkt. Es ist jedoch über die Eigenschaft BaseStream dennoch möglich, auf den zugrunde liegenden Stream zuzugreifen und, falls es sich um einen FileStream handelt, dort Seek() auszuführen. Die Dokumentation rät allerdings aus gutem Grund davon ab, denn in diesem Moment ist nicht mehr gewährleistet, dass Sie auch wirklich die Daten bekommen, die Sie möchten. Vor allem dann nicht, wenn Sie Strings gespeichert haben.
Umgang mit Zeichenketten BinaryReader und BinaryWriter berücksichtigen beim Lesen bzw. Schreiben von Char- und String-Variablen die Kodierung, die beim Erzeugen des BinaryWriter-Objekts angegebenen wurde. Fehlt diese Angabe, wird die Standardkodierung verwendet (wie bei StreamReader
Sandini Bib
Binäre Dateien
397
und StreamWriter ist das UTF-8). Das bedeutet allerdings auch, dass je nach Zeichen unterschiedlich viele Bytes zur Kodierung verwendet werden. BinaryWriter speichert zudem bei Variablen vom Typ string die Anzahl der Zeichen. Das hilft dem BinaryReader später, die richtige Länge der Zeichenkette zu erkennen. Die Zei-
chenanzahl wird dabei auf eine besondere Weise kodiert: f Werte bis zu 127 werden in einem einzigen Byte gespeichert. f Größere Werte werden auf mehrere Bytes verteilt, wobei in jedem Byte nur sieben Bits für die Kodierung der Zahl genutzt werden. Das achte Bit gibt an, ob im nächsten Byte weitere Bits folgen. Auf diese Weise können beliebig große ganze Zahlen gespeichert werden, ohne jedes Mal vier Bytes zu beanspruchen.
14.5.8
Beispielprogramm – unterschiedliche Daten schreiben und lesen
CD
Das folgende Beispielprogramm speichert eine beliebige Anzahl Strukturen in eine Datei. Die Strukturen enthalten jeweils nur Daten und sind in einer Liste (in diesem Fall in einer ListBox) abgelegt. Für jeden struct wird die Methode zum Speichern aufgerufen, die die einzelnen Bestandteile mittels eines BinaryWriter in die Datei schreibt. Geladen werden die Elemente wieder mithilfe eines BinaryReader. Die Darstellung erfolgt wie angesprochen in einer Listbox. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\RWBinary.
Aufbau der Datenstruktur Als Beispieldaten werden Adressen verwendet, ein aus der Tradition gewachsener Standard für eine solche Art Anwendung. ListBox-Steuerelemente benutzen grundsätzlich die Methode ToString() der enthaltenen Objekte zur Anzeige. Unsere Struktur wird also ebenfalls eine solche Methode erhalten, damit die Daten auch korrekt angezeigt werden können. Mit der eigentlichen Speicherung hat diese Methode allerdings nichts zu tun. Um zu zeigen, dass auch die Speicherung eines Datums- oder Zeitwerts möglich ist, wird das Geburtsdatum mitgespeichert. Der Aufbau des struct ist denkbar einfach. Im Beispielprogramm befindet er sich in einer eigenen Datei: public struct Address { private private private private private
string name; string street; int zip; string city; string email;
Sandini Bib
398
14 Dateien und Verzeichnisse
public string Name { get { return name; } set { name = value; } } public string Street { get { return street; } set { street = value; } } public int Zip { get { return zip; } set { zip = value; } } public string City { get { return city; } set { city = value; } } public string Email { get { return email; } set { email = value; } } // Für die Ausgabe public override string ToString() { return this.name + " " + this.street + " " + this.zip.ToString() + " " + this.city + " " + this.email; } }
Die Methoden zum Lesen und Schreiben Die eigentlichen Methoden zum Lesen und Schreiben werden ebenfalls in einer eigenen Klasse abgelegt, der Einfachheit und Übersichtlichkeit halber diesmal als statische Methoden. Die Klasse trägt den Namen BinaryFileManager, der Methodenaufruf muss mit dem Klassennamen qualifiziert werden, da es sich um statische Methoden handelt. Die Schreibmethode überprüft zunächst, ob die übergebene List leer ist (dann erfolgt keine Aktion) oder nicht. Im letzteren Fall werden die Daten geschrieben. Um das Ganze übersichtlich zu halten, enthält das Programm (wieder einmal) keine Fehlerbehandlung, außerdem ist auch der Dateiname hartkodiert:
Sandini Bib
Binäre Dateien
399
public static void WriteData( List addressList ) { // Jeden Datensatz schreiben if ( addressList.Count == 0 ) { MessageBox.Show( "Es sind keine Daten vorhanden", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Stop ); return; } FileStream stream = new FileStream( @"C:\test.dat", FileMode.Create, FileAccess.Write ); BinaryWriter writer = new BinaryWriter( stream ); // Schreiben foreach ( Address address in addressList ) { writer.Write( address.Name ); writer.Write( address.Street ); writer.Write( address.Zip ); writer.Write( address.City ); writer.Write( address.Email ); } // Datei schließen writer.Close(); stream.Close(); }
Das Laden der Dateien erfolgt ähnlich, nur wird diesmal ein BinaryReader verwendet und natürlich eine List zurückgeliefert. Vor dem Laden wird kontrolliert, ob die Datei existiert. public static List ReadData() { // Alle Datensätze lesen if ( !File.Exists( @"C:\Test.dat" ) ) { MessageBox.Show( "Es ist keine Datei vorhanden", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Stop ); return null; } List result = new List(); FileStream stream = new FileStream( @"C:\Test.dat", FileMode.Open, FileAccess.Read ); BinaryReader reader = new BinaryReader( stream ); // Lesen while ( reader.PeekChar() > -1 ) {
Sandini Bib
400
14 Dateien und Verzeichnisse // Es sind Daten vorhanden Address address = new Address(); address.Name = reader.ReadString(); address.Street = reader.ReadString(); address.Zip = reader.ReadInt32(); address.City = reader.ReadString(); address.Email = reader.ReadString(); result.Add( address );
} // Datei schließen reader.Close(); stream.Close(); return result; }
In der Methode ReadData() wird die Methode PeekChar() des BinaryReader-Objekts verwendet, um zu kontrollieren, ob der Dateizeiger schon am Ende der Datei ist. PeekChar() liest einfach das nächste Zeichen und liefert –1, wenn kein Zeichen mehr vorhanden ist (demnach ist dann das Dateiende erreicht). Ansonsten gibt es in diesem Quelltext keine Überraschungen.
Das Hauptformular Der Aufbau des Hauptformulars ist ebenfalls denkbar einfach. Damit wir Daten eingeben können, wurden Textboxen auf der Form platziert, weiterhin sehen Sie noch das ListBoxSteuerelement, das zur Anzeige dient, und eine Anzahl Buttons zum Laden, Speichern und Löschen der ListBox-Anzeige. Die Bezeichner der Steuerelemente sind wieder sprechende Bezeichner, sodass es mit der Zuordnung keine Probleme geben sollte. Abbildung 14.15 zeigt den Aufbau des Hauptformulars.
Abbildung 14.15: Der Aufbau des Hauptformulars der Anwendung
Sandini Bib
Binäre Dateien
401
Die Methoden des Hauptformulars enthalten keinen verwirrenden Code und sollten sofort verständlich sein. Abgedruckt sind natürlich wieder nur die relevanten Methoden. private void btnClear_Click( object sender, EventArgs e ) { this.lstItems.Items.Clear(); } private void btnAdd_Click( object sender, EventArgs e ) { Address newAddress = new Address(); newAddress.Name = this.txtName.Text; newAddress.Street = this.txtStreet.Text; newAddress.Zip = Int32.Parse( this.txtZip.Text ); newAddress.City = this.txtCity.Text; newAddress.Email = this.txtMail.Text; this.lstItems.Items.Add( newAddress ); } private void BtnSave_Click( object sender, EventArgs e ) { List addresses = new List(); foreach ( object o in this.lstItems.Items ) addresses.Add((Address)o); BinaryFileManager.WriteData( addresses ); } private void BtnLoad_Click( object sender, EventArgs e ) { List addresses = BinaryFileManager.ReadData(); if ( addresses != null ) { this.lstItems.Items.Clear(); foreach ( Address address in addresses ) { this.lstItems.Items.Add( address ); } } }
Auf diese Weise haben wir mit relativ wenig Code eine einfache Möglichkeit zusammengestellt, Strukturen in Dateien speichern zu können. Und das auch noch so, dass sie in der Regel nicht so ohne weiteres lesbar sind. Das Programm im Einsatz zeigt Abbildung 14.16.
Sandini Bib
402
14 Dateien und Verzeichnisse
Abbildung 14.16: Das Beispielprogramm RWBinary zur Laufzeit
14.5.9
Syntaxzusammenfassung
Die Klasse FileStream FileStream-Objekt erzeugen (aus System.IO) new FileStream(string filename, FileMode mode); new FileStream(string filename, FileMode mode, FileShare share); new FileStream(string filename, FileMode mode FileShare share FileAccess acc); new FileStream(string filename, FileMode mode FileShare share FileAccess acc int bufSize); new FileStream(string filename, FileMode mode FileShare share FileAccess acc int bufSize, bool async);
erzeugt ein FileStream-Objekt. Über die FileMode-Aufzählung wird angegeben, in welchem Modus die Datei geöffnet oder erzeugt werden soll. Der Wert vom Typ FileShare gibt an, ob ein gemeinsamer Zugriff auf die Datei möglich ist oder nicht. Der Parameter vom Typ FileAccess legt die Zugriffsart fest, der Parameter bufSize gibt die Puffergröße an. Letztendlich kann mit einem booleschen Parameter noch angegeben werden, ob es sich um einen asynchronen Zugriff handeln soll.
Eigenschaften und Methoden der Klasse FileStream (aus System.IO) CanRead
gibt an, ob aus dem FileStream gelesen werden kann.
CanSeek
gibt an, ob die Methode Seek() auf den Stream angewendet werden kann.
CanWrite
gibt an, ob in den Stream geschrieben werden kann.
IsAsync
gibt an, ob der Stream asynchron geöffnet wurde.
Sandini Bib
Binäre Dateien
403
Eigenschaften und Methoden der Klasse FileStream (aus System.IO) Length
gibt die Länge des Streams an oder legt sie fest. Mit einer Festlegung dieses Werts kann eine zugrunde liegende Datei gekürzt werden.
Name
liefert den Namen der Datei.
Position
liefert die aktuelle Position des Dateizeigers.
Close()
schließt die Datei.
Flush()
speichert alle offenen Änderungen in der Datei.
Lock( long position, long length )
blockiert length Bytes ab Position position für jeden Zugriff durch andere Prozesse.
Read( byte[] b, int offset, int count )
liest count Bytes in das byte-Array b. Die Daten werden ab der Position offset in das Array geschrieben.
ReadByte()
liest ein Byte und liefert einen entsprechenden int-Wert oder -1, wenn das Dateiende erreicht ist.
Seek( long offset, SeekOrigin origin )
verändert die Position des Dateizeigers. Der Parameter origin gibt die Startposition an, der Parameter offset die Anzahl der Bytes um die der Dateizeiger verschoben wird.
Unlock( long position, long length )
Das Gegenstück zu Lock(), gibt entsprechend die Bytes wieder frei
WriteByte( byte b )
speichert den byte-Wert b in den Stream.
Write( byte [] b, int offset, int count )
Speichert die Bytes aus dem Array b in den Stream. Der Parameter count gibt die Anzahl der Bytes an, offset die Startposition, ab der die Bytes im Array gelesen werden sollen.
Aufzählung SeekOrigin (aus System.IO) SeekOrigin.Begin
Startpunkt für die Positionsänderung ist der Dateianfang.
SeekOrigin.Current
Startpunkt für die Positionsänderung ist die aktuelle Position.
SeekOrigin.End
Startpunkt für die Positionsänderung ist das Dateiende.
Aufzählung FileMode (aus System.IO) FileMode.Append
öffnet die Datei, um an ihrem Ende Daten anzufügen. Es können keine Daten gelesen werden. Die Datei muss bereits existieren.
FileMode.Create
erzeugt bzw. überschreibt die Datei.
FileMode.CreateNew
erzeugt eine neue Datei. Die Datei darf noch nicht existieren.
Sandini Bib
404
14 Dateien und Verzeichnisse
Aufzählung FileMode (aus System.IO) FileMode.Open
öffnet die angegebene Datei. Die Datei muss bereits existieren.
FileMode.OpenOrCreate
öffnet die angegebene Datei oder erzeugt sie neu.
FileMode.Truncate
öffnet die angegebene Datei und löscht ihren Inhalt. Die Datei muss bereits existieren.
Bitfeld FileAccess (aus System.IO) FileAccess.Read
öffnet die Datei nur zum Lesen.
FileAccess.ReadWrite
öffnet die Datei zum Lesen und zum Schreiben.
FileAccess.Write
öffnet die Datei nur zum Schreiben.
Bitfeld FileShare (aus System.IO) FileShare.None
verwehrt allen anderen Prozessen den Zugriff auf die Datei, bis diese geschlossen wird (Das ist der Standard bei Schreibzugriffen).
FileShare.Read
erlaubt anderen Prozessen, die Datei zur gleichen Zeit zu lesen (das ist der Standard bei Lesezugriffen).
FileShare.ReadWrite
erlaubt anderen Prozessen, die Datei zur gleichen Zeit zu lesen und zu verändern.
FileShare.Write
erlaubt anderen Prozessen, die Datei zur gleichen Zeit zu verändern.
MemoryStream Eigenschaften und Methoden der Klasse MemoryStream (aus System.IO) Capacity
liefert die Größe des internen Pufferspeichers.
ToArray()
liefert den Inhalt des Streams als byte-Feld.
WriteTo( Stream stream )
speichert den Inhalt in einem anderen Stream-Objekt.
Sandini Bib
Asynchroner Dateizugriff
405
BinaryReader und BinaryWriter BinaryReader- und BinaryWriter-Objekte erzeugen (aus System.IO) New IO.BinaryReader(FileStream)
erzeugt aus einem FileStream-Objekt ein BinaryReader-Objekt.
New IO.StreamReader(FileStream, enc)
wie oben, aber unter Anwendung eines bestimmten Zeichensatzes (Encoding)
New IO.BinaryWriter(FileStream)
erzeugt aus einem FileStream-Objekt ein BinaryWriter-Objekt.
New IO.BinaryWriter(FileStream, enc)
wie oben, aber unter Anwendung eines bestimmten Zeichensatzes (Encoding)
Methoden der Klasse BinaryReader (aus System.IO) Close()
schließt die Datei.
PeekChar()
liest ein Zeichen, ohne den Dateizeiger zu verändern.
Read()
liest ein Zeichen und liefert einen Integer-Wert oder -1, wenn das Dateiende erreicht ist.
ReadByte(), ReadChar() etc.
liest eine Variable des angegebenen Typs (so viele Bytes, wie zur Speicherung des Datentyps erforderlich sind). Falls das Dateiende erreicht wird, tritt ein EndOfStream-Fehler auf.
Methoden der Klasse BinaryWriter (aus System.IO) Close()
schließt die Datei.
Flush()
speichert alle offenen Änderungen auf dem Datenträger.
Seek( n, origin )
verändert die Position des Dateizeigers.
Write( x )
speichert die in x enthaltenen Daten in binärer Form. x muss einer der elementaren .NET-Datentypen sein (außer bool und DateTime).
14.6
Asynchroner Dateizugriff
Bisher haben wir nur synchron auf eine Datei zugegriffen, d.h. bevor das Programm weiterlaufen konnte, mussten erst die Schreib- bzw. Lesezugriffe beendet werden. Asynchroner Zugriff bedeutet nun, dass der Anwender weiterarbeiten kann, während das Programm noch am Speichern ist. Ein solches Verhalten kennen Sie möglicherweise aus Word, das eine automatische Speicherung im Hintergrund oder auch das Drucken im Hintergrund durchführen kann.
Sandini Bib
406
14 Dateien und Verzeichnisse
Da der Anwender weiterarbeitet, besteht bei einem asynchronen Dateizugriff immer die Gefahr, dass Daten, mit denen gearbeitet werden soll, noch nicht vollständig gelesen oder geschrieben sind. Darauf ist bei der Programmierung zu achten. Es gibt jedoch ein Hilfsmittel, nämlich eine so genannte Callback-Methode, die dann aufgerufen wird, wenn der Schreib-/Lesevorgang explizit beendet ist (dabei handelt es sich intern um nichts anderes als ein Ereignis, dem eine beliebige Methode zugeordnet werden kann. Dieses ist jedoch nicht als solches veröffentlicht, sondern versteckt sich im Aufruf der Methoden BeginWrite() bzw. BeginRead()). Der Vorteil asynchroner Dateizugriffe ist nicht zu verachten, immerhin wird der Arbeitsfluss nicht gestört. Der Nachteil besteht allerdings in dem deutlich höheren Kontrollaufwand, denn Sie können sich nicht mehr darauf verlassen, dass die Daten sofort zur Verfügung stehen. Aufgrund des höheren Verwaltungsaufwands (auch intern) ist der Einsatz dieser Möglichkeit daher nur bei großen Dateien sinnvoll. Im Falle weniger Daten ist der synchrone Zugriff in der Regel deutlich schneller (und aufgrund des geringen Datenvolumens auch kaum spürbar).
14.6.1
Verwendung eines asynchronen Streams
Voraussetzungen Grundvoraussetzung ist das Vorhandensein der Daten als binäres Array. Lediglich direkt von Stream abgeleitete Klassen (FileStream, BufferedStream, MemoryStream und auch die in diesem Kapitel nicht behandelten NetworkStream bzw. CryptoStream) implementieren die für den asynchronen Zugriff zuständigen Methoden BeginRead(), BeginWrite(), EndRead() und EndWrite(). Die Angabe, ob ein Stream-Objekt (in den Beispielen wird ein FileStream-Objekt benutzt) als asynchroner Stream geöffnet wird, erfolgt im Konstruktor. Dazu ist es notwendig, wirklich alle möglichen Parameter anzugeben. Je nach Betriebssystem ist es dabei nicht sicher, ob der Zugriff wirklich asynchron erfolgt, denn das ist nicht vom .NET Framework, sondern vielmehr vom zugrunde liegenden Betriebssystem abhängig. Unter Windows 2000 bzw. XP gibt es jedoch keine Probleme (ein Test mit Windows 98, ME bzw. NT4 fiel mangels installierter Version dieser Betriebssysteme flach). Sollte das Betriebssystem keinen asynchronen Zugriff unterstützen, wird keine Fehlermeldung ausgegeben, stattdessen arbeitet der Stream eben synchron. Die Syntax für den Konstruktor eines asynchronen FileStream-Objekts sieht folgendermaßen aus: new FileStream( string fileName, FileMode mode, FileAccess access, FileShare share, int bufsize, bool useAsync );
Der letzte Parameter bestimmt die Art des Zugriffs.
Sandini Bib
HINWEIS
Asynchroner Dateizugriff
407
Die Puffergröße ist bei asynchronen Zugriffen wichtig. Wie angesprochen kann bei einer kleinen Datenmenge (und auch bei einer kleinen Puffergröße) ein synchroner Zugriff wesentlich schneller sein als ein asynchroner. Die Grenze liegt bei einer Puffergröße von 64 kByte. Unterhalb dieser Größe arbeitet ein Stream-Objekt unter Windows grundsätzlich synchron.
BeginRead() und BeginWrite() Gestartet wird der Lese- oder Schreibvorgang durch den Aufruf einer der Methoden BeginRead() oder BeginWrite(). Diese Methoden erwarten eine Menge Parameter. Einmal natürlich das Array mit den zu schreibenden Daten (bzw. das Array, in das die Daten eingelesen werden sollen). Weiterhin den Startindex und die Anzahl der zu schreibenden/zu lesenden Werte, schließlich einen Parameter vom Typ AsyncCallback, bei dem es sich um einen Delegate handelt, sowie ein Objekt, das diese Lese-/Schreibanforderung von anderen unterscheidet. Die letzten beiden sind sozusagen optional, d.h. es kann der Wert null übergeben werden. Wenn es sich um nur einen Dateizugriff handelt, sodass nicht zwischen verschiedenen asynchronen Streams unterschieden werden muss, wird auch kein entsprechendes Objekt benötigt. Eine Callback-Funktion ist auch nicht erforderlich, höchstens sinnvoll. AsyncCallback bezeichnet die Callback-Methode, die hier angegebene Methode wird aufgerufen, wenn der Schreibvorgang oder der Lesevorgang beendet sind. So können Sie feststellen, wann die Daten explizit im System vorhanden sind und Sie damit arbeiten können. Zur Deklaration dieser Methode und des notwendigen Parameters (es ist nur einer) gleich mehr.
Die Methoden BeginWrite()/BeginRead() starten den Schreib- bzw. Lesevorgang und werden dann sofort wieder beendet. Zurückgeliefert wird ein Objekt, das das Interface IAsyncResult implementiert und über dessen Methoden und Eigenschaften den Status des Vorgangs liefert. Von einem Interface kann zwar keine Instanz erzeugt werden, eine Variable vom Typ eines Interface kann jedoch jedes beliebige Objekt aufnehmen, das dieses Interface implementiert (siehe dazu auch Abschnitt 9.1.2 ab Seite 201). Aus diesem Grund ist folgende Deklaration möglich und üblich: byte[] b = new byte[5000]; // 5000 Bytes werden gelesen FileStream fs = new FileStream( fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 256, true ); IAsyncResult result = fs.BeginRead( b, 0, 5000, null, null ); // Lesen starten
Das Interface IAsyncResult implementiert eine Eigenschaft namens IsCompleted, die einen booleschen Wert liefert. Dieser gibt an, ob die Verarbeitung beendet ist oder nicht. Eine weitere Eigenschaft ist AsyncWaitHandle, die ein Objekt des Typs WaitHandle zurückliefert. Dieser Datentyp ist im Namespace System.Threading deklariert und dient zur Synchronisierung von Threads (Threads bzw. Multithreading werden in Kapitel 15 ab Seite 429 behandelt).
Sandini Bib
408
14 Dateien und Verzeichnisse
Über die Eigenschaft StateObject des Interface IAsyncResult können Sie auf das Objekt zugreifen, das Sie als letzten Parameter an die Methode BeginRead() (bzw. BeginWrite()) übergeben haben (falls Sie eines übergeben haben). Während das Programm nun im Hintergrund die Daten in die Datei überträgt, können Sie in Ihrem Programm andere Dinge erledigen. (Falls dabei Fehler auftreten können, müssen Sie eine Fehlerabsicherung durchführen, damit der Schreibvorgang sicher zu Ende geführt werden kann!)
EndRead() und EndWrite()
HINWEIS
Die Methoden EndRead() und EndWrite() werden dann benötigt, wenn Sie auf jeden Fall sicherstellen müssen, dass an dieser Stelle des Programms sämtliche Daten gelesen bzw. geschrieben wurden. Die Methoden erwarten als einzigen Parameter das von BeginWrite() bzw. BeginRead() gelieferte Objekt. Die weitere Ausführung des Programms wird dann blockiert, bis der entsprechende Schreib- oder Lesevorgang beendet ist. Im Prinzip geschieht hier nichts anderes, als dass darauf gewartet wird, dass die Eigenschaft IsCompleted true liefert. Man könnte das Verhalten von EndRead() bzw. EndWrite() also auch mit einer Schleife nachbilden. Der Vorteil von EndRead() und EndWrite() besteht darin, dass sie auch noch die Anzahl gelesener/geschriebener Bytes zurückliefern, wenn sie aufgerufen werden.
Asynchrone Callbacks Eine Callback-Methode informiert Sie, wann der Schreib-/Lesevorgang beendet ist. Sie wird als vierter Parameter an BeginRead()/BeginWrite() übergeben und muss folgende Signatur besitzen: public void aCallback( IAsyncResult asyncResult )
Die Adresse dieser Prozedur wird als vierter Parameter an BeginRead()/BeginWrite() übergeben. Da der Parameter asyncResult nun wieder das IAsyncResult implementierende Objekt darstellt, das auch von der Startmethode geliefert wurde, kommt dem letzten Parameter von BeginRead()/BeginWrite() wieder eine besondere Bedeutung zu. Hier können Sie beispielsweise zusätzliche Informationen einpacken, die dann in der Callback-Methode über die Eigenschaft AsyncState des Parameters zur Verfügung stehen. Eine übliche Vorgehensweise ist, nur eine Callback-Methode für verschiedene asynchrone Zugriffe zu verwenden und anhand des Objekts AsyncState den Vorgang zu identifizieren.
Fehlerabsicherung Wenn bereits als Reaktion auf eine der Methoden BeginRead() oder BeginWrite() ein Fehler auftritt, können Sie davon ausgehen, dass die asynchrone Operation gar nicht gestartet wurde (in diesem Fall kommt es daher auch nicht zum Aufruf von Callback-Methoden).
Sandini Bib
Asynchroner Dateizugriff
409
VERWEIS
Komplizierter wird es, wenn während der asynchronen Operation ein Fehler auftritt. In diesem Fall wird die entsprechende Exception erst ausgelöst, wenn Sie EndRead() oder EndWrite() ausführen. Allgemeine Grundlageninformationen zur asynchronen Programmierung (also losgelöst von den hier beschriebenen Anwendungen) finden Sie in der Online-Hilfe, wenn Sie nach Asynchrone Programmierung suchen: ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.VisualStudio.v80.de/dv_fxadvance/html/c9b3501e6bc6-40f9-8efd-4b6d9e39ccf0.htm
14.6.2
Asynchrones Schreiben mit Callback
Das folgende Beispiel zeigt das Schreiben mit Callback, d.h. mit einer Rückmeldung, wenn der eigentliche Schreibvorgang beendet ist. Das Schreiben wurde in eine eigene Klasse ausgelagert. Es wird eine Datei von 50 MB erzeugt (auf dem Laufwerk C). Während des Schreibens, das asynchron vorgenommen wird, wird auch eine Berechnung ausgeführt. Diese Berechnung ist eigentlich vollkommen nutzlos, gibt aber einen Fortschritt in Prozent aus. An der Ausgabe wird so ersichtlich, dass die Schreibroutine bereits fertig ist, während die Berechnung noch läuft.
CD
Die Klasse, in der die Datei erzeugt wird, erhält eine Callback-Methode für den asynchronen Stream. In dieser soll zusätzlich noch die Dateigröße ausgeben werden, wobei wir mit der Eigenschaft AsyncState arbeiten. Um die Dateigröße zu ermitteln genügt es, das verwendete FileStream-Objekt an die Methode BeginWrite() zu übergeben. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\AsyncWithCallback.
Die Callback-Methode ist nicht weiter spektakulär. Wie angesprochen muss ihre Signatur der des Delegates AsyncCallback entsprechen: public void AsyncFinishedCallback( IAsyncResult asyncResult ) { Console.WriteLine( "\r\nSchreiben der Datei ist beendet." ); FileStream fs = (FileStream)asyncResult.AsyncState; Console.WriteLine( "Dateigröße: {0}\r\n", fs.Length ); }
Da AsyncState vom Typ object ist, müssen wir natürlich zum Ausgeben der Dateigröße in FileStream casten. Der eigentlichen Schreibmethode wird der Callback-Delegate übergeben. Die Puffergröße wurde auf 256 kB festgelegt. public void WriteAsync( string fileName, byte[] b ) { FileStream fs = new FileStream( fileName, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize, true );
Sandini Bib
410
14 Dateien und Verzeichnisse
// Callback-Methode festlegen über Delegate AsyncCallback callback = new AsyncCallback( this.AsyncFinishedCallback ); // Schreiben asynchron IAsyncResult result = fs.BeginWrite( b, 0, b.Length, callback, fs ); double s = DoSomething(); fs.EndWrite( result ); fs.Close(); }
Aus dem Hauptprogramm heraus wird die Methode nun angestoßen. static void Main( string[] args ) { // Anzahl Bytes int byteCount = 1024 * 1024 * 50; // 50 MByte // Objekt erzeugen AsyncWriter writer = new AsyncWriter(); // Array füllen Console.WriteLine( "Array wird erzeugt ... \r\n" ); byte[] b = new byte[byteCount]; Random rnd = new Random( DateTime.Now.Millisecond ); rnd.NextBytes( b ); // Dateinamen erzeugen string fileName = Path.GetTempFileName(); Console.WriteLine( "Start ... \r\n" ); // Asynchron schreiben writer.WriteAsync( fileName, b ); Console.WriteLine(); File.Delete( fileName ); Console.WriteLine( "\r\nAbfolge beendet." ); // Warten auf Return Console.ReadLine(); }
Abbildung 14.17 zeigt das Programm zur Laufzeit. Sie sehen, dass der Schreibvorgang tatsächlich endet, während die Berechnung noch läuft, und auch dass die korrekte Dateigröße ausgegeben wird.
Sandini Bib
Verzeichnisse überwachen
411
Abbildung 14.17: Asynchrones Schreiben mit Callback-Methode
14.7
Verzeichnisse überwachen
Die Klasse FileSystemWatcher aus System.IO dient zur Überwachung eines Verzeichnisses und gegebenenfalls auch aller Unterverzeichnisse. Überwacht werden das Anlegen, Umbenennen, Löschen und Erzeugen von Dateien. Das Verschieben einer Datei kann aus einem Grund nicht überwacht werden: Der FileSystemWatcher kann nur die Hierarchie von Verzeichnissen ausgehend von einem Verzeichnis überwachen, nicht mehrere. Eine Verschiebung in einen anderen Verzeichnisbaum (mit unterschiedlichem Basisverzeichnis), der nicht überwacht wird, kann daher nicht festgestellt werden. Das Verschieben einer Datei innerhalb des überwachten Verzeichnisbaums resultiert daher in zwei verschiedenen Meldungen: einmal, dass eine Datei gelöscht wurde und einmal, dass eine Datei erstellt wurde.
14.7.1
FileSystemWatcher verwenden
Bei Windows-Programmen empfiehlt es sich, den FileSystemWatcher von der Toolbox auf das Formular zu ziehen. Dadurch haben Sie die Möglichkeit, die Eigenschaften im Eigenschaftenfenster einzustellen, was wesentlich komfortabler ist, als sie im Code zu setzen. Das Anlegen der Ereignisse wird dadurch ebenfalls vereinfacht. Um jedoch zu zeigen, dass es auch anders geht, werden wir an dieser Stelle ein Konsolenprogramm vorstellen, das zur Verzeichnisüberwachung mittels einer Logdatei verwendet werden kann.
Überwachte Verzeichnisse und Dateien In der Standardeinstellung wird lediglich das Verzeichnis überwacht, das in der Eigenschaft Path eingestellt wird. Bei manueller Erstellung des FileSystemWatcher kann der Pfad auch dem Konstruktor übergeben werden. Über die Eigenschaft IncludeSubdirectories können Sie festlegen, dass alle untergeordneten Verzeichnisse ebenfalls überwacht werden.
Sandini Bib
412
14 Dateien und Verzeichnisse
Die Eigenschaft Filter legt die zu überwachenden Dateien fest. Dabei können Jokerzeichen verwendet werden. Wenn Sie also nur überwachen wollen, ob sich etwas an einer Textdatei ändert, legen Sie Filter auf *.txt fest. Mehrere Dateitypen werden durch ein Semikolon getrennt. Die Standardeinstellung ist übrigens *.*, d.h. wenn Sie die Eigenschaft Filter leer lassen, werden alle Dateien überwacht, die eine Endung besitzen. Auch der Wert der Eigenschaft Filter kann dem Konstruktor übergeben werden.
Ereignisse zur Benachrichtigung Zur Benachrichtigung bietet FileSystemWatcher vier Ereignisse: Changed, Created, Deleted und Renamed. Parameter dieser Ereignisse sind natürlich einmal der Parameter sender vom Typ object und dann ein Parameter vom Typ FileSystemEventArgs. Das Ereignis Renamed fällt hier ein wenig aus der Reihe, denn es erwartet einen Parameter vom Typ RenamedEventArgs. Über die Parameter können unter anderem Informationen über die erstellte, gelöschte oder geänderte Datei ermittelt werden, über RenamedEventArgs auch noch der alte Dateiname, da es sich hierbei ja um eine Umbenennung handelt. Ein weiteres Ereignis ist nicht im Eigenschaften-/Ereignisfenster zu sehen, kann aber ebenfalls verwendet werden. Es handelt sich um das Ereignis Error, das dann ausgelöst wird, wenn der interne Puffer des FileSystemWatcher-Objekts überläuft. Der interne Puffer dient dazu, alle Änderungen in einem Verzeichnis oder einem Verzeichnisbaum aufzulisten, um dann entsprechend dieser Reihenfolge die notwendigen Ereignisse auszulösen. Bei zahlreichen Veränderungen kann es sein, dass die Ereignisse nicht schnell genug aufgerufen werden können. In diesem Fall kommt es zu einer Fehlermeldung und das Error-Ereignis wird ausgelöst. Die Größe dieses internen Puffers kann variiert werden und wird durch die Eigenschaft InternalBufferSize festgelegt. Der Standardwert beträgt 8 kByte, das Minimum 4 kByte.
Wenn Sie im Voraus wissen, dass es viele Änderungen geben kann, können Sie den Wert entsprechend erhöhen und so die Gefahr eines Fehlers ausschließen.
Änderungen auswerten Besonders interessant ist die Möglichkeit, mehr zu überwachen als nur die Erstellung, das Löschen oder das Umbenennen einer Datei. Diese werden ohnehin überwacht und es kann über die entsprechenden Ereignisbehandlungsroutinen darauf reagiert werden. Das Ereignis Changed jedoch stellt einen Sonderfall dar, da Sie festlegen können, auf welche Art der Änderung reagiert werden soll. Festgelegt wird das durch die Eigenschaft NotifyFilter vom Typ NotifyFilters. Dabei handelt es sich um ein Bitfeld, durch oder-Verknüpfung können Sie also mehrere Überwachungsmöglichkeiten festlegen. So können Sie z.B. auf Änderungen der Dateiattribute, der Sicherheitseinstellungen, der Dateigröße oder des letzten Zugriffsdatums reagieren. Innerhalb der Ereignisbehandlungsroutinen können Sie auswerten, welcher Art die Änderung war, auf die der FileSystemWatcher reagiert hat. Die passende Eigenschaft ChangeType vom Typ WatcherChangeTypes liefert der Parameter e. WatcherChangeTypes ist wiederum ein Bitfeld, denn es können ja mehrere Änderungen geschehen sein. Leider können Sie nichts
Sandini Bib
Verzeichnisse überwachen
413
Genaueres darüber erfahren, wodurch das Ereignis Changed ausgelöst wurde, lediglich dass es ausgelöst wurde. Nachdem alle Einstellungen getätigt sind, aktivieren Sie den FileSystemWatcher, indem Sie die Eigenschaft EnableRaisingEvents auf true setzen.
14.7.2
Verzeichnisüberwachung mit Logdatei
ACHTUNG
Das kleine Beispielprogramm zeigt die Überwachung eines Verzeichnisses. Wird eine Dateioperation ausgeführt, wird diese in eine Logdatei geschrieben. Bei einer bestimmten Größe der Logdatei wird sie gelöscht, damit der Festplattenplatz nicht allzusehr strapaziert wird. Da es sich aber um reine Texteinträge handelt, können Sie schon allerhand Vorgänge überwachen, bevor Sie die Logdatei löschen müssen. Legen Sie die Logdatei auf keinen Fall im überwachten Verzeichnis an! Eine Änderung würde auch eine Änderung der Logdatei nach sich ziehen, was der FileSystemWatcher bemerken und demnach die Logdatei erweitern würde – was wiederum eine Änderung ist. Sie hätten also eine Endlosschleife gebaut, die Ihnen Ihre Festplatte unter Garantie in kürzester Zeit füllt ...
Im Beispiel wird das Verzeichnis D:\Buchprojekte überwacht, inklusive aller Unterverzeichnisse. Die Logdatei wird auf dem Laufwerk C:\ abgespeichert. Damit kann es nicht zu Konflikten kommen (oder zu einer festplattenfüllenden Endlosschleife). Überwacht werden, festgelegt durch NotifyFilter, Änderungen am Dateinamen, am letzten Zugriff, an den Attributen, an Verzeichnisnamen und am Zeitpunkt der Erstellung. Für die Überwachung wurde wieder eine eigene Klasse erstellt. Darin wird ein FileSystemWatcher-Objekt erzeugt, das das (hartkodierte) Verzeichnis D:\Eigene Buecher überwacht.
CD
Damit beim Ende des Programms die Überwachung auch wirklich endet, wird eine Methode Close() zur Verfügung gestellt, die den FileSystemWatcher »ausschaltet«. Verwendet werden die Namespaces System, System.IO und System.Text (für Encoding). Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\WatchFolder.
public class FolderWatchdog { FileSystemWatcher watcher; string logFileName = @"C:\logfile.log"; string pathToWatch = @"D:\Buecher\Eigene Buecher"; private void WriteLogFile( string msg ) { // Deklaration StreamWriter writer;
Sandini Bib
414
14 Dateien und Verzeichnisse // Datei erzeugen wenn nicht da if ( !File.Exists( logFileName ) ) { writer = new StreamWriter( logFileName, false, Encoding.Default ); writer.WriteLine( " --- Datei erstellt --- " + DateTime.Now.ToString() ); writer.Close(); } writer = new StreamWriter( logFileName, true, Encoding.Default ); writer.WriteLine( msg ); writer.Close();
} private void HasChanged( object sender, FileSystemEventArgs e ) { // Datei wurde geändert string timeStr = DateTime.Now.ToShortDateString() + ":" + DateTime.Now.ToShortTimeString(); string msg = String.Format( "{0} | Datei geändert: {1}", timeStr, e.FullPath ); WriteLogFile( msg ); } private void HasRenamed( object sender, RenamedEventArgs e ) { // Datei wurde umbenannt string timeStr = DateTime.Now.ToShortDateString() + ":" + DateTime.Now.ToShortTimeString(); string msg = String.Format( "{0} | Datei umbenannt: {1} nach {2}", timeStr, e.OldFullPath, e.FullPath ); WriteLogFile( msg ); } private void HasCreated( object sender, FileSystemEventArgs e ) { // Datei wurde erstellt string timeStr = DateTime.Now.ToShortDateString() + ":" + DateTime.Now.ToShortTimeString(); string msg = String.Format( "{0} | Datei erstellt: {1}", timeStr, e.FullPath ); WriteLogFile( msg ); } private void HasDeleted( object sender, FileSystemEventArgs e ) { // Datei wurde gelöscht string timeStr = DateTime.Now.ToShortDateString() + ":" + DateTime.Now.ToShortTimeString(); string msg = String.Format( "{0} | Datei gelöscht: {1}", timeStr, e.FullPath ); WriteLogFile( msg ); }
Sandini Bib
Verzeichnisse überwachen
415
private void HasError( object sender, ErrorEventArgs e ) { // Pufferüberlauf WriteLogFile( "*** INTERNER PUFFERÜBERLAUF ***" ); } public void Close() { // Überwachung ausschalten watcher.EnableRaisingEvents = false; } public FolderWatchdog() { // Überwachung festlegen watcher = new FileSystemWatcher( pathToWatch, "*.*" ); // Festlegen, welche Meldungen kommen sollen watcher.NotifyFilter = NotifyFilters.Attributes | NotifyFilters.CreationTime | NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastAccess; watcher.IncludeSubdirectories = true; // Ereignisse festlegen watcher.Deleted += new FileSystemEventHandler( HasDeleted ); watcher.Changed += new FileSystemEventHandler( HasChanged ); watcher.Created += new FileSystemEventHandler( HasCreated ); watcher.Renamed += new RenamedEventHandler( HasRenamed ); // Und ab dafür ... watcher.EnableRaisingEvents = true; } }
Abbildung 14.18 zeigt, was Word so macht, wenn man es ein paar Sekunden in Ruhe lässt.
Abbildung 14.18: Die Log-Datei hatte schon nach ein paar Sekunden eine Größe von 4 kByte.
Sandini Bib
416
14 Dateien und Verzeichnisse
14.8
Serialisierung
Serialisierung beschreibt eine Technik, die grundsätzlich auch mit Dateien zu tun hat. Mithilfe dieser Technik können Sie Ihre eigenen Objekte in Dateien serialisieren (also in Dateien schreiben) und auch wieder aus Dateien erzeugen. Dabei ist sowohl die Verwendung von XML-Dateien möglich als auch herkömmlicher binärer Formate. Serialisierung wird unter anderem auch bei Web Services verwendet (das .NET Framework beinhaltet einen Serialisierer, der in das SOAP-Format serialisieren kann).
VERWEIS
Die Einsatzgebiete für Serialisierung sind vielfältig. So können Sie beispielsweise Objekte, die wichtige Daten beinhalten, mittels Serialisierung in eine XML-Datei überführen und sie dann im Netzwerk oder über das Internet übertragen. Ebenso wäre es denkbar, eine UndoFunktion zu realisieren oder ganz einfach nur die Objekte dauerhaft zu speichern. Dieser Abschnitt kann lediglich eine Einführung in das Thema Serialisierung bieten. Weitere Informationen erhalten Sie über die Online-Hilfe. Aufgrund des Umfangs dieser Informationen empfiehlt es sich, im Index einfach den Begriff »Serialisierung« einzugeben und das entsprechende Thema zu wählen.
14.8.1
Grundlagen
Das .NET Framework bietet drei Arten von Serialisierern: f Die Klasse BinaryFormatter aus dem Namespace System.Runtime.Serialization.Formatters.Binary können Sie Objekte in ein binäres Format übertragen. Bei dieser Form der Serialisierung werden auch zirkuläre Referenzen unterstützt. f Die Klasse SoapFormatter aus dem Namespace System.Runtime.Serialization.Formatters.Soap ermöglicht die Serialisierung in das SOAP-Format (Simple Object Access Protocol, eigentlich das Format, mit dem Web Services kommunizieren). Wenn Sie diese Klasse verwenden wollen, müssen Sie die DLL System.RuntimeSerialization.Formatters. Soap.dll den Verweisen Ihres Projekts hinzufügen. f Die Klasse XmlSerializer serialisiert ein Objekt in das XML-Format. Anders als BinaryFormatter und SoapFormatter unterstützt der XmlSerializer keine zirkulären Referenzen und ist auch ansonsten in der Bedienung anders als die beiden vorher genannten Klassen, weshalb er getrennt betrachtet wird.
14.8.2
Serialisieren mit BinaryFormatter und SoapFormatter
Jede Klasse, die serialisiert werden soll, muss das Attribut Serializable besitzen. Bei der Serialisierung werden alle Felder der Klasse serialisiert, unabhängig vom Zugriffsrecht (also sowohl private als auch public Member). Sie haben jedoch die Möglichkeit, die Serialisierung für bestimmte Elemente zu verhindern, indem Sie explizit das Attribut NonSerialized darauf anwenden.
Sandini Bib
Serialisierung
417
Die Serialisierung erfolgt über die Methode Serialize() des Serialisierer-Objekts. Diese Methode erwartet als Parameter einen Stream, in den serialisiert wird und das zu serialisierende Objekt. Zum Deserialisieren rufen Sie entsprechend die Methode DeSerialize() auf, die in der einfachsten Form einen Stream erwartet, aus dem das Objekt gelesen wird.
Beispielanwendung Im folgenden Beispielprogramm wird ein Objekt zunächst serialisiert und danach wieder deserialisiert. Für dieses und die restlichen Beispiele wird dabei die gleiche Klasse verwendet, deren Aufbau Sie hier sehen. [Serializable()] public class SerializationClass { private private private private private private private
string name = String.Empty; string firstName = String.Empty; string street = String.Empty; string zip = String.Empty; string city = String.Empty; DateTime birthday = DateTime.MinValue; WeekDays favouriteDay = WeekDays.Friday;
public string Name { get { return name; } set { name = value; } }
public string FirstName { get { return firstName; } set { firstName = value; } } public string Street { get { return street; } set { street = value; } } public string Zip { get { return zip; } set { zip = value; } }
Sandini Bib
418
14 Dateien und Verzeichnisse
public string City { get { return city; } set { city = value; } } public DateTime Birthday { get { return birthday; } set { birthday = value; } } public WeekDays FavouriteDay { get { return favouriteDay; } set { favouriteDay = value; } } public override string ToString() { string favDay = Enum.GetName( typeof( WeekDays ), this.favouriteDay ); string formatString = "Name: {0}, {1}\r\nStrasse: {2}\r\nPLZ: {3} Ort: {4}\r\n"; formatString += "\r\nGeboren: {5}\r\n\r\nLieblingstag: {6}\r\n"; return String.Format( formatString, this.name, this.firstName, this.street, this.zip, this.city, this.birthday, favDay ); } public SerializationClass() { } }
Die Aufzählung WeekDays ist folgendermaßen definiert: [Serializable()] public enum WeekDays { Sunday = 0, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday }
Der Aufzählungstyp WeekDays wurde absichtlich aufgenommen, ebenso wie der Datumstyp, damit Sie sehen können, dass auch diese Datentypen einwandfrei serialisiert werden können. Das Beispielprogramm selbst serialisiert in eine binäre Datei oder in eine XMLDatei, allerdings im Format SOAP. Die XML-Datei wird nach der Erstellung auch gleich mit ausgegeben. Das zu serialisierende Objekt wird im Load-Ereignis des Hauptformulars
Sandini Bib
Serialisierung
419
CD
initialisiert (also bereits wenn das Hauptprogramm geladen wird) und steht anschließend zur Verfügung. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\SerializationExample.
Um zu zeigen, dass hier wirklich ein komplettes Objekt serialisiert wird, erfolgt auch die Deserialisierung in ein neues Objekt des passenden Typs. Bei der Deserialisierung wird allerdings der Datentyp Object zurückgeliefert, sodass ein Casting in den korrekten Datentyp gemacht werden muss. Dank der Partial Classes können alle selbst definierten Methoden des Hauptformulars abgedruckt werden. Das Programm besteht aus einer Textbox und vier Buttons, je zwei zum Schreiben und Lesen der Daten. SoapFormatter und BinaryFormatter unterscheiden sich nicht in der Anwendung. public partial class FrmMain : Form { public FrmMain() { InitializeComponent(); } private SerializationClass serObject = new SerializationClass(); private string binFileName = @"C:\binSerialize.bin"; private string soapFileName = @"C:\soapSerialize.xml"; private void BtnExit_Click( object sender, EventArgs e ) { Close(); } private void FrmMain_Load( object sender, EventArgs e ) { // Serialisierungsobjekt initialisieren this.serObject.Name = "Eller"; this.serObject.FirstName = "Frank"; this.serObject.Birthday = new DateTime( 1968, 5, 22 ); this.serObject.City = "Emmerting"; this.serObject.Zip = "85457"; this.serObject.Street = "Wird nicht verraten"; this.serObject.FavouriteDay = WeekDays.Friday; // Textbox löschen this.txtResult.Clear(); }
Sandini Bib
420
14 Dateien und Verzeichnisse
private void BtnSerializeBinary_Click( object sender, EventArgs e ) { // Binär serialisieren FileStream stream = null; try { stream = new FileStream( binFileName, FileMode.Create ); BinaryFormatter formatter = new BinaryFormatter(); formatter.Serialize( stream, serObject ); stream.Close(); } catch { txtResult.AppendText( "Serialisierung fehlgeschlagen.\r\n\r\n" ); } finally { stream.Close(); } txtResult.AppendText( "Objekt Serialisiert.\r\n\r\n" ); } private void BtnDeserializeBinary_Click( object sender, EventArgs e ) { // Deserialisieren, wenn Datei vorhanden if ( File.Exists( binFileName ) ) { FileStream stream = new FileStream( this.binFileName, FileMode.Open ); try { BinaryFormatter formatter = new BinaryFormatter(); SerializationClass newObj = (SerializationClass)formatter.Deserialize( stream ); txtResult.AppendText( "Objekt deserialisiert, Daten:\r\n" ); txtResult.AppendText( newObj.ToString() ); } catch { txtResult.AppendText( "Deserialisierung fehlgeschlagen\r\n" ); } finally { stream.Close(); } } else { txtResult.AppendText( "Datei existiert nicht." ); } } private void BtnSerializeSoap_Click( object sender, EventArgs e ) { // Serialisiert in eine SOAP-Datei FileStream stream = null; try { stream = new FileStream( soapFileName, FileMode.Create ); SoapFormatter formatter = new SoapFormatter(); formatter.Serialize( stream, serObject ); stream.Close();
Sandini Bib
Serialisierung
421
} catch { txtResult.AppendText( "Serialisierung fehlgeschlagen.\r\n\r\n" ); } finally { stream.Close(); } txtResult.AppendText( "Objekt Serialisiert. Datei:\r\n" ); StreamReader sr = new StreamReader( soapFileName, true ); string s = sr.ReadToEnd(); sr.Close(); txtResult.AppendText( s + "\r\n\r\n" ); } private void BtnDeserializeSoap_Click( object sender, EventArgs e ) { // Deserialisieren, wenn Datei vorhanden if ( File.Exists( soapFileName ) ) { FileStream stream = null; try { stream = new FileStream( soapFileName, FileMode.Open ); SoapFormatter formatter = new SoapFormatter(); SerializationClass newObj = (SerializationClass)formatter.Deserialize( stream ); txtResult.AppendText( "Objekt deserialisiert, Daten:\r\n" ); txtResult.AppendText( newObj.ToString() ); } catch { txtResult.AppendText( "Deserialisierung fehlgeschlagen\r\n" ); } finally { stream.Close(); } } else { txtResult.AppendText( "Datei existiert nicht." ); } }
Das Resultat des kleinen Beispielprogramms zeigt auch die XML-Datei, die der SOAPFormatter erzeugt hat. Sie sehen Sie in Abbildung 14.19.
Sandini Bib
422
14 Dateien und Verzeichnisse
Abbildung 14.19: Das Resultat der SOAP-Serialisierung. Beim Zurücklesen wird aus diesen Daten wieder ein Objekt erzeugt.
14.8.3
Angepasste Serialisierung
Wenn Sie selbst bestimmen wollen, welche Daten serialisiert werden bzw. auf die Daten während der Serialisierung Einfluss nehmen wollen, können Sie auch das tun. Dazu implementieren Sie in Ihrem Objekt einfach die Schnittstelle ISerializable, die im Namespace System.Runtime.Serialization zu finden ist. Diese Schnittstelle implementiert nur eine einzige Methode, GetObjectData(), die zur Serialisierung verwendet wird – nicht zur Deserialisierung, wie der Name vermuten lässt. In GetObjectData() werden die zu serialisierenden Daten in ein SerializationInfo-Objekt übertragen (und können dabei modifiziert werden). Das folgende Beispiel zeigt die Anwendung dieses Interfaces. Dazu wird unsere Basisklasse SerializationClass um die Methode der Schnittstelle, um ein Feld SerializationDate und um einen benutzerdefinierten Konstruktor erweitert. Der Quelltext zeigt nur die hinzugekommenen Bestandteile: public class SerializationClass : ISerializable { [...] public DateTime SerializationDate; public void GetObjectData( SerializationInfo info, StreamingContext context ) { // Serialisieren info.AddValue( "Name", this.name ); info.AddValue( "FirstName", this.firstName ); info.AddValue( "Street", this.street ); info.AddValue( "Zip", this.zip ); info.AddValue( "City", this.city ); info.AddValue( "Birthday", this.birthday ); info.AddValue( "FavouriteDay", this.favouriteDay ); info.AddValue( "SerializationDate", DateTime.Now ); }
Sandini Bib
Serialisierung
423
public SerializationClass( SerializationInfo info, StreamingContext context ) { // Deserialisieren - Konstruktor wird aufgerufen this.name = info.GetString( "Name" ); this.firstName = info.GetString( "FirstName" ); this.street = info.GetString( "Street" ); this.zip = info.GetString( "Zip" ); this.city = info.GetString( "City" ); this.birthday = info.GetDateTime( "Birthday" ); this.serializationDate = info.GetDateTime( "SerializationDate" ); this.favouriteDay = (WeekDays)( info.GetValue( "FavouriteDay", typeof( WeekDays ) ) ); } }
CD
Den hier hinzugefügten Konstruktor mit den Parametern SerializationInfo und StreamingContext nennt man Deserialisierungskonstruktor. Dieser Konstruktor wird bei der Deserialisierung aufgerufen, falls vorhanden, und kann dementsprechend dazu verwendet werden, das Ergebnis der Deserialisierung zu beeinflussen. Es wäre ja möglich, dass Sie Werte z.B. noch verschlüsseln (beim Serialisieren) und sie dann im Deserialisierungskonstruktor entschlüsseln müssen. Auch wenn sich an dem Programm ansonsten nichts geändert hat, finden Sie es auf der Buch-CD als eigenständige Version im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\SerializationInterface.
Die Ausgabe zeigt, dass das benutzerdefinierte Serialisieren und Deserialisieren in der Tat funktioniert:
Abbildung 14.20: Die deserialisierte Datei mit dem Serialisierungsdatum, das erst bei der Serialisierung eingefügt wurde.
Sandini Bib
424
14 Dateien und Verzeichnisse
14.8.4
Die Klasse XmlSerializer
Der XML-Serialisierer unterscheidet sich stark von den beiden vorgestellten SoapFormatter und BinaryFormatter. Die folgende Liste zählt die wichtigsten Unterschiede auf: f SoapFormatter und BinaryFormatter serialisieren sowohl öffentliche als auch private Member, der XmlSerializer nur die öffentlichen. f Der XmlSerializer ruft beim Deserialisieren den parameterlosen Konstruktor der deserialisierten Klasse auf, die beiden anderen tun dies nicht. f Der XmlSerializer kann keine zirkulären Referenzen verarbeiten. f Die Steuerung des XmlSerializer geschieht über Attribute, nicht über Interfaces. f Der XmlSerializer arbeitet mit Klassen zusammen, die von System.Xml.XmlTextReader oder auch System.IO.TextReader abgeleitet sind. Die Serialisierung in diese Klassen ist möglich. f Der XmlSerializer erwartet sowohl beim Serialisieren als auch beim Deserialisieren den Typ des zu serialisierenden Objekts. Die Steuerung des XmlSerializer-Objekts ist sehr umfangreich. Wie angesprochen funktioniert alles über Attribute. Die benötigten Attribute befinden sich ausschließlich im Namespace System.Xml.Serialization. Mit ihrer Hilfe können Sie beispielsweise das RootElement umbenennen oder festlegen, dass ein Member nicht serialisiert wird. Auch die Art der Serialisierung eines Members, ob als Element (das ist die Standardeinstellung) oder als Attribut ist möglich. Wir werden das gleich in einem Beispiel sehen.
Beispielanwendung Es soll wieder das gleiche Objekt serialisiert werden wie in den Beispielen vorher. Diesmal allerdings wieder das ursprüngliche. Damit die Anzeige ein wenig besser aussieht (immerhin verwenden wir XML), wird das neue Webbrowser-Steuerelement als Anzeigeinstrument eingebunden. Im Falle dieses Beispiels wurde es in browser umbenannt.
CD
Ansonsten besitzt das Formular nur zwei Buttons, einen zum Serialisieren und einen zum Deserialisieren. Nach der Serialisierung wird der Browser dazu benutzt, die erzeugte Datei zu laden, und zeigt uns so die resultierende XML-Datei an. Bei der Deserialisierung gehen wir ähnlich vor wie in den vorangegangenen Beispielen, wir deserialisieren in ein neues Objekt. Der Inhalt dieses Objekts wird dann in eine Textdatei geschrieben und diese wird in den Browser geladen. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_14\XMLSerializerExample.
Für das Beispielprogramm werden wieder ein paar Deklarationen benötigt. Da wir hier mit XML-Serialisierung arbeiten, benötigen wir außerdem zusätzlich zu den standardmäßig eingebundenen Namespaces noch ein paar mehr:
Sandini Bib
Serialisierung using using using using
System.Xml.Serialization; System.Xml; System.Text; System.IO;
425 // // // //
für für Für Für
Serialisierer XmlTextWriter das Encoding die Textdatei
[ ... ] // Deklarationen private string xmlFileName = @"C:\serialization.xml"; private SerializationClass serObj = new SerializationClass(); private string textFileName = @"C:\serialization.txt";
Das geschieht nicht durch Laden der Datei, sondern indem wir ihn einfach auf die erzeugte Datei navigieren lassen. Das Objekt selbst wird analog zu den vorherigen Beispielen wieder im Load-Ereignis des Formulars initialisiert. Dieser Code wird nicht mehr abgedruckt, da er mit dem ersten Beispiel dieses Kapitels identisch ist. Die Methoden zum Serialisieren und Deserialisieren ähneln denen aus den vorangegangenen Beispielen. In diesem Fall verwenden wir allerdings zum Serialisieren einen XmlTextWriter und zum Deserialisieren einen XmlTextReader (deshalb muss auch der Namespace System.Xml eingebunden werden). Der Quellcode der Methoden sollte keine Schwierigkeiten bereiten. private void BtnSerialize_Click( object sender, EventArgs e ) { // Serialisieren in einen XmlTextWriter XmlTextWriter xmlWriter = new XmlTextWriter( this.xmlFileName, Encoding.UTF8 ); XmlSerializer serializer = new XmlSerializer( typeof( SerializationClass ) ); serializer.Serialize( xmlWriter, serObj ); xmlWriter.Close(); // Datei laden zur Anzeige browser.Navigate( @"file:///" + this.xmlFileName, false ); } private void BtnDeserialize_Click( object sender, EventArgs e ) { // Neues Objekt für Deserialisierung SerializationClass deserObj; // Deserialisieren aus XmlTextReader XmlTextReader xmlReader = new XmlTextReader( this.xmlFileName ); XmlSerializer serializer = new XmlSerializer( typeof( SerializationClass ) ); deserObj = (SerializationClass)serializer.Deserialize( xmlReader ); xmlReader.Close();
Sandini Bib
426
14 Dateien und Verzeichnisse
// Deserialisiertes Objekt in Textdatei schreiben StreamWriter writer = new StreamWriter( this.textFileName, false, Encoding.Default ); writer.Write( deserObj.ToString() ); writer.Close(); // Anzeigen in Browser browser.Navigate( @"file:///" + this.textFileName, false ); }
Das Ergebnis der Serialisierung sehen Sie in Abbildung 14.21.
Abbildung 14.21: Ein serialisiertes Objekt im (eigenen) Browser
Serialisierungsattribute Das Ergebnis ist nicht ganz befriedigend. Als Root-Element steht in der Datei aktuell ein Element mit der Bezeichnung SerializationClass, was nicht verwundert, ist das doch der Bezeichner der serialisierten Klasse. Viel schöner wäre es aber, wenn dort »Adresse« stehen würde, oder? Außerdem könnte man noch den Namen als Attribut statt als Element einfügen. Nichts leichter als das, wir können das Verhalten über Attribute steuern. Das folgende Listing zeigt die benötigten Attribute für die Klasse SerializationClass. Dabei werden in diesem Fall nur wenige Zeilen verändert, die gesamte Klasse wird daher nicht mehr abgedruckt. [XmlRoot( "Adresse", Namespace = "http://www.frankeller.de/xmlSerialization" )] public class SerializationClass { [ ... ]
Sandini Bib
Serialisierung
427
[XmlAttribute()] public string Name { get { return name; } set { name = value; } } [XmlAttribute()] public string FirstName { get { return firstName; } set { firstName = value; } } [ ... ] }
Die so geänderte Klasse sieht serialisiert aus wie in Abbildung 14.22. Wie Sie unschwer erkennen können, ist das Root-Element jetzt »Adresse« und die Namen sind Attribute statt Elemente.
Abbildung 14.22: Die serialisierte Datei bei Verwendung von Attributen
Die Verwendung der Attribute ist im Prinzip also recht einfach, es handelt sich lediglich um eine recht umfangreiche Anzahl. Auf die Beschreibung all dieser Attribute muss hier (wieder einmal aus Platzgründen) verzichtet werden – die detaillierte Beschreibung aller Attribute könnte gut und gerne eine dreistellige Seitenzahl füllen. In der Online-Hilfe sind die Attribute aber ausführlich beschrieben.
Sandini Bib
Sandini Bib
15 Multithreading
HINWEIS
Multithreading bezeichnet die Möglichkeit eines Programms, mehrere Vorgänge zur gleichen Zeit durchzuführen. Ein gutes Beispiel hierfür ist die Garbage-Collection, die in ihrem eigenen Thread läuft, der von Zeit zu Zeit angestoßen wird. Diese Vorgänge laufen im Hintergrund ab und sind somit für den Anwender transparent. Mit Multithreading können Sie beispielsweise Daten im Hintergrund abspeichern, während das eigentliche Hauptprogramm weiter läuft. Dieses Kapitel zeigt Ihnen, wie Multithreading im .NET Framework implementiert ist und wie Sie es in Ihren eigenen Anwendungen verwenden können. In diesem Kapitel wird in den Beispielen auch Multithreading mit den Steuerelementen von Windows.Forms beschrieben. Obwohl Steuerelemente noch nicht detailliert erläutert wurden, sollten die Beispielprogramme keine Probleme bereiten. Falls Sie sich zuerst in die Steuerelemente einarbeiten wollen, lesen Sie zuerst die Kapitel 17 ab Seite 479 und 18 ab Seite 501.
15.1
Grundlagen
Viele Anwendungen, die heute auf Windows-Systemen ablaufen, sind so genannte Multithread-Anwendungen. Das bedeutet, dass die Anwendung an sich nicht nur einen ablaufenden Programmpfad hat, sondern mehrere parallel ablaufende. Beispielsweise um Operationen im Hintergrund ausführen zu können und dem Benutzer trotzdem zu ermöglichen, neue Aktionen anzustoßen. Das .NET-Framework bietet eine recht umfangreiche Unterstützung für solche Programme. Die Hauptklasse ist die Klasse Thread aus dem Namespace System.Threading.
15.1.1
Preemptives Multitasking
Multithread-Anwendungen werden erst ermöglicht durch die Multitasking-Fähigkeit des Betriebssystems. Multitasking unter Windows ist ein so genanntes preemptives Multitasking, d.h. Programme scheinen zwar gleichzeitig abzulaufen, sie tun es aber in Wirklichkeit nicht. In der Realität wird jedem Prozess, der aktiv ist – dazu gehören auch WindowsDienste, Programme, Threads – eine gewisse Zeit zur Abarbeitung zugestanden. Ist diese so genannte Zeitscheibe abgelaufen, wird zum nächsten Prozess gesprungen und dessen Zeitscheibe wird gestartet. Diese Zeiten sind extrem kurz, wodurch der Benutzer die Illusion gleichzeitig ablaufender Funktionen erhält. Innerhalb Ihres Programms können Sie nun auch festlegen, dass es mehrere Ablaufstränge gibt, die dann ebenfalls ein Scheibchen Zeit zum Ablauf erhalten. Anders als bei Systemanwendungen können Sie aber selbst festlegen, welche Priorität diese Threads erhalten, bzw. wie viel Zeit ihnen zugestanden wird.
Sandini Bib
430
15 Multithreading
HINWEIS
Ein Beispiel hierfür ist die Überwachung des Dateisystems, ähnlich wie beim Windows Explorer. Wenn irgendein Programm eine neue Datei in ein Verzeichnis speichert, das vom Windows-Explorer angezeigt wird, zeigt auch dieser die Datei an, d.h. die Anzeige wird sofort aufgefrischt. Neben den von Ihnen verwalteten Threads gibt es noch mindestens zwei weitere Threads, die von den .NET-Bibliotheken benötigt werden. Diese dienen unter anderem dazu, regelmäßig den Speicher von nicht mehr benötigten Objekten durch eine Garbage Collection freizugeben. Deswegen zeigt der Task Manager selbst bei einer einfachen Konsolenanwendung mindestens drei Threads an. Auf die durch .NET vorgegebenen Threads haben Sie im Regelfall keinen direkten Einfluss. Wenn in diesem Abschnitt von Threads die Rede ist, sind deshalb nur die vom Hauptprogramm verwalteten Threads gemeint.
15.1.2
Multithreading-Modelle
Es gibt verschiedene Arten des Multithreadings. Der Großteil der .NET-Bibliothek sowie gewöhnliche C#-Anwendungen (inklusive Konsolenanwendungen) verwenden so genanntes free threading (freies Threading); dort ist das Erstellen neuer Threads und der Wechsel zwischen den Threads problemlos möglich. Jeder Thread ist selbstständig. Es besteht aber auch die Möglichkeit, mehrere Threads in so genannte Apartments zu gruppieren. Soweit sich mehrere Threads im selben Apartment befinden, vereinfacht sich dadurch der gegenseitige Zugriff auf Objekte (d.h. ein Thread kann Methoden eines Objekts nützen, das in einem anderen Thread erstellt wurde etc.). Es gibt zwei Apartment-Modelle: f Windows.Forms-Programme basieren auf dem Singlethread-Apartment-Modell (STA). Allerdings ergeben sich daraus Einschränkungen, wenn ein neuer (eigener) Thread, der sich außerhalb des Apartments befindet, auf Fenster und Steuerelemente zugreifen will. Die Steuerelemente unter Windows.Forms sind nicht threadsicher, hier müssen Synchronisierungsmechanismen verwendet werden. f Für Server-Anwendungen empfiehlt sich die Verwaltung eines so genannten ThreadPools unter Zuhilfenahme der ThreadPool-Klasse. Dabei werden die Threads in einem Multithread-Apartment (MTA) verwaltet.
15.1.3
Wozu Multithreading?
Je nach Anwendung kann Multithreading verschiedene Vorteile mit sich bringen: f Bei Server-Anwendungen, in denen ein Programm gleichzeitig auf unterschiedliche Datenanfragen (üblicherweise aus dem Netzwerk) antworten soll, kann Multithreading die Effizienz steigern. Der Grund dafür ist, dass mehrere Threads für die Beantwortung der Anfragen verwendet werden und so eine Anfrage nicht warten muss, bis die vorherige beendet ist.
Sandini Bib
Arbeiten mit Threads
431
f Bei interaktiven Programmen (z.B. bei einer gewöhnlichen Windows-Anwendung) kann durch Multithreading ein höherer Benutzerkomfort erreicht werden. Beispielsweise kann eine Berechnung in einem eigenen Thread gestartet werden, während der Haupt-Thread für die Benutzeroberfläche weiterläuft. Damit wird die Benutzeroberfläche nicht durch die Berechnung blockiert. (Denselben Effekt haben Sie auch, wenn Sie in Word ein Dokument im Hintergrund ausdrucken und währenddessen ein anderes Dokument bearbeiten oder wenn Sie in Outlook E-Mails versenden und zugleich eine neue verfassen etc.)
ACHTUNG
Beachten Sie, dass Multithreading ein Programm nicht generell schneller macht – im Gegenteil! Multithreading bringt einigen Overhead mit sich, sowohl auf Betriebssystemebene durch die Thread-Wechsel als auch in Ihrem Programm, in dem die Threads miteinander kommunizieren und sich oft auch synchronisieren müssen. Wenn Sie also eine Aufgabe möglichst rasch erledigen möchten, ist der herkömmliche Single-Threaded-Lösungsansatz der effizienteste. Die Programmierung stabiler Multithreading-Anwendungen ist relativ kompliziert, insbesondere dann, wenn mehrere Threads gemeinsame Daten ändern müssen (was sich nur selten vermeiden lässt). Erschwerend kommt hinzu, dass die Fehlersuche in Multithreading-Anwendungen mühsam ist, dass eventuelle Fehler möglicherweise nur sehr selten auftreten und daher schwerer zu finden sind. Programmieren Sie also nicht einfach los, sondern lesen Sie sich vorher ausführlich in die Materie ein. Dieses Kapitel kann nur als eine Einführung in dieses doch recht komplexe Thema fungieren.
15.2
Arbeiten mit Threads
15.2.1
Die Klasse Thread
Die Klasse Thread ist die Hauptklasse für Threads. Der Konstruktor erwartet einen Delegate, der die Startmethode für den Thread definiert. Dieser Delegate vom Typ ThreadStart ist parameterlos und ohne Rückgabewert deklariert.
Einen Thread starten Der eigentliche Start eines Threads erfolgt durch den Aufruf der Methode Start() der Klasse Thread. Sollte der Thread bereits gestartet worden sein, wird eine Exception ausgelöst. Da festgelegt ist, dass ThreadStart für die Definition einer Thread-Methode verwendet werden muss, ist es nicht möglich, auf einfache Art Startparameter an den neuen Thread zu übergeben. Das ist auch nicht nötig, denn dieser läuft (in der Regel) in der gleichen Applikationsdomäne (im gleichen Prozess) und kann somit auf die vorhandenen Klassen und Objekte zugreifen. Dabei muss allerdings darauf geachtet werden, dass dieser Zugriff
Sandini Bib
432
15 Multithreading
synchronisiert abläuft. Es ist nicht möglich, dass zwei Threads zur gleichen Zeit auf ein Objekt zugreifen, da dessen Status dann nicht mehr klar definiert wäre. Hierfür gibt es aber Möglichkeiten, die in Abschnitt 15.3 ab Seite 440 näher erläutert werden.
Einen Thread stoppen Ein Thread endet automatisch, wenn das Ende der Thread-Methode erreicht ist. Wenn Sie also innerhalb des Threads aus der aktuellen Methode herausspringen, ist dieser auch beendet. Normalerweise wird ein Thread allerdings von außen abgebrochen. Dazu wird die Methode Abort() der entsprechenden Thread-Instanz aufgerufen. Dadurch wird innerhalb des Threads eine so genannte stille Exception namens ThreadAbortException ausgelöst und der Status des Threads auf ThreadState.AbortRequested gesetzt. Der Thread wird dadurch nicht sofort beendet, es wird lediglich eine Nachricht übermittelt, dass er beendet werden soll. Dass der Thread wirklich beendet ist, können Sie über die Methode Join() sicherstellen, die dazu dient, auf das definitive Ende des Threads zu warten. An die Abort()-Methode kann optional ein beliebiges Objekt übergeben werden, das bei der Auswertung des ThreadAbortException-Objekts aus der Eigenschaft ExceptionState entnommen werden kann. Dieses Objekt kann beispielsweise dazu dienen, den Thread über die Gründe des Abbruchs zu informieren. Wichtig ist, dass Sie Abort() nicht aufrufen, wenn ein Thread überhaupt nicht läuft. Deshalb ist es sinnvoll, vorher den Status des Threads zu ermitteln. Die dafür zuständige Eigenschaft ist ThreadState, die vom gleichnamigen Datentyp ist. Der Datentyp ThreadState ist ein Bitfeld, das in System.Threading definiert ist. Die Reaktion eines Threads auf eine ThreadAbortException ist aus mehreren Gründen zumindest gewöhnungsbedürftig: f Wenn der Code des Threads nicht abgesichert ist (wenn es also keine try-catch-Konstruktion gibt), wird der Thread ohne Fehlermeldung still beendet. Das Gesamtprogramm wird fortgesetzt (sofern es noch andere Vordergrund-Threads gibt). Das ist insofern untypisch, als nicht abgesicherte Exceptions im Allgemeinen zu einer Fehlermeldung und dann zum Programmende führen. Das außergewöhnliche Verhalten auf eine ThreadAbortException kann auch im Dialog DEBUGGEN|AUSNAHMEN nachvollzogen werden: Dort lautet die Einstellung (im Unterschied zu anderen Exceptions), dass das Programm ohne Fehlermeldung fortgesetzt werden soll. f Die ThreadAbortException kann wie jede andere Exception durch try-catch abgefangen werden. Am Ende der try-Konstruktion wird der Fehler aber neuerlich ausgelöst und der Thread somit trotz der try-Konstruktion beendet. Sie können somit ein geordnetes Ende der Prozedur erreichen, offene Ressourcen schließen etc., aber im Gegensatz zu gewöhnlichen Exceptions gilt der Fehler durch die try-Konstruktion nicht als behoben. Wenn Sie einen Thread trotz dieses merkwürdigen Verhaltens nach einer ThreadAbortException fortsetzen möchten, müssen Sie innerhalb des catch-Blocks die Methode ResetAbort() ausführen. ResetAbort() darf nur dann verwendet werden, wenn für Ihr
Sandini Bib
Arbeiten mit Threads
433
Programm erweiterte Sicherheitseinstellungen gelten (was oft nicht der Fall ist). Generell ist es nur in sehr seltenen Fällen sinnvoll, einen Thread trotz Abort()-Aufforderung fortzusetzen. f Der finally-Block einer try-Konstruktion wird auf jeden Fall ausgeführt, selbst dann, wenn die ThreadAbortException nicht durch catch abgefangen wird (d.h. wenn es in der try-Konstruktion gar keinen catch-Block gibt oder wenn es nur catch-Blöcke für andere Fehler gibt). Beachten Sie, dass ResetAbort() im Finally-Block nicht mehr wirksam ist. Wenn der Code eines Threads Dispose()-fähige Objekte erzeugt, Datenbankverbindungen herstellt, Dateien öffnet etc., sollte (zumindest) eine Fehlerabsicherung folgender Form vorliegen: private void MyMethod() { try { // Der Code ... } finally { // Aufräumarbeiten } }
Programmende Ein Multithreading-Programm läuft normalerweise so lange, bis alle Threads beendet sind. Wenn Sie also in einem Konsolenprogramm in Main() einige neue Threads starten und die Methode dann verlassen, läuft das Programm so lange weiter, bis der letzte der Threads beendet ist. Um ein geordnetes Programmende zu erreichen, müssen Sie vor diesem sicherstellen, dass auch wirklich alle Threads beendet wurden. Das geschieht auf zwei Arten: f Im Falle von Vordergrund-Threads müssen Sie Abort() aufrufen und das Ende des Threads mit Join() abwarten (oder ggf. die Eigenschaft ThreadState auf die entsprechenden Werte überprüfen). f Um Threads automatisch zu beenden, können Sie diese als Hintergrund-Threads definieren. Das ist möglich, indem Sie der Eigenschaft IsBackground des Threads den Wert true zuweisen. Solche Hintergrund-Threads werden automatisch beendet, wenn der letzte Vordergrund-Thread beendet wurde. Wichtig hierbei ist, dass diese Threads nach dem letzten Vordergrund-Thread beendet werden. Das kann Auswirkungen haben, wenn dieser Hintergrund-Thread auf Steuerelemente zugreift. In diesem Fall kann es nämlich passieren, dass das Handle des Steuerelements gar nicht mehr existiert, während der Thread darauf zugreifen will. Dann müssen Sie auch einen Hintergrund-Thread explizit mit Abort() beenden, bevor der Vordergrund-Thread beendet wird. Die Standardeinstellung für neue Threads ist übrigens Vordergrund-Thread. Für Arbeiten wie z.B. das Speichern einer größeren Datei im Hintergrund oder das Drucken im Hintergrund sollten Sie Background-Threads verwenden.
Sandini Bib
434
15 Multithreading
Thread-Priorität Bei der Ausführung eines Programms werden die einzelnen Threads des Programms nacheinander aufgerufen, wobei jedem Thread ein bestimmtes Maß an Ausführungszeit zugeteilt wird. Diese Ausführungszeit nennt man auch die Zeitscheibe des Threads. Sie können die Größe der Zeitscheibe über die Prioritätseinstellungen des Threads verändern. Gesteuert wird dies über die Eigenschaft Priority eines Threads. Je niedriger die Priorität ist, desto weniger Zeit bekommt der Thread zur Ausführung. Priority ist vom Typ ThreadPriority, einer Aufzählung, die im Namespace System.Threading definiert ist. Die folgenden Werte sind möglich: f ThreadPriority.Highest gibt dem Thread die höchstmögliche Priorität. Diese Einstellung sollten Sie nur dann verwenden, wenn es unbedingt nötig ist, da ein solcher Thread andere Threads mit einer niedrigeren Priorität komplett blockieren kann. f ThreadPriority.AboveNormal gibt dem Thread ein wenig mehr Priorität als einem »normalen« Thread. f ThreadPriority.Normal ist die Standardeinstellung für einen neuen Thread. f ThreadPriority.BelowNormal gibt dem Thread weniger Priorität, als es normalerweise der Fall ist. f ThreadPriority.Lowest ist die niedrigste Einstellung. Wie auch für ThreadPriority.Highest gilt, dass Sie diese Einstellung möglichst nicht verwenden sollten, da ein solcher Thread sehr leicht von anderen aufgrund seiner niedrigen Prioritätsstufe blockiert wird.
HINWEIS
Eigentlich gibt es nur wenige Gründe, die Standardeinstellungen zu verändern. Falls Sie es dennoch tun wollen, sollten Sie den höchsten und den niedrigsten Wert tunlichst vermeiden. Geben Sie einen Thread einen zu hohen Prioritätswert, kann es sein, dass sich die Hauptfunktionen ihres Programms schwerfällig verhalten. Wenn das Hauptprogramm gar nicht mehr reagiert bleibt nur noch der Weg über den Taskmanager. Es empfiehlt sich also, nur die mittleren drei Einstellungen zu verwenden. Noch besser ist es, alle Threads auf der Einstellung ThreadPriority.Normal zu belassen und nur die Threads, die wirklich nicht bedeutend sind, eine Stufe niedriger zu setzen. Die Priorität bestimmt zwar grundlegend, wie viel Laufzeit ein Thread zugewiesen bekommt; sie ist aber dennoch von vielen Faktoren abhängig. Die Einstellung der Eigenschaft Priority ist nur einer dieser Faktoren. Der reale Anteil an Laufzeit, der einem Thread zugewiesen wird, berechnet sich in .NET unter anderem aus der Priorität des Threads, der Priorität des Prozesses, in dem der Thread läuft und aus einem dynamischen Wert. Der aus diesen Werten errechnete Wert ist die tatsächliche Priorität des Threads.
Sandini Bib
Arbeiten mit Threads
435
Zugriff auf Steuerelemente aus einem Thread Die Steuerelemente des .NET Frameworks sind per Definitionem nicht threadsicher. Das bedeutet, dass der Aufruf von Methoden dieser Steuerelemente aus einem Thread heraus nicht synchronisiert abläuft. Aus diesem Grund müssen Sie sicherstellen, dass auf ein Steuerelement immer aus dem Haupt-Thread zugegriffen wird. Der Weg dorthin führt über die Methode Invoke(). Diese Methode, die jedes Steuerelement besitzt, ist threadsicher. Sie bewirkt, dass die übergebene Methode mit den übergebenen Parametern aus dem Haupt-Thread der Anwendung heraus aufgerufen wird. Dazu wird wieder der Mechanismus der Delegates verwendet. f Wenn die aufzurufende Methode keine Parameter besitzt, können Sie hierzu einfach den Delegate MethodInvoker verwenden. Dieser ist für einen solchen Zweck vorgesehen. Die Methode, die durch MethodInvoker augerufen wird, darf weder Parameter noch Rückgabewert haben.
HINWEIS
f Besitzt die aufzurufende Methode Parameter, werden diese in Form eines objectArrays an Invoke() als zweiter Parameter übergeben. In diesem Fall müssen Sie auch einen eigenen Delegate deklarieren. Diese Vorgehensweise wird gleich anhand eines Beispiels gezeigt. Die Online-Hilfe rät explizit davon ab, die im Abschnitt 15.3 ab Seite 440 beschriebenen Mechanismen zur Synchronisation für Steuerelemente zu verwenden. Zwar wäre der Zugriff dann synchronisiert, er würde aber aus einem zweiten Thread heraus erfolgen. Das könnte zu einem so genannten Deadlock führen, wobei es sich um so ziemlich das Schlimmste handelt, was Ihnen als Programmierer passieren kann. Im Falle eines Deadlocks sperren sich zwei Threads gegenseitig (Thread A wartet darauf, dass Thread B das Objekt freigibt, und Thread B wartet darauf, dass Thread A das Objekt freigibt – auf diese Weise gesperrte Threads bekommen Sie nicht mehr zum Laufen).
Liste aller Threads ermitteln Den aktuellen Thread können Sie immer ermitteln. Dazu dient die statische Eigenschaft CurrentThread der Klasse Thread. Wenn Sie alle Threads ausgeben wollen, die im aktuellen Programm laufen, müssen Sie ein wenig weiter ausholen. Die Klasse Process aus dem Namespace System.Diagnostics ermöglicht die Ermittlung aller Threads eines Prozesses in Form von ProcessThread-Objekten, die mehr Eigenschaften aufweisen als die ThreadKlasse. Damit können Sie die laufenden Threads ideal auswerten. Zugriff auf das Process-Objekt des aktuell laufenden Programms erhalten Sie über Process.GetCurrentProcess(). Die folgenden Zeilen zeigen beispielhaft, wie eine solche Auswertung funktioniert:
Sandini Bib
436
15 Multithreading
class MyClass { public void CheckProcesses() { Process currentProc = Process.GetCurrentProcess(); foreach (ProcessThread pt in currentProc.Threads) { // Auswertung der Eigenschaften } } }
15.2.2
Beispielanwendung
Ein verhältnismäßig einfaches Beispiel für einen Thread und gleichzeitig den Beweis, dass sowohl der Thread als auch das Hauptprogramm vollkommen unabhängig voneinander laufen, liefert der folgende Programmcode. Das Hauptformular ist einfach aufgebaut. Zur Unterscheidung der Steuerelemente, die durch den Haupt-Thread bzw. den zweiten laufenden Thread angesprochen werden, wurden GroupBox-Steuerelemente benutzt. Den Aufbau des Formulars zeigt Abbildung 15.1.
Abbildung 15.1: Ein Formular für eine Multithread-Anwendung
Der Thread bzw. die Hauptmethode des Threads ändert einfach die Farbe eines Panels. Zum Beweis, dass das Hauptprogramm weiterhin läuft, wurde die Berechnung der Fibonacci-Zahlen implementiert, die unabhängig von der sich ändernden Panel-Farbe gestartet werden kann.
Sandini Bib
CD
Arbeiten mit Threads
437
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_15\FibonacciThread.
Wie so oft steht auch hier am Anfang die Deklaration einiger Variablen. In diesem Fall benötigen wir einen Delegate, um eine Methode aufzurufen, die den Farbwechsel des Panels vornimmt. Dieser Aufruf darf ja bekanntlich nur aus dem Haupt-Thread erfolgen und muss somit über Invoke() angeregt werden. Weiterhin benötigen wir eine Thread-Variable, auf die wir aus dem ganzen Programm zugreifen können. // Der zweite Thread private Thread SecondThread; // Delegate für den Farbänderungsaufruf (in eigener Datei) private delegate void ChangePanelColorDelegate(Color color);
Methoden für den Thread Die Methode, die den Farbwechsel durchführt, ist sehr einfach aufgebaut. Die drei Textfelder werden mit den RGB-Werten der Farbe belegt und das Panel erhält die übergebene Hintergrundfarbe. private void ChangePanelColor( Color color ) { // Panel-Farbe wechseln this.pnlThread.BackColor = color; this.txtRed.Text = color.R.ToString(); this.txtGreen.Text = color.G.ToString(); this.txtBlue.Text = color.B.ToString(); }
Die Hauptmethode des Threads macht eigentlich auch nicht viel. Hier wird ein zufälliger Farbwert berechnet und dann die Methode ChangePanelColor() über Invoke() aufgerufen. Interessant ist hierbei die Implementierung der Funktionalität. Die Schleife des Threads läuft endlos, solange dieser im Status ThreadState.Running ist (also läuft). Sobald Abort() aufgerufen wird, ist dieser Status nicht mehr gegeben und der Thread wird automatisch beendet. private void ThreadMain() { // Hauptmethode für den Thread int r = 0; int g = 0; int b = 0; object[] args = new Object[1]; Color myColor; ChangePanelColorDelegate Colorize = new ChangePanelColorDelegate( ChangePanelColor );
Sandini Bib
438
15 Multithreading
// Ab hier arbeiten while ( (Thread.CurrentThread.ThreadState & ThreadState.Running) == ThreadState.Running ) { Random rnd = new Random( DateTime.Now.Millisecond ); r = rnd.Next( 0, 255 ); g = rnd.Next( 0, 255 ); b = rnd.Next( 0, 255 ); myColor = Color.FromArgb( r, b, g ); args[0] = myColor; pnlThread.Invoke( Colorize, args ); Thread.Sleep( 500 ); } }
Die Aktualisierung des Panels erfolgt alle 0,5 Sekunden, was durch den Aufruf von Thread.Sleep() erreicht wird. Sie können den Wert gerne erhöhen, um z.B. jede Sekunde die Farbe zu ändern. Der Start des Threads erfolgt im Load-Ereignis des Formulars. private void FrmMain_Load( object sender, EventArgs e ) { // Thread erzeugen this.SecondThread = new Thread( new ThreadStart( ThreadMain ) ); this.SecondThread.Start(); }
Methoden des Haupt-Threads Die Berechnung der Fibonacci-Zahlen sollte weitgehend bekannt sein. Beachten Sie bitte, dass hier keine Kontrolle vorgenommen wird, ob auch wirklich eine Zahl eingegeben wurde. Das Programm ist also sehr leicht zum Absturz zu bringen. private int[] CalculateFibonacci( int fiboCount ) { // Fibonacci-Berechnung int[] result = new int[fiboCount]; result[0] = 1; result[1] = 1; for ( int i = 2; i < fiboCount; i++ ) result[i] = result[i - 1] + result[i - 2]; return result; }
Sandini Bib
Arbeiten mit Threads
439
private void BtnCalculate_Click( object sender, EventArgs e ) { // Fibonacci-Zahlen berechnen int[] fibo = CalculateFibonacci( Int32.Parse( txtFiboCount.Text ) ); this.lstResult.Items.Clear(); foreach ( int value in fibo ) this.lstResult.Items.Add( value ); }
Bleibt noch, den Thread beim Programmende ebenfalls zu beenden. Es würde nichts bringen, ihn als Hintergrund-Thread zu deklarieren und die Methode Abort() damit automatisch aufzurufen, da dies geschehen würde, nachdem das Programm beendet wurde (also nachdem der letzte Vordergrund-Thread beendet wurde). Damit könnte es passieren, dass die Methode Invoke() aufgerufen wird, obwohl kein Handle mehr auf das Panel besteht (was letztendlich zu einer Exception führt). Wir müssen den Thread also explizit beenden und tun das sinnvollerweise im Closing-Ereignis des Hauptformulars. private void FrmMain_Closing(object sender, System.ComponentModel.CancelEventArgs e) { this.SecondThread.Abort(); this.SecondThread.Join(); }
15.2.3
Syntaxzusammenfassung
Methoden der Klasse Thread (aus System.Threading) Abort() Abort( object status )
initiiert das Beenden des Threads. Der Thread wird nicht sofort gestoppt, stattdessen wird eine ThreadAbortException ausgelöst, die das Beenden des Threads bewirkt. Alternativ kann auch ein Statusobjekt übergeben werden.
Join()
wartet auf das Ende des Threads.
ResetAbort()
setzt die Ausführung des Threads trotz einer ThreadAbortException fort.
Resume()
setzt einen unterbrochenen Thread fort.
Sleep( int milliseconds ) Sleep( TimeSpan ts )
unterbricht die Ausführung des gerade aktiven Threads für eine bestimmte Zeit. (Während dieser Zeit können andere Threads ausgeführt werden.)
Start()
startet den Thread.
Suspend()
hält die Ausführung eines Threads vorübergehend an. Der Thread kann mit Resume fortgesetzt werden.
Sandini Bib
440
15 Multithreading
Eigenschaften der Klasse Thread (aus System.Threading) ApartmentState
gibt an, ob der Thread Teil eines Single- oder Multithread-Apartments (STA oder MTA) ist.
CurrentCulture
gibt das CultureInfo-Objekt des Threads an, das unter anderem Einfluss auf Konvertierungs- und Formatierungsmethoden hat (z.B. die Darstellung von Datum und Uhrzeit).
CurrentThread
verweist auf das Thread-Objekt des gerade laufenden Threads.
IsAlive
gibt an, ob der Thread grundsätzlich aktiv ist (ThreadState ungleich ThreadState.Unstarted, ThreadState.Stopped oder ThreadState.Aborted).
IsBackground
gibt an, ob der Thread ein Hintergrund-Thread ist. In diesem Fall endet er automatisch nach dem letzten Haupt-Thread.
Name
gibt den Namen des Threads an. Standardmäßig ist hier kein Wert angegeben.
Priority
gibt die Priorität des Threads an (per Default ThreadPriority.Normal).
ThreadState
gibt den aktuellen Zustand des Threads durch eine Konstante der ThreadState-Aufzählung an (z.B. ThreadState.Running, ThreadState.Stopped, ThreadState.Aborted, ThreadState.WaitSleepJoin).
15.3
Synchronisation
15.3.1
Wozu synchronisieren?
Wenn Sie mit mehreren Threads arbeiten, müssen Sie möglicherweise auch auf die Synchronisation dieser Threads achten. Und zwar dann, wenn Sie mit mehreren Threads auf das gleiche Objekt zugreifen wollen. Der Grund ist ganz einfach. Nehmen wir an, Sie hätten ein Objekt, in dem sich ein Array befindet. Nehmen wir weiter an, dieses Objekt stelle Methoden zur Verfügung, mit deren Hilfe die Werte dieses Arrays ausgelesen oder verändert werden könnten, und Methoden zum Sortieren dieses Arrays. Wenn Sie nun nur mit einem Thread arbeiten, ist das Ganze kein Problem. Sie können die Methoden aufrufen und die gewünschte Funktion wird ausgeführt. Genauer gesagt, es kann keine unerwünschte Funktion ausgeführt werden, z.B. der Inhalt des zu sortierenden Arrays geändert werden, während dieses noch sortiert wird. Innerhalb ein und desselben Threads werden Methoden ja nacheinander aufgerufen. Verwenden Sie aber zwei Threads, die beide auf das gleiche Objekt zugreifen, kann Folgendes passieren: f Thread 1 greift auf das Objekt zu und versucht, das enthaltene Array zu sortieren, d.h. es wird die Sortier-Routine aufgerufen. Da aber die Zeitscheibe eines Threads sehr klein ist, wird dieser beendet bevor die Routine ausgeführt wurde (lange vorher sogar,
Sandini Bib
Synchronisation
441
wir reden hier von Millisekunden an Zeitscheibe). Nun bekommt der nächste Thread Zeit zugewiesen. f Wenn dieser zweite Thread nun auch auf das Objekt zugreift, aber die Methode zum Ändern der Array-Werte benutzt, kann es sein, dass das Array verändert wird, während der erste Thread praktisch noch am Sortieren ist – d.h. die Werte würden verändert und die Sortierung liefert ein eigentlich falsches Ergebnis. Viel schlimmer kann es bei komplexeren Situationen kommen. Wenn zwei Threads unsynchronisiert auf das gleiche Objekt zugreifen, ist es durchaus möglich, dass bestimmte Werte einmal ungültig sind, weil der eine Thread dem anderen dazwischengepfuscht hat. Dann haben Sie ein Problem. Wenn es sich bei dem Objekt um ein Dateiobjekt handelt, kann es sehr schnell zu korrupten Daten kommen.
Mutex
HINWEIS
Vermieden werden solche Situationen durch einen so genannten Mutex (Mutual exclusion lock). Es wird schlicht verhindert, dass ein Thread auf ein Objekt zugreift, das von einem anderen Thread gerade bearbeitet wird. Das Prinzip ist recht einfach. Ein Thread sperrt ein Objekt für den Zugriff, bevor er mit dem Aufruf einer Methode des Objekts beginnt. Er legt also einen Mutex »um« das Objekt. Wenn seine Zeitscheibe beendet ist und ein anderer Thread auf das gleiche Objekt zugreifen will, wird dieser so lange in den Wartemodus geschickt, bis der Mutex wieder freigegeben und der Zugriff möglich ist. Dann ist die Arbeit des ersten Threads auch üblicherweise beendet – die Threads bzw. ihre Zugriffe sind synchronisiert. Grundsätzlich werden die lokalen Variablen einer Methode, die in einem Thread aufgerufen wird, auch lokal für jeden Thread verwaltet. Damit ist es kein Problem, die gleiche Methode aus verschiedenen Threads gleichzeitig aufzurufen – solange diese nicht auf Instanzvariablen des Objekts zugreifen, denn diese können im Kontext des Programms nur ein einziges Mal existieren.
15.3.2
Die Klasse Monitor
Im Namespace System.Threading gibt es eine Klasse Monitor, die die Funktionalität eines Mutex beinhaltet. Diese Klasse stellt sechs statische Methoden zur Verfügung, die in der nachfolgenden Tabelle aufgelistet sind. Methode
Funktion
Enter()
sperrt das angegebene Objekt für den aktuellen Thread. Die Sperre wird durch den Aufruf von Exit() wieder aufgehoben.
Exit()
hebt eine Sperre wieder auf. Exit() wird üblicherweise am Ende einer Methode, die gemeinsam verwendet wird, aufgerufen.
Sandini Bib
442
15 Multithreading
Methode
Funktion
Pulse()
Pulse()
PulseAll()
wie Pulse(), nur dass jetzt alle Threads informiert und ggf. in die Warteschlange gestellt werden. Auch hier übernimmt der Thread, der in der Warteschlange an vorderster Position steht.
TryEnter()
wie Enter(), allerdings wird hier lediglich versucht, ein Objekt zu sperren, ggf. für eine bestimmte Anzahl Millisekunden. Dadurch kann sichergestellt werden, dass nur ein Objekt gesperrt wird, das auch freigegeben ist.
Wait()
hebt die Blockierung auf und blockiert stattdessen den aktuellen Thread, bis dieser erneut versucht, das Objekt zu sperren.
informiert den nächsten anstehenden Thread davon, dass ein Objekt blockiert ist. Falls dieser auch auf das Objekt zugreifen will, wird er in eine Warteschlange gestellt. Der nächste Thread, der in der Warteschlage steht, bekommt den Zugriff, sobald die Sperre aufgelöst wurde.
Allen Methoden wird zumindest die Instanz des Objekts übergeben, das gesperrt werden soll. Am häufigsten werden vermutlich die Methoden Enter(), TryEnter() und Exit() benutzt.
Beispielanwendung für die Monitor-Klasse Die Funktion der Monitor-Klasse soll anhand eines kleinen Beispiels mit mehreren Arrays gezeigt werden. Verwendet wird eine Klasse, die zehn Arrays mit je zehn Elementen implementiert, die ausgelesen oder gesetzt werden können. Der erste Thread soll nun die Arrays sortieren, der zweite soll immer das aktuelle Array ausgeben. Das Ganze soll unabhängig voneinander funktionieren. Obwohl es sich hier um ein Beispiel handelt, könnte es ebenso gut sein, dass der zweite Thread das Array manipulieren will.
CD
Das Formular besteht aus zwei Listboxen, genannt lbxResult und lbxError, die zur Anzeige etwaiger Fehler bzw. des Ergebnisses dienen. Aufgeteilt wurden die Meldungen deshalb, weil dann klar ersichtlich ist, dass auch wirklich beide Threads ausgeführt werden und nacheinander Zugriff auf das gemeinsame Objekt erhalten. Dazu kommen ein Button zum Beenden des Programms und zwei Buttons zum Starten bzw. Stoppen der Threads. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_15\MonitorExample.
Die Klasse MultiArray Sortiert werden Arrays, die in einer Klasse zusammengefasst sind. Aus dieser Klasse wird ein Objekt erzeugt, auf das beide Threads Zugriff erhalten. Dieses Beispiel hat zugegebenermaßen keinen besonderen Realitätsanspruch, zeigt aber gut, wie die Monitor-Klasse verwendet werden kann. Schauen wir uns zunächst die Klasse MultiArray an:
Sandini Bib
Synchronisation public class MultiArray { private int[,] intArrays = new int[10, 10]; private int currArray = 0; private void CreateArrays() { // Deklarationen Random rnd = new Random(); // Arrays erzeugen und füllen for ( int i = 0; i < 10; i++ ) for ( int u = 0; u < 10; u++ ) intArrays[i, u] = rnd.Next( 1, 100 ); } public int[] GetCurrentArray() { // Aktuelle Werte liefern int[] resultArray = new int[10]; // Werte holen for ( int i = 0; i < 10; i++ ) { resultArray[i] = intArrays[this.currArray, i]; } return resultArray; } public void SetCurrentArray( int[] values ) { // Aktuelles Array mit Werten füllen for ( int i = 0; i < 10; i++ ) intArrays[this.currArray, i] = values[i]; } public void MoveNext() { // Geht zum nächsten Array this.currArray++; if ( this.currArray > 9 ) this.currArray = 0; }
443
Sandini Bib
444
15 Multithreading
public new string ToString() { // Ausgabe zurückliefern string result = String.Empty; result = "Array Nummer: " + this.currArray.ToString() + ": "; for ( int i = 0; i < 10; i++ ) { result += intArrays[this.currArray, i].ToString(); if ( i < 9 ) result += " "; } return result; } public MultiArray() { CreateArrays(); } }
Der Quelltext dieser Klasse birgt eigentlich keine Geheimnisse. Es handelt sich lediglich um die Verwaltung von zehn Arrays mit je zehn Werten, auf die über die Klasse zugegriffen werden kann. Die Methode GetCurrentArray() liefert das aktuelle Array, die Methode SetCurrentArray() ermöglicht, es zu schreiben. Die Methode ToString() dient dazu, einen String mit den Werten des aktuellen Arrays zurückzuliefern. Normalerweise müssten wir hierzu diese Methode überladen (man erwartet ja, dass der gesamte Objektinhalt zurückgeliefert wird), für dieses Beispiel aber ist es so praktikabler.
Methoden des Hauptformulars Im Hauptformular benötigen wir zunächst einige Deklarationen. Dazu gehören die Variablen für die verwendeten Threads sowie eine Variable für die MultiArray-Klasse. Da wir aus den Threads heraus auch noch in die Listboxen schreiben, benötigen wir hierzu auch wieder einen Delegate. Einer genügt in diesem Fall, wir werden ihn mehrfach verwenden. Der Übersicht halber ist der Delegate in einer eigenen Datei deklariert – hier steht er unter den Deklarationen. private Thread workThread1; // Erster Thread private Thread workThread2; // Zweiter Thread private MultiArray workMultiArray = new MultiArray(); public delegate void AddMessageDelegate( string msg );
Weiterhin benötigen wir einige allgemeine Methoden, u.a. zum Sortieren eines Arrays oder auch zum Schreiben in die Listboxen. Letztere werden später über die entsprechenden Invoke()-Methoden aufgerufen, unter Verwendung des deklarierten Delegate. An den Methoden ist nichts Geheimnisvolles, weshalb sie einfach abgedruckt werden.
Sandini Bib
Synchronisation
445
private void AddErrorMessage( string msg ) { this.lstError.Items.Add( msg ); } private void AddResultMessage( string msg ) { this.lstResult.Items.Add( msg ); } private void Swap( ref int a, ref int b ) { int tmp = a; a = b; b = tmp; } private void SortArray( ref int[] theArray ) { // Array sortieren - BubbleSort-Algorithmus bool hasChanged; do { hasChanged = false; for ( int i = 1; i < 10; i++ ) { if ( theArray[i] < theArray[i - 1] ) { hasChanged = true; Swap( ref theArray[i], ref theArray[i - 1] ); } } } while ( hasChanged ); }
Starten und Stoppen der Threads Die Threads werden wie gehabt gestartet und gestoppt. Zum Stoppen wird eine eigene Methode StopThreads() implementiert, die sowohl vom STOP-Button als aus aus dem Closing-Ereignis des Formulars heraus aufgerufen wird. private void StopThreads() { // Stoppen if ( (workThread1 != null) && workThread1.IsAlive ) { workThread1.Abort(); workThread1.Join(); }
Sandini Bib
446
15 Multithreading
if ( (workThread2 != null) && workThread2.IsAlive ) { workThread2.Abort(); workThread2.Join(); } } private void BtnStartThreads_Click( object sender, EventArgs e ) { // Starten der Threads this.workThread1 = new Thread( new ThreadStart( ThreadMethod1 ) ); this.workThread2 = new Thread( new ThreadStart( ThreadMethod2 ) ); workThread1.Start(); workThread2.Start(); } private void BtnStopThreads_Click( object sender, EventArgs e ) { StopThreads(); }
Die Thread-Methoden In Thread 1 wird das jeweils aktuelle Array sortiert und zum nächsten Array weitergeschaltet. Falls der Zugriff möglich war, wird eine Erfolgsmeldung ausgegeben. War der Zugriff nicht erfolgreich, wird eine Fehlermeldung in die Fehler-Listbox geschrieben. Das Gleiche gilt für Thread 2, der allerdings immer nur das aktuelle Array in der Listbox ausgibt. Falls ihm der Zugriff verwehrt wurde, gibt auch er eine Fehlermeldung aus. Nach der Ausführung des Codes werden die Threads für 500 Millisekunden gesperrt, damit man das Ganze auch verfolgen kann. Um den Fehlerfall zu simulieren, wird Thread.Sleep(0) aufgerufen, bevor Monitor.Exit() aufgerufen wird. Dadurch wird anderen Threads die Möglichkeit gegeben, ihren Code auszuführen. Da beide Threads hier auf das gleiche Objekt zugreifen, muss es zwangsläufig zu einer Konstellation kommen, bei der ein Thread ausgesperrt wird (in diesem Fall abwechselnd Thread 1 und Thread 2). private void ThreadMethod1() { // Methode für Thread 1 // Sortiert alle Arrays nacheinander und gibt sie aus. // Delegates für Wertübergabe an Listboxen AddMessageDelegate AddResult = new AddMessageDelegate( this.AddResultMessage ); AddMessageDelegate AddError = new AddMessageDelegate( this.AddErrorMessage ); int[] currentArray; bool couldEnter = false;
Sandini Bib
Synchronisation
447
while ( (Thread.CurrentThread.ThreadState & ThreadState.Running) == ThreadState.Running ) { if ( Monitor.TryEnter( this.workMultiArray ) ) { couldEnter = true; currentArray = this.workMultiArray.GetCurrentArray(); SortArray( ref currentArray ); this.workMultiArray.SetCurrentArray( currentArray ); this.workMultiArray.MoveNext(); Thread.Sleep( 0 ); Monitor.Exit( this.workMultiArray ); } else { couldEnter = false; } // Message an Listbox object[] parameters = new object[1]; if ( couldEnter ) { parameters[0] = "Ein Array wurde sortiert"; this.lstResult.Invoke( AddResult, parameters ); } else { parameters[0] = "Thread 1: Kein Zugriff möglich"; this.lstError.Invoke( AddError, parameters ); } Thread.Sleep( 500 ); } } private void ThreadMethod2() { // Methode für Thread 2 // Gibt den Inhalt der Arrays aus // Delegates für Wertübergabe an Listboxen AddMessageDelegate AddResult = new AddMessageDelegate( this.AddResultMessage ); AddMessageDelegate AddError = new AddMessageDelegate( this.AddErrorMessage ); string resultString = String.Empty; bool couldEnter = false; while ( (Thread.CurrentThread.ThreadState & ThreadState.Running) == ThreadState.Running ) { if ( Monitor.TryEnter( this.workMultiArray ) ) { couldEnter = true;
Sandini Bib
448
15 Multithreading resultString = this.workMultiArray.ToString(); Thread.Sleep( 0 ); Monitor.Exit( this.workMultiArray ); } else { couldEnter = false; } // Message an Listbox object[] parameters = new object[1]; if ( couldEnter ) { parameters[0] = resultString; this.lstResult.Invoke( AddResult, parameters ); } else { parameters[0] = "Thread 2: Kein Zugriff möglich"; this.lstError.Invoke( AddError, parameters ); } Thread.Sleep( 500 );
}
HINWEIS
}
Dieses Beispiel zeigt die Schwierigkeiten bei der Programmierung von MultithreadAnwendungen. In diesem Fall lief die Applikation auf meinem Laptop wie erwartet, auf meinem Hauptrechner jedoch nicht. Dieser scheint so schnell zu sein, dass kein Thread mehr ausgesperrt wird, und noch dazu gab es eine Fehlermeldung beim Beenden des Programms. Die Methode StopThreads() musste danach so erweitert werden, dass garantiert kein Hintergrund-Thread mehr läuft.
15.3.3
Die Anweisung lock()
Statt der Verwendung der Monitor-Klasse können Sie auch die Anweisung lock verwenden. Im Prinzip macht diese Anweisung nichts anderes als die Methoden Enter() und Exit() der Klasse Monitor. Die Syntax dieser Anweisung sieht folgendermaßen aus: lock(myObject) { // Anweisungen } // Ab hier wieder normal
Sandini Bib
Die Komponente BackgroundWorker
15.4
449
Die Komponente BackgroundWorker
Multithreading ist kein einfaches Thema, und auch das Erstellen einer Anwendung, die mit mehreren Threads arbeitet, ist nicht trivial. Mitunter ist es aber wünschenswert, einen zweiten Thread zur Verfügung zu haben, mit dem dann eine zeitaufwändige Operation ausgeführt werden kann. Für derart simple Szenarios gibt es die neue Komponente BackgroundWorker. Obwohl diese eigentlich in den Bereich Steuerelemente gehören würde, wird sie aufgrund der Nähe zur Multithread-Programmierung an dieser Stelle behandelt. Der BackgroundWorker ersetzt in umfangreichen Applikationen keineswegs die Arbeit mit Threads; zumal es dort häufig vorkommt, dass nicht nur zwei Threads zur gleichen Zeit laufen, sondern drei, vier oder noch mehr. Für zeitaufwändige Operationen ist die Komponente aber die richtige Wahl. Sie führt nicht nur die gewünschte Hintergrundoperation aus und meldet deren Beendigung, sondern ermöglicht auch den Abbruch der Operation sowie eine Statusmeldung (über ein Ereignis), mit der beispielsweise der Fortschritt mithilfe eines ProgressBar-Steuerelements angezeigt werden kann.
15.4.1
Methoden und Ereignisse
Die Methode RunWorkerAsync() startet den gewünschten Hintergrundprozess. Die Steuerung, beispielsweise das Abbrechen des Vorgangs oder auch die Aktualisierung einer Fortschrittsanzeige, geschieht ausschließlich über Ereignisse. Der Grund vor allem für letzteres ist, dass der BackgroundWorker natürlich einen Thread eröffnet, der Zugriff auf Steuerelemente von Windows.Forms aber nur innerhalb des Haupt-Threads erlaubt ist. RunWorkerAsync() ist zweifach überladen, für den Fall, dass Sie dem Hintergrundprozess
noch Parameter übergeben möchten. Die zweite Überladung erwartet dazu einen Parameter vom Typ object. RunWorkerAsync() führt selbst nicht die Hintergrundoperation aus, sondern stößt die Ausführung lediglich an und löst das Ereignis DoWork aus, in dem die Hintergrundoperation wirklich gestartet wird. Dieses Ereignis besitzt einen Parameter vom Typ DoWorkEventArgs, dessen Eigenschaft result Sie ein etwaiges Ergebnis Ihrer Operation übergeben können. result ist vom Typ object, Sie können also einen beliebigen Wert übergeben. Ist die Operation beendet, wird das Ereignis RunWorkerCompleted ausgelöst. In diesem Ereignis können Sie das Ergebnis wiederum auswerten (wieder über den Ereignisparameter, Eigenschaft result).
Kontrolle über den Vorgang Die Komponente BackgroundWorker besitzt natürlich nur rudimentäre Informationen über den Ablauf oder das Ergebnis Ihrer Operation. Wenn Sie also eine Fortschrittsanzeige programmiert haben oder Ihre Operation abbrechen wollen, müssen Sie sich darum in gewisser Weise selbst kümmern. Die Fortschrittsanzeige aktualisieren Sie über das Ereignis ProgressChanged. Ausgelöst wird dieses Ereignis über die Methode ReportProgress() des BackgroundWorker, die Sie selbst aufrufen müssen. Anders ausgedrückt: Innerhalb der zeitaufwändigen Operation müssen Sie das BackgroundWorker-Objekt kennen, mit dem Sie arbeiten. Da der Parameter sender eines
Sandini Bib
450
15 Multithreading
Ereignisses grundsätzlich das Steuerelement bzw. das Objekt kennzeichnet, das das Ereignis ausgelöst hat, haben Sie das korrekte BackgroundWorker-Objekt sozusagen frei Haus, denn das Ereignis DoWork wird vom BackgroundWorker selbst ausgelöst und der Parameter sender entspricht damit auch dem korrekten BackgroundWorker-Objekt. Wenn Sie es an die Methode, die die eigentliche Operation durchführt, übergeben, können Sie benötigte Ereignisse leicht auslösen. Der Abbruch eines Vorgangs wird über die Methode CancelAsync() ausgelöst. Wie auch bei einer Fortschrittsanzeige hat der BackgroundWorker keinen Einfluss auf die von Ihnen programmierte Methode; stattdessen wird durch den Aufruf von CancelAsync() lediglich die Eigenschaft CancellationPending auf true gesetzt. Sie müssen diese Eigenschaft innerhalb Ihres Codes ständig kontrollieren (an geeigneter Stelle) und den eigentlichen Abbruch selbst programmieren. Auch die Weitergabe der Information, dass abgebrochen wurde, bleibt Ihnen überlassen. Der eigentlichen Arbeitsmethode werden daher in der Regel nur zwei Argumente übergeben, nämlich der BackgroundWorker und der gesamte Parameter e vom Typ DoWorkEventArgs, der auch die an RunWorkerAsync() übergebenen Argumente in der Eigenschaft Argument enthält. Ist die Arbeit des BackgroundWorker beendet (wodurch auch immer, ob er nun fertig ist oder der Vorgang durch Benutzereingriff bzw. Fehler abgebrochen wurde) wird das Ereignis RunWorkerCompleted ausgelöst.
15.4.2
Beispielapplikation: Fibonacci
Die Berechnung der Fibonacci-Zahlen ist immer ein sehr schönes Beispiel für asynchrone Abläufe, da sie durch rekursive Methoden schön in die Länge gezogen werden kann. Außerdem kann an diesem Beispiel der Einsatz eines BackgroundWorker-Objekts sauber demonstriert werden. Auch wenn die unterschiedlichen Steuerelemente noch nicht besprochen wurden, sollte das Erstellen des Hauptformulars keine Probleme bereiten. Auf dem Hauptformular benötigen Sie aus der Toolbox lediglich eine GroupBox zur Abgrenzung, eine RichTextBox zur Demonstration, dass Sie im Vordergrund durchaus weiterarbeiten können sowie zwei Buttons, Labels, eine TextBox für die Eingabe der Anzahl der zu berechnenden Zahlen und eine ProgressBar-Konponente. Abbildung 15.2 zeigt die Entwurfsansicht des Formulars.
Sandini Bib
Die Komponente BackgroundWorker
451
Abbildung 15.2: Die Entwurfsansicht der Testapplikation für den BackgroundWorker
Der Button zum Abbrechen der Operation soll erst nach dem Start sichtbar sein, seine Eigenschaft Visible wird daher auf false gesetzt. Die Bezeichnungen der einzelnen Steuerelemente sollte im folgenden Code größtenteils eindeutig sein. Die Fortschrittsanzeige trägt den Namen prgFibonacci. Natürlich wird noch der BackgroundWorker selbst benötigt. Sie können die Komponente einfach auf das Formular ziehen, sie wird unten im Arbeitsbereich abgelegt. Der Name des BackgroundWorkers ist bgwMain. Zwei Einstellungen müssen allerdings noch geändert werden, da im Beispielprogramm sowohl ein Abbruch als auch die Anzeige eines Fortschritts möglich sein soll. Die Eigenschaften WorkerReportsProgress und WorkerSupportsCancellation, deren Standardwert false ist, müssen beide auf true gesetzt werden.
Die Fibonacci-Funktion Fibonacci-Zahlen mittels einer Schleife auszurechnen geht zu schnell, daher greifen wir hier auf eine Rekursion zurück. Diese wird ab etwa 40 geforderten Elementen langsam genug, um auch das Abbrechen testen zu können oder mal was in der RichTextBox zu schreiben. Die eigentliche Berechnung sehen Sie im folgenden Code. private long CalcFibo( int count, BackgroundWorker worker ) { // Abbruch? if ( worker.CancellationPending ) return -1; if ( count < 3 ) return 1;
Sandini Bib
452
15 Multithreading
else return ( CalcFibo( count - 1, worker ) + CalcFibo( count - 2, worker ) ); } private List CalcFibonacci( BackgroundWorker worker, DoWorkEventArgs e ) { // count ist die Anzahl der Fibonacci-Zahlen, die berechnet werden sollen int count = (int)e.Argument; List result = new List(); for ( int i = 0; i < count; i++ ) { // Prozentzahl für Progress berechnen int percent = (int)( ( 100d / count ) * i ); // Reporten worker.ReportProgress( percent ); // Abbruch if ( worker.CancellationPending ) { e.Cancel = true; return null; } // Weiterrechnen result.Add( CalcFibo( i, worker ) ); } // Ergebnis zurückliefern return result; }
Die Methode CalcFibo() wird rekursiv aufgerufen. Um alle Werte der Fibonacci-Reihe zu erhalten fügt die Methode CalcFibonacci() (die als eigentliche Hauptoperation dient) die jeweiligen Ergebnisse einer generischen Liste hinzu, die das Endresultat darstellt. Übergeben wird der BackgroundWorker (wegen des Abbrechens) sowie ein Parameter e vom Typ DoWorkEventArgs. Dieser Parameter stammt aus dem Ereignis DoWork, das ja vom BackgroundWorker aufgerufen wird wenn RunWorkerAsync() aufgerufen wird. Bei einem Abbruch kümmert sich die Methode selbst darum, dass die Eigenschaft Cancel des Parameters e auf true gesetzt wird. Der BackgroundWorker tut dies nicht selbstständig; auch der eigentliche Abbruch selbst muss ja über CancellationPending ermittelt und durchgeführt werden.
Starten der Hintergrundoperation Der Button btnStartFibonacci ruft im Click-Ereignis die Methode RunWorkerAsync() des BackgroundWorker-Objekts auf. Übergeben wird dieser Methode die Anzahl der zu berechnenden Werte. Dieser übergebene Wert taucht später wieder als Eigenschaft Argument in den DoWorkEventArgs auf.
Sandini Bib
Die Komponente BackgroundWorker
453
Der BackgroundWorker selbst sieht sich nun veranlasst, das Ereignis DoWork auszulösen, in dem dann die Berechnung durchgeführt wird. Das Ergebnis wird der Eigenschaft Result des Parameters e zugewiesen. Da dieser Parameter aber ohnehin an die asynchron laufende Methode übergeben wird, hätte das auch in CalcFibonacci() geschehen können. private void BtnStartFibonacci_Click( object sender, EventArgs e ) { // Keine Kontrolle, ob eine Zahl wirklich enthalten ist // Hier soll es ja um den BackgroundWorker gehen int valueCount = Int32.Parse( this.txtFiboCount.Text ); this.btnCancel.Visible = true; this.btnCancel.Update(); Application.DoEvents(); // Hintergrund-Thread starten bgwMain.RunWorkerAsync( valueCount ); } private void bgwMain_DoWork( object sender, DoWorkEventArgs e ) { e.Result = CalcFibonacci( sender as BackgroundWorker, e ); }
Die Fortschrittsanzeige wird ausgelöst durch den Aufruf von ReportProgress() in der Methode CalcFibonacci(). Dieser Aufruf löst seinerseits das Ereignis ProgressChanged auf: private void BgwMain_ProgressChanged( object sender, ProgressChangedEventArgs e ) { // Fortschritt anzeigen this.prgFibonacci.Value = e.ProgressPercentage; this.prgFibonacci.Update(); }
Die korrekte Zuweisung des Werts an die Eigenschaft ProgressPercentage erfolgt in CalcFibonacci().
Abbruch und Ergebnis Das Abbrechen des Vorgangs ist nicht weiter schwierig, es muss lediglich CancelAsync() aufgerufen werden. Innerhalb des BackgroundWorker-Objekts führt dies dazu, dass CancellationPending true wird. private void BtnCancel_Click( object sender, EventArgs e ) { // Asynchronen Vorgang abbrechen this.bgwMain.CancelAsync(); }
Sandini Bib
454
15 Multithreading
Das Ergebnis liefert das Ereignis RunWorkerCompleted. Hier kann dann auch ausgewertet werden, ob es sich um einen Abbruch handelt, ob ein Fehler auftrat (dieser wird dann in der Eigenschaft Error gespeichert, auch das muss händisch geschehen) oder (falls keines der beiden der Fall war) ob eben die Methode korrekt bis zum Ende durchlaufen wurde. In letzterem Fall wird das Ergebnis ausgegeben. Wurde abgebrochen erfolgt die Ausgabe einer entsprechenden Meldung. Der Fehlerfall ist hier nicht implementiert. private void bgwMain_RunWorkerCompleted( object sender, RunWorkerCompletedEventArgs e ) { this.btnCancel.Visible = false; if ( e.Cancelled ) { MessageBox.Show( "Backgroundworker: abgebrochen" ); return; } List result = e.Result as List; string resultString = "Fibonacci komplett:\r\n"; for ( int i = 0; i < result.Count - 1; i++ ) { resultString += String.Format( "{0}, ", result[i] ); } resultString += result[result.Count - 1].ToString(); MessageBox.Show( resultString ); }
Sandini Bib
16 Fehlersuche und Fehlerabsicherung Kein Programm ist fehlerfrei – weder eines, das Sie oder ich geschrieben haben noch eines, das von großen Firmen wie Microsoft, Novell, Sun oder auch aus einer Open-Source-Ecke kommt. Der Unterschied zwischen einer sauber programmierten Applikation und einer unsauber programmierten Applikation liegt eher darin, wie mit fehlerbehafteten Situationen umgegangen wird. Im Windows API beispielsweise, das in C (nicht C++) geschrieben ist, werden Fehler über eine Variable namens HResult abgefangen. HResult enthält einen (nichtssagenden) Fehlercode, der erst nochmals ausgewertet werden muss bevor der Anwender einen Klartext zum Fehler erhält. Eine solche Fehlerbehandlung überprüft schrittweise, ob eine undefinierte Situation auftritt, setzt entsprechend die Fehlervariable und springt zur Fehlerbehandlung. Das ist nicht nur umständlich zu programmieren, Sie benötigen auch eine umfangreiche Liste von Fehlernummern und müssen diese zentral behandeln (was ebenfalls enorme Ausmaße annehmen kann), um dem Anwender mitteilen zu können, was nun falsch gelaufen ist. Eine der schlimmsten Fehlerbehandlungen existiert nach wie vor in Visual Basic und stammt von VB6. Dort gibt es die schon berüchtigte Zeile On Error Resume Next (bei einem Fehler wird mit der darauf folgenden Anweisung weitergemacht). Das ist natürlich keine Fehlerbehandlung im wirklichen Sinne. In .NET existiert das Konzept der Exceptions. Fehler werden durch entsprechende Fehlerklassen repräsentiert, die zusätzliche Informationen zum Fehler, eine Fehlernachricht sowie den Stack Trace enthalten. Der Stack Trace ist der Weg, den das Programm genommen hat, bis der Fehler aufgetreten ist. Auftretende Fehler können abgefangen werden, Sie können auch eigene Fehlerklassen implementieren und eine Exception (auch Ausnahme genannt) selbst auslösen. Beides wird in diesem Kapitel behandelt.
16.1
Fehlerabsicherung
Grundsätzlich existieren drei Arten von Fehlern: f Auf Syntaxfehler reagieren bereits das Visual Studio bzw. der Compiler. Dieser verweigert den Dienst, das Programm würde nicht kompiliert. f Logische Fehler führen zu einem falschen Ergebnis bei ansonsten korrekter Vorgehensweise und lösen dementsprechend auch keine Fehlermeldung aus. f Laufzeitfehler sind die Fehler, die zur Entwurfszeit nicht erkannt werden können und dann möglicherweise zu einem Abbruch des Programms führen. Beispielsweise können Sie während der Erstellung einer Applikation nicht wissen, ob auch wirklich alle Dateien vorhanden sind oder der spätere Benutzer alle Eingaben korrekt vornimmt (was in der Regel nicht der Fall ist). Diese Fehler sind es, die in .NET durch Exceptions repräsentiert werden und um die es sich auch in diesem Kapitel dreht.
Sandini Bib
456
16 Fehlersuche und Fehlerabsicherung
Laufzeitfehler werden durch Exceptions repräsentiert. In vielen Fällen handelt es sich dabei nicht um einen wirklichen Fehler, sondern nur um eine unvorhergesehene Situation, die den weiteren Programmablauf behindert und die nicht erwünscht ist. Die Division durch null könnte eine solche Situation sein, die bei einer Berechnung auftritt. Diese Ausnahmesituationen werden als Exceptions bezeichnet und durch Fehlerklassen repräsentiert, die von der Basisklasse Exception abgeleitet sind. Das System liefert bereits eine umfangreiche Anzahl vordefinierter Exceptionklassen, hier nur einige Beispiele: f System.IOException steht für eine Ausnahme, die bei einer Dateioperation entsteht, beispielsweise wenn die Datei nicht vorhanden ist. f System.IndexOutOfRangeException wird ausgelöst, wenn Sie versuchen, auf einen Eintrag in einem Array zuzugreifen, der nicht existiert (z.B. bei einem zu hohen Indexwert). f System.InvalidCastException tritt bei ungültigen Typumwandlungen auf (im Falle von Objekten kann dies mit dem as-Operator umgangen werden). f System.ArgumentNullException tritt auf, wenn eine Variable null ist, obwohl sie für die angestrebte Operation gültig sein muss. Alle auftretenden Exceptions sollten auch angefangen werden, um den Programmfluss nicht zu stören.
16.1.1
Abfangen von Exceptions
Exceptions werden mithilfe der try-catch-Syntax abgefangen. Dabei wird der kritische Programmcode in einen try-Block eingeschlossen, die Fehlerbehandlung in einen catchBlock. Tritt innerhalb des try-Blocks ein Fehler auf, springt das Programm sofort in den catch-Block (alle im try-Block folgenden Anweisungen werden ignoriert) und führt die darin enthaltenen Anweisungen aus. Danach wird mit der ersten Anweisung fortgefahren, die dem catch-Block folgt. Existiert kein solcher catch-Block in der aktuellen Tiefe des Programms (also in der aktuellen Methode), springt die Laufzeitumgebung aus dieser Methode in die nächsthöhere Hierarchie (also in die aufrufende Methode) und sucht dort nach einem catch-Block. Ist dort auch keiner vorhanden, wird so weit weitergesprungen, bis die allgemeine Fehlerbehandlung ausgelöst wird.
CD
Ein kleines Beispielprogramm verdeutlicht dies. In diesem Programm wird eine Division durch 0 ausgelöst, die zunächst nicht abgefangen wird. Daraufhin wird die allgemeine Fehlerbehandlung ausgelöst. Das Programm ist ein Windows-Programm mit einer sehr einfachen Oberfläche, es befinden sich darin nur zwei Buttons (einer zum Starten einer Berechnung, einer zum Beenden) und zwei Textboxen, in die Werte eingegeben werden können. Das Programm dividiert 100 durch alle Werte, die zwischen den eingegebenen liegen. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_16\ExceptionsIntro.
Sandini Bib
Fehlerabsicherung
457
private void DoCalculate1( int start, int end ) { int startValue = 100; for ( int i = start; i != end; i-- ) { int result = (int)( startValue / i ); this.lstResult.Items.Add( "100 : " + i.ToString() + " = " + result.ToString() ); } }
Die Werte für start und end sind beim Programmlauf 5 bzw. -5. Bei der Ausführung des Programms tritt daher zwangsläufig ein Fehler auf, da der Wert 0 innerhalb des Bereichs liegt und so zwangsläufig eine Division durch 0 auftreten muss. Während des Debuggens wird das Programm an der angegebenen Stelle unterbrochen und es erscheint der so genannte Exception-Assistent, der den unschönen Dialog aus dem Visual Studio 2003 ersetzt und weitergehende Möglichkeiten bietet (siehe auch Abbildung 16.1).
Abbildung 16.1: Der Exception-Assistent im Visual Studio 2005
Sollte der gegebene Hinweis nicht ausreichen, haben Sie die Möglichkeit, sich weitere Details zur Exception anzeigen zu lassen, die Details ins Clipboard zu kopieren oder auch in die Hilfe zu springen. Wenn Sie das Programm fortsetzen, wird es beendet. Der Grund dafür ist, dass die Exception (da nicht behandelt) nun bis in die Main()-Methode durchgereicht wird und das generelle Exceptionhandling des .NET Frameworks in Kraft tritt.
16.1.2
try-catch-Syntax
Mithilfe der try-catch-Syntax können Sie den Fehler abfangen und dem Benutzer eine Meldung zukommen lassen. Der folgende Codeabschnitt zeigt dies.
Sandini Bib
458
16 Fehlersuche und Fehlerabsicherung
private void DoCalculate2( int start, int end ) { int startValue = 100; try { for ( int i = start; i != end; i-- ) { int result = (int)( startValue / i ); this.lstResult.Items.Add( "100 : " + i.ToString() + " = " + result.ToString() ); } } catch ( DivideByZeroException ex ) { this.lstResult.Items.Add( "Durch Null darf nicht dividiert werden" ); } }
Das Resultat sehen Sie in Abbildung 16.2.
Abbildung 16.2: Das Programm mit Fehlerabsicherung
So ganz optimal ist das aber noch nicht, denn nach Abarbeitung von catch werden die verbleibenden Anweisungen innerhalb des try-Blocks nicht weiter ausgeführt. Es soll aber sichergestellt sein, dass alle Berechnungen ausgeführt werden. Stellen Sie also den Programmcode einmal um und platzieren die try-catch-Anweisung innerhalb der for-Schleife. private void DoCalculate3( int start, int end ) { int startValue = 100; for ( int i = start; i != end; i-- ) {
Sandini Bib
Fehlerabsicherung try { int result = (int)( startValue / this.lstResult.Items.Add( "100 : } catch ( DivideByZeroException ex this.lstResult.Items.Add( "Durch }
459
i ); " + i.ToString() + " = " + result.ToString() ); ) { Null darf nicht dividiert werden" );
} }
Die Ausführung dieses Programms zeigt nun, dass es so funktioniert, wie wir uns das vorgestellt haben. Nach der Ausführung des catch-Blocks wird an das Ende des ihn umschließenden Blocks gesprungen. In diesem Fall ist das das Ende der for-Schleife, was zur Folge hat, dass der nächste Schleifendurchlauf gestartet wird. Abbildung 16.3 zeigt das Programm zur Laufzeit mit dem korrekten Resultat.
Abbildung 16.3: Das Programm mit dem korrekten Resultat
Verschiedene Exceptions abfangen Im ersten Beispiel haben Sie gesehen, dass wir eine ganz bestimmte Exception namens DivideByZeroException abgefangen haben. Exceptions sind im .NET Framework einfach als Klassen definiert, die von der Basisklasse Exception abstammen. In einem catch-Block können wir bestimmen, auf welche Exception dieser reagieren soll. Ein catch-Block ohne diese Angabe reagiert auf alle Exceptions.
Sandini Bib
460
16 Fehlersuche und Fehlerabsicherung
private void DoCalculate4( int startValue, int endValue ) { int startValue = 100; for ( int i = start; i != end; i-- ) { try { int result = (int)( startValue / i ); this.lstResult.Items.Add( "100 : " + i.ToString() + " = " + result.ToString() ); } catch ( DivideByZeroException ex ) { this.lstResult.Items.Add( "Durch Null darf nicht dividiert werden" ); } catch ( ArgumentException ex ) { this.lstResult.Items.Add( "Das Argument ist fehlerhaft" ); } catch { this.lstResult.Items.Add( "Es ist eine Exception aufgetreten" ); } }
HINWEIS
}
Die Laufzeitumgebung geht die catch-Blöcke der Reihe nach durch und verwendet den passendsten catch-Block, den sie findet. Sie sollten also immer die spezifizierten catch-Blöcke zuerst und ganz zuletzt den allgemeinen catch-Block aufführen. Nach der Abarbeitung eines catch-Blocks ist die Exception nicht mehr vorhanden.
Der finally-Block Mitunter kann es notwendig sein, Aufräumarbeiten nach dem Auftreten einer Exception durchzuführen (z.B. Schließen einer Datei, Löschen temporärer Dateien oder Ähnliches). Hierzu kann der finally-Block verwendet werden. Code, der innerhalb eines finallyBlocks steht, wird immer ausgeführt (also sowohl im Fehlerfall als auch, wenn die Anweisungen innerhalb des try-Blocks ohne Fehler durchlaufen wurden). try { FileStream fs = new FileStream( "c:\\myBitmap.bmp" ); Bitmap bmp = new Bitmap(fs); ... // Weitere Anweisungen } catch ( FileNotFoundException ex ) { MessageBox.Show( ex.Message ); } finally { fs.Close(); }
Sandini Bib
Fehlerabsicherung
461
Explizites Auslösen von Exceptions Nicht immer wird eine Exception an genau der Stelle ausgelöst, an der sie auch abgehandelt wird. Wenn Sie eigene Klassen oder Komponenten schreiben, die anderen zur Verfügung gestellt werden, möchten Sie möglicherweise (wie es auch das .NET Framework tut) diese Exception einfach nur weiterreichen und dem Benutzer Ihrer Komponente (also einem anderen Programmierer) ermöglichen, darauf zu reagieren. Für diesen Fall ist vorgesehen, dass sie Exceptions auch selbst auslösen können. Das dafür verwendete reservierte Wort heißt throw. Dadurch wird eine Exception ausgelöst und die aktuelle Methode sofort verlassen. Die Exception wird an die nächste Hierarchiestufe weitergeleitet. Das folgende Beispiel zeigt die Möglichkeit, eine Ausnahme explizit auszulösen, am Beispiel des Ladens einer Datei. Wird die Datei nicht gefunden, wird eine Ausnahme ausgelöst, behandelt und an die nächste Hierarchiestufe weitergegeben – allerdings mit mehr Informationen. Diese Informationen kann der Anwender an Sie weitergeben, womit Sie detailliert auswerten können, an welcher Stelle des Programms ein Fehler aufgetreten ist.
CD
Das Programm besteht nur aus einer Textbox, in der der Inhalt einer Textdatei angezeigt werden kann, einer Textbox zur Eingabe des Dateinamens und dem Button zum Laden. Der eigentliche Ladevorgang geschieht in LoadFile(). Dort wird eine Exception ausgelöst, wenn die Datei nicht gefunden wird. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_16\ExceptionRethrow.
private void LoadFile( string fileName ) { // Datei laden StreamReader reader = null; try { reader = new StreamReader( fileName ); this.txtResult.Text = reader.ReadToEnd(); } catch ( FileNotFoundException ex ) { // ErrorMessage bauen string errorMessage = "Fehler in Anwendung ExceptionRethrow\r\n"; errorMessage += "Methode: LoadFile(), Fehler beim Laden einer Datei\r\n"; errorMessage += "Interner Fehlercode: 1123"; // neu auslösen throw new FileNotFoundException( errorMessage, fileName ); } finally { if ( reader != null ) reader.Close(); } }
Sandini Bib
462
16 Fehlersuche und Fehlerabsicherung
private void BtnLoad_Click( object sender, EventArgs e ) { try { LoadFile( this.txtFileName.Text ); } catch ( FileNotFoundException ex ) { // Hier erfolgt die eigentliche Auswertung der Exception MessageBox.Show( ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Stop ); } }
Wenn Sie dieses Programm ausführen und einen nicht vorhandenen Dateinamen eingeben, erhalten Sie die Fehlermeldung aus Abbildung 16.4:
HINWEIS
Abbildung 16.4: Die Fehlermeldung mit eigener Errormessage
Achten Sie darauf, dass sowohl der try-Block als auch der catch-Block eigenständige Codeblöcke sind. Das heißt, Variablen, die im try-Block deklariert sind, sind im catch-Block nicht sichtbar.
Vorsicht ist dennoch angebracht, wenn Sie durch throw selbst Exceptions auslösen. Wenn throw nämlich innerhalb eines try-Blocks ausgeführt wird, wird das Programm im dazugehörenden catch-Block fortgesetzt (und die Exception nicht an die nächste Ebene weitergegeben). Selbstverständlich zeigt dieses Beispiel eine Anwendung von Exceptions, bei der Sie sicherlich sagen, das hätte man auch durch eine einfache if-Abfrage regeln können. Das ist richtig, hier dient der Code auch nur zu Demonstrationszwecken. Der Einsatz von Exceptions muss natürlich überlegt werden. Wenn Sie einen Fehler leicht über eine if-Anweisung vermeiden können, sollten Sie das in der Regel tun. Tritt dieser Fehler aber in der Regel nicht auf (ist also die Fehlerbehandlung wirklich nur zur absoluten Absicherung da), sollten Sie die Fehlerbehandlung verwenden, auch wenn es so aussieht, als sei das ein unglaublicher Overhead. Sie liefern damit aber wesentlich detailliertere Informationen, die ausgewertet werden können.
Sandini Bib
HINWEIS
Fehlerabsicherung
463
Wenn Sie innerhalb eigener Klassen oder Komponenten auf Exceptions reagieren, die beispielsweise aus dem .NET Framework kommen, sollten Sie diese nicht in Ihrer Komponente behandeln. Stattdessen ist der Benutzer Ihrer Komponente verantwortlich dafür, dass die Exception ausgewertet wird. Meistens müssen Sie aber zumindest auf die Exception reagieren, d.h. diese mittels try-catch-Block abfangen. In diesem Fall können Sie durch Aufruf von throw innerhalb des catch-Blocks und ohne Angabe eines Exception-Objekts die gleiche Exception erneut auslösen.
16.1.3
Eigenschaften und Methoden der Klasse Exception
Alle Exceptions stammen von der Basisklasse Exception ab. Diese Klasse bietet natürlich auch einige Informationen, die Sie innerhalb eines catch-Blocks auswerten können. Die folgende Tabelle liefert Ihnen einen Überblick über die Eigenschaften der Klasse Exception. Eigenschaften und Methoden der Klasse Exception (aus System) GetBaseException()
liefert das Exception-Objekt, das am Beginn einer Kette von einander auslösenden Fehlern steht. (GetBaseException() verfolgt InnerException bis an den Anfang zurück.) Wenn der aktuelle Fehler nicht durch einen anderen Fehler ausgelöst wurde, liefert GetBaseException() den Wert null.
HelpLink
verweist auf einen zum Fehler passenden Hilfetext.
InnerException
Falls der Fehler durch einen anderen Fehler ausgelöst wurde, verweist InnerException auf den vorangegangenen Fehler.
Message
enthält die Fehlerbeschreibung.
Source
gibt das Programm an, in dem der Fehler aufgetreten ist. Standardmäßig wird hier einfach der Name der Assembly geliefert.
StackTrace
gibt an, wie es zum Aufruf der Prozedur bzw. der Methode kam, in dem der Fehler ausgelöst worden ist. Der Inhalt der Eigenschaft StackTrace entspricht den Informationen, die in der Entwicklungsumgebung im Fenster AUFRUFLISTE angezeigt werden.
TargetSite
verweist auf ein Objekt der Klasse System.Reflection.MethodBase, das detaillierte Informationen über die Prozedur bzw. die Methode gibt, in der der Fehler aufgetreten ist. (Über das MethodBase-Objekt können Sie z.B. feststellen, welche Parameter die Methode kennt, welchen Typ diese Parameter haben usw.)
16.1.4
Eigene Exception-Klassen
Das .NET Framework beherbergt eine große Anzahl an Exception-Klassen, die für alle möglichen Eventualitäten verwendet werden können. Sie haben aber auch die Möglichkeit, eigene Exception-Klassen zu erstellen und diese in Ihrem Programm zu verwenden. Dabei müssen Sie auf einige Dinge achten:
Sandini Bib
464
16 Fehlersuche und Fehlerabsicherung
f In der Hierarchieebene der Exception-Klassen folgen nach Exception zwei Klassen, mit denen die Art der Exception unterschieden wird: ApplicationException und SystemException. Diese beiden Klassen dienen der Unterscheidung nach Exceptions, die vom System generiert werden, und Exceptions, die von Anwendungen generiert werden. Ihre selbst definierten Exceptions werden nie vom System ausgelöst werden, Sie sollten daher für eigene Exceptions immer die Klasse ApplicationException als Basis nehmen. f Der Klassenname Ihrer Exception sollte auf Exception enden, wie es bei den anderen Exceptions auch der Fall ist. Das ist zwar nur eine Konvention, hilft aber, den Programmcode schneller zu verstehen. f Machen Sie die Exception serialisierbar und implementieren Sie ggf. einen entsprechenden Konstruktor für die Deserialisierung. Das bedeutet natürlich auch, dass Sie das Interface ISerializable implementieren müssen. Damit kann diese Exception dann auch bei verteilten Anwendungen verwendet werden, bei denen Serialisierung für die Datenübertragung verwendet wird. f Sie müssen für jeden Konstruktor der Exception-Klasse einen eigenen Konstruktor implementieren.
VERWEIS
f ApplicationException ist serialisierbar und verwendet sowohl einen Deserialisierungskonstruktor als auch eine Methode GetObjectData(). Beide können Sie aus Ihrer eigenen Exception-Klasse aufrufen und so die Standard-Daten automatisch serialisieren/deserialisieren. Einen lesenswerten Artikel (The Well-Tempered Exception von Eric Gunnerson) zur Programmierung eigener Exception-Klassen finden Sie hier: http://msdn.microsoft.com/library/en-us/dncscol/html/csharp08162001.asp
Beispielklasse
CD
Die folgende Klasse implementiert eine Exception, bei der weitere Felder und Eigenschaften hinzugefügt wurden. Zum Einen beinhaltet diese Exception eine detaillierte Fehlermeldung, die zusätzlich zur normalen Fehlermeldung angegeben werden kann, zum Zweiten wird auch der Zeitpunkt des Auftretens des Fehlers mitgespeichert, was für LogEinträge oder auch weitere Fehlerverfolgung eine sinnvolle Erweiterung ist. Abgedruckt ist hier die gesamte Klasse. Die Kommentare sollten ausreichend Informationen liefern. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_16\CustomExceptionExample.
Sandini Bib
Fehlerabsicherung [Serializable()] public class DetailedException : ApplicationException, ISerializable { private DateTime errorTime = DateTime.Now; // Fehlerzeit private string detailedMessage = String.Empty; // Detaillierte Meldung public DateTime ErrorTime { get { return this.errorTime; } } public string DetailedMessage { get { return this.detailedMessage; } } public DetailedException() : base() { } // Ab hier Konstruktoren public DetailedException( string message ) : base( message ) { // Werte übernehmen this.errorTime = DateTime.Now; this.detailedMessage = message; } public DetailedException( string message, Exception inner ) : base( message, inner ) { // Werte übernehmen this.errorTime = DateTime.Now; this.detailedMessage = message; } public DetailedException( string message, string detailed ) : base( message ) { this.errorTime = DateTime.Now; this.detailedMessage = detailed; } // Deserialisierungskonstruktor public DetailedException( SerializationInfo info, StreamingContext context ) : base( info, context ) { // Da der Basiskonstruktor einen Teil der Deserialisierung übernimmt, // hier nur zusätzliche Member deserialisieren this.errorTime = info.GetDateTime( "ErrorTime" ); this.detailedMessage = info.GetString( "DetailedMessage" ); }
465
Sandini Bib
466
16 Fehlersuche und Fehlerabsicherung
// Serialisierungsmethode (GetObjectData) public void GetObjectData( SerializationInfo info, StreamingContext context ) { // Basismethode aufrufen base.GetObjectData( info, context ); // Eigene Werte serialisieren info.AddValue( "ErrorTime", this.errorTime ); info.AddValue( "DetailedMessage", this.detailedMessage ); } }
16.2
Fehlersuche (Debugging)
Der Begriff Debugging bezeichnet die Suche nach Fehlern in einem Programm. Debugging betreiben Sie immer dann, wenn Ihr Programm nicht so funktioniert, wie Sie es erwarten, und Sie auf der Suche nach den Ursachen sind. In diesem Abschnitt werden die Hilfsmittel vorgestellt, die das Visual Studio zu diesem Zweck zur Verfügung stellt.
16.2.1
Grundlagen
Debug- oder Release-Kompilat Die Debugging-Eigenschaften einer Applikation hängen stark vom Typ der Kompilierung ab. In der Standardeinstellung wird ein Programm in der Debug-Version erstellt, die benötigten Informationen zur Fehlersuche werden in einer Datei mit der Endung .pdb abgelegt. In der Release-Variante sind diese Informationen standardmäßig nicht enthalten, Sie haben aber die Möglichkeit, einzustellen, wie sich Debug- und Release-Version unterscheiden. Sie können über den Konfigurationsmanager der Anwendung (ERSTELLEN|KONFIGURAdie Konfiguration wählen. Schneller geht es über die Symbolleiste, in der eine entsprechende Combobox zur Verfügung gestellt wird. Um die in diesem Abschnitt vorgestellten Schritte durchführen zu können, müssen Sie sich in der Debug-Konfiguration befinden. TIONSMANAGER)
Einstellungen Die Unterschiede zwischen Debug- und Release-Version Ihres Programms können Sie in den Projektoptionen einstellen, die im Visual Studio neu gestaltet wurden (auch aufgrund des Umfangs) und nun im mittleren Bereich angezeigt werden (siehe Abbildung 16.5). Die Standardunterschiede zwischen Debug und Release sind in der folgenden Liste aufgeführt. f In der Debug-Version wird eine .pdb-Datei mit Informationen zur Fehlersuche erstellt, im Release-Modus nicht. Diese Datei ist Grundvoraussetzung für das Debugging. f Als bedingte Kompilierungskonstante werden im Debug-Modus DEBUG und TRACE vordefiniert und können ausgewertet werden. Im Release-Modus ist es nur TRACE.
Sandini Bib
Fehlersuche (Debugging)
467
f Im Debug-Modus wird der Code nicht optimiert. f Die Klasse Debug aus dem Namespace System.Diagnostics kann im Debug-Modus verwendet werden, im Release-Modus wird entsprechender Programmcode nicht berücksichtigt. Daneben können noch eine Reihe weitere Eigenschaften unterschiedlich eingestellt werden, z.B. das Verzeichnis, in dem das Kompilat erstellt wird, vordefinierte Konstanten usw. Sie können beispielsweise weitere Kompilierungskonstanten deklarieren, die Sie später im Programm auswerten können.
TIPP
Die Grundeinstellungen für die aktuelle Konfiguration finden Sie übrigens nicht im Bereich DEBUG sondern vielmehr unter BUILD. Der Bereich Debug enthält Einstellungen, mit denen Sie beispielsweise das Remote-Debuggen oder das Debuggen im SQL Server aktivieren können. Im Konfigurationsmanager (Menüpunkt ERSTELLEN|KONFIGURATIONSMANAGER) können Sie neben Debug und Release weitere Konfigurationen (Profile) definieren und deren Eigenschaften dann in den Projekteigenschaften einstellen.
Abbildung 16.5: Die Build-Konfiguration in den Projekteigenschaften
Sandini Bib
468
16 Fehlersuche und Fehlerabsicherung
Programmausführung unterbrechen Wenn Sie ein Programm in der Entwicklungsumgebung ausführen, können Sie dieses unterbrechen. Drücken Sie dazu die Tastenkombination (Strg)+(Alt)+(Pause) (in der Standardeinstellung). Diese Tastenkombinationen lassen sich einstellen, sodass nicht sicher ist, ob Sie die gleiche haben. Alternativ können Sie auch den Menüpunkt DEBUGGEN|ALLE UNTERBRECHEN verwenden. Es muss aber das Visual Studio aktiv sein, nicht die Anwendung.
16.2.2
Fehlersuche in der Entwicklungsumgebung
Haltepunkte Haltepunkte sind Positionen, an denen das Programm die Ausführung unterbricht und in die Entwicklungsumgebung schaltet, wo Sie sich unter Umständen die Werte der Variablen ansehen oder auch den Code, der an dieser Stelle ausgeführt wird, nachverfolgen können. In der Entwicklungsumgebung werden Haltepunkte durch einen roten Kreis neben der Programmzeile angezeigt. Sie haben nun auch die Möglichkeit, das Programm zeilenweise fortzusetzen, sodass Sie jeden Schritt genau beobachten können. Falls Ihr Gesamtprojekt auch DLLs beinhaltet, die in diesem Programm verwendet werden und sich in der gleichen Projektmappe befinden, können Sie auch automatisch in die dort deklarierten Methoden springen. Neben einfachen Haltepunkten kennt die Entwicklungsumgebung auch Haltepunkte, die nur dann berücksichtigt werden, wenn eine bestimmte Bedingung zutrifft. Für derartige Zusatzbedingungen hat der Breakpoint selbst (also wirklich der rote Punkt in der rechten Leiste des Visual Studio) ein Kontextmenü erhalten, mit dem Sie zusätzliche Dinge definieren können. Die Bedingungsdefinition sehen Sie in Abbildung 16.6.
Abbildung 16.6: Eine Haltepunkt-Bedingung
Einen genaueren Überblick über alle Haltepunkte liefert ein Fenster der Entwicklungsumgebung. Sie erreichen das Haltepunktefenster durch Drücken der Tastenkombination (Strg)+(Alt)+(B).
Sandini Bib
Fehlersuche (Debugging)
469
Falls Sie alle Haltepunkte loswerden wollen und das Visual Studio in der Standardeinstellung benutzen, können Sie das über den entsprechenden Menüpunkt im Menü DEBUGGEN oder einfacher über die Tastenkombination (Strg)+(ª)+(F9) realisieren.
TIPP
Abbildung 16.7: Das Haltepunktfenster zur Verwaltung aller Haltepunkte
Wenn Haltepunkte in der Entwicklungsumgebung mit einem Fragezeichen angezeigt werden, haben Sie Ihr Programm vermutlich in der Release-Konfiguration kompiliert. Haltepunkte werden aber nur in der Debug-Konfiguration unterstützt. Stellen Sie also auf die Debug-Konfiguration um und kompilieren Sie Ihr Programm neu.
Programm zeilenweise ausführen Nach einer Unterbrechung können Sie das Programm zeilenweise (also Anweisung für Anweisung) fortsetzen. Dazu können Sie entweder die Kommandos des DEBUGGEN-Menüs, die dort angegebenen Tastenkürzel oder die Buttons der DEBUGGEN-Symbolleiste verwenden. Die folgende Aufzählung beschreibt die verschiedenen Kommandos, die dabei zur Auswahl stehen: f STARTEN/FORTSETZEN: setzt die Programmausführung unbegrenzt fort (bis der nächste Fehler auftritt bzw. der nächste Haltepunkt erreicht wird). Das Standard-Tastenkürzel ist (F5). f EINZELSCHRITT: führt nur die nächste Anweisung aus. Das Standard-Tastenkürzel ist (F11). f PROZEDURSCHRITT: führt ebenfalls nur die nächste Anweisung aus. Wenn es sich dabei um den Aufruf einer selbst definierten Methode oder Prozedur handelt, wird diese Prozedur vollständig ausgeführt (und nicht Anweisung für Anweisung wie bei EINZELSCHRITT). Das Standard-Tastenkürzel ist (F10). f AUSFÜHRUNG BIS RÜCKSPRUNG: führt die Prozedur bis an ihr Ende aus. Die Programmausführung wird bei der nächsten Anweisung nach dem Aufruf der Prozedur wieder unterbrochen. Das Standard-Tastenkürzel ist (ª)+(F11).
Sandini Bib
TIPP
470
16 Fehlersuche und Fehlerabsicherung
Obwohl es im Menü der Entwicklungsumgebung und in den Symbolleisten wirklich nicht an Kommandos mangelt, die man nie braucht, fehlt per Default ein Teil der hier beschriebenen Kommandos. Das können Sie mit EXTRAS|ANPASSEN aber schnell ändern. Das BEFEHLE-Dialogblatt enthält die vollständige Liste aller Debugging-Kommandos, die Sie einfach per Drag&Drop in das Menü einbauen können.
Befehlsfenster Während das Programm unterbrochen ist, können Sie im Befehlsfenster (ANSICHT|ANDERE FENSTER|BEFEHLSFENSTER) einfache Kommandos ausführen. Das Fenster eignet sich insbesondere dazu, um Prozeduren aufzurufen oder den Wert einer Variablen anzuzeigen.
Variablenwerte Im Bereich der Überwachung von Variablen hat sich viel getan. Die bisherige Vorgehensweise mit eigenen Fenstern für die verschiedenen aktuell gültigen Variablen, in denen die Inhalte hierarchisch dargestellt wurden, ist zwar nach wie vor vorhanden aber nicht der Weisheit letzter Schluss. Sollen umfangreiche Informationen über beispielsweise Objekte ermittelt werden, ist eine hierarchische Anordnung eher unübersichtlich. Das Visual Studio 2005 (in allen Versionen) beinhaltet eine neue Art der Werteermittlung. Die Werte werden direkt in der Entwicklungsumgebung dargestellt. Sie kennen das bereits in der Form, dass ein enthaltener Wert angegeben wird. Diese Funktion aber geht noch weiter. Sie können die gesamte Objekthierarchie durchforsten, ohne den Editor verlassen zu müssen. Alles was dazu nötig ist, ist, die Maus über die betreffende Variable zu ziehen. Es erscheint der Inhalt der Variablen. Falls es sich um ein Objekt handelt, können Sie sämtliche Bestandteile durchforsten, wie Abbildung 16.8 zeigt.
Abbildung 16.8: Einsicht in das komplette Objekt und sämtliche Daten direkt im Editor
Sandini Bib
Fehlersuche (Debugging)
471
Bei manchen Werten wird eine kleine Lupe angezeigt, mit der Sie einen so genannten Visualisierer (oder in deutsch: Schnellansichten) aufrufen können, was vor allem bei umfangreichen Werten sinnvoll ist. Sie können Werte sowohl als Text, in XML-Form oder in HTML-Form anzeigen lassen. Bei den beiden letztgenannten Schnellansichten handelt es sich (natürlich) um den Internet-Explorer. Abbildung 16.9 zeigt die Text-Schnellansicht.
Abbildung 16.9: Die Text-Schnellansicht des Visual Studio
Edit&Continue Edit&Continue ist ein Feature das vor allem Visual-Basic-Programmierer unter .NET vermisst haben. Ob es wirklich ein derartiges Killerfeature ist, dass es sogar als Grund dafür herhalten musste, nicht auf .NET umzusteigen, ist fraglich – mitunter wurde es aber genannt. Mit dem Visual Studio 2005 ist das Vergangenheit, die neue Version unterstützt Edit&Continue sowohl bei Visual Basic als auch bei C#. Sie können das Programm unterbrechen (entweder manuell oder über einen Breakpoint), Werte ändern und den Programmfluss wieder aufnehmen, ohne neu kompilieren zu müssen. Das Ändern eines Variablenwerts ist ähnlich genial gelöst wie die Anzeige des Variableninhalts: die Änderung wird direkt im Editor vorgenommen. Das sieht dann so aus wie in Abbildung 16.10.
Abbildung 16.10: Ändern des Werts einer Variablen direkt im Editor
Nach der Änderung können Sie das Programm fortsetzen. Sie werden in den meisten Fällen feststellen, dass der neue Wert einfach übernommen wird, ohne dass eine Neukompilierung erfolgt. In den meisten Fällen bedeutet dies, dass es einige Fälle gibt, in denen das
Sandini Bib
472
16 Fehlersuche und Fehlerabsicherung
nicht der Fall ist. Die Rede ist hier von Generics. Implementierungsbedingt ist es nicht möglich, sobald Generics im Spiel sind, Werte zu ändern, ohne dass neu kompiliert werden muss.
Aufrufliste Das Fenster AUFRUFLISTE zeigt an, wie die aktuelle Codeposition erreicht worden ist. Dabei steht die erste ausgeführte Anweisung an unterster Stelle, es folgen alle Anweisungen, die bis zur aktuellen Position ausgeführt wurden. Abbildung 16.11 zeigt ein solches Fenster, bei dem es sich um eine Windows-Anwendung handelt.
Abbildung 16.11: Die Aufrufliste im Visual Studio
Durch einen Doppelklick innerhalb der Aufrufliste können Sie den aktuellen Gültigkeitsbereich (Kontext) verändern. So richtig sinnvoll funktioniert das aber nur bei den Einträgen, die tiefschwarz dargestellt werden. Für alle grau dargestellten Einträge existiert kein verwertbarer Quellcode (beispielsweise, wenn sich die aktuelle Position in einer DLL des .NET Frameworks befindet).
Threads-Fenster Wenn Sie Multithreading-Anwendungen erstellen, können Sie im Threads-Fenster zwischen den Threads Ihres Programms wechseln und das Programm gezielt in einem bestimmten Thread fortsetzen. Außerdem können Sie dort einen Thread sperren. Das bedeutet, dass dieser Thread nicht mehr ausgeführt wird, bis die Sperre aufgehoben wird.
Ausnahmen-Fenster Standardmäßig unterbricht der integrierte Debugger jedes Programm, wenn er auf eine nicht abgesicherte Ausnahme trifft, nicht aber, wenn die Ausnahme abgefangen wird. Sie können dieses Verhalten ändern. Über den Menüpunkt DEBUGGEN|AUSNAHMEN gelangen Sie in den Dialog aus Abbildung 16.12, in dem Sie festlegen können, wie sich der Debugger bei den entsprechenden Exceptions verhalten soll. In der Standardeinstellung wird angehalten, sobald eine Exception nicht abgehandelt wird.
Sandini Bib
Fehlersuche (Debugging)
473
Abbildung 16.12: Voreinstellungen für das Unterbrechen des Programms im Falle einer Exception
16.2.3
Ausgaben der Klasse Debug
Alle folgenden Methodennamen beziehen sich, falls nicht anders erwähnt, auf die Klasse Debug aus dem Namespace System.Diagnostics. Diese Klasse beinhaltet eine Anzahl statischer Methoden, die für Debugzwecke sinnvoll sind.
Schreiben in das Ausgabefenster Write() und WriteLine() funktionieren ähnlich wie die gleichnamigen Methoden der Klasse
HINWEIS
Console, schreiben aber in das Ausgabefenster des integrierten Debuggers. Wird ein Objekt übergeben, dann wird dessen Methode ToString() für die Ausgabe verwendet. Analog funktionieren die Methoden WriteIf() und WriteLineIf(), diese ermöglichen aber zusätzlich die Angabe einer Bedingung, die erfüllt sein muss, damit die Ausgabe erfolgt. Debug.Write[Line]() ist im Gegensatz zu Console.Write[Line]() leider nicht in der Lage, Formatanweisungen zu interpretieren. Debug.WriteLine("abc = {0}", abc) führt also nicht zu der von Console.WriteLine() vertrauten Ausgabe. Sie müssen die Ausgabe stattdessen in der Form Debug.WriteLine("abc = " + abc.ToString()) durchfüh-
ren. Die Methode Indent() erhöht die Eigenschaft IndentLevel um den Wert 1. Damit können Sie bewirken, dass bei der Ausgabe Einrückungen verwendet werden. Entsprechend wird das Maß der Einrückung durch UnIndent() verringert. IndentLevel gibt lediglich an, um wie viele Stufen eingerückt werden soll. IndentSize wiederum gibt an, wie viele Zeichen pro Einrückstufe verwendet werden sollen. Die Standardeinstellung sind 4 Zeichen.
Sandini Bib
474
16 Fehlersuche und Fehlerabsicherung
Debug-Ausgaben umleiten In der Standardeinstellung erfolgen die Ausgaben in das Ausgabefenster des integrierten Debuggers. Die Ausgabe kann allerdings in ein beliebiges Objekt der Klasse TextWriterTraceListener erfolgen. Die Eigenschaft Listeners der Klasse Debug enthält eine Liste dieser Objekte. TextWriterTraceListener erwartet im Konstruktor ein Stream-Objekt, in das er schreiben kann. Dadurch können Sie also beispielsweise die Ausgabe in eine Datei oder auf die Konsole umleiten: string FileName = "C:\\DebugTrace.txt"; Debug.Listeners.Add( new TextWriterTraceListener( new StreamWriter( filename ) ) ); Debug.Listeners.Add( new TextWriterTraceListener( Console.Out ) );
Assertions Assertions (auf deutsch etwa »Annahmen«) dienen dazu, im Quellcode bestimmte Bedingungen zu überprüfen. Wenn Sie die Methode Assert() verwenden, behaupten Sie, dass die als Parameter übergebene Bedingung true liefert. Ist dem nicht so, ist also die angegebene Bedingung nicht erfüllt, wird ein Meldungsfenster wie in Abbildung 16.13 angezeigt. Assert()-Anweisungen eignen sich beispielsweise dazu, um die Parameter von Prozeduren
oder Methoden zu überprüfen oder um während der Programmentwicklung andere unzulässige Zustände festzustellen. Sie sind keine Exceptions und können daher nicht durch try-catch-Konstrukte abgefangen werden. In Release-Versionen eines Programms werden Assert()-Anweisungen ignoriert.
Abbildung 16.13: Eine Assert-Meldung aus einem Windows.Forms-Programm
Mit der Methode Fail() können Sie ebenfalls eine Fehlermeldung ausgeben. Im Unterschied zu Assert() gibt es bei Fail() keinen Parameter für die Bedingung, so dass die Fehlermeldung immer angezeigt wird.
Sandini Bib
Fehlersuche (Debugging)
16.2.4
475
Syntaxzusammenfassung
Methoden und Eigenschaften der Klasse Debug (aus System.Diagnostics) Assert( bool condition ) Assert( bool condition, string message )
zeigt eine Assertion-Fehlermeldung an, wenn die Bedingung nicht (!) erfüllt ist. Anschließend kann das Programm beendet oder fortgesetzt werden.
Close()
schließt Dateien für Debug-Ausgaben.
Fail( string message )
zeigt eine Assertion-Fehlermeldung an.
Flush()
speichert noch gepufferte Ausgaben in den Ausgabedateien.
Indent()
rückt alle weiteren Ausgaben um eine Ebene ein.
IndentLevel
gibt an, um wie viele Ebenen Ausgaben eingerückt werden.
IndentSize
gibt an, um wie viele Zeichen Ausgaben pro Ebene eingerückt werden.
Listeners
verweist auf die Liste aller TraceListener-Objekte, an die Fehlermeldungen geschrieben werden (per Default nur das Ausgabefenster).
UnIndent()
reduziert die Einrückung für weitere Ausgaben.
Write( string text ) WriteLine( string text )
schreibt den Text in das Ausgabefenster.
WriteIf( bool condition, string text ) WriteLineIf( bool condition string text )
schreibt den Text in das Ausgabefenster, wenn die angegebene Bedingung erfüllt ist.
Sandini Bib
Sandini Bib
Teil IV Windows-Programmierung
Sandini Bib
Sandini Bib
17 Einführung in Windows.Forms Der Namespace System.Windows.Forms ist der Teil des .NET Frameworks, der für die Anzeige und Verwaltung grafischer Oberflächen zuständig ist. Dieser Teil der Klassenbibliothek ermöglicht eine komfortable visuelle Zusammenstellung aller notwendigen Komponenten für eine vollständige Windows-Anwendung. Dieses Kapitel gibt eine erste Einführung in die Erstellung und Verwendung eigener Formulare sowie einen Überblick über die dabei zur Anwendung kommenden Bibliotheken, Klassen und Steuerelemente. Außerdem beschreibt das Kapitel die Anwendung der Entwicklungsumgebung zum Design von Formularen.
17.1
Einführung
Eine erste kleine Windows.Forms-Applikation haben Sie ja bereits in Abschnitt 2.2 ab Seite 52 kennen gelernt. Auch wenn es sich dabei nur um ein kleines Beispiel handelte, haben Sie die grundlegende Vorgehensweise schon gesehen. Basis einer Windows.Forms-Anwendung ist ein Hauptformular, dem weitere Steuerelemente hinzugefügt werden. Dreh- und Angelpunkt der Programmierung sind die Ereignisse sowohl des Formulars als auch der Steuerelemente, mit denen die eigentliche Funktionalität des Programms zur Verfügung gestellt wird. Wie auch bei Konsolenanwendungen wird eine Applikation über die Methode Main() gestartet. Wenn Sie eine neue Windows.Forms-Anwendung erstellen, befindet sich in dieser Methode unter anderem folgende Anweisung: Application.Run( new Form1() );
HINWEIS
Diese Anweisung bewirkt, dass Form1 zum Hauptformular der Anwendung wird und dass die Nachrichtenwarteschleife von Windows gestartet wird. Erst nach dem Schließen des angegebenen Hauptformulars kehrt die Methode Run() wieder zurück und der Rest von Main() (falls dort noch weitere Funktionalität angegeben ist) wird abgearbeitet. Das Programm endet, wenn die Methode Main() komplett abgearbeitet ist. Die Methode Application.Run() ist essenziell wichtig für ein Windows-Programm, denn diese Methode »hängt« Ihre Applikation in die Windows-Nachrichtenschleife ein, d.h. erst nach dem Aufruf von Application.Run() läuft Ihr Programm. Üblicherweise wird ein Formular übergeben, das dann als Hauptformular fungiert. Das ist aber nicht immer notwendig, beispielsweise kann auch ein ApplicationContextObjekt übergeben werden.
Anders als in .NET 1.1, wo die Methode Main() in das erste erstellte Formular eingebettet wurde, finden Sie sie nun in der Klasse Program, die eigens für die Main()-Methode erzeugt wird. Üblicherweise werden Sie der Klasse selbst nicht sehr viel hinzufügen, die Methode Main() aber können Sie dort bearbeiten (z.B. für die Anzeige eines Splashscreens).
Sandini Bib
480
17.1.1
17 Einführung in Windows.Forms
Interaktion zwischen Code und Designer
In C# wird das Konzept verfolgt, dass jede Änderung, die im Entwurfsmodus vorgenommen wird, sich auch im Quelltext widerspiegelt. Wenn Sie beispielsweise ein Steuerelement auf dem Formular platzieren, wird dieses Steuerelement als Feld des Formulars eingefügt und der Liste der Steuerelemente hinzugefügt. Auf die Liste aller Steuerelemente können Sie über die Eigenschaft Controls eines Formulars zugreifen. Darin enthalten ist eine Liste mit Elementen vom Typ Control. Alle Steuerelemente sind in der Klassenhierarchie von Control abgeleitet, Polymorphie erlaubt es also, dieser Liste jedes beliebige Steuerelement hinzuzufügen.
Das Programmgerüst Bevor wir an die Art und Weise gehen, wie .NET bzw. das Visual Studio Elemente zu einem Formular hinzufügt, zunächst ein paar Hinweise zu den Änderungen, die sich im Vergleich zur Version 2003 der Entwicklungsumgebung ergeben haben. Im Vorgänger wurde vom Designer generierter Code direkt in das Hauptformular eingetragen, in eine spezielle Region namens »Vom Windows Forms Designer generierter Code«. Diese Region existiert im Visual Studio 2005 nicht mehr, statt dessen finden Sie im Quelltext des Formulars nur noch die Klassendeklaration und den Konstruktor der Klasse. Natürlich generiert das Visual Studio weiterhin Code, sehr umfangreichen Code sogar. Dieser ist allerdings dank des neuen Partial-Class-Features in einer eigenen Datei untergebracht, die den Namen .Designer.cs trägt. Dadurch wird nicht verhindert, dass Sie diesen generierten Code manipulieren können (was Sie aber dennoch nicht tun sollten), es wird aber eine bessere Übersicht über den Code erzeugt, den Sie selbst schreiben. Dieser wird nun nicht mehr durch den designergenerierten Code unterbrochen. Die Methoden, die es auch in .NET 1.1 gab, existieren natürlich weiterhin und auch die grundsätzliche Funktionsweise ist die gleiche. Für neu hinzugefügte Elemente wird ein Feld des Formulars erzeugt (in der Designer-Codedatei) und die Initialisierungsanweisungen werden in die Methode InitializeComponent() eingetragen. InitializeComponent() befindet sich ebenfalls in der Designer-Codedatei und muss aus dem Konstruktor des Formulars heraus aufgerufen werden. Sollten Sie selbst einen Konstruktor schreiben, der noch Parameter übernimmt (was vor allem bei Dialogen häufig vorkommen wird), dürfen Sie diesen Aufruf nicht vergessen – vor allem dann nicht, wenn der Standardkonstruktor nicht mehr zur Verfügung stehen soll und Sie ihn löschen. Sämtliche eigenen Aktionen müssen nach dem Aufruf von InitializeComponent() eingefügt werden, da diese Methode alle auf dem Formular befindlichen Steuerelemente zunächst initialisiert. Normalerweise müssen Sie in der Designer-Codedatei keine Änderungen vornehmen, allerdings hindert Sie auch niemand daran. Wichtig ist, dass der Designer Ihre Änderungen versteht, denn bezogen auf die Designer-Codedatei oder die Methode InitializeComponent() beeinflussen Änderungen im Quelltext unmittelbar auch die Entwurfsansicht.
Sandini Bib
Einführung
481
Ereignisprozeduren von Steuerelementen Ein Programm läuft unter Windows grundsätzlich ereignisorientiert ab. Ereignisprozeduren werden normalerweise über das Eigenschaften-/Ereignisfenster hinzugefügt. In diesem Fall erfolgt die Verknüpfung des Ereignisses mit dem Steuerelement automatisch innerhalb von InitializeComponent(). Jedes Steuerelement besitzt ein so genanntes Standardereignis, das automatisch eingefügt wird, wenn Sie einen Doppelklick auf das Steuerelement ausführen. Das Standardereignis entspricht in der Regel dem üblichen Gebrauch eines Steuerelements. Für einen Button handelt es sich beispielsweise um das Ereignis Click, bei einem Formular ist es das Ereignis Load. Wenn Sie bei markiertem Steuerelement von der Eigenschaftsansicht in die Ereignisansicht wechseln (im Eigenschaftenfenster, durch Klick auf das Blitz-Symbol), steht der Cursor automatisch auf dem Standardereignis. Sie können durch einen Doppelklick jedes beliebige Ereignis einfügen, das Ihnen in der Ereignisliste angezeigt wird. Dabei vergibt das Visual Studio dann automatisch einen Namen für die eingefügte Ereignisbehandlungsroutine. Das ist allerdings aus mehreren Gründen in umfangreichen Applikationen nicht wünschenswert: f In einer realen Applikation verweisen häufig mehrere Ereignisse unterschiedlicher Steuerelemente auf die gleiche Methode. Es ist nicht konsistent und führt zu Verwirrung, wenn das Click-Ereignis eines Buttons auf das Click-Ereignis eines Menüs verweist. f Laut Codekonventionen des .NET Frameworks werden die Namen privater Felder eines Formulars in camelCasing geschrieben (kleiner Buchstabe vorn, jedes neue Wort mit Großbuchstaben begonnen), Methodennamen in PascalCasing (großer Buchstabe vorn). Der erste Teil des automatisch eingefügten Namens für eine Ereignisbehandlungsroutine ist allerdings der Name des Felds (der ja in camelCasing geschrieben ist). Um wirklich konsistent zu sein, müsste hier umbenannt werden. Um diese Probleme zu umgehen, gibt es eine weitere Möglichkeit, eine Ereignisbehandlungsroutine zu benennen. Schreiben Sie in das freie Feld hinter dem Ereignisnamen einfach den Namen der Methode, die die Ereignisbehandlungsroutine darstellen soll. Das Visual Studio verwendet dann den von Ihnen eingegebenen Namen. Für das Öffnen einer Datei wäre z.B. ein Name wie Event_OpenFile sinnvoll, der angibt, dass es sich um ein Ereignis handelt und wofür dieses gut ist. Die Methode kann dann von unterschiedlichen Stellen aus benutzt werden. Möchten Sie eine bestehende Ereignisbehandlungsroutine verwenden, müssen Sie nur die ComboBox neben dem Ereignisnamen aufklappen und können dann unter allen Ereignissen wählen, die die passende Signatur besitzen. Diese Signatur unterscheidet sich nur im zweiten Parameter. Der erste Parameter vom Typ object stellt das Steuerelement dar, das das Ereignis ausgelöst hat. Der zweite Parameter ist vom Typ EventArgs bzw. einer davon abgeleiteten Klasse und übermittelt weitere Informationen.
Sandini Bib
482
17 Einführung in Windows.Forms
Ein Ereignis für mehrere Steuerelemente Der Parameter sender vom Typ object ist dann wichtig, wenn eine Ereignisbehandlungsroutine von unterschiedlichen Steuerelementen verwendet wird. Durch ihn kann unterschieden werden, welches Steuerelement das Ereignis ausgelöst hat. Als Beispiel soll hier das Einschränken der Eingabe in einer Textbox dienen. Für den Computer dient als Dezimalpunkt bekanntlich der Punkt, während es im deutschen Sprachgebrauch das Komma ist. Es wäre daher sinnvoll, bei einer Eingabe eines Kommas einen Punkt zu schreiben und das Komma zu ignorieren. Bei nur einer Textbox (z.B. mit dem Namen textBox1) kann das sehr leicht bewerkstelligt werden: private void TextBox1_KeyPress( object sender, KeyPressEventArgs e ) { if (e.KeyChar == ',') { e.Handled = true; textBox1.SelectedText = "."; } } e.KeyChar liefert das vom Anwender eingegebene Zeichen, dieses wird mit dem Komma
verglichen. Wenn es sich um ein Komma handelt, wird dem System mitgeteilt, dass das Ereignis manuell abgehandelt wurde (e.Handled = true) und SelectedText wird auf den Punkt eingestellt (was der normalen Vorgehensweise entspricht). In diesem Fall wird jedoch textBox1 explizit behandelt. Wenn Sie dieses Verhalten nun auf weitere Textboxen übertragen wollen, ist nur eine kleine Änderung im Code notwendig. Sie müssen lediglich über den Parameter sender ermitteln, welche Textbox das Ereignis ausgelöst hat, und deren SelectedText-Eigenschaft ändern: private void TextBox1_KeyPress( object sender, KeyPressEventArgs e ) { if ( e.KeyChar == ',' ) { e.Handled = true; ( sender as TextBox ).SelectedText = "."; } }
Über den as-Operator wird der Parameter sender in den Typ TextBox konvertiert. Das ist problemlos möglich, da wir in diesem Fall wissen, dass dieses Ereignis lediglich von Textboxen aufgerufen wird (was aber nicht immer der Fall sein muss). Diese Vorgehensweise funktioniert in gleicher Form selbstverständlich auch für andere Arten von Steuerelementen.
Entfernen von Ereignissen Zum Entfernen eines Ereignisses müssen Sie lediglich die Zuweisung in der Ereignisansicht des Eigenschaftenfensters löschen. Daraufhin wird die Verknüpfung zwischen Steuerelement und Ereignis aus dem Quellcode gelöscht. Das Visual Studio entfernt jedoch keinen programmierten Code, d.h. die Ereignisprozedur selbst bleibt, sofern Quellcode darin enthalten ist, bestehen, auch wenn sie nicht mehr verwendet wird.
Sandini Bib
Einführung
17.1.2
483
Arbeiten mit dem Visual Studio
In der Entwurfsansicht ermöglicht Ihnen das Visual Studio einen komfortablen Zugriff auf die Steuerelemente und Komponenten, die Sie im Programm benötigen, sowie auf deren Eigenschaften und Ereignisse. In der Codeansicht wird die Lesbarkeit des Programms durch farbliche Hervorhebung verbessert, die IntelliSense-Hilfe hilft bei der Eingabe beispielsweise durch automatisches Anzeigen der Parameter einer Methode.
Regions Eine weitere Möglichkeit, den Code innerhalb einer Klasse bzw. einer Datei zu verbessern, sind die bereits angesprochenen Regions. Diese werden nicht nur vom Visual Studio eingefügt, Sie können sie auch selbst verwenden, um Ihren Code zu strukturieren. Bewährt hat sich die Methode, eine Region für Felder, Eigenschaften und Methoden einzufügen – mindestens. Weitere Unterteilungen sind selbstverständlich möglich.
HINWEIS
An der linken Seite werden Sie auch neben den Methoden kleine Kästen mit dem Minuszeichen bemerken. Wie Regions können auch Methoden auf- und zugeklappt werden. Während es jedoch möglich ist, die Regions bereits beim Laden der Datei auf- oder zugeklappt darzustellen, sind Methoden nach dem Laden immer aufgeklappt. Leider können Sie nur global angeben, ob Regions nach dem Laden auf- oder zugeklappt dargestellt werden sollen. Die Angabe, bestimmte Regions aufzuklappen und andere wiederum nicht, ist nicht möglich.
Namespaces Wenn Sie ein neues Programm erstellen, wird automatisch ein Standard-Namespace angelegt, der dem Programmnamen entspricht. Jede neue Datei gehört automatisch zu diesem Namespace. Dieser dient aber lediglich als übergeordneter Namespace, Sie können selbst weitere anlegen. Auch hierbei hilft das Visual Studio mit einer sehr intelligenten Methode. Der Hintergrund ist, dass die Untergliederung in Namensräume auch auf der Festplatte sichtbar gemacht wird, nämlich durch Verzeichnisse. Sicherlich haben Sie bemerkt, dass das Projekt selbst sich in einem Verzeichnis befindet, das dem Projektnamen entspricht (oder genauer: dem Stammnamensraum des Projekts). Um einen weiteren Namespace anzulegen, können Sie den Projektmappen-Explorer verwenden. In diesem können Sie nämlich mittels Rechtsklick auch Unterverzeichnisse für ein aktuelles Projekt anlegen. Diese Unterverzeichnisse entsprechen Namensräumen und werden auch physikalisch auf der Festplatte angelegt. Der Namespace trägt entsprechend den Namen des Unterverzeichnisses, allerdings mit dem Stammnamensraum als übergeordnetem Namespace. Wenn Sie eine neue Datei in einem solchen Verzeichnis anlegen (über den ProjektmappenExplorer), wird sie physikalisch auf der Platte im angegebenen Verzeichnis erstellt. Wei-
Sandini Bib
484
17 Einführung in Windows.Forms
HINWEIS
terhin ist der Namespace der des Verzeichnisses. Um das zu verdeutlichen zeigt Abbildung 17.1 eine Grafik eines immer noch kleinen, aber mit mehreren Namensräumen ausgestatteten Projekts. Demnach ist der Stammnamensraum der Namespace NETJongg, die markierte Datei Tile.cs befindet sich im Ordner PlayField. Die gleichnamige Klasse Tile befindet sich damnach im Namespace NETJongg.PlayField. Auf diese Art ist es sehr einfach, ein Projekt sinnvoll zu strukturieren. Wenn Sie mehrere Projekte innerhalb einer Projektmappe zusammenfassen, ist es sinnvoll, ein Projektmappenverzeichnis zu erstellen. Dieses Verzeichnis trägt dann den Namen der Projektmappe und ist den Verzeichnissen der Projekte übergeordnet. Damit haben Sie auch physikalisch alle Projekte zusammengefasst, die zusammengehören. Bei den Beispielen zu diesem Buch wurde ebenso vorgegangen, wobei hier der Name der Projektmappe meist auch dem Namen des einzigen darin enthaltenen Projekts entspricht. Ach ja … das Programm .NETJongg finden Sie in einer Version für .NET 2.0 auf der beiliegenden CD.
Abbildung 17.1: Mehrere Namensräume in einem Projekt
Sandini Bib
Einführung
485
Das Eigenschaftsfenster Dreh- und Angelpunkt beim Entwerfen einer Benutzeroberfläche ist das Eigenschaftenfenster, das ja eigentlich ein kombiniertes Eigenschaften-/Ereignisfenster ist. Auch hier werden Ihnen einige Hilfestellungen geboten: f Alle Eigenschaftswerte, die nicht der Standardeinstellung entsprechen, sind fett hervorgehoben. f Die Sortierung der Werte im Eigenschaftenfenster kann wahlweise nach Gruppenzugehörigkeit oder alphabetisch erfolgen. Zwischen diesen beiden Modi kann mit den Buttons im linken oberen Eck des Eigenschaftsfensters gewechselt werden. Die alphabetische Anordung erleichtert oft die Suche nach einer bestimmten Eigenschaft, da man in der Regel den Namen weiß und so nicht erst nach der Gruppe suchen muss, in der sie eingeordnet ist. f Die Werte der meisten Eigenschaften können über den Menüpunkt ZURÜCKSETZEN des Kontextmenüs der Eigenschaft auf den Standardwert gesetzt werden. Das gilt natürlich nur, wenn der Wert auch geändert wurde. Dazu müssen Sie das Kontextmenü verwenden, das sich hinter dem Eigenschaftsnamen verbirgt, nicht hinter dem Eigenschaftswert. f Im Eigenschaftenfenster können Sie auch gemeinsame Eigenschaften mehrerer Steuerelemente verändern. Dazu müssen Sie die Steuerelemente gemeinsam markieren – entweder mit (Strg)+Mausklick, oder indem Sie mit der Maus einen Rahmen über alle zu markierenden Steuerelemente zeichnen. Das Eigenschaftenfenster zeigt dann nur die Eigenschaften, die allen markierten Steuerelementen gemeinsam sind. f Wenn Sie im Eigenschaftenfenster eine Eigenschaft markieren, gelangen Sie mit (F1) direkt zum dazugehörenden Hilfetext. f Bei der Ausrichtung mehrerer Steuerelemente (z.B. Einstellung eines gleichmäßigen Abstands) hilft die LAYOUT-Symbolleiste. Wenn Sie diese Symbolleiste wie ich aus Platzgründen per Default nicht anzeigen lassen, vergessen Sie nicht, dass es sie gibt und dass sie durchaus praktisch ist! f Wenn Sie mehrere gleichartige Steuerelemente benötigen, ist es oft am einfachsten, zuerst ein Steuerelement einzufügen, dessen Eigenschaften einzustellen und das Steuerelement dann mit KOPIEREN/EINFÜGEN ((Strg)+(C), (Strg)+(V)) zu vervielfältigen.
Hinweise zum Programmieren Programme müssen auch dann übersichtlich bleiben, wenn sie eine gewisse Größe überschreiten. Damit ein Programm übersichtlich bleibt, sollten Sie sich von Anfang an eine übersichtliche Arbeitsweise angewöhnen. Die folgenden Hinweise sind genau das, nämlich lediglich Hinweise, die Sie natürlich nicht beherzigen müssen. In der Praxis haben sich diese Vorgehensweisen jedoch bewährt. f Das Visual Studio ist darauf ausgelegt, für jedes Formular eine eigene Codedatei bereitzustellen. Da der Designer zwei Formulare innerhalb einer Datei nicht zur visuellen
Sandini Bib
486
17 Einführung in Windows.Forms
Bearbeitung öffnen kann, sind Sie zu dieser Vorgehensweise zumindest bei Formularen gezwungen. Gehen Sie aber am besten mit allen Programmelementen, also auch Klassen, Enumerationen, Strukturen, Listen usw. so vor. Der Name einer Datei entspricht dann auch dem Namen des darin befindlichen Programmelements, eine langwierige Suche bleibt Ihnen erspart. f Sollten Sie bei Klassen das Partial-Class-Feature verwenden, bemühen Sie sich um eine saubere Benennung der Codedateien. Aus deren Namen sollte immer der Name der darin enthaltenen Klasse, bei Partial Classes auch noch der Zweck des darin enthaltenen Codes deutlich werden. Bei umfangreichen Klassen beispielsweise WorkerClass.Definitions.cs und WorkerClass.Methods.cs. f Nutzen Sie von Anfang an die Möglichkeit der Unterteilung in möglichst sinnvolle Namensräume. Das erspart eine spätere Umstrukturierung. Auch wenn die meist ziemlich kleinen Beispielprogramme auf der Buch-CD nicht so geordnet sind – aber deshalb sind das ja auch Beispielprogramme. Bei größeren Projekten ist eine saubere Unterteilung zwingend erforderlich, um den Quelltext übersichtlich zu halten. f Legen Sie bei Projekten, die auch DLLs beinhalten, ein Projektmappenverzeichnis an. Da jede DLL und jedes ausführbare Programm als Projekt betrachtet wird, haben Sie so alle zusammengehörigen Dateien/Projekte an einem Platz. f Nutzen Sie Regions bei umfangreichen Klassen. Sie werden die zusätzliche Tipparbeit nicht bereuen, wenn Sie bemerken, dass die Zeit zum Suchen einer bestimmten Stelle sehr klein wird. f Nutzen Sie keine anonymen Methoden. Auch wenn es am Anfang so scheint, dass diese Methoden Zeit sparen, führen sie doch zu extrem unübersichtlichem Code.
17.2
Arbeiten mit Formularen
Formulare sind die Basiselemente einer Windows-Anwendung, über sie funktioniert die gesamte Interaktion des Anwenders mit dem Programm. Daher sind sie auch eines der wichtigsten Elemente eines Programms. Oft werden Formulare auch als Fenster oder als Dialoge bezeichnet, in diesem Kapitel werden alle diese Begriffe verwendet und bedeuten das Gleiche. Der Abschnitt liefert einige grundlegende Informationen über das Arbeiten mit Formularen. Detailliertere Informationen z.B. zur Gestaltung von Benutzeroberflächen erhalten Sie in Kapitel 20 ab Seite 647.
17.2.1
Eigenschaften von Formularen
Die Formulareigenschaften ermöglichen nicht nur die Änderung des Aussehens eines Formulars, viele sind auch für das Verhalten desselben verantwortlich. Die folgende Tabelle gibt Ihnen eine Überblick über die Eigenschaften der Klasse Form, der Basisklasse eines jeden Formulars. Achtung, diese Liste ist recht lang. Sie dient lediglich dem Überblick, mit
Sandini Bib
Arbeiten mit Formularen
487
den wichtigsten Eigenschaften werden wir im Verlaufe dieses und der nächsten Kapitel noch in Berührung kommen. Eigenschaften der Klasse Form (aus System.Windows.Forms) AcceptButton
legt im Falle eines Dialogfensters fest, welcher Button der OK-Button ist. Beim Betätigen dieses Buttons wird die Eigenschaft DialogResult des Formulars automatisch auf den entsprechenden Wert des angegebenen Buttons eingestellt. Dieser Button wird automatisch betätigt, wenn die [RETURN]Taste gedrückt wird.
ActiveControl
liefert das Steuerelement, das aktuell den Fokus besitzt. Geliefert wird ein Wert vom Typ Control.
ActiveForm
Diese statische Eigenschaft liefert das aktuell in der Anwendung aktive Fenster.
ActiveMdiChild
liefert das aktive MDI-Child-Fenster zurück.
AllowDrop
gibt an, ob das Formular Ziel einer Drag&Drop-Operation sein kann.
AutoScale
gibt an, ob die Steuerelemente des Formulars skaliert werden sollen oder nicht. Das betrifft eine Änderung der Schriftgröße. Wird diese geändert und AutoScale ist true, werden die Steuerelemente in ihrer Größe an die neue Schriftart angepasst.
AutoScaleBaseSize
ruft die Grundgröße für die automatische Skalierung eines Formulars ab. Diese Größe dient als Vergleichswert, wenn das Formular skaliert werden muss. Ein derartiger Vergleich wird nur einmal durchgeführt (bei Erstellung des Formulars). Eine Änderung des Werts zur Laufzeit hat daher keinen Effekt.
AutoScroll
gibt an, ob der automatische Bildlauf ermöglicht wird oder nicht.
AutoScrollMargin
legt die Größe des Rands für den automatischen Bildlauf fest.
AutoScrollMinSize
gibt die minimale Größe des automatischen Bildlaufs an.
AutoScrollPosition
liefert die Position des automatischen Bildlaufs.
BackColor
gibt die Hintergrundfarbe für das Formular an.
BackgroundImage
gibt ein Hintergrundbild für das Formular an.
CancelButton
gibt bei Dialogen an, welcher Button zum Abbruch vorgesehen ist. Bei einem Klick auf den angegebenen Button wird DialogResult automatisch auf den entsprechenden Wert des Buttons eingestellt. Dieser Button wird automatisch betätigt, wenn die Taste (Esc) gedrückt wird.
CanFocus
gibt an, ob das Formular den Fokus erhalten kann.
Capture
gibt an, ob die Maus das Formular erfasst hat.
CausesValidation
gibt an, ob alle untergeordneten Steuerelemente, die eine Validierung erfordern, validiert werden, wenn das Formular den Fokus erhält.
ClientSize
liefert die Größe des Client-Bereichs des Formulars.
Sandini Bib
488
17 Einführung in Windows.Forms
Eigenschaften der Klasse Form (aus System.Windows.Forms) ContextMenuStrip
gibt das Kontextmenü für dieses Formular an. Ersetzt ContextMenu aus dem Vorgänger.
ControlBox
gibt an, ob das Fenster ein Systemmenü enthalten soll. Dazu gehört auch der Button zum Schließen in der Kopfzeile des Formulars.
Controls
liefert eine Liste der Steuerelemente des Formulars. Steuerelemente, die auf Containern liegen, die wiederum Bestandteil des Formulars sind, werden nicht zurückgeliefert. Stattdessen müssen die Steuerelemente aller Container ebenfalls durchsucht werden.
Cursor
gibt an, wie der Mauszeiger über dem Formular angezeigt werden soll. Der Standardwert ist Cursors.Default, wenn langwierige Operationen anstehen sollte Cursors.WaitCursor (die berühmte Sanduhr).
DialogResult
gibt an, welcher Ergebniswert (im Falle dafür, dass das Formular als Dialog angezeigt wird) zurückgeliefert werden soll.
DisplayRectangle
liefert eine Rectangle-Instanz, die den sichtbaren Bereich des Formulars angibt.
Dock
gibt an, an welchen Rändern des Formulars andere Formulare angedockt werden können.
DockPadding
ruft ab, welchen Leerraum die Ränder des Formulars für das Andocken haben.
Focused
gibt an, ob das Formular den Fokus hat.
Font
legt den verwendeten Zeichensatz des Formulars fest.
ForeColor
legt die Schriftfarbe des Formulars fest.
FormBorderStyle
legt die Art der Umrandung für das Formular fest. Hier kann eingestellt werden, ob es sich um ein größenveränderliches Formular, einen Dialog (nicht größenveränderlich) oder eine Toolbox (mit kleiner Titelleiste) handeln soll.
Handle
liefert einen Handle auf das Formular.
HasChildren
gibt an, ob das Formular untergeordnete Steuerelemente enthält. Wenn Controls.Count größer 0 ist, wird true zurückgeliefert.
Height
gibt die Höhe des Formulars an.
HelpButton
legt fest, ob ein Hilfe-Button in der Titelleiste des Formulars angezeigt werden soll.
Icon
legt das Icon für das Formular fest.
KeyPreview
legt fest, ob alle Tastendrücke zuerst an das Formular gesendet werden, bevor sie an das Steuerelement gehen, das aktuell den Fokus hat. Damit können beispielsweise globale Tastenkürzel ausgewertet werden.
Left
gibt die x-Koordinate der linken oberen Ecke des Formulars an.
Sandini Bib
Arbeiten mit Formularen
489
Eigenschaften der Klasse Form (aus System.Windows.Forms) MaximizeBox
legt fest, ob ein Button zum Maximieren des Formulars in dessen Titelleiste angezeigt werden soll.
MaximumSize
legt die maximale Größe des Formulars fest.
MdiChildren
ruft ein Array aus Formularen ab, die die untergeordneten MDI-Formulare darstellen.
MdiParent
gibt an, ob es sich bei dem Formular um ein MDI-Containerformular handelt.
MainMenuStrip
legt das Hauptmenü des Formulars fest. Ersetzt Menu aus der Vorgängerversion.
MinimizeBox
legt fest, ob ein Button zum Minimieren des Formulars in dessen Titelleiste angezeigt wird.
MinimumSize
legt die minimale Größe des Formulars fest.
Modal
gibt an, ob das Formular modal oder nicht-modal angezeigt wird.
Name
Der Name des Formulars
Opacity
legt fest, wie transparent das Formular angezeigt werden soll.
ShowInTaskbar
legt fest, ob das Formular in der Taskbar angezeigt wird.
Size
liefert die Größe des Formulars.
SizeGripStyle
legt fest, ob der Größenveränderungspunkt in der rechten unteren Ecke des Formulars dargestellt wird. Der Wert ist vom Typ SizeGripStyle.
StartPosition
dient zum Festlegen der Startposition des Formulars.
Text
dient dem Festlegen des Textes in der Titelleiste des Formulars.
Top
gibt die y-Position des oberen Rands des Formulars an.
TopMost
legt fest, ob das Formular innerhalb der Applikation immer im Vordergrund angezeigt werden soll.
TransparencyKey
dient zum Festlegen der Farbe, die die transparenten Bereiche des Formulars festlegt. Alle Bereiche des Formulars, die diese Farbe tragen, werden transparent dargestellt.
Width
legt die Breite des Formulars fest.
WindowState
dient zum Festlegen des Status dieses Formulars. Das Formular kann maximiert, minimiert oder normal angezeigt werden. Der zugewiesene Wert ist vom Typ FormWindowState.
Sandini Bib
490
17 Einführung in Windows.Forms
17.2.2
Grundlegende Vorgehensweisen
Das Hauptformular der Anwendung wird bekanntlich automatisch beim Programmstart erzeugt und angezeigt (über die Anweisung Application.Run( new Form1() )). Bei allen weiteren Formularen ist Handarbeit angebracht, um sie zur Anzeige zu bringen. Dabei gibt es zwei verschiedene Modi, nämlich die modale und die nicht-modale Anzeige. Modale Fenster, meist auch Dialoge genannt, sind Ihnen aus Windows selbst und auch aus zahlreichen Applikationen bekannt. Die Eigenart dieser Fenster ist, dass das Programm so lange an der aktuellen Stelle verharrt, bis der Anwender das Fenster wieder geschlossen hat. Fehlermeldungen und Hinweise sind häufig auftretende modale Fenster. Nicht-modale Fenster werden angezeigt, danach läuft das Programm jedoch weiter. Ein Beispiel für ein solches Fenster ist der Suchen/Ersetzen-Dialog vieler Programme, beispielsweise von Microsoft Word. Sie können weiterhin mit dem Programm arbeiten, der Dialog bleibt jedoch angezeigt und verwendbar. Vor der Verwendung eines Formulars müssen Sie dem Projekt zunächst eine neue Formularklasse hinzufügen (entweder über den Menüpunkt PROJEKT|WINDOWS FORM HINZUFÜGEN oder über das Kontextmenü des Projekts im Projektmappenexplorer), die Sie dann in der Entwurfsansicht bearbeiten und letztlich im Projekt verwenden können. Sie können selbstverständlich Ihre unterschiedlichen Formulare auch in verschiedenen Namensräumen anlegen, müssen dann allerdings darauf achten, dass die Namensräume mittels using eingebunden bzw. die Namen der Formularklassen korrekt qualifiziert sind.
Anzeigen eines Formulars Zur Anzeige erstellen Sie ein neues Objekt der gewünschten Formularklasse und rufen dann die Methoden Show() bzw. ShowDialog() auf. Show() dient zur nicht-modalen Anzeige des Formulars, d.h. Ihr Programm läuft weiter, während das Formular angezeigt wird. ShowDialog() zeigt das Formular an und wartet auf dessen Beendigung, bevor mit dem Hauptprogramm fortgefahren wird. In diesem Fall haben Sie auch die Möglichkeit, die Reaktion des Anwenders zu überprüfen. ShowDialog() liefert einen Wert des Aufzählungstyps DialogResult zurück. Dieser zurückgelieferte Wert entspricht dem Wert der gleichnamigen Eigenschaft des Formulars, die Sie je nach Benutzeraktion festlegen können.
CD
Als Beispiel soll ein kleines Projekt mit einem zweiten Formular dienen. Über einen Button wird das zweite Formular angezeigt, und nachdem dieses durch den Benutzer geschlossen wurde, wird angezeigt, welcher der Buttons dazu benutzt wurde. Das zweite Formular besitzt lediglich zwei Buttons, OK und ABBRECHEN. Wird der OK-Button betätigt, soll DialogResult.OK zurückgeliefert werden, im Falle des ABBRECHEN-Buttons DialogResult. Cancel. Zunächst die »umständliche« Methode mit Zuweisung des Werts von Hand. Danach die wesentlich einfachere Möglichkeit, bei der die Zuweisung dem Programm selbst überlassen wird. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_17\FormShow.
Sandini Bib
Arbeiten mit Formularen
491
Die Formularklasse des zweiten Formulars heißt FrmSecond. Hier zunächst der Code zur Anzeige und Auswertung innerhalb des Hauptformulars: private void BtnShowForm_Click( object sender, EventArgs e ) { FrmSecond frm2 = new FrmSecond(); if ( frm2.ShowDialog() == DialogResult.OK ) this.lblLastButton.Text = "Ok-Button"; else this.lblLastButton.Text = "Abbruch-Button"; }
Jetzt der Code für die Buttons des zweiten Formulars. Hier erfolgt eigentlich nur die Zuweisung des gewünschten Werts und das Schließen des Formulars. private void BtnOk_Click( object sender, EventArgs e ) { this.DialogResult = DialogResult.OK; Close(); } private void btnCancel_Click( object sender, EventArgs e ) { this.DialogResult = DialogResult.Cancel; Close(); }
Das Ergebnis dieser wenigen Codezeilen zeigt Abbildung 17.2. Die Anweisung ShowDialog() wird üblicherweise in der hier gezeigten Form, nämlich direkt innerhalb einer if-Anweisung, verwendet. Ebenfalls möglich wäre es natürlich, das Ergebnis einer Variablen zuzuweisen und danach diese auszuwerten.
Abbildung 17.2: Das Ergebnis des Beispielprogramms FormShow
Einfachere Zuweisung der Ergebniswerte Wie die Eigenschaftenliste am Anfang dieses Abschnitts bereits zeigt, besitzt die Klasse Form, von der alle Formulare abgeleitet sind, zwei interessante Eigenschaften: AcceptButton und CancelButton. Diesen beiden Eigenschaften können bereits zur Entwurfszeit bestehende Buttons zugewiesen werden. Ein Druck auf die Taste (¢) bewirkt dann automatisch
Sandini Bib
492
17 Einführung in Windows.Forms
das Betätigen des Buttons, der als AcceptButton eingestellt ist, ein Druck auf die Taste (Esc) automatisch das Betätigen des Buttons, der als CancelButton festgelegt ist. Zusätzlich besitzen auch die Buttons jeweils eine Eigenschaft DialogResult. Ist diese zugewiesen, so wird dieser Wert automatisch in die Eigenschaft DialogResult des Formulars übertragen und der Dialog wird automatisch geschlossen. Um das gleiche Verhalten wie in obigem Programm, allerdings ohne jeglichen Quellcode im zweiten Formular herbeizuführen, müssen Sie also lediglich die Eigenschaft DialogResult der Buttons entsprechend vorbelegen. Im Programm auf der CD sind diese Eigenschaften vorbelegt, der Quellcode ist auskommentiert. Sie können sich das Verhalten also genau ansehen.
Standarddialoge Nicht immer müssen Sie für eine bestimmte Funktionalität einen Dialog programmieren bzw. ein Formular erstellen. Einige Standarddialoge sind bereits von Haus aus im System vorhanden. In der Toolbox finden Sie zahlreiche Dialoge für die gängigsten Vorgehensweisen, z.B. zum Öffnen oder Speichern einer Datei, für die Farbauswahl oder die Auswahl einer Schriftart. Details zu diesen Dialogen finden Sie in Abschnitt 20.2 auf Seite 667.
Die Klasse MessageBox Ein Meldungsfenster existiert ebenfalls, wobei es sich um das Standard-Meldungsfenster von Windows handelt (samt entsprechendem Systemton), und das für Meldungen daher erste Wahl ist. Zugriff erhalten Sie über die Klasse MessageBox. Die statische Methode MessageBox.Show() ist mehrfach überladen und ermöglicht es Ihnen, auch ein Symbol, die Art und Anzahl der anzuzeigenden Buttons und den Meldungstext anzugeben. Ein typisches Beispiel für einen Aufruf sieht folgendermaßen aus: MessageBox.Show( "Dies ist ein Hinweisfenster","Hinweis", MessageBoxButtons.OKCancel, MessageBoxIcon.Information ); MessageBoxButtons und MessageBoxIcon sind Aufzählungen, deren Werte Sie verwenden können, um die Art der angezeigten Buttons bzw. das angezeigte Symbol festzulegen. Der MessageBoxIcon-Wert bestimmt außerdem den Signalton, der vom System dazu geliefert wird.
Da verschiedene Buttons für eine Messagebox verwendet werden können, sollte auch ein Weg zur Verfügung stehen, zu kontrollieren, welcher Button vom Anwender betätigt wurde. Show() liefert einen Wert vom Typ DialogResult zurück, die Kontrolle gestaltet sich daher (wie auch im Beispiel zu sehen) ebenso wie bei anderen Formularen: if ( MessageBox.Show( ... ) == DialogResult.OK ) { // OK-Button betätigt }
Beenden eines Programms Das Hauptformular der Anwendung wird bekanntlich bereits erstellt und angezeigt, wenn das Programm gestartet wird, und mit dem Schließen desselben endet auch das Programm (normalerweise – es gibt auch noch andere Wege, ein Windows-Programm zu starten).
Sandini Bib
Arbeiten mit Formularen
493
Manuell können Sie dies durch die Anweisung Close() erreichen, die auch in den zahlreichen Beispielprogrammen verwendet wurde, die Sie bereits kennen gelernt haben. Jedes Formular kann aber auch über das aus allen Windows-Programmen bekannte Systemmenü bzw. den Schließen-Button im Kopf des Formulars geschlossen werden. Das funktioniert immer, wobei Sie allerdings die Anzeige der entsprechenden Buttons beeinflussen können. Eine weitere Einflussnahme ist durch die Ereignisse Closing und Closed möglich, die jedes Formular bereitstellt. Closing tritt auf, wenn das Formular geschlossen werden soll. An dieser Stelle können Sie beispielsweise das Schließen verhindern, indem Sie die Eigenschaft Cancel des Parameters e auf true setzen. Closed ist das richtige Ereignis um noch anfallende Aufräumarbeiten zu erledigen (beispielsweise speichern von Optionen usw.). Die Ereignisse Closing und Closed treten auch auf, wenn die Methode Close() für das Formular ausgeführt wird.
Zugriff auf das Hauptformular Im Falle eines nicht-modalen Dialogs ist es die Regel, dass vom Dialog aus auf das Hauptformular zugegriffen werden muss. Dies zu bewerkstelligen erscheint zu Anfang gerade beim Hauptformular relativ schwierig, denn von diesem existiert ja keine greifbare Instanz. Der Methode Application.Run() wird eine Instanz des Hauptformulars übergeben, diese hat aber keinen Namen (da sie nach Erzeugung keiner Variablen zugewiesen wird). Man kann dennoch sehr einfach darauf zugreifen. Erstellen Sie einfach ein Feld innerhalb des aufgerufenen Formulars vom Typ des Hauptformulars. Angenommen, die Klasse des Hauptformulars heißt FrmMain, die des aufgerufenen Formulars FrmNotModal: public class FrmNotModal : System.Windows.Forms.Form { [...] FrmMain main = null; ...
Jetzt überschreiben Sie einfach den Konstruktor des zweiten Formulars und ermöglichen die Übergabe der Referenz des Hauptformulars: public FrmNotModal( FrmMain mainForm ) : this() { this.main = mainForm; }
und schon können Sie über das Feld main auf die Instanz des Hauptformulars zugreifen. Die Verkettung mit dem Standardkonstruktor ist notwendig, da darin InitializeComponent() aufgerufen wird, was die Erzeugung der Steuerelemente bewirkt. Zwar könnte InitializeComponent() auch nochmals in den zweiten Konstruktor programmiert werden, das bedeutet allerdings eine unnötige Redundanz. Die Erzeugung des zweiten Formulars muss folgendermaßen aussehen:
Sandini Bib
494
17 Einführung in Windows.Forms
public void btnShow_Click( object sender, EventArgs e ) { FrmNotModal myForm = new FrmNotModal( this ); myForm.Show(); ... }
Übergabe von Daten an ein Formular Zur Übergabe von Daten können Sie theoretisch auf zwei Arten vorgehen. In der Regel werden Formulare ja zur Anzeige von Daten verwendet, wodurch eine der einfachsten Möglichkeiten ist, die entsprechenden Steuerelemente als public zu deklarieren (das geht über die Entwurfsansicht und das Eigenschaftsfenster, indem die Eigenschaft Modifiers der Steuerelemente entsprechend eingestellt wird). In diesem Fall können die Steuerelemente bereits vor der Anzeige gefüllt und nach dem Schließen des Formulars ausgelesen werden. Diese Vorgehensweise, die zunächst logisch anmutet, ist aber keineswegs zu empfehlen, da sie allen objektorientierten Grundsätzen widerspricht. Die Steuerelemente eines Formulars sind Felder (also Daten des Formulars) und als solche darf nur das Formular selbst darauf zugreifen. Ein Zugriff von außen verbietet sich. Wesentlich sinnvoller (und konform zur Objektorientierung) ist es, dem Formular gleich das ganze Objekt zu übergeben, dessen Daten angezeigt werden sollen – genauso wie gerade eben gezeigt, nur dass es diesmal nicht um eine Referenz des Formulars geht, sondern um ein Objekt. Diese Vorgehensweise hat zwei ganz große Vorteile: Erstens bleibt der Zugriff auf die Steuerelemente eines Formulars von außen verwehrt, was dem Gedanken der Kapselung entspricht. Zweitens kann das Objekt, das die Daten beinhaltet, komplett übergeben werden, was weniger Schreibarbeit bedeutet.
CD
Im folgenden kleinen Beispielprogramm wird die Übertragung der Daten in das Objekt selbstverständlich auch noch ausprogrammiert. In der Realität ergibt sich die Ersparnis des Quellcodes aus der Tatsache, dass diese Übertragung wirklich nur einmal stattfindet (häufig auch aus einer Datenbank heraus) und dass in der Folge nur noch das Objekt selbst übergeben werden muss. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_17\DataTransfer.
Das Programm selbst zeigt lediglich Adressdaten im Hauptfenster und einem zweiten Fenster an, ermöglicht die Änderung derselben und die Übernahme zurück in das Hauptfenster. Verwendet werden hierbei nur Name, Straße, Postleitzahl und Ort. Alle Daten werden als string-Werte gespeichert. Die Adressenklasse hat folgendes Aussehen:
Sandini Bib
Arbeiten mit Formularen
495
public class Address { private private private private
string string string string
name; street; zip; city;
// Ab hier Eigenschaften public string Name { get { return this.name; } set { this.name = value; } } public string Street { get { return this.street; } set { this.street = value; } } public string Zip { get { return this.zip; } set { this.zip = value; } } public string City { get { return this.city; } set { this.city = value; } } // Constructor public Address( string[] data ) { this.name = data[0]; this.street = data[1]; this.zip = data[2]; this.city = data[3]; } }
Die Verwendung eines string-Arrays im Konstruktor der Klasse Address hat Faulheitsgründe. So ist es einfacher, die Daten zu initialisieren. Im Hauptformular wird eine Methode GetData() definiert, die ein entsprechendes string-Array zurückliefert, wodurch das Ergebnis der Methode direkt an den Konstruktor der Address-Klasse übergeben werden kann. Das Hauptformular besteht lediglich aus den benötigten Textfeldern für die Anzeige der Daten (die also auch im Hauptformular geändert werden können) und zwei Buttons, einmal zur Anzeige des zweiten Formulars und zum Zweiten zum Beenden des Programms.
Sandini Bib
496
17 Einführung in Windows.Forms
Das zweite Formular hat exakt das gleiche Aussehen, allerdings mit einem Ok- und einem Abbruch-Button. Deren DialogResult-Werte wurden entsprechend der Bedeutung des Buttons eingestellt. Das Aussehen des Hauptformulars entnehmen Sie Abbildung 17.3:
Abbildung 17.3: Das Hauptformular der Anwendung
Außer der Methode GetData(), die wir bereits erörtert haben, wird im Hauptformular noch eine Methode SetData() benötigt, der ein Address-Objekt übergeben wird und die die Felder des Hauptformulars füllt. Sie wird aufgerufen, wenn das zweite Formular mittels des OKButtons beendet wurde. Die dritte relevante Methode des Hauptformulars, das KlickEreignis des Anzeigebuttons, erledigt die Anzeige und Datenübergabe. private string[] GetData() { // Datenübernahme return new String[] { txtName.Text, txtStreet.Text, txtZip.Text, txtCity.Text }; } private void SetData( Address adr ) { // Daten schreiben txtName.Text = adr.Name; txtStreet.Text = adr.Street; txtZip.Text = adr.Zip; txtCity.Text = adr.City; }
Sandini Bib
Arbeiten mit Formularen
497
private void BtnShow_Click( object sender, EventArgs e ) { Address adr = new Address( GetData() ); FrmShow frmShow = new FrmShow( adr ); if ( frmShow.ShowDialog() == DialogResult.OK ) SetData( adr ); }
Wie Sie aus dem Quelltext entnehmen können ist der Name der Klasse des zweiten Formulars, das zur Anzeige dient, FrmShow. Das Aussehen entspricht bis auf die Buttons dem Hauptformular. Der Konstruktor ist, wie ebenfalls aus der Methode BtnShow_Click() zu entnehmen, überladen, sodass die Übergabe des Objekts (oder genauer der Objektreferenz) ermöglicht wird. An dieser Stelle soll nochmals die Wichtigkeit angesprochen werden, den Unterschied zwischen Referenz- und Wertetypen zu verstehen. Das übergebene Objekt ist ein Referenztyp. Das bedeutet, obwohl hier nicht das reservierte Wort ref verwendet wird, wird dennoch lediglich eine Referenz übergeben. Jede Änderung, die im zweiten Formular an dem übergebenen Objekt vorgenommen wird, schlägt automatisch auf das Ursprungsobjekt durch. Daher ist es nicht notwendig, das Objekt mit den geänderten Daten nochmals aus dem zweiten Formular an das erste zu liefern – die Daten in adr entsprechen später genau denen, die im zweiten Formular angezeigt werden. Das sind die Eigenheiten eines Referenztyps, die das Verständnis eines Programmcodes manchmal recht schwierig gestalten können. Natürlich benötigt auch Formular 2 Methoden zum Beschreiben und Auslesen der Daten. Wichtig ist, dass die Objektreferenz nicht geändert wird (also kein neues Objekt erzeugt wird), weil die Änderungen dann nicht mehr im Ursprungsformular sichtbar wären. Hier alle relevanten Methoden des Formulars FrmShow: // Feld zur Aufnahme der Objektreferenz Address adr; Address currentAddress; private void GetData() { // Daten auslesen this.currentAddress.Name = txtName.Text; this.currentAddress.Street = txtStreet.Text; this.currentAddress.Zip = txtZip.Text; this.currentAddress.City = txtCity.Text; }
Sandini Bib
498
17 Einführung in Windows.Forms
private void SetData() { // Daten schreiben txtName.Text = this.currentAddress.Name; txtStreet.Text = this.currentAddress.Street; txtZip.Text = this.currentAddress.Zip; txtCity.Text = this.currentAddress.City; } private void BtnOk_Click( object sender, EventArgs e ) { GetData(); }
Für den Abbruch-Button ist kein Code notwendig. Durch die Einstellung der Eigenschaft DialogResult des Buttons wird automatisch DialogResult.Cancel zurückgeliefert und keine Änderung vorgenommen. Abbildung 17.4 zeigt das Programm zur Laufzeit.
Abbildung 17.4: Das Beispielprogramm zur Laufzeit
Zugriff auf Steuerelemente eines Formulars Alle Steuerelemente eines Formulars sind in der Liste Controls enthalten. Dabei handelt es sich jedoch nur um die Steuerelemente des Formulars selbst. Falls es sich dabei um weitere Container handelt, die ihrerseits wiederum Steuerelemente enthalten, ist der Zugriff nicht ganz so trivial. In diesem Fall ist es sinnvoll, einen rekursiven Algorithmus zu verwenden, der alle Steuerelemente und deren untergeordnete Steuerelemente durchläuft und die Gesamtliste zurückliefert. Das folgende kleine Beispielprogramm erledigt dies. Auf dem Formular befinden sich eine beliebige Anzahl Steuerelemente, Container mit untergeordneten Steuerelementen usw. Die eigentliche Methode, mit der die Gesamtliste aller Steuerelemente ermittelt wird, ist GetControls().
Sandini Bib
CD
Arbeiten mit Formularen
499
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_17\GetAllControls.
Darin werden zunächst die Steuerelemente des Formulars durchlaufen. Über die Eigenschaft HasChildren wird kontrolliert, ob das Steuerelement ein Container ist bzw. untergeordnete Steuerelemente enthält. Wenn dem so ist, werden auf die untergeordneten Steuerelemente ermittelt. Dargestellt ist hier nur die relevante Methode GetControls(). private List<string> GetControls( Control ctrl ) { List<string> result = new List<string>(); foreach ( Control c in ctrl.Controls ) { result.Add( c.Name ); if ( c.HasChildren ) result.AddRange( GetControls( c ) ); } return result; }
Die Methode benötigt lediglich einen Parameter vom Typ Control. Alle visuellen Steuerelemente sind in der Objekthierarchie von diesem Typ abgeleitet, auch die Klasse Form als Basis eines Formulars. Um also alle Steuerelemente eines Formulars zu ermitteln muss lediglich das entsprechende Form-Objekt an die Methode übergeben werden. Das Ergebnis des Aufrufs zeigt Abbildung 17.5.
Abbildung 17.5: Ermittlung aller Steuerelemente eines Formulars
Sandini Bib
Sandini Bib
18 Standard-Steuerelemente In diesem Kapitel dreht sich alles um die Steuerelemente, die Ihnen das .NET Framework standardmäßig zur Verfügung stellt. Zunächst werden gemeinsame Merkmale beschrieben, danach erfolgt die Vorstellung der einzelnen Steuerelemente im Detail. Dabei handelt es sich allerdings nicht um eine Aufzählung der verschiedenen Eigenschaften, Methoden und Ereignisse, sondern vielmehr um eine Beschreibung des konkreten Einsatzes.
18.1
Überblick
Alle Steuerelemente und Komponenten, die Ihnen das .NET Framework zur Verfügung stellt, finden Sie in der Toolbox. Diese wurde im Vergleich zum Vorgänger um einige Kategorien erweitert. Einige Steuerelemente wurden gegen modernere Pendants ausgetauscht, andere wiederum sind vollkommen neu. Die aus .NET 1.1 bekannten Steuerelemente existieren zwar nach wie vor, werden allerdings nicht mehr angezeigt. Falls Sie diese verwenden wollen, müssen Sie sie manuell im Code einfügen oder aber explizit in die Toolbox einbinden. Die für den Datenzugriff und speziell für die Anzeige von Daten zuständigen Steuerelemente werden erst im Datenbankteil des Buchs beschrieben.
18.1.1
.NET-Steuerelemente
Die folgende Tabelle liefert Ihnen einen Überblick über die verfügbaren Steuerelemente. Beachten Sie bitte, dass es hierbei um die sichtbaren Steuerelemente geht. Die Komponenten, in der Toolbox unter der gleichnamigen Kategorie zu finden, sind nicht Bestandteil dieses Kapitels. Elementare Steuerelemente Buttons/Schaltflächen
Button, CheckBox, RadioButton
Textanzeige/-eingabe
Label, LinkLabel, TextBox, RichTextBox, MaskedTextBox
Grafik
PictureBox
Bitmap-Container
ImageList
Listenfelder
ListBox, CheckedListBox, ComboBox, ListView
Hierarchische Listen
TreeView
Tabellenfeld
DataGrid
Eigenschaftsfeld
PropertyGrid
Zeit, Datum
DateTimePicker, MonthCalender
(in vielen anderen Steuerelementen können aber ebenso eine Bitmap dargestellt bzw. Grafikmethoden ausgeführt werden)
Sandini Bib
502
18 Standard-Steuerelemente
Elementare Steuerelemente Gruppierung
GroupBox, Panel, TabControl
(Dialogblätter)
Bildlaufleisten
HScrollBar, VScrollBar
Werte einstellen
DomainUpDown, NumericUpDown, TrackBar
Zustandsanzeige
ProgressBar
Zeitgeber
Timer
Infotext anzeigen
ToolTip
Fehlerindikator
ErrorProvider
Hilfefenster anzeigen
HelpProvider
Programmindikator
NotifyIcon
Html-Darstellung
WebBrowser
Gestaltung der Benutzeroberfläche Menüs
MenuStrip, ContextMenuStrip
Symbolleiste
ToolStrip, ToolStripContainer
Statusleiste
StatusStrip
Fensterteiler
SplitContainer
Standarddialoge
OpenFileDialog, SaveFileDialog, FontDialog, ColorDialog, FolderBrowserDialog
Steuerelemente für den Ausdruck Ausdruck steuern
PrintDocument (Namespace System.Drawing.Printing)
Drucker einstellen
PrintDialog
Seite einstellen
PageSetupDialog
Seitenvorschau
PrintPreviewDialog, PrintPreviewControl
Datenbankberichte
CrystalReportViewer
18.1.2
COM und .NET
Obwohl die ausführliche Behandlung dieses Themas den Rahmen des Kapitels sprengen würde, soll die Möglichkeit dennoch angesprochen werden. COM ist keineswegs tot – auch wenn zukünftig in der Hauptsache .NET die große Rolle in der WindowsProgrammierung spielen wird und sowohl COM als auch das Win32-API in den Hintergrund treten werden. Dennoch sind COM-Komponenten auch unter .NET nutzbar und
Sandini Bib
Überblick
503
umgekehrt (auch wenn das weit weniger der Fall sein dürfte). Das Visual Studio unterstützt Sie sogar dabei. Sie können eine COM-Komponente problemlos in die Toolbox einbinden. Im Hintergrund allerdings arbeitet dann ein Tool namens tlbImp.exe, das einen Wrapper um die COM-Komponente legt, der deren Methoden von .NET aus ausrufbar macht. Diesen Wrapper nennt man auch Runtime Callable Wrapper, oder kurz RCW. Diese Implementierung geht erstaunlich weit. So können Sie auf die Eigenschaften der gewrappten Klasse über den Eigenschaftseditor zugreifen und sogar Eigenschaften, die eigentlich .NET-typisch sind (wie z.B. Anchor) werden angezeigt und sind nutzbar. Dennoch: Diese Möglichkeit wird in Zukunft immer weniger eine Rolle spielen, denn trotz umfangreicher Unterstützung sind COM-Komponenten in der Regel schwieriger zu handhaben als reine .NET-Klassen.
Primary Interop Assemblies Für seinen großen COM-Server Office hat Microsoft schon vor längerer Zeit so genannte Primary Interop Assemblies zur Verfügung gestellt, die optimiert waren und damit auch besser zu handlen als die automatisch erzeugten. Mit Office 2003 wurde dies verbessert – nun mussten die PIAs nicht mehr aus dem Internet geladen werden sondern sind Bestandteil des Office-Pakets und können mitinstalliert werden (es geht dabei um den Installationspunkt ».NET Programmierunterstützung«). Das Visual Studio 2005 geht einen weiteren Schritt in diese Richtung und ermöglicht über Visual Studio Tools for Office eine noch einfachere Programmierung des Office-Pakets. Damit können dann Word-Projekte angelegt werden (oder auch Excel-Projekte), im Visual Studio erscheint auch eine Instanz von Word, im Hintergrund wird allerdings .NET und nicht VBA für die Programmierung verwendet. Das Objektmodell muss dennoch bekannt sein, denn das kann auch das Visual Studio nicht ändern. Visual Studio Tools for Office sind allerdings nicht Bestandteil der Professional-Version der Entwicklungsumgebung, sondern erst ab Visual Studio Team System enthalten.
18.1.3
Steuerelemente in Toolbox einfügen
Über das Kontextmenü der Toolbox können Sie weitere Elemente zur Toolbox hinzufügen, die Ansicht verändern (allerdings gibt es nur eine Alternativansicht) oder die Sortierung der Elemente ändern. Die Standardsortierung entspricht einer Gruppierung nach Zugehörigkeit, d.h. die wichtigsten, grundlegendsten Elemente sind oben angesiedelt und nach Funktion gruppiert. Wenn Sie sich eher mit einer alphabetischen Anordnung anfreunden können, lässt sich die Toolbox über das Kontextmenü auch sortieren. Oder Sie erstellen Ihre eigene Anordnung und sortieren die Elemente per Drag&Drop neu. Über den Menüpunkt ELEMENTE AUSWÄHLEN des Kontextmenüs können Sie weitere Steuerelemente zur Toolbox hinzufügen. Dabei kann es sich um weitere .NET-Komponenten handeln, die Sie selbst geschrieben oder aus dem Internet heruntergeladen haben, oder um die beschriebenen COM-Objekte. Beliebt war hier unter .NET 1.1 vor allem das InternetExplorer-Control, das aber mittlerweile fester Bestandteil der Toolbox bzw. der Komponentenliste des .NET Frameworks ist.
Sandini Bib
504
18 Standard-Steuerelemente
HINWEIS
Der Dialog in Abbildung 18.1 liefert Ihnen eine Auswahl der zur Verfügung stehenden Steuerelemente bzw. der DLLs, in denen die Steuerelemente definiert sind. Sollte das gewünschte Steuerelement nicht in der Liste enthalten sein (z.B. bei einem gerade heruntergeladenen Steuerlement) können Sie auch DURCHSUCHEN anklicken und die entsprechende DLL direkt auswählen. Falls Sie zu viel mit der Toolbox experimentiert haben, und die ursprüngliche Sortierung bzw. die ursprünglich enthaltenen Steuerelemente wieder herstellen wollen, klicken Sie im Dialog ELEMENTE AUSWÄHLEN einfach auf ZURÜCKSETZEN. Beachten Sie aber bitte, dass diese Aktion nicht mehr rückgängig gemacht werden kann.
Abbildung 18.1: Das Dialogfeld »Toolboxelemente auswählen«
18.2
Gemeinsame Member der Steuerelemente
Dieser Abschnitt beschreibt gemeinsame Eigenschaften, Methoden und Ereignisse, die bei vielen Steuerelementen zur Verfügung stehen (allerdings auch nicht bei jedem). Viele der hier erwähnten Member werden auch für das Form-Objekt verwendet, das das zugrunde liegende Formular beschreibt (bei dem es sich auch nur um eine Klasse handelt). Bei den Eigenschaften handelt es sich in der Regel um direkt zuweisbare Werte. Oftmals jedoch sind es auch Aufzählungen, die dahinter stehen. Die verbesserte IntelliSense-Hilfe ist hier von enormem Vorteil, da sie sofort die passende Aufzählung anzeigt. Das war un-
Sandini Bib
Gemeinsame Member der Steuerelemente
505
ter .NET 1.1 noch nicht der Fall, dort mussten Sie zumindest den Namen der Aufzählung kennen. Beachten Sie auch, dass im Eigenschaftsfenster lediglich die Werte der Aufzählung angezeigt werden, Sie im Editor aber den kompletten Aufzählungsnamen für die Zuweisung verwenden müssen. Ein Beispiel ist die Eigenschaft FlatStyle, die unterschiedliche Darstellungsformen beispielsweise eines Buttons ermöglicht. Während Sie im Eigenschaftenfenster lediglich den Wert Flat einstellen, müssen Sie bei der Zuweisung zur Laufzeit den Aufzählungstyp verwenden: button1.FlatStyle = FlatStyle.Flat;
Diese Vorgehensweise ist grundsätzlich die gleiche für (fast) alle dieser Eigenschaften. Bei Unterschieden werden wir Sie darauf hinweisen. Wenn ein Wert angegeben wird, wird immer die vollständige Qualifikation verwendet, also inkl. des Aufzählungstyps. Nun aber zu den gemeinsamen Eigenschaften der Steuerelemente. Um eine ebenso langweilige wie auch ermüdende Aufzählung zu vermeiden wurden die gemeinsamen Eigenschaften, Methoden und Ereignisse in thematischen Gruppen zusammengefasst. Wer eine alphabetische Liste bevorzugt, findet diese in der Syntaxzusammenfassung am Ende des Kapitels.
18.2.1
Aussehen
Das Aussehen eines Steuerelements wird nicht nur von den eigenen Einstellungen bestimmt. Beispielsweise gilt in der Standardeinstellung, dass ein Steuerelement automatisch die Schriftart und die Hintergrundfarbe des übergeordneten Steuerelements (also des Steuerelements, auf dem es platziert wurde) annimmt. Diese Vorgehensweise ist durchaus logisch, da sich so eine Konsistenz in der Benutzeroberfläche ergibt. Wenn Sie beispielsweise ein Label auf ein Formular legen, dessen Hintergrundfarbe weiß ist, erwarten Sie sicherlich, dass das Label diese Farbe automatisch annimmt, sodass Sie nichts mehr umstellen müssen.
Text Die Eigenschaft Text gibt die Beschriftung eines Steuerelements an, im Falle eines Formulars z.B. den Text in der Kopfzeile, bei einem Label den angezeigten Text und bei einer GroupBox die Überschrift über der Box. Manche Steuerelemente brechen zu langen Text automatisch um. Bei vielen Steuerelementen kann die Ausrichtung des Textes über die Eigenschaft TextAlign eingestellt werden. Schriftart, Schriftgröße und Schriftschnitt werden über die Eigenschaft Font eingestellt. Standardmäßig wird hier auch zunächst die Schriftart des übergeordneten Steuerelements angenommen. Eine Änderung der Eigenschaften einer Schriftart ist zur Laufzeit nicht direkt möglich, stattdessen muss ein neues Font-Objekt erzeugt und der Eigenschaft Font zugewiesen werden. Auch dann, wenn nur eine Änderung des Stils vorgenommen werden soll. Der folgende Codeschnipsel zeigt die grundsätzliche Vorgehensweise:
Sandini Bib
506
18 Standard-Steuerelemente
VERWEIS
public void button1_Click(object sender, EventArgs e) { // Stellt den eingestellten Font fett dar Font newFont = new Font(button1.Font, FontStyle.Bold); button1.Font.Dispose(); button1.Font = newFont; }
Eine Menge weiterer Informationen zum Umgang mit Font-Objekten finden Sie in Abschnitt 21.3 ab Seite 731.
Mit .NET 2.0 hat auch endlich die Möglichkeit Einzug gehalten, zu lange Texte wie unter Windows gewohnt mit drei Punkten abzukürzen. Bisher war das nur möglich, wenn Texte selbst auf eine Oberfläche gezeichnet wurden. Die entsprechende Eigenschaft heißt AutoEllipsis und muss hierzu auf true eingestellt werden.
Grafiken auf Steuerelementen Eine große Anzahl der vorhandenen Steuerelemente ermöglicht auch das Anzeigen von Grafiken. Teilweise ist bei dem entsprechenden Steuerelement eine Eigenschaft Image verfügbar, in die das Bild zur Entwurfszeit geladen werden kann. Aber auch wenn diese Eigenschaft nicht verfügbar ist, können Sie dennoch mithilfe der Zeichenmethoden des .NET Frameworks Grafiken auf dem Steuerelement zeichnen. f Unterstützt das Steuerelement verschiedene Bilder, z.B. bei einer TreeView, können Sie eine ImageList-Komponente verwenden. In dieser sind mehrere Bilder gleicher Größe gespeichert, die über einen Index angesprochen werden können (siehe auch Abschnitt 18.5.2 ab Seite 541). Dem Steuerelement muss lediglich mitgeteilt werden, welche ImageList verwendet werden soll und welchen Index darin das zu verwendende Bild haben soll. In diesem Fall sind entsprechende Eigenschaften des Steuerelements vorhanden. Über ImageIndex können Sie den Index der Grafik, über ImageKey den Schlüssel (den Namen) der Grafik angeben. f Sie können auch eine ImageList zur Speicherung mehrerer Bilder verwenden, diese dann programmiertechnisch daraus entnehmen und sie z.B. im Ereignis Paint eines Steuerelements verwenden. f Die Eigenschaft BackgroundImage, die auch von zahlreichen Steuerelementen zur Verfügung gestellt wird, steht für ein Hintergrundbild, das Sie frei bestimmen können. Das Hintergrundbild wird zyklisch wiederholt, wie man es von den Hintergründen im Internet kennt. Es empfiehlt sich daher entsprechend angepasste Grafiken zu verwenden, die ein homogenes Erscheinungsbild zeigen und an den Kanten zueinander passen. f Das Ereignis Paint verschiedener Steuerelemente ermöglicht das freie Zeichnen einer Grafik auf dem entsprechenden Steuerelement. Genauere Informationen darüber, wie dieses bewerkstelligt wird, finden Sie im Grafik-Kapitel, genauer in Abschnitt 21.4.2 ab Seite 767.
Sandini Bib
Gemeinsame Member der Steuerelemente
507
f Bei Grafiken beispielsweise auf Buttons kann über TextImageRelation angegeben werden, wie der Text zum Bild angeordnet werden soll. Die Einstellung TextImageRelation.ImageBeforeText bewirkt beispielsweise, dass die Grafik vor dem Text angeordnet wird.
Farben Jedes Steuerelement besitzt eine Hintergrund- und eine Vordergrundfarbe (Eigenschaften BackColor und ForeColor). Bei der Vordergrundfarbe handelt es sich um die Farbe des Texts auf dem Steuerelement. Standardmäßig werden hierfür die Windows-Systemfarben verwendet. Das hat den Vorteil, dass eine gute Lesbarkeit in der Regel gewährleistet ist (da hoffentlich kein Anwender sein Windows so einstellt, dass er seine Texte nicht mehr lesen kann). Sie haben allerdings die Möglichkeit, diese Farben zu ändern. Achten Sie darauf, dass Sie bei Verwendung absoluter Farbwerte dies wirklich konsequent durchziehen und dass das Aussehen des Programms dadurch nicht negativ beeinträchtigt wird. Inkonsequenz kann in diesem Fall dazu führen, dass manche Bestandteile auf Benutzersystemen mit geänderter Standardeinstellung nicht mehr deutlich sichtbar sind. Es empfiehlt sich daher nur in seltenen Fällen, von dieser Möglichkeit Gebrauch zu machen. Zur Laufzeit können Sie Farben relativ leicht abändern. Da es sich bei allen Farbwerten um Instanzen der Struktur Color handelt, müssen Sie zum Zuweisen eines neuen Farbwerts eine neue Color-Instanz erzeugen. Vordefinierte Farben liefern die statischen Methoden der Klassen SystemColors bzw Color. Eigene Farben aus Rot-, Grün- und Blau-Werten können Sie sich über die Methode Color.FromArgb() zusammenstellen. Genaueres über die Verwendung und Zuweisung verschiedener Farbwerte finden Sie auch in Abschnitt 21.2.2 ab Seite 712.
Sichtbarkeit Ob ein Steuerelement sichtbar ist oder nicht, wird durch die Eigenschaft Visible gesteuert. Im Regelfall sind alle Steuerelemente sichtbar. In manchen Fällen kann es aber sinnvoll sein, einige Steuerelemente nur bei Bedarf anzuzeigen. Statt den Wert der Eigenschaft Visible direkt zu ändern können Sie auch die Methoden Hide() und Show() verwenden. Die Eigenschaft Enabled hat nur bedingt mit der Sichtbarkeit zu tun, damit bestimmen Sie vielmehr, ob das Steuerelement derzeit zur Verfügung steht oder nicht. Durch Zuweisung des Werts false an die Eigenschaft Enabled deaktivieren Sie das Steuerelement, was eine Änderung des Aussehens zur Folge hat (in der Regel wird das Steuerelement »ausgegraut«). Enabled ist standardmäßig auf true eingestellt. Es ist allerdings sehr hilfreich für die Usability Ihres Programms, zurzeit nicht verwendbare Steuerelemente entsprechend anzuzeigen.
Sandini Bib
508
18 Standard-Steuerelemente
Manchmal besteht der Wunsch, Steuerelemente durchsichtig darzustellen. Beispielsweise sollte ein Beschriftungsfeld (Label) das Hintergrundmuster des Formulars nicht überdecken (was aber standardmäßig der Fall ist). Eine spezielle Eigenschaft dafür gibt es leider nicht, Sie können aber die Eigenschaft BackColor auf Transparent einstellen (Sie finden diese Farbe im Eigenschaftsfenster als erste Farbe der WEB-Gruppe). Änderungen in Steuerelementen (z.B. Label1.Text = ...) werden im Regelfall erst am Ende der Ereignisprozedur sichtbar, in der die Änderung durchgeführt wird. Wenn in der Prozedur eine länger andauernde Berechnung ausgeführt wird, kann eine sofortige Aktualisierung durch den Aufruf der Methode Update() erreicht werden. Noch umfassender ist die Wirkung von Refresh() – diese Methode erzwingt ein komplettes Neuzeichnen des Steuerelements, unabhängig davon, ob dies erforderlich ist oder nicht. Wenn nur Teile des Steuerelements neu gezeichnet werden sollen, können Sie auch die Methode Invalidate() verwenden. In jedem Fall aber muss Windows die entsprechende Nachricht verarbeiten können. Sie sollten dazu in rechenintensiven, länger andauernden Methoden in regelmäßigen Abständen Application.DoEvents() aufrufen.
Maus Das Aussehen der Maus wird durch die Eigenschaft Cursor bestimmt. Wenn diese Eigenschaft verändert wird, nimmt die Maus ein anderes Aussehen an, solange sie sich über dem Steuerelement befindet. Die Cursors-Klasse stellt einige Standardformen für den Mauszeiger zur Verfügung. Der Mauscursor wird automatisch verändert, wenn das Steuerelement Ziel einer Drag&Drop-Operation ist. In diesem Fall aber nicht über die Eigenschaft Cursor, sondern automatisch über den angegebenen Zieheffekt. Mehr über Drag&Drop erfahren Sie in Abschnitt 20.4.3 ab Seite 678.
Aussehen Steuerelemente werden üblicherweise mit einem dreidimensionalen Aussehen dargestellt. Die Standardeinstellung im Visual Studio 2005 ist FlatStyle.Standard, was das Aussehen entsprechend der im System eingestellten Darstellungsart anpasst. Bei manchen Steuerelementen kann durch Änderung der Eigenschaft FlatStyle auf den Wert FlatStyle.Flat aber auch ein flaches, zweidimensionales Aussehen erreicht werden. In diesem Fall können Sie auch Hintergrundfarbe, Rahmenfarbe und Farbe bei gedrücktem Element festlegen. Das funktioniert über die Eigenschaft FlatAppearance, die weitere Untereigenschaften enthält.
18.2.2
Größe, Position und Layout
Üblicherweise werden Position und Größe von Steuerelementen zur Entwurfszeit festgelegt. Die Einstellung erfolgt mithilfe der Maus direkt auf dem Formular. Teilweise ist es allerdings auch möglich, dass Sie Position und Größe zur Laufzeit festlegen wollen,
Sandini Bib
Gemeinsame Member der Steuerelemente
509
beispielsweise um auf eine Größenänderung eines Formulars durch Neuarrangieren der enthaltenen Steuerelemente zu reagieren. Für diesen Fall bieten alle (sichtbaren) Steuerelemente verschiedene Eigenschaften.
Eigenschaften für Größe und Position Die Eigenschaften Left und Top legen die linke und obere Position des Steuerelements in Pixeln fest. Die Eigenschaften Width und Height sind für die Breite und Höhe eines Steuerelements zuständig. Diesen Eigenschaften können problemlos neue Werte zugewiesen werden. Die Eigenschaften Right und Bottom geben die rechte und untere Position des Steuerelements zurück, sind aber nur lesbar – sie ergeben sich automatisch aus den vorher beschriebenen Werten. Auf Position und Größe kann auch auf andere Art zugegriffen werden, die allerdings weniger üblich ist. Die Eigenschaft Position liefert einen Wert vom Typ Point, der die Werte von Left und Top beinhaltet. Point selbst ist ein struct, womit zur Zuweisung zunächst eine neue Instanz von Point erzeugt und der Eigenschaft Position zugewiesen werden muss. Size vereint entsprechend die Werte von Width und Heigth. Es handelt sich dabei um einen Wert des Typs Size, der ebenfalls um ein struct ist. Alle Werte gemeinsam, in Form einer Rectangle-Instanz, liefert die Eigenschaft Bounds. Auch über diese Eigenschaft können die Werte verändert werden, wozu eine neue Rectangle-Instanz zugewiesen werden muss.
Da viele Steuerelemente sowohl einen Außenbereich als auch einen Innenbereich besitzen, können deren Werte durch verschiedene Eigenschaften ermittelt werden. Left, Top, Width und Height beziehen sich immer auf den Außenbereich. Um auf den Innenbereich zuzugreifen verwenden Sie die Eigenschaften ClientSize (liefert ein Size-Objekt mit der Größe des Innenbereichs) oder ClientRectangle (liefert den gesamten Innenbereich). Falls das Steuerelement nicht zwischen Innen- und Außenbereich unterscheidet, liefern ClientSize und Size identische Werte. Manche Steuerelemente, z.B. Label, stellen auch eine Eigenschaft AutoSize zur Verfügung, die bewirkt, dass das Steuerelement sich zur Laufzeit automatisch an seinen Inhalt anpasst. Vor allem bei Labels ist das eine oft gesehene Vorgehensweise (und auch bei den Labels der Beispielprogramme dieses Buchs wurde so vorgegangen).
Verankern von Steuerelementen Es ist sehr häufig der Fall, dass die Position von Steuerelementen an die Größe eines Formulars bzw. des übergeordneten Containers angepasst werden muss. In anderen Programmiersprachen wurde dies so erreicht, dass die korrekte Position im Resize-Ereignis ermittelt und dann entsprechend eingestellt wurde. Das .NET Framework bzw. die enthaltenen Steuerelemente bieten einen wesentlich einfacheren Weg. Die Kanten der Steuerelemente lassen sich über die Eigenschaft Anchor an den Kanten des übergeordneten Containers verankern. Anchor ist vom Typ AnchorStyles, bei dem es sich um ein Bitfeld handelt. Üblicherweise werden die Werte der Eigenschaft Anchor nie zur Laufzeit, sondern immer schon beim Entwurf festgelegt.
Sandini Bib
510
18 Standard-Steuerelemente
In der Standardeinstellung sind alle Steuerelemente links oben verankert. Das bedeutet, der Abstand von links und der Abstand von oben bleiben immer gleich, egal wie groß das Fenster wird. Wenn ein Steuerelement rechts unten verankert wird, sorgt das .NET Framework dafür, dass der Abstand von der rechten und der unteren Kante immer gleich bleiben. Selbstverständlich sind auch andere Kombinationen denkbar.
HINWEIS
Wenn Sie beispielsweise möchten, dass ein Steuerelement immer in der Mitte eines Formulars positioniert ist und von allen Kanten immer den gleichen Abstand hat, verankern Sie es einfach an allen vier Seiten. Die Folge ist, dass das Steuerelement seine Größe dynamisch ändert, denn nur so können bei einer Größenänderung die Abstände zu den Rändern auch gleich bleiben. Die Einstellungen für Anchor wirken sich nicht erst zur Laufzeit aus, sondern können bereits im Entwurfsmodus beobachtet werden. Wenn Sie das Formular in der Größe verändern, werden Sie bemerken, dass sich die Steuerelemente sofort entsprechend der Anchor-Einstellung verhalten.
CD
Ein kleines Beispielprogramm zeigt das Verhalten der unterschiedlichen Einstellungen. Abbildung 18.2 zeigt die verschiedenen Anordnungsmöglichkeiten. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\Anchors.
Abbildung 18.2: Die verschiedenen Verankerungsarten in der Gesamtheit
Sandini Bib
Gemeinsame Member der Steuerelemente
511
Beachten Sie bitte, dass sich die Anchor-Einstellung immer auf den übergeordneten Container bezieht, also nicht zwangsläufig auf das Formular. Zum Zweck der Gruppierung von Steuerelementen werden auch häufig GroupBox-Steuerelemente oder Panel-Steuerelemente als Container benutzt.
Andocken von Steuerelementen Gemeint ist hiermit nicht etwa die bekannte Drag&Dock-Technologie, sondern vielmehr die Möglichkeit, Steuerelemente an die Ränder »festzukleben«. Zuständig hierfür ist die Eigenschaft Dock, der ein Wert vom Typ DockStyle zugewiesen werden kann. Anders als bei Anchor können diese Werte nicht miteinander kombiniert werden. Sie werden in der Regel auch im Entwurfsmodus festgelegt. Der Unterschied zu Anchor besteht in der Hauptsache darin, dass das Steuerelement, wenn an den Rand eines Containers angedockt, immer die gesamte Breite dieses Containers einnimmt – automatisch auch bei einer Größenänderung. Das ist bei Anchor nicht der Fall, wo es lediglich darum geht, die Position relativ zum Rand beizubehalten. Beide Eigenschaften unterscheiden sich also grundlegend. Mögliche Einstellungen für die Eigenschaft Dock sind DockStyle.None (Standard), DockStyle.Left, DockStyle.Right, DockStyle.Top, DockStyle.Bottom und DockStyle.Fill. Letztere bewirkt, dass das Steuerelement den gesamten zur Verfügung stehenden Raum des Formulars bzw. des übergeordneten Containers einnimmt, die übrigen (außer DockStyle.None) bewirken ein Andocken an den jeweiligen Rand des Containers (also in der Regel des Formulars). Mithilfe der Eigenschaft Dock können recht komplexe Layouts gestaltet werden, sobald man verstanden hat, wie man richtig damit umgeht. Zunächst sollte man wissen, dass durchaus mehrere Steuerelemente angedockt werden können, allerdings innerhalb eines Containers nur in einer Richtung. Andere Einstellungen machen in der Regel wenig Sinn und führen zu unerwartetem Verhalten. Das Steuerelement SplitContainer ermöglicht das Design auch komplexerer Formulare. Bei einem SplitContainer handelt es sich in der Hauptsache um das auch schon aus dem Vorgänger bekannte Splitter-Steuerelement, an das allerdings in diesem Fall zwei PanelSteuerelemente schon angedockt sind, was weitere Flexibilität ermöglicht. Wenn Sie komplexe Oberflächen nachbauen wollen, müssen Sie stets beachten, dass die Reihenfolge, in der die Elemente hinzugefügt werden, durchaus eine Rolle spielt. Alle Steuerelemente werden der Aufzählung Controls hinzugefügt, die dann durchlaufen wird. Das Element, das zuerst da war, wird auch zuerst angeordnet. Für jeden Container können Sie auch festlegen, wie groß der Abstand zwischen Rand und angedocktem Steuerelement sein soll. Dies ist eine Festlegung, die nur einmal für jeden Container festgelegt wird und sich auf alle Ränder auswirkt. Die zuständige Eigenschaft heißt DockPadding.
Sandini Bib
512
18 Standard-Steuerelemente
18.2.3
Eingabefokus, Validierung
Eingabefokus Ein Steuerelement besitzt den so genannten Eingabefokus, wenn es Tastatureingaben entgegennehmen kann. Es kann immer nur ein Steuerelement zurzeit den Eingabefokus besitzen, standardmäßig das erste Steuerelement, das einem Formular hinzugefügt wurde. Durch einen Klick auf ein Steuerelement erhält dieses den Eingabefokus (das kennen Sie ja von den Textboxen in einem Programm). Die Tabulatortaste schaltet den Eingabefokus weiter, über Shortcuts (die Tastenkürzel mittels der Taste [Alt]) können Sie den Eingabefokus auch direkt auf ein Steuerelement legen.
HINWEIS
Bei einem Fokuswechsel tritt zuerst für das Steuerelement, das den Fokus bisher hatte, ein Leave-Ereignis auf, anschließend für das Steuerelement, das den Fokus nun erhält, ein Enter-Ereignis. Ob ein Steuerelement gerade den Fokus besitzt, können Sie mit der Eigenschaft ContainsFocus feststellen. Neben den Enter- und Leave-Ereignissen gibt es auch die Ereignisse GotFocus und LostFocus. Die Dokumentation empfiehlt aber, diese Ereignisse nicht zu nutzen und stattdessen auf Enter und Leave zurückzugreifen.
Tabulaturreihenfolge (Aktivierreihenfolge) Wie gerade erwähnt, kann der Eingabefokus durch (Æ) verändert werden. Wenn Sie möchten, dass ein Steuerelement nicht per (Æ) aktiviert werden kann, setzen Sie einfach den Wert der Eigenschaft TabStop auf False. Die Reihenfolge, mit der die Steuerelemente durch (Æ) angesprungen werden, wird durch die Eigenschaft TabIndex gesteuert. Das erste Steuerelement für die Tabulatorreihenfolge hat TabIndex=0, das zweite 1 usw. Zur Einstellung der Tabulatorreihenfolge verwenden Sie am besten das Menükommando ANSICHT|TABULATORREIHENFOLGE. Damit wird bei jedem Steuerelement die aktuelle TabIndex-Nummer angezeigt. Um die Reihenfolge zu ändern, klicken Sie die Steuerelemente einfach in der Reihenfolge an, in der diese den Fokus erhalten sollen. Anschließend deaktivieren Sie ANSICHT|TABULATORREIHENFOLGE wieder. Über die TabIndex-Einstellungen ist es selbstverständlich auch möglich, die Reihenfolge zur Laufzeit zu verändern oder mithilfe der Eigenschaft TabStop je nach Kontext Elemente von der Tabulatorreihenfolge auszunehmen. Abbildung 18.3 zeigt einen Screenshot der Tabulatorreihenfolge-Ansicht.
Sandini Bib
Gemeinsame Member der Steuerelemente
513
Abbildung 18.3: Einstellung der Tabulatorreihenfolge
Tastenkürzel Der Eingabefokus kann auch durch [Alt]-Tastenkürzel verändert werden. Derartige Tastenkürzel werden dadurch angezeigt, dass der betroffene Buchstabe in der Beschriftung des Steuerelements unterstrichen ist (also beispielsweise ABBRUCH, um den Button mit [Alt]+(A) auszuwählen). Um für ein Steuerelement ein Tastenkürzel zu definieren, geben Sie einfach in der Text-Eigenschaft vor dem betreffenden Buchstaben ein &-Zeichen an (also Text="&Abbruch"). Wenn Sie in einem Steuerelement das &-Zeichen anzeigen möchten, müssen Sie es im Text zweimal hintereinander angeben (z.B. Text="Drag && Drop") oder die Möglichkeit zur Definition von Tastenkürzeln durch UseMnemonic=False ganz deaktivieren. Bei Steuerelementen, für die kein Tastenkürzel definiert werden kann (z.B. TextBox), können Sie ein Label-Feld zur Beschriftung voranstellen und dieses mit einem Tastenkürzel ausstatten. Das Label-Feld kann den Eingabefokus selbst nicht erhalten und gibt ihn an das nächste Steuerelement in der Tabulatorreihenfolge weiter. Die Reaktion auf das [Alt]-Kürzel hängt vom Steuerelementtyp ab. Die meisten Steuerelemente erhalten einfach nur den Eingabefokus. Bei Buttons, Optionsfeldern und Auswahlkästchen wird das Steuerelement außerdem ausgewählt, d.h. das [Alt]-Kürzel hat dieselbe Wirkung wie ein Anklicken mit der Maus.
Fokus per Programmcode verändern Nicht nur die Anwender können den Fokus verändern (per Tastatur oder Maus), Sie können den Fokus auch per Programmcode auf ein anderes Steuerelement richten. Dazu führen Sie für das entsprechende Steuerelement einfach die Methode Focus() aus. textbox1.Focus();
Sandini Bib
514
18 Standard-Steuerelemente
Die Methode liefert true oder false zurück, je nachdem, ob der Fokuswechsel geglückt ist oder nicht. (Der Fokuswechsel kann scheitern, wenn er von dem Steuerelement, das den Fokus momentan innehat, in der Validating-Ereignisbehandlungsroutine verhindert wird.)
Validierung Es gibt verschiedene Zeitpunkte, zu denen Sie überprüfen können, ob Benutzereingaben im Steuerelement bzw. im gesamten Dialog korrekt sind (welche dieser drei Varianten die beste ist, hängt von der jeweiligen Anwendung ab). f Bei jeder Änderung der Daten im Steuerelement: Dazu sehen viele Steuerelemente Changed-Ereignisse vor, z.B. TextChanged bei einem TextBox-Steuerelement. f Bei einem Fokuswechsel: Dazu ist das Ereignis Validating vorgesehen. Dieses Ereignis liefert einen Parameter e vom Typ ValidatingEventArgs. Falls ein Eingabefehler festgestellt wird, kann ein Fokuswechsel verhindert werden, indem die Eigenschaft Cancel dieses Parameters auf true gesetzt wird. f Falls die Validating-Methode erfolgreich beendet wird, tritt danach ein ValidatedEreignis auf. Es kann dazu verwendet werden, eventuell im Validating-Code dargestellte Fehlermeldungen, Farbveränderungen etc. rückgängig zu machen. f Beim Beenden des Dialogs: In diesem Fall hängt der Ort für den Validierungscode von der Logik des Dialogs bzw. Fensters ab. Bei einfachen Dialogen eignet sich die Ereignisprozedur des OK-Buttons.
VERWEIS
Die Validating- und Validated-Ereignisse können auch unterbunden werden. Stellen Sie hierzu den Wert der Eigenschaft CausesValidation auf false ein. Ein einfaches Beispiel für die Anwendung des Validating-Ereignisses zur Überprüfung, ob eine Zahleneingabe in einer TextBox korrekt durchgeführt wurde, finden Sie auf Seite 494. Die Anwendung des ErrorProvider-Steuerelements als Fehlerindikator wird in Abschnitt 18.10.7 ab Seite 614 demonstriert.
18.2.4
Sonstiges
Tastatur und Maus Fast alle Steuerelemente können Tastatur- und Mauseingaben verarbeiten. Diese sind Grundlage für die Interaktion zwischen Benutzer und Programm und dementsprechend ständig präsent. Einige davon haben Sie bereits kennen gelernt, beispielsweise das ClickEreignis eines Buttons. Das Aussehen der Maus kann über die Eigenschaft Cursor für jedes Steuerelement eingestellt werden. Der Mauszeiger nimmt die in dieser Eigenschaft eingestellte Form an, wenn er sich über dem Steuerelement befindet. Die häufigste Einstellung ist vermutlich Cursors.WaitCursor.
Sandini Bib
Gemeinsame Member der Steuerelemente
515
Die Eigenschaften MousePosition und MouseButtons eines Steuerelements geben Auskunft über die aktuelle Mausposition und den Zustand der Maustasten. Die Eigenschaft ModifierKeys enthält außerdem den Zustand der Sondertasten ([Alt], (ª), (Strg)). Die Methoden PointToScreen() und PointToClient() führen eine Umrechnung zwischen lokalen Koordinaten und absoluten Bildschirmkoordinaten durch. Jedes sichtbare Steuerelement kann mit der Eigenschaft ContextMenuStrip mit einem eigenen Menü ausgestattet werden, das erscheint, wenn die rechte Maustaste gedrückt wird. Manche Steuerelemente besitzen automatisch ein solches Kontextmenü. Um dies zu unterdrücken, können Sie ein leeres Kontextmenü zuweisen.
Datengebundene Steuerelemente Die meisten Steuerelemente können mit verschiedenen Datenquellen verbunden werden. Oftmals handelt es sich bei derartigen Datenquellen um Objekte, die mit einer Datenbank in Verbindung stehen oder entsprechende Daten beinhalten (z.B. DataTable- oder DataViewObjekte). Mit .NET 2.0 ist nun auch eine Bindung an Objekte möglich. Genaueres darüber erfahren Sie im Datenbankteil des Buchs ab Kapitel 27, beginnend auf Seite 941.
Zugriff auf Steuerelemente Über die Eigenschaft Modifiers können Sie den Zugriff auf das jeweilige Steuerelement festlegen. Die Standardeinstellung ist private, d.h. auf Steuerelemente kann nur innerhalb des jeweiligen Formulars zugegriffen werden. Das entspricht auch dem Konzept der Kapselung (bei der Datenübergabe an ein Formular auf Seite 494 haben Sie bereits gesehen, wie Daten an ein Formular übergeben werden können, ohne dass die Steuerelemente öffentlich gemacht werden müssen). Über die Eigenschaft Locked können Sie das Steuerelement sperren, d.h. gegen Veränderungen schützen.
Weitere nützliche Eigenschaften und Methoden Jedes Steuerelement befindet sich automatisch innerhalb eines Containers. Dieser muss nicht zwangsläufig das Formular selbst sein. Auf den Container können Sie über die Eigenschaft Parent zugreifen. Wollen Sie auf das Formular zugreifen, das in der Containerhierarchie per Definitionem an der Spitze steht, können Sie das über die Eigenschaft TopLevelControl tun. Der Zugriff auf die enthaltenen Steuerelemente kann über die Eigenschaft Controls des Containers geschehen. Dabei handelt es sich um eine Liste, die alle Steuerelemente dieses Containers beinhaltet. Ein entsprechendes Beispiel, das auch die in untergeordneten Container-Steuerelementen enthaltenen Controls berücksichtigt, wurde bereits auf Seite 498 vorgestellt. Ebenfalls in vielen Fällen sehr nützlich ist die Eigenschaft Tag, die jedes Steuerelement im .NET Framework zur Verfügung stellt. Tag ist vom Typ object und kann daher ideal dazu verwendet werden, zusätzliche Informationen zu speichern.
Sandini Bib
516
18 Standard-Steuerelemente
In Multithread-Umgebungen darf generell nur der Haupt-Thread einer Anwendung auf Steuerelemente zugreifen. Falls Sie sich in einer Multithread-Umgebung befinden, müssen Sie daher die Methoden BeginInvoke(), Invoke() und EndInvoke() verwenden. Ein Beispiel für die Anwendung von Invoke() finden Sie auf Seite 435. In Europa nicht unbedingt wichtig, aber für den Export in verschiedene Länder durchaus zu beachten, ist die Richtung, in der geschrieben wird. Wir schreiben und lesen bekanntlich von links nach rechts, während das in anderen Ländern nicht der Fall ist. Um auch mit diesen Ländern kompatibel zu sein, gibt es die Eigenschaft RightToLeft. Sie dreht die Schreibrichtung um.
VERWEIS
Ebenso wurde beim Entwurf des .NET Frameworks auf sehbehinderte Menschen Rücksicht genommen, etwas, was leider immer noch nicht selbstverständlich ist. Mit den Eigenschaften AccessibleName, AccessibleDescription und AccessibleRole können Sie den Namen, die Bedeutung und die Funktion eines Steuerelements in Ihrem Programm beschreiben. Weitere Hintergrundinformationen und Tipps, wie Programme gestaltet werden können, damit sie auch von körperlich behinderten Benutzern genutzt werden können, finden Sie in der Online-Hilfe (MSDN), wenn Sie nach Ressourcen für das Entwerfen von Anwendungen mit Eingabehilfen suchen: ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.VisualStudio.v80.de/dv_vslegal/html/426bf023-bb3443c4-9edb-c307191c8170.htm
18.2.5
Syntaxzusammenfassung
Gemeinsame Eigenschaften für Steuerelemente AccessibleDescription
beschreibt das Steuerelement (für sehbehinderte Benutzer).
AccessibleName
benennt das Steuerelement (für sehbehinderte Benutzer).
AccessibleRole
beschreibt die Funktion des Steuerelements (für sehbehinderte Benutzer).
Anchor
ermöglicht die Festlegung einer Verankerung. Über Werte vom Typ AnchorStyles wird festgelegt, an welchen Seiten der Abstand zwischen dem Steuerelementrand und dem Rand des Formulars (bzw. des übergeordneten Steuerelements) konstant bleiben soll.
AutoSize
Wenn AutoSize den Wert true enthält, passt das Steuerelement seine Größe automatisch an seinen Inhalt an.
BackColor
legt die Hintergrundfarbe eines Steuerelements fest. Durchsichtige Steuerelemente können durch Zuweisung der transparenten Farbe aus der Kategorie WEB erzeugt werden.
Bounds
gibt die Außenposition und -größe eines Steuerelements an. Dabei handelt es sich um einen Wert vom Typ Rectangle.
BorderStyle
legt den Rahmenstil für das Steuerelement fest.
Sandini Bib
Gemeinsame Member der Steuerelemente
517
Gemeinsame Eigenschaften für Steuerelemente Bottom
gibt die Y-Position des unteren Rands an. Diese Eigenschaft ist nur lesbar, der Wert ergibt sich aus den Werten für Top und Height.
CausesValidation
Wenn CausesValidation den Wert true enthält, werden die Ereignisse Validating und Validated bei einem Fokuswechsel ausgelöst, bei einem Wert false nicht. Die Standardeinstellung ist true.
ClientRectangle
gibt die inneren Maße eines Steuerelements an. Dieser Wert ist vom Typ Rectangle.
ClientSize
gibt die innere Größe eines Steuerelements an. Dieser Wert ist vom Typ Size.
ContainsFocus
gibt an, ob das Steuerelement aktuell den Fokus besitzt.
ContextMenu
enthält das Kontextmenü des Steuerelements. Manche Steuerelemente besitzen ein voreingestelltes Kontextmenü, das auch dann verfügbar ist, wenn der Eigenschaft ContextMenu nichts zugewiesen wurde.
Controls
verweist auf die enthaltenen Steuerelemente, wenn das Steuerelement ein Container ist.
Cursor
legt fest, welches Aussehen der Mauscursor hat, wenn die Maus sich über dem Steuerelement befindet.
Dock
gibt an, ob und an welchem Rand des Fensters bzw. Containers das Steuerelement angedockt ist.
Enabled
legt fest, ob das Steuerelement verfügbar ist oder nicht.
FlatStyle
legt fest, ob das Steuerelement in 3D-Optik oder flach angezeigt werden soll.
Font
gibt die Schriftart für den Text an.
ForeColor
legt die Vordergrundfarbe (Schriftfarbe) des Steuerelements fest.
Height
legt die Höhe des Steuerelements fest.
Image
enthält das im Steuerelement angezeigte Bild.
ImageAlign
legt fest, wie das im Steuerelement enthaltene Bild positioniert wird.
Left
legt die X-Position des linken Rands fest.
Location
gibt die Position des linken oberen Eckpunkts des Steuerelements an oder legt diesen fest. Dieser Wert ist vom Typ Point.
ModifierKeys
liefert den aktuellen Zustand der Tasten [SHIFT], [STRG], [ALT] usw.
MouseButtons
liefert den aktuellen Zustand der Maustasten.
MousePosition
liefert die aktuelle Mausposition als Point-Instanz in Bildschirmkoordinaten.
Name
liefert den Namen des Steuerelements bzw. legt diesen fest.
Sandini Bib
518
18 Standard-Steuerelemente
Gemeinsame Eigenschaften für Steuerelemente Parent
verweist auf das übergeordnete Steuerelement.
Size
gibt die Außengröße des Steuerelements an. Dieser Wert ist vom Typ Size.
Visible
legt fest, ob das Steuerelement für den Benutzer sichtbar ist.
Right
gibt die X-Position des rechten Rands des Steuerelements an. Diese Eigenschaft ist nur lesbar.
RightToLeft
Wenn RightToLeft den Wert true enthält, werden Texte in diesem Steuerelement von rechts nach links ausgegeben. Die Standardeinstellung für Europa ist false.
TabStop
Wenn TabStop den Wert true enthält, kann das Steuerelement mithilfe der Tabulatortaste angesprungen werden. Der Standardwert ist abhängig vom Typ des Steuerelements.
TabIndex
gibt an, an welcher Stelle der Tabulatorreihenfolge sich das Steuerelement befindet.
Text
legt den im Steuerelement angezeigten Text fest.
TextAlign
Legt die Positionierung und Ausrichtung des Textes fest (z.B. oben zentriert).
Top
legt die Y-Position des oberen Rands fest.
TopLevelControl
verweist auf den Basiscontainer, in dem sich die Steuerelemente befinden und dessen Parent-Eigenschaft null enthält. Üblicherweise handelt es sich dabei um das übergeordnete Formular.
UseMnemonic
gibt an, ob das Zeichen & in der Text-Eigenschaft dazu dient, Tastenkürzel zu definieren. Der dem &-Zeichen nachfolgende Buchstabe wird in diesem Fall unterstrichen dargestellt.
Width
gibt die Breite des Steuerelements an bzw. legt diese fest.
Gemeinsame Methoden für Steuerelemente BeginInvoke()
führt eine Methode asynchron aus. BeginInvoke() wird nur in Multithreading-Anwendungen benötigt.
BringToFront()
platziert das Steuerelement über allen anderen Steuerelementen.
EndInvoke()
beendet die mit BeginInvoke() gestartete asynchrone Ausführung einer Prozedur.
Focus()
versucht den Eingabefokus in das Steuerelement zu setzen und liefert true oder false, je nachdem, ob der Fokuswechsel gelungen ist oder nicht.
GetChildAtPoint()
ermittelt das Steuerelement, das sich an einer bestimmten Koordinatenposition befindet.
Hide()
macht das Steuerelement unsichtbar (entspricht Visible=false).
Sandini Bib
Gemeinsame Member der Steuerelemente
519
Gemeinsame Methoden für Steuerelemente Invalidate()
bewirkt das Neuzeichnen eines Teils des Steuerelements.
Invoke()
löst die synchrone Ausführung einer Methode im Haupt-Thread der Anwendung aus. Invoke() wird bei Multithreading-Anwendungen benötigt.
PointToClient()
rechnet Bildschirmkoordinaten in das lokale Koordinatensystem des Steuerelements um.
PointToScreen()
Ist die Umkehrung zu PointToClient().
Refresh()
erzwingt ein Neuzeichnen des Steuerelements und aller eventuell darin enthaltenen Steuerelemente.
Select()
aktiviert das Steuerelement und gibt ihm den Fokus.
SetBounds()
verändert Position und Größe des Steuerelements.
Show()
macht das Steuerelement sichtbar (entspricht Visible=true).
Update()
führt noch nicht ausgeführte Zeichenoperationen sofort aus.
Gemeinsame Ereignisse für Steuerelemente Click
wird ausgelöst, wenn das Steuerelement angeklickt wurde.
DoubleClick
wird ausgelöst, wenn ein Doppelklick auf das Steuerelement ausgeführt wurde.
Enter
wird ausgelöst, wenn das Steuerelement den Eingabefokus erhält.
GotFocus
folgt dem Ereignis Enter, ist aber nur für interne Zwecke vorgesehen.
Leave
wird ausgelöst, wenn das Steuerelement den Eingabefokus verliert.
LostFocus
kommt vor dem Ereignis Leave, ist aber wie GotFocus nur für interne Zwecke vorgesehen.
KeyDown, KeyPress, KeyUp
wird bei verschiedenen Tastaturereignissen ausgelöst.
MouseDown, MouseUp, usw.
wird bei diversen Mausereignissen wie Bewegung oder Mausklick ausgelöst.
Paint
wird ausgelöst, wenn Teile des Steuerelements neu gezeichnet werden müssen.
SystemColorsChanged
wird ausgelöst, wenn sich die Windows-Standardfarben verändert haben.
Validating
wird ausgelöst, wenn das Steuerelement den Eingabefokus verliert. Dieses Ereignis kann dazu verwendet werden, die Richtigkeit einer Eingabe in einem Steuerelement zu kontrollieren.
Validated
wird ausgelöst, wenn die Validating-Ereignisbehandlungsroutine erfolgreich beendet wurde.
Sandini Bib
520
18.3
18 Standard-Steuerelemente
Buttons
In diesem Abschnitt stehen die Steuerelemente Button, RadioButton und CheckBox im Mittelpunkt. Auch wenn die letzten beiden nicht direkt etwas mit Buttons zu tun haben, so sind sie doch ähnlich genug, um mit in diese Gruppierung zu gehören.
18.3.1
Das Steuerelement Button
Das Steuerelement Button wird grundsätzlich in Dialogboxen verwendet, um diese zu schließen, findet aber auch an zahlreichen anderen Plätzen Anwendung. Die Art des Aussehens kann nur sehr begrenzt geändert werden. Über die Eigenschaft FlatStyle vom Typ FlatStyle können Sie festlegen, wie der Button dargestellt werden soll. Dabei stehen vier Stile zur Auswahl. FlatStyle.Flat zeigt den Button grundsätzlich als flaches Element an. FlatStyle.Popup zeigt ebenfalls einen flachen Button, der aber zu einem dreidimensionalen Button wird, wenn die Maus darübergezogen wird. FlatStyle.Standard zeigt die gewohnte Darstellung, FlatStyle.System scheinbar auch. FlatStyle.System entspricht allerdings immer der vom Betriebssystem vorgegebenen Darstellung. Die Eigenschaft FlatAppearance ermöglicht es Ihnen weiterhin, das Aussehen eines flach eingestellten Buttons ganz nach Belieben zu ändern. So können Sie die Rahmenfarbe, die Hintergrundfarbe eines gedrückten Buttons, die Hintergrundfarbe eines Buttons mit darüberstehender Maus oder auch die Breite des Rahmens frei ändern. FlatAppearance besitzt dazu mehrere Untereigenschaften.
Grafiken Über die Eigenschaft BackgroundImage legen Sie ein Hintergrundbild für den Button fest. Ein Symbol kann ebenfalls platziert und ausgerichtet werden. Das Symbol wird dabei in der Eigenschaft Image festgelegt, die Ausrichtung über ImageAlign. Wahlweise kann auch eine ImageList festgelegt und über ImageIndex das gewünschte Bild aus der eingestellten ImageList ausgewählt werden. Auch hierbei dient die Einstellung von ImageAlign der Ausrichtung. Wenn Sie dem Button auf diese Art eine Grafik hinzufügen, kann es passieren, dass Text und Grafik sich überlappen. Über die Eigenschaft TextAlign können Sie auch einstellen, an welcher Stelle der Text dargestellt werden soll. Außerdem ist es möglich, die Ausrichtung einer eventuell auf dem Button befindlichen Grafik und des Textes zu beeinflussen. Dazu dient die Eigenschaft TextImageAlign. Das Steuerelement Button sieht keine Möglichkeit vor, einen »Umschalt-Button« zu erzeugen (also einen Button, der durch einmaliges Anklicken gedrückt und durch nochmaliges Klicken wieder losgelassen wird). Derartige Buttons können Sie mit dem CheckBoxSteuerelement erzeugen, indem Sie Appearance = Appearance.Button einstellen.
Sandini Bib
Buttons
521
Ereignisse Üblicherweise ist das relevante Ereignis eines Buttons das Ereignis Click. Es ist auch als Standardereignis voreingestellt, d.h. ein Doppelklick auf den Button im Entwurfsmodus fügt eine Ereignisbehandlungsroutine für das Click-Ereignis ein. Darin programmieren Sie die Funktionalität. Geht es um Dialoge, kommen Sie in der Regel sogar ohne dieses Ereignis aus. Wie bereits ab Seite 491 beschrieben, reicht die korrekte Zuweisung der Eigenschaft DialogResult aus, um einen Dialog automatisch beim Klick auf einen Button zu schließen und den DialogResult-Wert des Buttons der DialogResult-Eigenschaft des Formulars zuzuweisen. Falls noch Aufgaben zu erledigen sind, können Sie diese natürlich dennoch im Click-Ereignis unterbringen, dieses wird grundsätzlich ausgeführt, bevor das Fenster geschlossen wird.
Standard-Buttons Ebenfalls bereits erklärt wurde das Verhalten von Buttons innerhalb eines Dialogs, wenn dessen Eigenschaften AcceptButton und CancelButton eingestellt werden. Ein Druck auf die Taste (¢) löst den AcceptButton aus, ein Druck auf (Esc) den CancelButton. Von dieser Regel gibt es jedoch (verständliche) Ausnahmen. Wenn der Fokus beispielsweise auf einem Textfeld liegt, das die (¢)-Taste verarbeiten kann, wird der unter AcceptButton eingestellte Button selbstverständlich nicht ausgelöst, denn das ist nicht das erwartete Verhalten. Ebenso wenig ist es so, dass ein Druck auf die (¢)-Taste bei einem fokussierten anderen Button den AcceptButton auslöst. Stattdessen wird das Click-Ereignis des fokussierten Buttons ausgelöst. AcceptButton und CancelButton schließen ein Formular nicht automatisch. Die dafür wichtige Eigenschaft ist die Eigenschaft DialogResult des Buttons. Nur wenn diese Eigenschaft eingestellt ist, wird der DialogResult-Wert übertragen und das Formular nach Abarbeitung des Click-Ereignisses geschlossen. Eine Verbindung beider Einstellungen ist natürlich möglich.
CD
Das folgende kleine Programm zeigt die verschiedenen Button-Einstellungen. Dabei besitzt ein Button eine Hintergrundgrafik, einer ist flach eingestellt, ein Button ist ein PopupButton, ein weiterer trägt ein Symbol, ein Button besitzt einen mehrzeiligen Text und einer ist ganz normal dargestellt. Quellcode existiert nicht, es wurden lediglich die Eigenschaften eingestellt. Abbildung 18.4 zeigt einen Screenshot. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\Buttons.
Sandini Bib
522
18 Standard-Steuerelemente
Abbildung 18.4: Verschiedene Buttons auf einem Formular
18.3.2
Das Steuerelement CheckBox
Das CheckBox-Steuerelement dient dazu, Auswahlkästchen in einem Formular zu realisieren. Jedes Auswahlkästchen kann unabhängig von allen anderen ausgewählt werden. Mit der Einstellung Button für die Eigenschaft Appearance erreichen Sie, dass das Kästchen als Umschalt-Button dargestellt wird. Diese Eigenschaft ist auch vom Typ Appearance, wieder einmal eine Aufzählung, sodass Sie diese Zuweisung auch zur Laufzeit durchführen können. Über die Eigenschaft FlatStyle erzielen Sie wieder unterschiedliche Optiken, die auch mithilfe der Eigenschaft FlatAppearance angepasst werden können. Mit CheckAlign und TextAlign können Sie die Position des Kästchens und des dazugehörenden Textes ändern. Standard ist eine linksbündige Ausrichtung sowohl des eigentlichen Auswahlkästchens als auch des Textes. Bei jeder Änderung des Auswahlkästchens (also auch beim Deaktivieren!) kommt es zum Ereignis CheckedChanged. Zwei Eigenschaften geben Auskunft über den aktuellen Zustand des Steuerelements: f CheckState ist vom Typ CheckState und kann drei Zustände einnehmen: Checked (ausgewählt), Unchecked (nicht ausgewählt) oder Indeterminate (unbestimmt, eine graue Checkbox). Der Zustand CheckState.Indeterminate kann nur eintreten, wenn die Eigenschaft ThreeState den Wert true enthält. f Checked ist einfacher auszuwerten und enthält False, wenn das Kästchen nicht ausgewählt wurde, sonst true. Leider gilt dabei auch dann true, wenn ThreeState true und CheckState auf Indeterminate eingestellt ist. Normalerweise wird der Zustand der Steuerelemente beim Anklicken automatisch verändert. Wenn Sie das nicht möchten, müssen Sie die Eigenschaft AutoCheck auf false setzen. Allerdings müssen Sie in diesem Fall das Click-Ereignis auswerten und CheckState selbst verändern.
Sandini Bib
VERWEIS
Buttons
523
Wenn Sie nicht eine einzelne CheckBox, sondern eine ganze Liste solcher Steuerelemente benötigen, können Sie auch auf die Steuerelemente CheckedListBox, ListView und TreeView zurückgreifen (siehe Abschnitte 18.6.2, 18.6.4 und 18.6.6).
CD
Das folgende Beispielprogramm zeigt das Verhalten von Checkboxen. Auch hier wurde wieder kein Quellcode eingegeben, lediglich die verschiedenen Arten von Checkboxen werden dargestellt. Abbildung 18.5 zeigt einen Screenshot. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\Checkboxen.
Außerdem haben Sie natürlich auch beim CheckBox-Steuerelement die Möglichkeit, mit Grafiken zu arbeiten, sowohl mit Hintergrundgrafiken als auch mit einem Symbol, das Sie ebenfalls wieder mit ImageAlign ausrichten können. Wie der Button besitzt auch das CheckBox-Steuerelement eine Eigenschaft TextImageAlign, mit der die Ausrichtung des Textes zu einer eventuell vorhandenen Grafik festgelegt werden kann.
Abbildung 18.5: Verschiedene Arten von Checkboxen
Sandini Bib
524
18.3.3
18 Standard-Steuerelemente
Das Steuerelement RadioButton
RadioButtons, manchmal auch Optionsfelder genannt, besitzen im Wesentlichen die gleichen Eigenschaften wie Auswahlkästchen. Die Eigenschaften Appearance, CheckAlign, FlatStyle und Checked haben dieselbe Bedeutung wie beim Steuerelement CheckBox. CheckState und ThreeState stehen dagegen nicht zur Verfügung, weil Radiobuttons keinen undefinierten Zustand einnehmen können. Der Unterschied zwischen Radiobuttons und Checkboxen besteht darin, dass von einer Gruppe Radiobuttons immer nur einer ausgewählt sein kann. Wird ein anderer Radiobutton ausgewählt, wird die bisherige Auswahl deaktiviert. Radiobuttons gehören dann zu der gleichen Gruppe, wenn sie auf dem gleichen Container angeordnet sind. Mithilfe von Steuerelementen wie z.B. Panel oder GroupBox können Sie mehrere Radiobutton-Gruppen auf einem Formular anordnen. Von jeder Gruppe ist jedoch immer nur ein Radiobutton ausgewählt. Ein weiterer Unterschied ist, dass zweimaliges Klicken auf einen Radiobutton die Auswahl nicht aufhebt, nur durch Auswählen eines anderen Radiobuttons der gleichen Gruppe wird die bisherige Auswahl aufgehoben. Daraus folgt zwangsläufig, dass innerhalb einer Gruppe von Radiobuttons stets einer ausgewählt sein muss. Beim Wechsel der Auswahl wird das Ereignis CheckedChanged ausgelöst – und zwar einmal für den Radiobutton, der angeklickt wurde, und ebenso für den Button, der jetzt nicht mehr ausgewählt ist. In beiden Fällen tritt ein Wechsel des Status auf. Kontrollieren können Sie diesen über die Eigenschaft Checked. Ebenso wie beim CheckBox-Steuerelement gibt es auch hier die Möglichkeit, die Darstellungsform zu variieren. Das folgende Programm dient wieder lediglich der Darstellung und enthält keinen Programmcode. Abbildung 18.6 zeigt einen Screenshot.
Abbildung 18.6: Einige Radiobuttons auf einem Formular
Sandini Bib
Steuerelemente für Text
525
CD
Die Darstellung als flacher Button bzw. Popup-Button hat allerdings einen gravierenden Nachteil, denn weder bei der Checkbox noch beim RadioButton sieht man hier einen definierten Zustand. Ausgewählte und nicht ausgewählte Felder sind dann nur sehr schwer zu unterscheiden. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\Radiobuttons.
18.4
Steuerelemente für Text
In diesem Abschnitt dreht sich alles um die Text-Steuerelemente des .NET Frameworks. Dabei geht es sowohl um reine Anzeigeelemente, wie z.B. das Steuerelement Label, als auch um Eingabeelemente wie TextBox oder RichTextBox.
18.4.1
Das Steuerelement Label
Zum Steuerelement Label gibt es nicht viel zu sagen: Es dient zur Beschriftung anderer Elemente im Formular. Der Beschriftungstext wird durch die Text-Eigenschaft eingestellt. TextAlign steuert die Ausrichtung des Texts. Zu langer Text wird normalerweise automatisch auf mehrere Zeilen verteilt. Wenn Sie AutoSize auf true setzen, wird der Text dagegen einzeilig dargestellt. Das Label-Steuerelement nimmt dann automatisch die erforderliche Größe (Breite) an.
Mit BorderStyle können Sie das Steuerelement mit einem zwei- oder dreidimensionalen Rand ausstatten. Sie können das Steuerelement auch durchsichtig machen, indem Sie als Hintergrundfarbe Transparent aus der Kategorie WEB auswählen. Hierbei gibt es allerdings einige Probleme, denn das Steuerelement scheint Teile des Hintergrunds erneut zu zeichnen, obwohl es als transparent eingestellt ist. Das Problem trat auf bei dem Versuch, eine Uhr zu programmieren, die lediglich die Uhrzeit anzeigt (wobei weder Formular noch Label zu sehen sein sollten). Versuche, sowohl Label als auch Formular über die Transparency-Eigenschaft des Formulars durchsichtig zu machen, scheiterten ebenso wie die Zuweisung einer transparenten Farbe für den Hintergrund des Labels. Die Lösung war schließlich, statt der Einstellung FlatStyle.Standard für FlatStyle die Einstellung FlatStyle.System zu verwenden. Das Programm finden Sie auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_18\Clock. Der Quelltext ist nicht weiter schwer, es wird ein Timer verwendet, der dem Label die aktuelle Uhrzeit jede Sekunde zuweist. Interessant ist nur die Einstellung der Eigenschaftswerte. Um die Uhr zu verschieben, bewegen Sie den Zeiger über die Zeitanzeige, die Form wird dann mit Rahmen angezeigt. Nach 5 Sekunden verschwindet der Rahmen wieder. Durch diese Anzeige verschiebt sich die Zeitdarstellung, dieses Manko wurde im Beispielprogramm allerdings nicht behoben, es soll nur der Darstellung der Problemlösung dienen.
Sandini Bib
526
18 Standard-Steuerelemente
Obwohl das Label-Steuerelement selbst keinen Eingabefokus erhalten kann, besteht die Möglichkeit, durch das kaufmännische und (&) im Text ein [Alt]-Tastenkürzel zu definieren. Bei der Eingabe des Tastenkürzels wird der Fokus an das nächste Steuerelement in der Tabulaturreihenfolge weitergeben. Das ist praktisch, um Tastenkürzel auch für solche Steuerelemente zu ermöglichen, für die selbst kein Tastenkürzel definiert werden kann (z.B. Steuerelemente wie TextBox, die die Eigenschaft UseMnemonic nicht besitzen). Neu sind die Eigenschaften UseCompatibleTextRendering sowie AutoEllipsis. Erstere legt fest, dass das Rendern der enthaltenen Texte kompatibel zu Vorgängerversionen von Windows.Forms (nicht Windows) sein soll. AutoEllipsis steht für das Standardverhalten eines Labels mit fester Größe, das einen zu langen String anzeigt. Hier wird der String mit drei Punkten abgekürzt.
18.4.2
LinkLabel
Das LinkLabel-Steuerelement ist vom Label-Steuerelement abgeleitet. Es vereinfacht den Aufruf von Webseiten, das Versenden von E-Mails bzw. die Anzeige von Dokumenten per Mausklick. Anders als das Label-Steuerelement kann LinkLabel mit (Æ) direkt ausgewählt werden. Wenn das Steuerelement den Eingabefokus hat, hat (¢) dieselbe Wirkung wie das Anklicken des Steuerelements. Das Steuerelement sieht auch Tastenkürzel vor (UseMnemonic hat per Default den Wert true), diese scheinen aber nicht zu funktionieren. Im Eigenschaftsfenster geben Sie mit der Text-Eigenschaft jenen Text an, der im Label angezeigt werden soll. Über die Eigenschaften LinkColor, VisitedLinkColor, ActiveLinkColor und DisabledLinkColor können Sie die Farben eingeben, die das Steuerelement verwenden soll. Das Verhalten der Anzeige entspricht standardmäßig dem des Internet Explorers, d.h. wenn im IE eingestellt ist, dass eine Unterstreichung nur dann geschehen soll, wenn sich die Maus über dem Text befindet, dann gilt dies auch für die Anzeige des LinkLabelSteuerelements. Wenn Sie ein eigenes Verhalten definieren wollen, können Sie dies über die Eigenschaft LinkBehaviour tun. Deren Werte sind recht eindeutig und sollten keine Probleme bereiten. Wenn Sie die Maus über den Text ziehen, erscheint sie, wie bei Links üblich, als Hand mit Zeigefinger. Dieses Verhalten lässt sich auch nicht ändern, indem Sie die Eigenschaft Cursor einstellen. Allerdings können Sie das Label selbst größer machen als den Text und werden beobachten, dass der Mauszeiger nur dann zur Hand wird, wenn er sich wirklich über dem Text befindet – über dem Steuerelement gilt in der Tat die Einstellung der Eigenschaft Cursor.
LinkLabel verwenden Die einfachste Möglichkeit, ein LinkLabel zu verwenden, ist, nur einen Link anzugeben. Ein Klick darauf löst das Ereignis LinkClicked aus, in dem Sie die gewünschte Funktion starten können. In der Regel wird es sich dabei um einen Link ins Internet oder eine Mailadresse
Sandini Bib
Steuerelemente für Text
527
CD
handeln. Zum Starten können Sie die Methode Start() der Klasse Process aus dem Namespace System.Diagnostics verwenden. Alles, was Sie übergeben müssen, ist die URL, das Standardprogramm wird automatisch gestartet. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\Links.
private void linkLabel1_LinkClicked( object sender, LinkLabelLinkClickedEventArgs e ) { System.Diagnostics.Process.Start( linkLabel1.Text ); }
Diese einfache Variante ist allerdings nicht der Weisheit letzter Schluss. Das LinkLabelSteuerelement hat weit mehr zu bieten. Über die Eigenschaft Links haben Sie die Möglichkeit, mehrere Links festzulegen. Dabei handelt es sich um eine Collection, die Werte vom Typ LinkLabel.Link aufnimmt. Diese Klasse ist eine geschachtelte Klasse, die nicht öffentlich verfügbar ist und auch außerhalb des LinkLabel-Steuerelements nicht benötigt wird. Der Link selbst ist unabhängig vom angezeigten Text. Wenn Sie der Aufzählung Links einen Wert hinzufügen, geben Sie einfach an, bei welchem Buchstaben der anklickbare Bereich beginnen soll, wieviele Buchstaben als Link dargestellt werden sollen und (optional) wohin der Link führen soll, bzw. irgendwelche Daten, die Sie später auswerten wollen. Dieser letzte Parameter ist vom Typ object, Sie haben also freie Wahl. In der Folge wird nur der von Ihnen angegebene Bereich wie ein Link dargestellt, der Rest des LinkLabel verhält sich wie ein normales Label-Steuerelement. Wenn Sie einen weiteren Linkbereich hinzufügen, verhält sich dieser zweite Bereich ebenfalls wieder wie ein Link. Wird nun ein Klick auf das Label ausgeführt (sinnvollerweise auf einen der Links), enthält der Parameter e der Ereignisbehandlungsroutine LinkClicked genau den Link mit allen Daten, auf den Sie geklickt haben. Über die Eigenschaft e.Link.LinkData können Sie die von Ihnen übergebenen Daten auswerten. Ein Beispiel zeigt mehr als alle Worte. Das Label enthält folgenden Text: Franks Seite ... Michaels Seite
Wird nun auf den Schriftzug Franks Seite geklickt, soll der Link nach http://www.frankeller.de verweisen. Wird auf Michaels Seite geklickt, soll es nach http://www.kofler.cc gehen. Im Load-Ereignis des Formulars initialisieren wir zunächst die Links. Der Text wurde bereits zur Entwurfszeit zugewiesen. private void Form1_Load(object sender, System.EventArgs e) { // Linklabel initialisieren linkLabel2.Links.Add(0,12,"http://www.frankeller.de"); linkLabel2.Links.Add(17,14,"http://www.kofler.cc"); }
Sandini Bib
528
18 Standard-Steuerelemente
Nun müssen wir lediglich noch die Ereignisbehandlungsroutine schreiben. Diese besteht wieder nur aus einer Zeile, da die Linkdaten (der dritte Parameter bei linkLabel2. Links.Add()) nur aus dem Link selbst bestehen. private void linkLabel2_LinkClicked( object sender, LinkLabelLinkClickedEventArgs e ) { System.Diagnostics.Process.Start( e.Link.LinkData.ToString() ); }
Wenn Sie das Programm starten, werden Sie bemerken, dass nur die beiden Links wirklich blau geschrieben sind, der Rest des Textes ist schwarz. Klicken Sie darauf und der Internet Explorer wird sich mit der gewünschten Seite öffnen. Auch diesen Code finden Sie im Programm Links. Auf eine Abbildung wird in diesem Fall verzichtet (die Hervorhebung der Links ist im Buch farblich ohnehin nicht zu unterscheiden).
18.4.3
Das Steuerelement TextBox
Das Steuerelement TextBox dient wie der Name bereits sagt zur Eingabe von Text. TextBox ist recht vielseitig. So kann dieses Steuerelement dazu verwendet werden, einzeilig Daten einzugeben, wie auch dazu, mehrzeilige Texte zu erfassen. Auch besteht die Möglichkeit, die Eingabe zu maskieren (beispielsweise bei einer Passworteingabe). Außerdem bietet eine TextBox bereits einen Großteil der Funktionalität, den Sie aus anderen Programmen kennen. So sind Funktionen enthalten, um mit der Zwischenablage zu kommunizieren, und sogar ein vordefiniertes Kontextmenü, sodass Sie diese Funktionen sofort aufrufen können. Damit stellt das TextBox-Steuerelement bereits ohne eine Zeile programmierten Code die wichtigsten Funktionen für einen kleinen Editor zur Verfügung.
CD
Selbstverständlich funktionieren auch die gewohnten Tastenkürzel zum Ausschneiden, Kopieren oder Einfügen. Abbildung 18.7 zeigt einen Screenshot mit den verschiedenen Darstellungsformen einer Textbox. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\TextboxExample.
Sandini Bib
Steuerelemente für Text
529
Abbildung 18.7: Verschiedene Gestaltungsvarianten des TextBox-Steuerelements
Die Anzeige des Passwortfelds wird über die Eigenschaft PasswordChar gesteuert. In dieser Eigenschaft können Sie ein beliebiges Zeichen festlegen, das dann an Stelle des eingegebenen Texts verwendet wird. Über die Eigenschaft UseSystemPasswordChar können Sie auch den ausgefüllten Kreis, der von Windows selbst als Passwort-Zeichen verwendet wird, verwenden. Über die Eigenschaft MultiLine stellen Sie die mehrzeilige Anzeige ein. Die dargestellten Zeilen werden dann in der Eigenschaft Lines gespeichert, bei der es sich um ein stringArray handelt. Auf diese Eigenschaft kann nur lesend zugegriffen werden. Die Umstellung auf die mehrzeilige Darstellung führt auch dazu, dass das Steuerelement jetzt die Taste (¢) »kennt«, d.h. es wird ein Zeilenwechsel durchgeführt. In der einzeiligen Variante piept der Rechner bei (¢). Der Zeilenumbruch erfolgt automatisch. Falls Sie diesen automatischen Zeilenumbruch nicht wünschen, stellen Sie die Eigenschaft WordWrap auf false. Die Eigenschaft Lines speichert auch jede dargestellte Zeile in einem neuen Wert, d.h. der Zeilenumbruch ist nicht nur kosmetischer Natur, sondern hat auch intern Auswirkungen. Falls Sie also einen Text speichern wollen, bei dem eine neue Zeile wirklich nur dort beginnen soll, wo der Anwender die Taste (¢) betätigt hat, müssen Sie diese Auswertung selbst vornehmen. Das kennen Sie möglicherweise von HTML-Editoren, bei denen der automatische Zeilenumbruch keine Auswirkungen auf den wirklichen Zeilenumbruch hat, sondern lediglich der besseren Lesbarkeit dient. Die Eigenschaft TextAlign gibt die Ausrichtung des Texts innerhalb des Steuerelements an. Mit der Eigenschaft ScrollBars können Sie die Anzeige der Bildlaufleisten steuern und BorderStyle steht für die Art der Umrandung.
Texteingabe In der Standardeinstellung ist es selbstverständlich möglich, Text einzugeben bzw. enthaltenen Text zu ändern. Falls Sie das Steuerelement nur zur Anzeige verwenden wollen,
Sandini Bib
530
18 Standard-Steuerelemente
können Sie die Eigenschaft Readonly auf true setzen. Das hat allerdings zur Folge, dass das Steuerelement jetzt in einer anderen Farbe angezeigt wird (eben in der Standardfarbe für nicht-aktivierte Elemente). Wollen Sie das gewohnte Aussehen beibehalten, können Sie einen kleinen Trick anwenden. Wenn das Steuerelement den Fokus erhält, wird das Ereignis Enter ausgelöst. Fügen Sie in diesem Ereignis einfach folgende Codezeile ein: private void textBox1_Enter(object sender, System.EventArgs e) { this.ProcessTabKey(true); }
Die Methode ProcessTabKey() der Klasse Form bewirkt das Gleiche wie ein Druck auf die Taste (Æ), das nächste Steuerelement wird ausgewählt. Natürlich funktioniert dieses Vorgehen nur dann, wenn es sich nicht um eine Multiline-Textbox handelt, die Tabstopps akzeptiert. Über die Eigenschaft CharacterCasing können Sie weiterhin erreichen, dass der eingegebene Text ausschließlich in Groß- oder Kleinbuchstaben angezeigt wird. Normalerweise ignoriert das TextBox-Steuerelement die Tasten (Æ) und (¢), weil damit üblicherweise ein Fokuswechsel zwischen den Steuerelementen möglich ist. Wenn Sie diese Zeichen direkt eingeben möchten, verändern Sie einfach die Eigenschaften AcceptsReturn und AcceptsTab. Wie angesprochen wird die (¢)-Taste lediglich in der einzeiligen Variante ignoriert. Anders als in den TextBox-Versionen anderer Programmiersprachen ist die Textlänge grundsätzlich nicht auf 32767 Zeichen limitiert, die Eigenschaft MaxLength allerdings trotzdem auf diesen Wert voreingestellt. Wenn Sie also umfangreichere Textdateien editieren möchten, müssen Sie MaxLength auf 0 setzen, was die Kontrolle der eingegebenen Zeichen abschaltet. Generell gilt MaxLength nur für Texteingaben per Tastatur; wenn Sie den Text per Programmcode ändern, dürfen Sie unabhängig von der Einstellung der Eigenschaft MaxLength beliebig lange Zeichenketten einfügen.
Programmierung allgemein Die Eigenschaft Text enthält stets den im Steuerelement angezeigten Text in Form eines einzigen Strings. In der mehrzeiligen Variante können Sie zum Auslesen auch auf die Eigenschaft Lines zurückgreifen. Hier kann jedoch kein Text zugewiesen werden. Die Länge des gesamten Textes können Sie über die Eigenschaft Length ermitteln, die Anzahl der angezeigten Zeilen über Lines.Length. Die aktuelle Cursorposition, gemessen in der Anzahl Zeichen ab Textbeginn, ermitteln Sie über die Eigenschaft SelectionStart. SelectedText enthält den aktuell markierten Text. In der Standardeinstellung wird automatisch der gesamte enthaltene Text einer TextBox markiert, wenn sie den Fokus erhält. Leider gibt es keine Eigenschaft, mit der dieses Verhalten geändert werden kann. Sie können jedoch einfach im Ereignis Enter folgenden Code einfügen:
Sandini Bib
Steuerelemente für Text
531
private void textBox1_Enter( object sender, System.EventArgs e ) { ( (TextBox)sender ).SelectionLength = 0; } SelectionLength steht für die Länge des markierten Texts. Wenn der gesamte Text markiert ist, entspricht dieser Wert also dem Wert der Eigenschaft Length. Indem Sie SelectionLength
auf 0 setzen, heben Sie die Auswahl auf. Die Ereignisbehandlungsroutine ist allgemein gehalten und funktioniert bei jeder TextBox (einfach dem Ereignis Enter zuweisen). Markieren können Sie einen Text auf mehrere Arten. Die erste Möglichkeit besteht darin, den Eigenschaften SelectionStart und SelectionLength entsprechende Werte zuzuweisen. Damit wird der entsprechende Text bereits markiert. Eine zweite Möglichkeit besteht in der Verwendung der Methode Select(). Dieser übergeben Sie einfach den Startindex, an dem die Markierung beginnen soll, und die Anzahl der zu markierenden Zeichen. Das Markieren des gesamten Inhalts ist noch einfacher – rufen Sie einfach die Methode SelectAll() auf. Leider ist dieses Verhalten nicht an ein Tastenkürzel gekoppelt (üblicherweise (Strg)+(A)). Sie können das aber leicht nachholen, indem Sie das Ereignis KeyDown
verwenden: private void textBox3_KeyDown( object sender, System.Windows.Forms.KeyEventArgs e ) { if ( e.KeyCode == Keys.A && e.Control ) ( (TextBox)sender ).SelectAll(); }
Die Eigenschaft e.KeyCode enthält den Code der aktuellen Taste. Dabei handelt es sich um einen Wert der Aufzählung Keys. e.Control ist true, wenn die Taste (Strg) gedrückt ist, entsprechend verhalten sich e.Alt für die (Alt)-Taste und e.Shift für die (ª)-Taste. In den Ereignisbehandlungsroutinen zu KeyDown und KeyUp können Sie diese Tasten auswerten. Über SelectionStart können Sie selbstverständlich auch die Cursorposition beeinflussen. SelectionStart bezeichnet immer die aktuelle Position des Cursors. Falls Sie jedoch hier eine Änderung vornehmen, sollten Sie danach die Methode ScrollToCaret() ausführen. Sie sorgt dafür, dass der Cursor wirklich im sichtbaren Bereich des Steuerelements ist (wenn nicht, wird entsprechend gescrollt).
Texte einfügen, kopieren und ausschneiden Um den Text in einem TextBox-Steuerelement zu ändern, weisen Sie einfach der Eigenschaft Text den neuen Wert zu. Wollen Sie weiteren Text hinzufügen, verwenden Sie die Methode AppendText(). Sie hängt den neuen Text hinten an. Wollen Sie den gesamten Text löschen, rufen Sie die Methode Clear() auf. Sie haben auch die Möglichkeit, Text an der Stelle einzufügen, an der sich der Cursor gerade befindet. Hierzu weisen Sie den neuen Text der Eigenschaft SelectedText zu. Beachten Sie bitte, dass evtl. markierter Text dabei gelöscht wird. Wenn kein Text markiert ist (also der Wert von SelectionLength 0 ist), wird der Text an der aktuellen Cursorposition eingefügt, ohne dass vorhandener Text beeinflusst wird.
Sandini Bib
532
18 Standard-Steuerelemente
Zeilenumbrüche können Sie auf mehrere Arten hinzufügen. Mit C# hat sich mittlerweile eingebürgert, die Zeichenkombination \r\n zu verwenden. Dabei steht \r für ein Carriage Return (Wagenrücklauf), \n für einen Zeilenvorschub. Eine weitere Möglichkeit ist die Verwendung von Environment.NewLine. Dieser Wert enthält immer das für das aktuelle System gültige Zeichen für den Zeilenumbruch. In weiser Voraussicht – weil das .NET Framework ja per Definitionem systemunabhängig konzipiert ist – wurde diese Eigenschaft eingeführt. Der folgende Code fügt ein Array aus Strings in ein Textfeld ein. Nach jedem String wird ein Zeilenumbruch erzeugt. private void InsertText( TextBox tb, string[] text ) { foreach ( string s in text ) tb.AppendText( s + Environment.NewLine ); }
Häufig wird auch die Zwischenablage verwendet, um Text einzufügen oder auszuschneiden. Wie schon angesprochen stellt das TextBox-Steuerelement bereits ein Kontextmenü zur Verfügung, das diese Funktionen beinhaltet (und auch entsprechende Funktionalität). Sie können diese Methoden aber auch selbst zur Laufzeit ausführen. f Cut() schneidet den markierten Text aus und legt ihn in der Zwischenablage ab. Ist kein Text markiert, geschieht nichts. f Copy() kopiert aktuell markierten Text in die Zwischenablage. Auch hier geschieht nichts, wenn kein Text markiert ist. f Paste() fügt Text aus der Zwischenablage an der aktuellen Cursorposition ein. Ist Text markiert, wird dieser gelöscht. Es wird nur reiner Text eingefügt, keine Formatierungen, und das geschieht natürlich auch nur dann, wenn die Zwischenablage Text enthält. Wenn Sie diese Methoden verwenden, werden Sie dies vermutlich mittels Menüpunkten tun. Sie sollten in diesem Fall auf eine intelligente Benutzerführung achten. Ausschneiden und Kopieren sollten nur dann verfügbar sein, wenn auch wirklich Text selektiert ist, das Einfügen nur dann, wenn die Zwischenablage Text enthält. Die Überprüfung ist noch einfacher als in der Vorgängerversion, denn die Klasse Clipboard hat Zuwachs bekommen, was die Anzahl der verfügbaren Methoden angeht. Für die gebräuchlichsten Datenformate existieren nun eigene Methoden zum Einstellen dieser Daten in das Clipboard, zum Auslesen und zum Kontrollieren, ob das betreffende Datenformat auch im Clipboard vorhanden ist. Im Falle eines Textes ist es sogar möglich, die Art des Textes (Html, UTF, Plain Text) zu überprüfen. Die entsprechende Methode heißt ContainsText(). Somit können Sie in einer Zeile kontrollieren, ob das Clipboard das gewünschte Format enthält und entsprechend die Menüpunkte aktivieren oder deaktivieren. private bool CheckTextPresent() { return Clipboard.ContainsText( TextDataFormat.Text ); }
Sandini Bib
Steuerelemente für Text
533
Ebenfalls vorhanden ist eine Rückgängig-Funktionalität. Die dazugehörige Methode heißt Undo() und macht die letzte Aktion bzw. die letzten Aktionen rückgängig. Über die Eigenschaft CanUndo können Sie kontrollieren, ob die Funktion überhaupt im Moment zur Verfügung steht, mit ClearUndo() löschen Sie den Rückgängig-Puffer. Diese Methode könnten Sie beispielsweise aufrufen, wenn die Datei gespeichert wird. Das Ereignis TextChanged wird immer dann aufgerufen, wenn der Text im Steuerelement geändert wird. Wenn Sie jedoch nur feststellen wollen, ob sich der Inhalt des Steuerelements geändert hat, ist es einfacher, die Eigenschaft Modified auszuwerten. Sie ist dann true, wenn eine Änderung vorgenommen wurde. Leider nicht immer – bei einer Zuweisung an die Eigenschaft Text bleibt Modified false. AppendText() und Zuweisungen an SelectedText ändern den Wert der Eigenschaft aber durchaus.
Texteingaben validieren Zum Kontrollieren der Texteingaben auf Korrektheit bietet sich das Ereignis Validating an. Es wird ausgelöst, wenn der Fokus das Steuerelement verlässt. Innerhalb der Ereignisbehandlungsroutine können Sie Ihre Kontrolle durchführen. Wenn sie fehlschlägt, müssen Sie lediglich die Eigenschaft Cancel des Parameters e auf true setzen, dann wird der Fokus wieder auf das Steuerelement zurückgesetzt. Das Ereignis Validating tritt nach dem Ereignis Leave auf.
ACHTUNG
Falls die Kontrolle erfolgreich ist, tritt danach das Ereignis Validated auf. In diesem Ereignis können Sie noch sichtbare Fehlerindikatoren entfernen, Berechnungen durchführen oder eben irgendwelche Funktionalitäten ausführen, die dann Sinn machen, wenn das Steuerelement bzw. dessen Inhalt korrekt validiert wurden. Oftmals ist es aber nicht erforderlich, dieses Ereignis zu verwenden. Das Ereignis Validating wird auch dann ausgelöst, wenn das Programm beendet wird. Daher bietet sich eine Fehlerkontrolle auf andere Art an. Das ErrorProviderSteuerelement ist weit besser geeignet. Ein Anwendungsbeispiel zu ErrorProvider finden Sie in Abschnitt 18.10.7 auf Seite 614.
AutoComplete Neu in .NET 2.0 ist das AutoComplete-Feature der Steuerelemente TextBox und ComboBox. Bisher musste eine solche Möglichkeit, die sicherlich allen aus der Adresszeile aller aktuellen Internet-Browser bekannt ist, manuell programmiert werden. Mit den neuen Controls hat sich das geändert, jetzt kann auch eine beliebige Liste oder eine aus dem System kommende Liste für die automatische Komplettierung bzw. Anzeige verfügbarer Elemente herangezogen werden. Die Eigenschaft AutoCompleteSource ist das Herzstück des Ganzen, hier wird festgelegt, welche Quelle für die automatische Komplettierung herangezogen wird. Die Eigenschaft ist vom Typ AutoCompleteSource, einem Enum mit den folgenden Einträgen:
Sandini Bib
534
18 Standard-Steuerelemente
f AutoCompleteSource.AllSystemSources: Alle Quellen des Systems, in diesem Fall das Dateisystem sowie die URLs aus dem Verlauf bzw. der MRU-Liste (MRU=Most Recently Used, kürzlich genutzte URLs) f AutoCompleteSource.AllUrl: Sämtliche URLs aus Verlauf und MRU-Liste f AutoCompleteSource.CustomSource: Die Werte kommen aus der eingebauten Liste, auf die Sie über die Eigenschaft AutoCompleteCustomSource zugreifen können. f AutoCompleteSource.FileSystem: Das Dateisystem ist die Quelle f AutoCompleteSource.FileSystemDirectories: Die Verzeichnisse des Dateisystems sind die Quelle. f AutoCompleteSource.HistoryList: Die Quelle besteht aus der Verlaufsliste des InternetBrowsers. f AutoCompleteSource.ListItems: Bei einer ComboBox gibt diese Einstellung an, dass die Elemente der ComboBox herangezogen werden sollen f AutoCompleteSource.None: Keine automatische Vervollständigung, der Standardwert f AutoCompleteSource.RecentlyUsedList: Die Elemente kommen aus der Liste der zuletzt angewählten URLs. Die zweite wichtige Eigenschaft in diesem Zusammenhang ist AutoCompleteMode vom Typ AutoCompleteMode. Hiermit wird das Verhalten gesteuert. Die möglichen Werte und ihr Verhalten sind die folgenden: f AutoCompleteMode.Append: Hierdurch wird der passendste Ausdruck aus der Liste vorgeschlagen, an die bestehende Eingabe angehängt aber auch hinterlegt, sodass ein direktes Weiterschreiben möglich ist. f AutoCompleteMode.Suggest: Durch diese Einstellung öffnet sich eine Liste, die die besten möglichen Vorschläge aus der Autocomplete-Liste enthält. f AutoCompleteMode.SuggestAppend: Verbindet die beiden Einstellungen AutoCompleteMode. Append und AutoCompleteMode.Suggest. f None: Die automatische Komplettierung wird deaktiviert.
AutoComplete-Beispiel Mit einem kleinen Beispielprogramm kann die Funktionsweise des AutoCompleteFeatures leicht demonstriert werden. Das Beispiel besteht aus einer TextBox, bei der die automatische Vervollständigung eingeschaltet ist. Über eine ComboBox können Sie die gewünschte Quelle, über drei RadioButtons die gewünschte Vervollständigungsart einstellen. Wie in Abbildung 18.8 zu sehen ist, wird auch bei der Textbox eine Vervollständigungsliste eingeblendet, sobald genügend auswertbare Zeichen eingegeben wurden.
Sandini Bib
CD
Steuerelemente für Text
535
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\AutoCompleteExample.
Abbildung 18.8: Automatische Vervollständigung in einer TextBox
18.4.4
Das Steuerelement MaskedTextBox
Die MaskedTextBox ist, auch auf vielfachen Wunsch seitens der Community, neu in das Sortiment der Standard-Steuerelemente aufgenommen worden. Es handelt sich vom Grundsatz her um eine TextBox, bei der es jedoch möglich ist, eine Eingabemaske vorzugeben, die für den Benutzer verbindlich ist. Somit lassen sich Eingaben gut steuern. Die grundsätzliche Funktionsweise entspricht der des TextBox-Steuerelements. Hinzu kommen die folgenden, auf die Eingabemaske bezogenen Eigenschaften: f Mask legt die Maske fest. Eine detailliertere Beschreibung der unterschiedlichen Möglichkeiten finden Sie im nächsten Abschnitt. f Über PromptChar können Sie ein Zeichen festlegen, das dort erscheint, wo eine Benutzereingabe erwartet wird. f AllowPromptAsInput legt fest, ob das erwähnte Prompt-Zeichen auch als Eingabewert gültig ist. Das ist unter Umständen dann sinnvoll, wenn der Benutzer nichts von einer Eingabemaske sehen soll (PromptChar wäre dann ein Leerzeichen), Leerzeichen aber erlaubt sein sollen. f Über die Eigenschaft HidePromptOnLeave legen Sie fest, ob das Prompt-Zeichen auch dann erscheinen soll, wenn das Steuerelement nicht den Fokus hat.
Sandini Bib
536
18 Standard-Steuerelemente
f InsertKeyMode legt die Art der Eingabe fest, entweder Einfügen oder Überschreiben. Die Eigenschaft ist vom Typ InsertKeyMode. Üblicherweise wird in der Hauptsache InsertKeyMode.Default verwendet werden, womit die Einstellungen Gültigkeit besitzen, die auch auf der Tastatur sichtbar sind. InsertKeyMode.Insert legt fest, dass neue Zeichen eingefügt werden, InsertKeyMode.Overwrite legt fest, dass bestehender Inhalt überschrieben wird. f Über RejectInputOnFirstFailure können Sie festlegen, wie sich die MaskedTextBox verhält, wenn ein ungültiges Zeichen erkannt wird. Ist die Eigenschaft auf true eingestellt, wird die Eingabe beim ersten ungültigen Zeichen zurückgewiesen und das Ereignis MaskInputRejected ausgelöst. Ist sie auf false eingestellt, wird die Eingabe ebenfalls zurückgewiesen, das Steuerelement kontrolliert aber alle Zeichen und löst für jedes ungültige Zeichen ein MaskInputRejected-Ereignis aus. f ResetOnPrompt legt das Verhalten bei der Eingabe des Prompt-Zeichens fest. Das Verhalten kann ebenfalls über die Eigenschaft AllowPromptAsInput gesteuert werden. In der Standardeinstellung true wird das eingegebene Zeichen akzeptiert (bei AllowPromptAsInput == true) und der Cursor auf das nächste einzugebende Zeichen gesetzt. ResetOnSpace funktioniert genauso, allerdings bei der Eingabe eines Leerzeichens. f SkipLiterals bewirkt, wenn true, dass literale Werte innerhalb der Eingabemaske übersprungen werden f Über TextMaskFormat können Sie festlegen, ob Literale sowie das Prompt-Zeichen in die Text-Eigenschaft des Steuerelements übernommen werden. Der Wert ist vom Typ MaskFormat, einem Enum.
Einstellen der Eingabemaske Für die Eingabemaske existieren einige wenige vorgegebene Formate (beispielsweise Datum oder Uhrzeit), in vielen Fällen vor allem bei spezielleren Formaten, müssen Sie allerdings selbst Hand anlegen. Die folgende Tabelle liefert einen Überblick über die möglichen Eingaben. Maskenelement
Beschreibung
0
Benötigte Ziffer. Hier muss eine Ziffer (0-9) eingegeben werden.
9
Optionale Ziffer oder Leerzeichen
#
Optionale Ziffer oder Leerzeichen. Wenn die Position leer gelassen wird, wird sie als Leerzeichen zurückgegeben.
L
Benötigter Buchstabe (A-Z und a-z)
?
Optionaler Buchstabe (A-Z und a-z)
&
Ein benötigtes Zeichen. Wenn die Eigenschaft AsciiOnly auf true eingestellt ist, verhält sich diese Eingabe wie L.
Sandini Bib
Steuerelemente für Text
537
Maskenelement
Beschreibung
C
Ein optionales Zeichen. Auch hier wird die Einstellung von AsciiOnly berücksichtigt.
A
Benötigtes alphanumerisches Zeichen, keine Ziffern. Auch hier wirkt AsciiOnly, A verhält sich dann wie L.
a
Optionales alphanumerisches Zeichen, ansonsten wie bei A.
.
Dezimalplatzhalter, die aktuell eingestellten Ländereinstellungen warden berücksichtigt, die Einstellung kann aber über die Eigenschaft Culture geändert werden.
,
Tausenderplatzhalter, ebenfalls beeinflussbar über Culture.
:
Trennzeichen für Zeitangaben, beeinflussbar durch die Eigenschaft Culture
/
Trennzeichen für das Datum, beeinflussbar durch die Eigenschaft Culture
$
Das Währungssysmbol entsprechend der Eigenschaft Culture
<
Dieses Zeichen bewirkt, dass alle nachfolgenden Zeichen klein eingegeben werden.
>
Dieses Zeichen bewirkt, dass alle nachfolgenden Zeichen großgeschrieben werden (versal).
|
Schaltet ein vorangegangenes <- oder >-Zeichen wieder aus.
\
Das Escape-Zeichen, das ebenso wie bei Strings wirkt. Sollten Sie beispielsweise einen Backslash als Literal eingeben wollen, müssen Sie \\ schreiben.
Alle Zeichen, die nicht in der Tabelle angegeben wurden, werden als Literale angesehen und dementsprechend angezeigt.
Beispiele für eine Eingabemaske Leider sind die vorgegebenen Eingabemasken recht begrenzt. Hier einige Beispiele für verschiedene Eingabemasken. Für einige muss allerdings auch noch ein entsprechendes Verhalten der Textbox implementiert werden, beispielsweise beim Drücken der (Æ)-Taste. f IP-Adresse: @"099\.099\.099\.099" f Telefonnummer: @"(+4\9) 0000 00099999" f vollständiges Datum: @"09/09/0000"
18.4.5
Das Steuerelement RichTextBox
Das RichTextBox-Steuerelement unterscheidet sich vom gewöhnlichen TextBox-Steuerelement dadurch, dass einzelne Textpassagen in unterschiedlichen Textformatierungen (Schriftart, Schriftgröße, Absatzformate etc.) dargestellt werden können. Hierzu beherrscht dieses Steuerelement eine Teilmenge des so genannten Rich Text Formats. In diesem Format
Sandini Bib
538
18 Standard-Steuerelemente
werden Formatierungsinformationen ebenfalls als Text ausgedrückt (aber eben als Formatierung angezeigt). Das Rich Text Format wird von nahezu allen gängigen Textverarbeitungen unterstützt, sodass Ihre Texte damit auch austauschbar sind. Im Gegensatz zum TextBox-Steuerelement kann eine RichTextBox auch Texte laden und speichern. Eine Methode zum direkten Ausdrucken fehlt leider, hier muss auf die Druckfunktionen des .NET Frameworks zurückgegriffen werden. Abbildung 18.9 zeigt eine RichTextBox im Einsatz.
Abbildung 18.9: Verschiedene Textformate in einem RichTextBox-Steuerelement
Leider bietet das RichTextBox-Steuerelement kein automatisches Kontextmenü wie die TextBox. Die Methoden Cut(), Copy(), Paste() und Undo() sind aber ebenso vorhanden wie die Eigenschaft Modified zum Kontrollieren, ob Text verändert wurde.
Textzugriff Im Regelfall werden Sie mit RTF-Codes nichts zu tun haben. Sie greifen über die Eigenschaft Text auf den normalen Text (ohne Formatinformationen) zu bzw. verwenden die Eigenschaft SelectedText, um den aktuell markierten Text zu lesen oder zu verändern. Der Zugriff auf die aktuelle Cursorposition erfolgt wie beim TextBox-Steuerelement über die Eigenschaften SelectionStart und SelectionLength. Die Cursorposition ergibt sich aus SelectionStart+SelectionLength. Für manche Anwendungen kann es notwendig sein, direkt auf die Formatcodes zuzugreifen. In diesem Fall verwenden Sie statt Text bzw. SelectedText die Eigenschaften RTF bzw. SelectedRTF, die den Text im RTF-spezifischen Format enthalten. Wenn Sie Formatinformationen direkt verändern möchten, ist allerdings eine ausgezeichnete Kenntnis der RTFCodes und ihrer Bedeutung erforderlich. Diese hier erschöpfend zu erläutern würde den Rahmen des Buchs definitiv sprengen, daher verweisen wir Sie hier auf die Online-Hilfe (suchen Sie nach RTF specification).
Sandini Bib
Steuerelemente für Text
539
Formatierung von Text Die Formatierung von Text erfolgt generell in zwei Schritten. Zuerst muss der zu formatierende Text markiert werden. Das kann sowohl vom Anwender des Programms als auch per Programmcode (über SelectionStart und SelectionLength) erfolgen. Anschließend können die Formatierungsattribute über verschiedene Eigenschaften ausgelesen werden. Einige dieser Eigenschaften sind die folgenden: f SelectionAlignment steht für die Ausrichtung des markierten Texts. Mögliche Werte sind HorizontalAlignment.Left, HorizontalAlignment.Center und HorizontalAlignment.Right. f SelectionBullet steht für eine Aufzählung. Dabei handelt es sich um einen booleschen Wert; ist er true, werden Aufzählungspunkte angezeigt, ansonsten nicht. f SelectionColor legt die Farbe des markierten Texts fest. f SelectionFont legt die Schriftart des markierten Texts fest. f SelectionIndent legt den Abstand zwischen linkem Rand des Steuerelements und dem Text fest. Die Festlegung erfolgt in Pixeln. Wie auch bei Einstellungen für den Zeichensatz anderer Steuerelemente muss auch hier immer ein neues Font-Objekt zugewiesen werden, womit der gesamte markierte Text mit dem ausgewählten Zeichensatz (inkl. Größe, Formatierung) versehen wird. Wenn der markierte Text in unterschiedlichen Schriftarten formatiert ist, liefert die Eigenschaft SelectionFont allerdings keine korrekte Angabe (was verständlich ist, da nur ein Font-Objekt zurückgeliefert werden kann). Verlassen Sie sich also nicht auf diese Angaben.
Text laden und speichern Das RichTextBox-Steuerelement bietet eine sehr einfache Möglichkeit, Text in verschiedenen Formaten zu laden oder zu speichern. Mit verschiedenen Formaten ist hierbei gemeint, dass sowohl RTF-formatierter Text als auch reiner ASCII-Text oder Unicode-Text geladen und gespeichert werden kann. Die Unterscheidung ist notwendig, weil ansonsten auch zu ASCII-Text Formatierungsinformationen gespeichert würden, was bei Anzeige in einem anderen Texteditor zu Verwirrung (und unlesbarem Text) führen würde. Die Methoden zum Laden und Speichern heißen LoadFile() und SaveFile(). Standardmäßig wird lediglich ein Stream-Objekt oder ein Dateiname übergeben, der Text wird dann im RTF-Format geladen oder gespeichert. Wollen Sie ein anderes Format verwenden, müssen Sie zusätzlich einen Parameter vom Typ RichTextBoxStreamType angeben. Dabei sind folgende Angaben möglich: f RichTextBoxStreamType.RichText lädt bzw. speichert den Text im RTF-Format. f RichTextBoxStreamType.PlainText lädt bzw. speichert Text ohne Formatierung. f RichTextBoxStreamType.UnicodePlainText lädt bzw. speichert Text im Format UTF-16. Zum Speichern haben Sie zwei weitere Auswahlmöglichkeiten:
Sandini Bib
540
18 Standard-Steuerelemente
f RichTextBoxStreamType.RichNoOleObjs speichert den Text als RTF, aber ohne eingebettete OLE-Objekte. f RichTextBoxStreamType.TextTextOleObjs speichert den Text im Ansi-Format, eingebettete Objekte werden als Text dargestellt falls möglich.
Text suchen Die RichTextBox bietet auch eine relativ elegante Möglichkeit, Text zu suchen. Dazu dient die Methode Find(). Sie erwartet als Übergabeparameter ein Suchmuster und liefert die erste Position, an der dieses Muster gefunden wurde. Optional haben Sie auch die Möglichkeit, nur ab einer bestimmten Position zu suchen oder sogar die Suche rückwärts durchzuführen. Find() ist wie so oft mehrfach überladen. Sie können sowohl nach einem String als auch
nach einem Array aus char-Werten suchen und falls gewünscht Suchoptionen in Form eines Parameters vom Typ RichTextBoxFinds angeben. Die enthaltenen Optionen entsprechen denen, die Sie von einem herkömmlichen Suchen/Ersetzen-Dialog kennen, und können auch miteinander kombiniert werden. f RichTextBoxFinds.MatchCase dient zum Suchen unter Berücksichtigung der Groß-/Kleinschreibung. f RichTextBoxFinds.NoHighlight bedeutet, dass der gefundene Text nicht markiert wird. f RichTextBoxFinds.Reverse ermöglicht das Suchen von hinten nach vorne. f RichTextBoxFinds.WholeWord dient zum Suchen nach ganzen Wörtern. f RichTextBoxFinds.None dient zum Suchen aller Instanzen des Suchworts ohne Berücksichtigung der Groß-/Kleinschreibung. Die Kombination dieses Werts mit mit RichTextBoxFinds.MatchCase ist daher nicht sinnvoll. Eine besondere Methode zum Ersetzen von Text gibt es nicht. Normalerweise wird der gefundene Text automatisch markiert (falls nicht RichTextBoxFinds.NoHighlight angegeben ist) und kann durch Zuweisung an SelectedText überschrieben werden.
18.5
Steuerelemente für Grafik
Das .NET Framework stellt zwei grafische Steuerelemente zur Verfügung, von denen allerdings nur eines – die PictureBox – sichtbar ist. Das zweite, die ImageList, dient hauptsächlich dazu, Symbole für Buttons oder andere Anzeigeelemente anzunehmen.
18.5.1
Das Steuerelement PictureBox
Das Steuerelement PictureBox dient zur Anzeige von Grafiken. Die Zuweisung einer Grafik an das Steuerelement geschieht über die Eigenschaft Image, die Ausrichtung der Grafik über die Eigenschaft ImageAlign.
Sandini Bib
Steuerelemente für Grafik
541
HINWEIS
Weiterhin ist es möglich, das Paint-Ereignis zu nutzen, um zusätzlich zur enthaltenen Grafik auf dem Steuerelement zu zeichnen. Dieses Ereignis tritt immer dann auf, wenn der Inhalt der PictureBox neu gezeichnet wird. Viele der in diesem Kapitel vorgestellten Steuerelemente eignen sich ebenfalls als Grafik-Container. Die PictureBox dient lediglich als ein weiteres Anzeigeelement und besitzt ansonsten keine besonderen Eigenschaften.
Weitere Informationen zur PictureBox und zum Arbeiten mit Grafiken finden Sie in Kapitel 21 ab Seite 691. Dort sind auch detaillierte Beispiele vorhanden.
18.5.2
Das Steuerelement ImageList
Die ImageList dient zum Speichern mehrerer Grafiken gleicher Größe und Farbtiefe. Üblicherweise werden diese bereits zur Entwurfszeit geladen und dann den Steuerelementen zugewiesen, auf denen sie angezeigt werden sollen. Das funktioniert mit allen Steuerelementen, die die Eigenschaft ImageList (und dann auch eine Eigenschaft ImageIndex) zur Verfügung stellen. Über die Eigenschaft TransparentColor der ImageList können Sie auch eine Farbe angeben, die beim Anzeigen der Grafik transparent dargestellt werden soll. Wollen Sie zur Laufzeit auf ein Bild der ImageList zugreifen, können Sie dies über die Eigenschaft Images tun. Der Index des gewünschten Bilds ist anzugeben. Sie können auch Bilder zur Laufzeit hinzufügen oder entfernen. Die Methode Add() der Eigenschaft Images ermöglicht es, ein Bild hinzuzufügen, Remove() bzw. RemoveAt() entfernen eine Grafik aus der Liste. Leider besitzt die ImageList einige zum Teil gravierende Nachteile: f Alle Grafiken einer ImageList müssen die gleiche Größe aufweisen, die über die Eigenschaft ImageSize des ImageList-Steuerelements vorgegeben ist. Dies ist jedoch relativ leicht zu verschmerzen. f Alle Grafiken einer ImageList müssen die gleiche Farbtiefe aufweisen. Die Farbtiefe wird durch die Eigenschaft ColorDepth vorgegeben, der Standardwert ist 8 Bit, was 256 Farben entspricht. Das ist zu verschmerzen, wäre aber sicherlich auch anders zu lösen gewesen. f Die Farbe, die transparent angezeigt werden soll, muss global für alle Grafiken innerhalb des Steuerelements angegeben werden. Das ist nicht nur unverständlich, sondern auch enorm nervend – denn jetzt müssen Sie wirklich peinlich genau darauf achten, die Farben korrekt einzustellen. Bei Bitmaps mit 65k Farben ist das recht schwierig, zumal Sie auch möglicherweise vorhandene Symbolgrafiken mit anderer Hintergrundfarbe nicht verwenden können. Hier ist Nacharbeit seitens Microsoft angesagt. Üblich ist es beispielsweise, die Farbe des ersten Pixels oben links (oder unten links) als transparente Farbe anzunehmen und die Bitmap entsprechend einzustellen. Noch ärgerlicher ist es, dass die Klasse Bitmap dafür sogar eine Methode bereitstellt (MakeTransparent()), die Implementierung also denkbar einfach wäre.
Sandini Bib
542
18 Standard-Steuerelemente
f Beim Entfernen einer Grafik ändern sich zudem alle Indizes, womit auch die Zuordnungen bei den verschiedenen Steuerelementen nicht mehr stimmen. Das entspricht dem Verhalten in anderen Programmiersprachen, könnte aber geändert werden.
18.6
Listen
Das .NET Framework stellt mehrere Steuerelemente zur Darstellung von Listen zur Verfügung. Sie unterscheiden sich allerdings in Anwendung und Programmierung. Dieser Abschnitt stellt die Listenelemente und ihre Programmierung vor.
VERWEIS
Neben den hier vorgestellten Listen-Steuerelementen gibt es einige weitere, die eigentlich in diese Kategorie passen würden, aber an anderer Stelle vorgestellt werden: f Das DateTimePicker-Steuerelement (siehe Abschnitt 18.7.2) gleicht im Aussehen einer ComboBox, ermöglicht aber die komfortable Auswahl eines Datums oder einer Uhrzeit. f Das DomainUpDown-Steuerelement (siehe Abschnitt 18.8.5) hat ebenfalls große Ähnlichkeit mit der ComboBox, die Auswahl des Listenelements geschieht aber über Pfeil-Buttons statt über eine Liste.
18.6.1
ListBox
Das Steuerelement ListBox wird sehr häufig verwendet und ist in fast jedem WindowsProgramm an irgendeiner Stelle zu finden. Listboxen können Werte einspaltig oder mehrspaltig anzeigen und, wie andere Steuerelemente auch, per DataBinding mit verschiedenen Datenquellen verbunden werden.
Aussehen Eine Listbox besitzt die von anderen Steuerelementen bekannten Eigenschaften FlatStyle oder BorderStyle, mit denen das Aussehen grundsätzlich eingestellt werden kann. Aber auch die Anzeige der Elemente kann beeinflusst werden. Die Eigenschaft MultiColumn gibt an, ob die Listeneinträge in einspaltiger Form oder mehrspaltig angezeigt werden. Die gewünschte Spaltenbreite kann über die Eigenschaft ColumnWidth eingestellt werden. Dadurch ändert sich auch das Verhalten der Scrollbars. Während bei einer einspaltigen ListBox lediglich eine vertikale Scrollbar benötigt wird, wird bei mehrspaltiger Anzeige zu-
sätzlich eine horizontale ScrollBar angezeigt. Die Eigenschaften Width und Height dienen auch hier der Einstellung der Breite und Höhe des Steuerelements. Eine Besonderheit ergibt sich durch die Eigenschaft IntegralHeight, die standardmäßig auf true eingestellt ist. Durch diese Eigenschaft kann die Höhe der Listbox nur auf einen Wert eingestellt werden, der ein Vielfaches der Höhe der enthaltenen Ele-
Sandini Bib
Listen
543
mente ist. Oder mit anderen Worten: Die Höhe wird immer so eingestellt, dass stets komplette Elemente sichtbar sind. Zur Entwurfszeit führt das mitunter zu Verwirrung, weil man im ersten Moment denkt, die Höhe ließe sich nicht pixelgenau einstellen. Setzen Sie in diesem Fall IntegralHeight auf false. Die Anzeige der Elemente folgt immer dem gleichen Schema – es wird die Methode ToString() verwendet. Wenn Sie der ListBox also Instanzen eigener Klassen hinzufügen, stellen Sie in der eigenen Klasse eine Methode ToString() zur Verfügung und programmieren Sie darin den Wert, der angezeigt werden soll. Ebenfalls interessant im Hinblick auf die Darstellung der Elemente ist die Eigenschaft TopIndex. Sie enthält immer den Index des Elements, das an oberster Stelle der ListBox angezeigt wird. Indem Sie diesen Wert ändern, können Sie programmtechnisch durch die ListBox scrollen.
Listenelemente verwalten Die Einträge der Listbox sind in der Eigenschaft Items gespeichert. Die Elemente dieser Liste sind vom Typ object, sodass Sie jeden beliebigen Datentyp hinzufügen können. Der Zugriff geschieht über den Index des Elements. Die Liste selbst ist vom Typ ListBox. ObjectCollection, einer geschachtelten Listenklasse. Sie implementiert die Interfaces IList, ICollection und IEnumerable. Damit ist es also möglich, alle enthaltenen Elemente mithilfe einer foreach-Schleife zu durchlaufen. Die Methode Clear() der Eigenschaft Items löscht die Liste der enthaltenen Elemente. Die Methode Add() fügt ein Element am Ende der Liste hinzu, die Methode Insert() fügt ein Element an einer beliebigen Stelle ein. AddRange() ermöglicht es, auch mehrere Elemente auf einen Schlag hinzuzufügen, z.B. mittels eines object-Arrays. Wenn Sie zahlreiche Elemente hinzufügen, kann es sinnvoll sein, die Aktualisierung der ListBox-Anzeige hinauszuzögern, bis alle Elemente enthalten sind. Eine Aktualisierung kostet Zeit, und sie wird im Regelfall jedes mal durchgeführt, wenn ein Element hinzugefügt wird. Durch die Methoden BeginUpdate() und EndUpdate() können Sie die ständige Aktualisierung verhindern.
HINWEIS
Wollen Sie eine Aktualisierung erzwingen, rufen Sie Invalidate() oder Update() auf (bei rechenintensiven Methoden gleich dahinter noch Application.DoEvents(), damit Windows seine Nachrichtenwarteschlangen abarbeiten kann – mit Update() senden Sie nämlich nur eine Nachricht, die das Neuzeichnen des Elements erzwingt). Alle Elemente der ListBox sind vom Typ object. Wenn Sie also auf ein Element zugreifen wollen, müssen Sie dieses zunächst in den korrekten Datentyp casten.
Der folgende Code fügt Elemente einer Listbox namens lstElements hinzu. Dabei wird aus Demonstrationsgründen eine foreach-Schleife und die Methode Add() verwendet. Falls die Anzahl der Elemente größer ist als 500, werden BeginUpdate() und EndUpdate() verwendet.
Sandini Bib
544
18 Standard-Steuerelemente
CD
Der Cursor wird während dieser Zeit als Sanduhr dargestellt, wobei sich this natürlich auf das Formular bezieht, in dem die ListBox enthalten ist. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\SimpleListBox.
private void AddElements( object[] obj ) { // Zu viele Elemente - BeginUpdate()/EndUpdate() verwenden bool tooMany = ( obj.Length > 500 ); if ( tooMany ) { lstElements.BeginUpdate(); this.Cursor = Cursors.WaitCursor; } // Hinzufügen foreach ( object o in obj ) lstElements.Items.Add( o ); if ( tooMany ) { this.Cursor = Cursors.Default; lstElements.EndUpdate(); } }
Abbildung 18.10 zeigt einen Screenshot des Programms zur Laufzeit. Sie können gerne einen Geschwindigkeitsvergleich mit und ohne BeginUpdate()/EndUpdate() durchführen (setzen Sie die Variable tooMany einfach fest auf false).
Abbildung 18.10: Ein Beispiel für eine ListBox
Sandini Bib
Listen
545
Sortieren von ListBox-Elementen Über die Eigenschaft Sorted können Sie die Einträge der ListBox auch sortieren. Das hat auch Auswirkungen auf die Methode Add(). Wenn Sie einer sortierten ListBox Elemente hinzufügen, werden diese nicht am Ende eingefügt, sondern automatisch an die richtige Stelle verschoben. Die Sortierung erfolgt nach dem Alphabet, kann aber auch geändert werden, wenn auch nicht so komfortabel wie bei anderen Listen. ListBox.ObjectCollection implementiert nicht das Interface IComparer, damit ist es auch nicht möglich, in eigenen Objekten die Methode Compare() zum Vergleichen zu verwenden. Sie können jedoch den Umweg über die ArrayList gehen. Die Methode ArrayList.Adapter()
(siehe auch Seite 584) ermöglicht es, eine Verwaltungsschicht über eine beliebige Liste zu legen. Damit sind Sie in der Lage, die Methoden von ArrayList auf jede beliebige Liste anzuwenden, die das Interface IList implementiert. Und das ist bei ListBox.ObjectCollection der Fall.
CD
Das folgende Beispiel arbeitet mit Zahlen, sortiert diese jedoch nach der Zahlengröße. Über die Eigenschaft Sorted würde eine falsche Reihenfolge angegeben, da hierbei nach dem Alphabet sortiert wird (10 käme vor 2, da die 1 im Alphabet vor der 2 steht). Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\ListBoxCustomSort.
Zunächst deklarieren wir eine Klasse, die die Sortierung übernimmt. Diese Klasse muss das Interface IComparer aus dem Namespace System.Collections implementieren. Das Interface erzwingt nur die Implementierung einer einzigen Methode namens Compare(): public class IntSorter : IComparer { public int Compare( object x, object y ) { //Integer-Vergleich int a = (int)x; int b = (int)y; return a.CompareTo( b ); } public IntSorter() { } }
In der entsprechenden Methode zum Sortieren im Hauptformular der Anwendung (im Beispiel wurde hierzu das Click-Ereignis eines Buttons verwendet) legen wir einen Wrapper um die Liste und sortieren mit der Methode Sort() der ArrayList:
Sandini Bib
546
18 Standard-Steuerelemente
private void BtnCustom_Click( object sender, System.EventArgs e ) { // Sorted auf false lstNumbers.Sorted = false; // Wrapper erzeugen ArrayList arl = ArrayList.Adapter( lstNumbers.Items ); // Sortieren arl.Sort( new IntSorter() ); }
Das Ergebnis wird sofort sichtbar. Während das Setzen der Eigenschaft Sorted auf true nicht die korrekte Sortierung erzielt, können wir auf diese Weise wirklich Zahlen in einer ListBox sortieren. Abbildung 18.11 zeigt einen Screenshot mit korrekter Sortierung.
Abbildung 18.11: Das Programm zum Sortieren von int-Werten mittels ArrayList-Wrapper
Leider ist es nicht auf einfache Art möglich, auch mithilfe einer generischen Lösung zu sortieren, was unter Umständen performanter wäre. Der gebildete Wrapper akzeptiert lediglich das Interface IComparer, nicht den generischen Gegenpart IComparer. Zwar könnten beide innerhalb einer Klasse implementiert werden, ein Casting wäre aber dennoch notwendig, sodass diese Vorgehensweise nichts bringt.
Listenelemente auswählen In der Standardeinstellung des ListBox-Steuerelements ist lediglich die Auswahl eines einzigen Elements möglich. Die Eigenschaft SelectionMode des gleichnamigen Typs ermöglicht aber auch eine Auswahl mehrerer Listenelemente. Die Standardeinstellung ist SelectionMode.One. Die Einstellungen SelectionMode.MultiSimple und SelectionMode.MultiExtended erlauben eine Mehrfachauswahl:
Sandini Bib
Listen
547
f In der Einstellung SelectionMode.MultiSimple können mehrere Elemente ausgewählt werden, indem sie einfach angeklickt werden. Die Auswahl wird bei erneutem Klicken wieder aufgehoben. Ebenso ist es möglich, Elemente über die Leertaste auszuwählen. f Die Einstellung SelectionMode.MultiExtended entspricht der aus Windows-Applikationen bekannten Auswahlmöglichkeit. Hierbei werden die (ª)- bzw. (Strg)-Taste verwendet, um mehrere Elemente auszuwählen.
Listenauswahl auswerten Bei jeder Veränderung der Listenauswahl tritt das Ereignis SelectedIndexChanged auf. Obwohl sich aber die Eigenschaft Text zwangsläufig dadurch auch ändert, tritt das TextChanged-Ereignis nicht auf. Die Ereignisbehandlungsroutine zu SelectedIndexChanged besitzt keinen besonderen Parameter zur Auswertung. Diese geschieht vielmehr über die zahlreichen Eigenschaften der ListBox. Bei der Auswertung muss beachtet werden, ob das Steuerelement eine Mehrfachauswahl ermöglicht oder nicht. Bei einer Mehrfachauswahl müssen andere Eigenschaften berücksichtigt werden. In diesem Fall existieren zwei verschiedene Listen innerhalb des Steuerelements, nämlich einmal die Liste aller Elemente und einmal eine Liste nur der ausgewählten Elemente. Die folgenden Tabellen fassen die wichtigsten Eigenschaften für beide Fälle zusammen. Auswertung bei Mehrfachauswahl GetSelected( int n )
stellt fest, ob das Listenelement mit dem Index n ausgewählt ist.
SelectedIndices
verweist auf ein Objekt der Klasse ListBox.SelectedIndexCollection. Diese Klasse realisiert die Schnittstellen ICollection, IEnumerable und IList. Das Objekt enthält die Indizes aller ausgewählten Listeneinträge. SelectedIndices[n] liefert den Index des n-ten ausgewählten Listeneintrags. SelectedIndices.Count liefert die Anzahl der ausgewählten Listeneinträge. SelectedIndices.Contains(n) testet, ob das Objekt mit dem Index n in der
Auswahl enthalten ist. SelectedItems
verweist auf ein Objekt der Klasse ListBox.SelectedObjectCollection, die sich durch die gleichen Eigenschaften wie SelectedIndexCollection auszeichnet. Allerdings enthält diese Liste nicht die Indizes der Listeneinträge, sondern die wirklichen Objekte. Auch diese sind vom Typ object und müssen demnach vor Verwendung gecastet werden.
Sandini Bib
548
18 Standard-Steuerelemente
Auswertung bei einfacher Auswahl SelectedIndex
gibt die Indexnummer des ausgewählten Listeneintrags an. Wenn kein Element ausgewählt ist, enthält SelectedIndex den Wert -1.
SelectedItem
verweist auf das Objekt, das dem ausgewählten Listeneintrag entspricht. Beachten Sie, dass das Ergebnis den Typ object hat und daher mittels Casting in den richtigen Datentyp überführt werden muss. Wenn kein Element ausgewählt ist, enthält SelectedItem den Wert null.
Text
enthält die Zeichenkette des ausgewählten Listeneintrags. Wenn kein Element ausgewählt ist, enthält Text eine leere Zeichenkette.
Bei der Auswertung der ausgewählten Einträge sollten Sie einige Dinge beachten: f Es kann vorkommen, dass gar kein Listeneintrag ausgewählt ist. Sie sollten also vor dem Zugriff über die Eigenschaft SelectedIndex oder bei Mehrfachauswahl über die Eigenschaft SelectedItems.Count feststellen, ob wirklich ein Objekt ausgewählt ist. Dieser Fall tritt insbesondere unmittelbar nach einem Programmstart oder direkt nach dem Anzeigen des Formulars ein. Gegebenenfalls erreichen Sie durch Zuweisung des Werts 0 an die Eigenschaft SelectedIndex, dass das erste Element ausgewählt wird. f Wenn Sie die ausgewählten Listeneinträge über eine Schleife entfernen möchten, müssen Sie darauf achten, diese korrekt zu formulieren. Das Problem ist, dass sich die Anzahl der enthaltenen Elemente ändert, wenn eines gelöscht wird. Für diesen Fall bietet sich eine while-Schleife an. f Der Zugriff auf SelectedIndices bzw. SelectedItems in Maus-Ereignisprozeduren (z.B. MouseUp oder MouseDown) sollte nach Möglichkeit vermieden werden. Es ist dort nicht immer klar, ob ein gerade angeklickter Eintrag bereits in die Aufzählung der ausgewählten Listeneinträge aufgenommen wurde oder nicht. Sie sollten daher Methoden zur Auswertung verwenden, die von den Mausereignissen losgelöst sind. Sie können Listeneinträge auch direkt per Programmcode auswählen bzw. die Auswahl aufheben. Die Methode SetSelected() erwartet den Index des Elements, auf das zugegriffen werden soll, und einen booleschen Parameter, der angibt, ob das Element ausgewählt oder die Auswahl aufgehoben werden soll. ClearSelected() hebt die gesamte Auswahl auf.
Beispiel zur Auswertung einer Mehrfachauswahl
CD
Das folgende Beispielprogramm fügt alle im System bekannten Farben in eine Listbox mit Mehrfachauswahl ein. Die Einträge können ausgewählt und in eine Textbox eingefügt werden. Weiterhin können Sie die Einträge löschen oder die Auswahl aufheben. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\ListBoxMultiSelect.
Sandini Bib
Listen
549
Der Name der Listbox ist lstColors, der Name der Textbox txtColors. Auch die Bezeichnungen der Buttons sind weitgehend selbsterklärend, sodass der Quellcode keine Verständnisschwierigkeiten mit sich bringen sollte. private void FrmMain_Load( object sender, System.EventArgs e ) { // ListBox laden string[] allColors = Enum.GetNames( typeof( KnownColor ) ); lstColors.Items.AddRange( allColors ); } private void BtnShow_Click( object sender, System.EventArgs e ) { // Alle ausgewählten Farben in Textbox anzeigen if ( lstColors.SelectedItems.Count > 0 ) { // TextBox-Inhalt löschen txtColors.Clear(); // Items einfügen foreach ( object o in lstColors.SelectedItems ) txtColors.AppendText( ( (string)o ) + " | " ); } } private void BtnDelete_Click( object sender, System.EventArgs e ) { // Alle markierten Einträge der ListBox löschen // Eine einfache while-Schleife reicht aus while ( lstColors.SelectedIndices.Count > 0 ) lstColors.Items.RemoveAt( lstColors.SelectedIndices[0] ); } private void BtnClear_Click( object sender, System.EventArgs e ) { // Auswahl aufheben lstColors.ClearSelected(); }
Sandini Bib
550
18 Standard-Steuerelemente
Abbildung 18.12 zeigt einen Screenshot des Programms zur Laufzeit.
Abbildung 18.12: Eine MultiSelect-ListBox im Einsatz
Listenelemente selbst zeichnen (owner-drawn ListBox) Normalerweise kümmert sich das ListBox-Steuerelement selbst um das Zeichnen der enthaltenen Elemente. Der Nachteil hierbei ist, dass es sich grundsätzlich um eine Textdarstellung handelt bzw. die Schriftart immer die gleiche ist. Es ist jedoch eine Möglichkeit vorhanden, die Anzeige der ListBox-Elemente so anzupassen, wie Sie selbst es wünschen. Dazu muss das automatische Zeichnen der Elemente abgeschaltet und durch selbst geschriebenen Programmcode übernommen werden. Die Eigenschaft DrawMode ist die Grundlage für diese Vorgehensweise. Die Standardeinstellung ist DrawMode.Normal. Über die Einstellungen DrawMode.OwnerDrawFixed bzw. DrawMode. OwnerDrawVariable können Sie festlegen, dass die enthaltenen Elemente nicht automatisch gezeichnet werden. Verwenden Sie DrawMode.OwnerDrawFixed, wenn alle Elemente die gleiche Größe haben, und DrawMode.OwnerDrawVariable bei unterschiedlichen Größen. Das eigentliche Zeichnen geschieht im Ereignis DrawItem. Dieses Ereignis wird für jedes Element der ListBox einmal ausgelöst. Falls Sie als Zeichenmodus DrawMode.Variable ausgewählt haben, müssen Sie auch noch das Ereignis MeasureItem behandeln, in dem die Größe des zu zeichnenden Elements ermittelt wird. Alle zum Zeichnen erforderlichen Daten werden durch den Parameter e vom Typ DrawItemEventArgs zur Verfügung gestellt. Dabei wird auch eine evtl. Hervorhebung des Listeneintrags berücksichtigt, d.h. die Eigenschaften BackColor und ForeColor des Parameters haben automatisch die richtigen Werte. Die folgende Tabelle listet die Eigenschaften von DrawItemEventArgs auf.
Sandini Bib
Listen
551
Eigenschaften der Klasse DrawItemEventArgs Graphics
die Zeichenfläche in Form eines Graphics-Objekts
Bounds
der Zeichenbereich (in Form einer Rectangle-Instanz)
BackColor
die Hintergrundfarbe für das Element
ForeColor
die Vordergrundfarbe für das Element
Font
die Standardschriftart für das Steuerelement
Index
die Indexnummer des Listeneintrags
State
der Zustand des Listeneintrags (z.B. DrawItemState.Selected, wenn der Eintrag ausgewählt ist)
Wenn DrawMode.OwnerDrawFixed eingestellt ist, wird die Höhe der Elemente aus der Eigenschaft ItemHeight der ListBox ermittelt, ansonsten über das Ereignis MeasureItem. In dieser können Sie die benötigte Elementgröße bestimmen und dann den Eigenschaften ItemHeight und ItemWidth des Parameters e vom Typ MeasureItemEventArgs zuweisen. Der Platzbedarf dieses Listenelements wird als feststehend angesehen, d.h. wenn Sie neue Elemente hinzufügen, wird MeasureItem nur für die neuen Elemente aufgerufen, nicht mehr für bereits enthaltene Elemente. Die folgende Tabelle fasst die Eigenschaften von MeasureItemEventArgs zusammen. Eigenschaften der Klasse MeasureItemEventArgs Graphics
die Zeichenfläche in Form eines Graphics-Objekts
Index
die Indexnummer des Listeneintrags
ItemWidth
die Breite des Listeneintrags
ItemHeight
die Höhe des Listeneintrags
Beispiel
VERWEIS
Das folgende Beispielprogramm zeigt, wie eine solche Listbox aussehen kann, wenn die Elemente selbst gezeichnet werden. Es werden zwei Listboxen dargestellt. Die erste ist eine Farb-Listbox, in der vor dem Farbwert ein Kästchen mit genau dieser Farbe gezeichnet wird. Die zweite zeigt Schriftarten an und verwendet zur Anzeige des Namens auch die entsprechende Schriftart. In den folgenden Routinen werden bereits einige Methoden gezeigt, die mit Grafik bzw. GDI+ in Zusammenhang stehen. Eine detaillierte Erläuterung zum Arbeiten mit Grafikfunktionen finden Sie in Kapitel 21 ab Seite 691.
Sandini Bib
CD
552
18 Standard-Steuerelemente
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\ListBoxOwnerDraw.
Zunächst zur Listbox für die Farben. Bei dieser Listbox wurde DrawMode auf DrawMode. OwnerDrawFixed eingestellt, die Höhe und Breite der Elemente soll also immer gleich bleiben. Damit wird auch das Ereignis MeasureItem nicht benötigt. Hier zunächst der Quellcode zum Ereignis DrawItem. private void LstColors_DrawItem( object sender, System.Windows.Forms.DrawItemEventArgs e ) { // Zeichnen der Farb-Elemente // Wir wissen, dass es sich um Farben handelt, keine Fehlerbehandlung hier // Wenn keine Items - nichts zeichnen if ( this.lstColors.Items.Count == 0 ) return; // Zeichenfläche ermitteln Graphics g = e.Graphics; string itemValue = (string)this.lstColors.Items[e.Index]; // Hintergrund zeichnen - automatisch in korrekter Farbe (Highlight) e.DrawBackground(); // Rechteck in angegebener Farbe zeichnen Color fillColor = Color.FromName( itemValue ); Rectangle rect = new Rectangle( e.Bounds.X + 1, e.Bounds.Y + 1, 20, e.Bounds.Height - 2 ); g.FillRectangle( new SolidBrush( fillColor ), rect ); g.DrawRectangle( Pens.Black, rect ); // Text ausgeben - 25 Punkte versetzt wg. Farbrechteck g.DrawString( itemValue, e.Font, new SolidBrush( e.ForeColor ), 25, e.Bounds.Y ); }
Der Quellcode sollte recht leicht verständlich sein. DrawBackground() zeichnet den Hintergrund, und zwar immer in der korrekten Farbe. Ist ein Element ausgewählt, wird das hier berücksichtigt. rect ist das Rechteck, das mit der zum Element passenden Farbe gefüllt wird. FillRectangle() erledigt diese Füllung, DrawRectangle() zeichnet zur Abgrenzung noch einen schwarzen Rand um dieses Rechteck. DrawString() schließlich schreibt den eigentlichen Text, wobei ForeColor automatisch korrekt ist (je nachdem, ob das Element hervorgehoben ist oder nicht). Die zweite Listbox zur Anzeige von Schriftarten in der jeweiligen Schriftart ist etwas mehr Arbeit. Hier müssen wir für DrawMode die Einstellung DrawMode.OwnerDrawVariable verwenden und auch das Ereignis MeasureItem. Der Code indes sollte auch relativ leicht verständlich sein.
Sandini Bib
Listen
553
private void LstFonts_MeasureItem( object sender, System.Windows.Forms.MeasureItemEventArgs e ) { // Ermittlung der Fontgröße string itemValue = (string)lstFonts.Items[e.Index]; Graphics g = e.Graphics; // Achtung: Font könnte auch nur kursiv sein Font fnt; try { fnt = new Font( itemValue, lstFonts.Font.SizeInPoints ); } catch { fnt = new Font( "Arial", lstFonts.Font.SizeInPoints ); } // Korrekte Größe ermitteln und zuweisen SizeF sizef = g.MeasureString( itemValue, fnt ); e.ItemHeight = (int)sizef.Height; e.ItemWidth = (int)sizef.Width; }
Die Methode MeasureString() ist das Herzstück. Hier wird ermittelt, wie viel Platz die Zeichenkette itemValue benötigt, wenn sie mit dem angegebenen Font gezeichnet wird. Basis aller Berechnung ist jedoch die für die Listbox eingestellte Fontgröße. Das Zeichnen sieht ähnlich aus wie bei den Farben. private void LstFonts_DrawItem( object sender, DrawItemEventArgs e ) { // Jetzt zeichnen string itemValue = (string)lstFonts.Items[e.Index]; Graphics g = e.Graphics; Font fnt; try { fnt = new Font( itemValue, lstFonts.Font.SizeInPoints ); } catch { fnt = new Font( "Arial", lstFonts.Font.SizeInPoints ); } e.DrawBackground(); g.DrawString( itemValue, fnt, new SolidBrush( e.ForeColor ), e.Bounds.X + 5, e.Bounds.Y ); }
Eigentlich war die Programmierung dieser drei Methoden nicht viel Arbeit. Abbildung 18.13 zeigt das im Vergleich zum geringen Aufwand doch erfreulich hochwertige Ergebnis.
Sandini Bib
554
18 Standard-Steuerelemente
Abbildung 18.13: Zwei selbstgezeichnete ListBox-Steuerelemente
Datenbindung mit einer ListBox Die ListBox ist nicht nur in der Lage, mit selbst hinzugefügten Elementen zu arbeiten, sie kann sich diese Elemente auch selbst holen. Diese Vorgehensweise bezeichnet man auch als Datenbindung. An dieser Stelle soll lediglich anhand eines kleinen Beispiels gezeigt werden, wie Sie Daten, die sich in einer List befinden, an eine Listbox binden können. Das ListBox-Steuerelement stellt für die Datenbindung drei Eigenschaften zur Verfügung. Die Eigenschaft DataSource steht für die Datenquelle selbst. Dabei muss es sich um ein Objekt handeln, das das Interface IList (oder IList) unterstützt. In der Eigenschaft DisplayMember wird angegeben, welches Element der in der Datenquelle gespeicherten Objekte angezeigt werden soll. Die dritte Eigenschaft, ValueMember, legt fest, welches Element für den Wert steht. Dieses wird dann von der Eigenschaft SelectedValue der ListBox zurückgeliefert.
CD
Ein kleines Beispielprogramm macht deutlich, wie einfach Datenbindung mit .NET funktioniert. Verwendet wird eine eigene Klasse namens Book. Diese Klasse speichert Werte zum Buchtitel, der ISBN und dem Autor eines Buchs. In der Listbox soll der Titel des Buchs angegeben werden, als Wert für SelectedValue soll jedoch der Autor geliefert werden. Dadurch kann dieser schnell angezeigt werden. Ein Doppelklick auf ein Element zeigt die gesamten Daten zum Buch in einem Meldungsfenster an. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\BookExample.
Sandini Bib
Listen
555
Hier zunächst der Code für die Klasse, die die Bücher aufnehmen soll. Hier sollte eigentlich nichts Unverständliches dabei sein. public class Book { private string title = String.Empty; private string author = String.Empty; private string isbn = string.Empty; public string Title { get { return this.title; } set { this.title = value; } } public string Author { get { return this.author; } set { this.author = value; } } public string Isbn { get { return this.isbn; } set { this.isbn = value; } } public Book( string title, string author, string isbn ) { this.author = author; this.title = title; this.isbn = isbn; } }
Im Load-Ereignis des Formulars wird die List (die als Feld des Formulars mit dem Namen books deklariert und dort auch initialisiert ist) mit Book-Objekten gefüllt. Danach wird die Bindung an die ListBox hergestellt. private void FrmMain_Load( object sender, System.EventArgs e ) { // Elemente initialisieren - books ist die generische Liste this.books.Add( new Book( "C# lernen", "Frank Eller", "3-8273-2045-3" ) ); this.books.Add( new Book( "Visual Basic .NET", "Michael Kofler", "3-8273-1982-1" ) ); this.books.Add( new Book( "Visual C#", "Frank Eller", "3-8273-2288-X" ) ); this.books.Add( new Book( ".NET Klassenbibliothek", "Holger Schwichtenberg, Frank Eller", "3-8273-2128-X" ) ); // Daten binden this.lstBooks.DataSource = books;
Sandini Bib
556
18 Standard-Steuerelemente
this.lstBooks.DisplayMember = "Title"; this.lstBooks.ValueMember = "Author"; }
Wenn der Index der Listbox wechselt, soll in einer Textbox namens txtAuthors der Name des Autors angezeigt werden. Das ist sehr leicht über das Ereignis SelectedIndexChanged der Listbox zu realisieren. Die Eigenschaft SelectedValue enthält aufgrund der Datenbindung den Namen des Autors. private void LstBooks_SelectedIndexChanged( object sender, System.EventArgs e ) { // Autor anzeigen this.txtAuthors.Text = lstBooks.SelectedValue.ToString(); }
Als Letztes soll nun bei einem Doppelklick auf die Listbox der gesamte Datensatz ausgelesen werden. Dieser kann auf zwei Arten ausgelesen werden. Einmal direkt aus der Listbox über die Eigenschaft SelectedItem, was allerdings ein Casting erfordert. Die zweite Möglichkeit funktioniert über die Liste books, die ja generisch ist. Die Indizes beider Listen, der generischen und der Liste in der Listbox, sind gleich, sodass über books[lstBooks.SelectedIndex];
das korrekte Ergebnis ermittelt werden kann – allerdings ohne Casting. private void LstBooks_DoubleClick( object sender, System.EventArgs e ) { // Buchdaten komplett anzeigen Book book = this.books[lstBooks.SelectedIndex]; string msg = "Folgendes Buch wurde gewählt:\r\n"; msg += "Titel: " + book.Title + "\r\n"; msg += "Autor: " + book.Author + "\r\n"; msg += "ISBN: " + book.Isbn; MessageBox.Show( msg, "Buchwahl", MessageBoxButtons.OK, MessageBoxIcon.Information ); }
Abbildung 18.14 zeigt einen Screenshot des Programms zur Laufzeit.
Abbildung 18.14: Ein Beispielprogramm für einfache Datenbindung mit einer ListBox
Sandini Bib
Listen
18.6.2
557
CheckedListBox
Das Steuerelement CheckedListBox ist von der herkömmlichen ListBox abgeleitet und verhält sich bis auf wenige Unterschiede auch genauso. Der Unterschied besteht darin, dass jedes Element nun eine CheckBox besitzt, mit der eine Auswahl getätigt werden kann. Über die Eigenschaft ThreeDCheckBoxes können Sie einstellen, ob die Checkboxen dreidimensional oder flach dargestellt werden sollen. Der Sinn und Zweck einer CheckedListBox besteht darin, die Auswahl deutlicher zu machen. Da diese nun über die Checkboxen eines jeden Elements vorgenommen wird, erübrigt sich die Möglichkeit der Mehrfachauswahl, die auch nicht verfügbar ist. Ebenso nicht verfügbar ist die Eigenschaft DrawMode, daher ist es nicht möglich, die enthaltenen Elemente selbst zu zeichnen. Leider ist die Bedienung dieses Steuerelements in der Standardeinstellung etwas gewöhnungsbedürftig, denn um die Checkbox vor einem Listenelement zu markieren, müssen Sie es zweimal anklicken. Abhilfe schafft die Eigenschaft CheckOnClick, die aus unerfindlichen Gründen den Standardwert false hat. Die Auswertung der ausgewählten Listenelemente erfolgt über die Eigenschaften CheckedIndices und CheckedItems, die vom Typ CheckedListBox.CheckedIndexCollection bzw. CheckedListBox.CheckedItemCollection sind. Sie haben die gleichen Eigenschaften wie die im vorigen Abschnitt angesprochenen Eigenschaften ListBox.SelectedItems und ListBox.SelectedIndices. Abbildung 18.15 zeigt eine CheckedListBox mit einigen Elementen.
Abbildung 18.15: CheckedListBox im Einsatz
Sandini Bib
558
18.6.3
18 Standard-Steuerelemente
ComboBox
Das Steuerelement ComboBox ist eigentlich nur eine Kombination aus den Elementen TextBox und ListBox. Der größte Unterschied ist die Darstellungsform. Während bei einer Listbox immer eine (vorgegebene) Anzahl von Listenelementen sichtbar ist, ist bei der Combobox normalerweise nur eine einzelne Zeile mit dem gerade ausgewählten Eintrag sichtbar. Erst durch Anklicken der Pfeilbuttons öffnet sich die Liste der enthaltenen Elemente. Eine Mehrfachauswahl ist demnach bei der Combobox nicht möglich.
Aussehen Wie die anderen Steuerelemente auch ist das Aussehen des ComboBox-Steuerelements mithilfe der Eigenschaften FlatStyle bzw. BorderStyle einstellbar. Dabei zeigt sich zunächst lediglich die TextBox, die das ausgewählte Element beinhaltet. Betreffend der enthaltenen Liste besitzt die Combobox drei Anzeigemodi, die über die Eigenschaft DropDownStyle eingestellt werden. f DropDownStyle.DropDown ist die Standardeinstellung. Das ausgewählte Element ist in der TextBox sichtbar, dieser Text kann vom Anwender über die Tastatur oder durch Auswahl eines anderen Elements geändert werden. In dieser Einstellung wird das erste enthaltene Element nicht automatisch ausgewählt. Sie können jedoch der Eigenschaft SelectedIndex einen Wert zuweisen und so ein Element programmtechnisch auswählen. f DropDownStyle.DropDownList ist eine Einstellung, die dann verwendet wird, wenn der Anwender nur die Möglichkeit der Auswahl und nicht der Eingabe haben soll. In das Textfeld kann hier nichts eingegeben werden. f DropDownStyle.Simple wird in der Praxis nahezu nie verwendet. Das Steuerelement zeigt sich dann in Form einer TextBox, verhält sich ansonsten wie bei der Einstellung DropDownStyle.DropDown. Die enthaltenen Elemente werden über die Pfeiltasten ausgewählt. Die Bedienung ist in dieser Form aber wenig intuitiv. In der Standardeinstellung werden in der ausgeklappten Liste maximal 8 Einträge angezeigt. Über die Eigenschaft MaxDropDownItems können Sie diesen Wert ändern. Über DropDownWidth lässt sich auch die Breite der ausgeklappten Liste ändern. Die Eigenschaft DroppedDown liefert Ihnen die Information, ob das Steuerelement gerade aufgeklappt ist.
Auswertung der Auswahl bzw. Texteingabe Wenn der Anwender einen Listeneintrag auswählt, treten nacheinander die Ereignisse TextChanged und SelectedIndexChanged auf. Wenn dagegen bei einer ComboBox mit der Einstellung DropDownStyle.DropDown mithilfe der Tastatur ein neuer Text eingegeben wird, tritt nur das SelectedTextChanged-Ereignis auf. Zur Auswertung des gewählten Listeneintrags können Sie die Eigenschaften aus der folgenden Tabelle verwenden.
Sandini Bib
Listen
559
ComboBox-Steuerelement auswerten SelectedIndex
gibt den Index des ausgewählten Listeneintrags an. Wenn kein Element ausgewählt ist, enthält SelectedIndex den Wert -1. SelectedIndex ändert sich bei einer Texteingabe nicht. Daher kann es passieren, dass die Eigenschaft Text eine andere Zeichenkette enthält als das zuletzt ausgewählte Listenelement.
SelectedItem
verweist auf das Objekt, das dem ausgewählten Listeneintrag entspricht. Wenn kein Element ausgewählt ist, enthält SelectedItem den Wert null. Wie SelectedIndex ändert sich auch SelectedItem nicht, wenn ein Text per Tastatur eingegeben wird. SelectedItem verweist dann weiterhin auf das Objekt des zuletzt ausgewählten Listeneintrags, während Text schon einen ganz anderen Text enthält. enthält die Zeichenkette des ausgewählten Listeneintrags bzw. den eingegebenen Text.
Text
Automatische Vervollständigung Wie auch bei der TextBox enthält die ComboBox in der Version 2.0 die Möglichkeit der automatischen Vervollständigung. Diese entspricht in Verhalten und Vorgehensweise exakt der TextBox, weshalb auf das Feature an dieser Stelle nicht genauer eingegangen wird. Detaillierte Informationen erhalten Sie ab Seite 533.
18.6.4
ListView
Da das Steuerelement ListView grundsätzlich auch der Anzeige einer Liste von Elementen dient, wird es oft mit der ListBox in Zusammenhang gebracht. Beide haben aber eigentlich nichts miteinander zu tun. Die Listview entspricht in Aussehen, Möglichkeiten und Funktion der rechten Seite des Windows-Explorers. Einträge können mit verschiedenen Bitmaps versehen werden, die sich auch in der Größe unterscheiden. Auch die aus dem Windows-Explorer bekannten Anzeigemodi sind enthalten. Weiterhin kann jedes Listenelement der Listview Unterelemente enthalten, die in der Detailansicht angezeigt werden, aber nicht auswählbar sind. Die vielfältigen Möglichkeiten des ListView-Steuerelements machen es auch relativ kompliziert zu programmieren. Dieser Abschnitt soll ein wenig Licht in diese Komplexität bringen.
Ansichtsmodi Die Art der Ansicht eines ListView-Steuerelements wird durch die Eigenschaft View bestimmt. Das ListView-Steuerelement stellt folgende Ansichtsmodi zur Verfügung: f View.LargeIcons: Listeneinträge werden durch ein großes Symbol und einen darunter angeordneten Text dargestellt. Lange Texte werden nach Möglichkeit auf mehrere Zeilen verteilt oder verkürzt.
Sandini Bib
560
18 Standard-Steuerelemente
f View.SmallIcon: Jeder Listeneintrag wird durch ein kleines Symbol und einen daneben angeordneten Text dargestellt. Bei SmallIcon werden so viele Spalten nebeneinander dargestellt, wie im Steuerelement Platz finden. Wenn nicht alle Listeneinträge Platz finden, erscheint ein vertikaler Scrollbalken. f View.List: Jeder Listeneintrag wird durch ein kleines Symbol und einem daneben angeordneten Text dargestellt. Anders als bei View.SmallIcon wird die Anzahl der Einträge pro Spalte durch die Steuerelementgröße limitiert. Wenn nicht alle Listeneinträge gleichzeitig angezeigt werden können, erscheint ein horizontaler Scroll-Balken. f View.Details: In dieser Darstellungsform werden zu jedem Listeneintrag auch die Detailinformationen angezeigt. Diese Ansicht ist die einzige, in der auch die Spaltenköpfe angezeigt werden. f View.Tiles ist eine Darstellungsform, die mit .NET 2.0 hinzugekommen ist und nur unter Windows XP bzw. Windows Server 2003 funktioniert. Um dieses Feature zu nutzen muss außerdem Application.EnableVisualStyles(true) aufgerufen worden sein. Die Darstellungsform ist Ihnen sicherlich bekannt; angezeigt wird dabei ein großes Symbol mit daneben stehendem Text. f Eine weitere Möglichkeit ist die Gruppierung von Elementen, ebenfalls neu in .NET 2.0 und ebenfalls nur unter Windows XP bzw. Windows 2003 Server verfügbar. Damit haben Sie die Möglichkeit, Gruppen anzulegen und die Einträge in diesen Gruppen zu platzieren. Über die Eigenschaft ShowGroups wird das Feature ein- bzw. ausgeschaltet.
Listeneinträge Jeder Listeneintrag des ListView-Steuerelements besteht aus einem Haupteintrag und einem oder mehreren Untereinträgen. Diese dienen nur zur Anzeige von Details und sind dementsprechend nur in der Detailansicht sichtbar. Sie können weder ausgewählt noch innerhalb des Steuerelements geändert werden. Der Zugriff auf die Hauptelemente erfolgt über die Eigenschaft Items. Diese ist vom Typ ListView.ListViewItemCollection, einer geschachtelten Klasse (wie auch bei den anderen Listen-Steuerelementen). Jedes Element ist vom Typ ListViewItem. Die Klasse ListViewItem besitzt ihrerseits wieder eine Eigenschaft SubItems, bei der es sich um die Liste der Untereinträge (also der Details zum Eintrag) handelt. Sie ist vom Typ ListViewItem.ListViewSubItemCollection (also wieder eine geschachtelte Klasse) und enthält Elemente vom Typ ListViewSubItem. Grundsätzlich haben Sie natürlich auch die Möglichkeit, die Detailansicht (und damit auch die Unterelemente) nicht zu benutzen. In diesem Fall genügt es, die Hauptelemente hinzuzufügen. Sollten Sie die Detailansicht nutzen, müssen Sie darauf achten, dass die Details nur dann angezeigt werden, wenn auch entsprechende Spaltenbeschriftungen angelegt wurden. Das können Sie entweder über die Entwurfsansicht tun oder aber zur Laufzeit. Die zuständige Eigenschaft Columns ist wiederum eine Aufzählung, diesmal vom Typ ListView.ColumnHeaderCollection. Die Elemente sind vom Typ ColumnHeader.
Sandini Bib
Listen
561
HINWEIS
Für jedes Element des Typs ListViewItem lässt sich die Erscheinungsform festlegen. Die Klasse besitzt die Eigenschaften ForeColor, BackColor oder Font. Damit lässt sich, falls Sie das möchten, jedes Element sogar mit einem anderen Zeichensatz darstellen. Das Ganze funktioniert in der Detailansicht auch mit den Untereinträgen (auch diese besitzen die Eigenschaften ForeColor, BackColor, Font usw.). Damit dies funktioniert, müssen Sie die Eigenschaft UseItemStyleForSubItems des entsprechenden ListViewItem-Objekts auf true setzen (was die Standardeinstellung ist). Bei der Veränderung der Schriftart sollten Sie allerdings vorsichtig sein. Wenn Sie die Schriftart größer als die Standardschriftart des Steuerelements einstellen, werden Teile des Texts abgeschnitten. Die Eigenschaft Font eignet sich daher eher zur Veränderung von Schriftattributen (fett, kursiv usw.).
Jeder Listeneintrag kann auch eine Auswahlbox erhalten. Dieses Vorgehen ist allerdings nicht zu empfehlen, da die Auswahlkästchen keinen Schönheitspreis verdient haben. Falls Sie es dennoch möchten, müssen Sie lediglich der Eigenschaft CheckBoxes des ListView-Steuerelements den Wert true zuweisen. Eine nützliche Eigenschaft ist AutoArrange, die es ermöglicht, die enthaltenen Elemente automatisch anzuordnen. Das gilt natürlich nicht für die Listen- bzw. Detailansicht. Hinzugefügt werden Listeneinträge entweder zur Entwurfszeit über die Eigenschaft Items oder zur Laufzeit, indem eine der Methoden Add() bzw. AddRange() der Eigenschaft Items verwendet wird. Wie bei anderen Listen auch empfiehlt es sich, bei großen Änderungen bzw. beim Hinzufügen zahlreicher Listeneinträge BeginUpdate() und EndUpdate() zu verwenden, um das ständige Aktualisieren der Anzeige und somit ein störendes Flackern zu verhindern. Die Methode Add() liefert beim Hinzufügen nicht den Index des hinzugefügten Elements, sondern das Element selbst zurück. Sie haben damit also die Möglichkeit, direkt auf die Unterelemente zuzugreifen. ListViewItem lvi = listView1.Items.Add("Haupteintrag 1"); lvi.SubItems.Add("Untereintrag 1.1"); lvi.SubItems.Add("Untereintrag 1.2"); lvi = listView1.Items.Add("Haupteintrag 2"); lvi.SubItems.Add("Untereintrag 2.1"); lvi.SubItems.Add("Untereintrag 2.2"); [...]
Die Methode AddRange() hingegen erwartet als Parameter ein Array aus ListViewItemObjekten, die Sie vorab erstellen müssen. Das sollte allerdings kein besonderes Problem darstellen, in der Regel werden die Listeneinträge und auch die Untereinträge ohnehin dynamisch im Programmcode erstellt.
Sandini Bib
562
18 Standard-Steuerelemente
Gestaltungs- und Bedienungsdetails Die Gestaltung und Bedienung des ListView-Steuerelements lässt sich durch zahlreiche Eigenschaften anpassen. Die folgende Liste gibt Ihnen einen Überblick: f AllowColumnReorder=true ermöglicht es dem Anwender, die Reihenfolge der Spalten in der Detailansicht zu verändern. f CheckBoxes=true bewirkt, dass bei jedem Listeneintrag ein Auswahlkästchen angezeigt wird. In manchen Anwendungsfällen ermöglichen Sie damit eine bessere bzw. übersichtlichere Mehrfachauswahl. Leider sind die Auswahlkästchen überdurchschnittlich hässlich. Eine 3D-Darstellung ist (zumindest auf herkömmliche Weise) nicht möglich. Die Kästchen werden am unteren Ende der Bitmap ausgerichtet. Wenn das Bild die Bitmap nicht vollständig ausfüllt, sollte der leere Teil der Bitmap oben (nicht unten) sein. Wenn Ihnen die Standardauswahlkästchen nicht gefallen (und das werden sie nicht) oder wenn der Anwender bei jedem Listeneintrag zwischen mehr als zwei Zuständen auswählen soll, können Sie Ihrem Formular eine ImageList hinzufügen, die Bitmaps für die verschiedenen Zustände enthält. Die ImageList wird über die StateImageListEigenschaft mit dem ListView-Steuerelement verbunden. Standardmäßig wird bei jedem Listenelement der erste Zustand (also die Grafik mit dem Index 0) angezeigt. Mit jedem Anklicken wird der jeweils nächste Zustand aktiviert. Um festzustellen, welche Listeneinträge ausgewählt wurden, können Sie bei jedem Listeneintrag (ListViewItem-Objekt) die Eigenschaften Checked oder StateImageIndex (falls es mehr als zwei Zustände gibt) auswerten. Noch praktischer ist zumeist die Eigenschaft CheckedItems, die direkt eine Aufzählung aller ausgewählten ListViewItem-Objekte liefert. f Die Eigenschaft FullRowSelect steht einerseits dafür, welcher Teil eines angeklickten Elements markiert werden soll, andererseits für die Position, an der der Mausklick erfolgen muss, damit ein Element markiert wird. Ist FullRowSelect true, kann in der gesamten Zeile geklickt werden und es wird auch die gesamte Zeile markiert. In der Einstellung false, der Standardeinstellung, entspricht das Verhalten dem WindowsExplorer, d.h. nur das Hauptelement kann angeklickt werden und wird auch hinterlegt angezeigt. f Die Eigenschaft GridLines ermöglicht eine Tabellenansicht des Steuerelements. Die Einstellung GridLines=true bewirkt, dass die Listeneinträge in der Detailansicht durch graue Tabellenlinien getrennt werden. f Über die Eigenschaft HeaderStyle können Sie festlegen, ob bei der Detailansicht Spaltentitel angezeigt werden sollen und ob diese angeklickt werden können (etwa um die Sortierreihenfolge zu verändern). Die Standardeinstellung lautet HeaderStyle.Clickable. f HoverSelection=true ermöglicht die Auswahl von Listeneinträgen ohne Mausklick. Die Maus muss dazu nur ein paar Sekunden über dem Listenelement bleiben. In der Praxis ist diese Art der Auswahl aber unüblich und wenig intuitiv.
Sandini Bib
Listen
563
f Über die Eigenschaft LabelWrap können Sie festlegen, ob lange Texte zur Beschriftung der Listeneinträge automatisch auf mehrere Zeilen verteilt werden. Das funktioniert (verständlicherweise) nur in der Ansicht View.LargeIcon, die Standardeinstellung ist true. f Über die Eigenschaft MultiSelect geben Sie an, ob mehrere Listeneinträge gleichzeitig ausgewählt werden dürfen. Die Standardeinstellung ist true. Die Mehrfachauswahl erfolgt wie auch beim Windows-Explorer entweder durch einen Mausklick zusammen mit einer der Tasten (ª) oder (Strg) oder durch die Markierung der gewünschten Einträge durch ein Auswahlrechteck, ebenfalls bei gedrückter Taste (Strg).
Sortierung der Spalten Das ListView-Steuerelement ermöglicht auch eine Sortierung der enthaltenen Elemente. Zuständig hierfür ist die Eigenschaft Sorting, der ein Wert des Typs SortOrder zugewiesen werden kann. Bei SortOrder handelt es sich natürlich wieder um eine Aufzählung. Die Standardeinstellung ist SortOrder.None, d.h. es wird nicht sortiert. Über die Einstellung SortOrder.Ascending können Sie eine aufsteigende Sortierung, über SortOrder.Descending eine absteigende Sortierung ermöglichen. Sortiert wird standardmäßig nach den Texten der Listeneinträge. Das ist natürlich nicht immer so erwünscht, wie auch im Windows-Explorer wollen Sie möglicherweise nach einem der Details des Listeneintrags sortieren. Aber auch das ist sehr leicht möglich, wobei wieder das Interface IComparer zum Einsatz kommt. Weisen Sie der Eigenschaft ListViewItemSorter einfach ein Objekt zu, das dieses Interface implementiert, und legen Sie in der Methode Compare() fest, nach welchem Kriterium sortiert werden soll. Die an die Methode übergebenen Objekte sind natürlich vom Typ ListViewItem. Das Beispiel in diesem Abschnitt zeigt genauer, wie es funktioniert. Bei der Verwendung der Eigenschaft ListViewItemSorter sind ein paar Dinge zu beachten: f Bei der Veränderung der ListViewItemSorter-Eigenschaft wird die Liste automatisch sortiert. Zu einem späteren Zeitpunkt können Sie die Sortierung manuell über die Methode Sort() auslösen. f Wenn Sie neue Einträge in das ListView-Steuerelement einfügen, werden diese entsprechend der aktuellen Sortierordnung automatisch korrekt eingeordnet. Allerdings funktioniert das bei der Verwendung einer eigenen Sortierfunktion nicht immer zuverlässig. So wurde etwa bei dem im nächsten Abschnitt vorgestellten Beispielprogramm beim Neuerstellen der Liste (also bei einem Verzeichniswechsel) immer genau ein Eintrag falsch einsortiert! Abhilfe schafft erst das manuelle Ausführen der Sort()-Methode am Ende der Einfügeoperation. Darüber hinaus hat sich herausgestellt, dass das Einfügen von Listenelementen bei der Verwendung einer eigenen Sortierfunktion unglaublich langsam erfolgt (und umso langsamer, je mehr Einträge die Liste bereits hat). Das gilt auch dann, wenn vor dem Einfügen der neuen Einträge BeginUpdate() und danach EndUpdate() ausgeführt wird. So dauert das Einlesen aller Dateien aus dem Verzeichnis Windows\System32 mehrere Sekunden, während die CPU-Aktivität 100 Prozent beträgt. Abhilfe schafft es, die Sor-
Sandini Bib
564
18 Standard-Steuerelemente
tierfunktion mit ListViewItemSorter=null vorübergehend abzuschalten und erst nach dem Ende der Einfügeoperationen wieder zu aktivieren. Damit wird die Liste erst zum Schluss neu sortiert, was sowohl Zeit spart als auch den gerade beschriebenen Sortierfehler umgeht. Die genaue Vorgehensweise wird im Beispielprogramm genauer erläutert. f Wenn Sie von einer individuellen Sortierung zur Standardsortierung zurückkehren möchten (also Sortierung nach den Namen der Listeneinträge), müssen Sie ListViewItemSorter auf null setzen und anschließend die Sorting-Eigenschaft neu einstellen. Bleibt als letzte Frage noch, wo die Sortierung ausgelöst werden soll. Der geeignete Ort ist üblicherweise das Ereignis ColumnClick. Der Index der angeklickten Spalte wird dem Ereignis in der Eigenschaft Column des Parameters e übergeben, wodurch die Eigenschaft ListViewItemSorter korrekt eingestellt werden kann.
Pfeile in den Spaltenüberschriften Aus Windows kennen Sie die Kennzeichnung der Sortierungsrichtung in den Spaltenüberschriften. Diese Kennzeichnung ist selbstverständlich auch mit .NET möglich, wenn auch nicht auf herkömmlichem Weg. Aber: Sowohl .NET als auch das Visual Studio beherrschen Unicode, was bedeutet, dass uns bzgl. der vorhandenen Zeichen nahezu keine Grenzen gesetzt sind. Die folgende Auflistung gibt Ihnen eine schrittweise Anleitung, mit der Sie die gewünschten Sonderzeichen in die Datei einfügen können. f Die Entwicklungsumgebung besitzt leider keinen Dialog, mit dem Sie Sonderzeichen in den Text einfügen könnten. Dazu müssen wir den Umweg über eine Textverarbeitung gehen, in diesem Fall Microsoft Word. Das Pfeilzeichen erreichen Sie dort über den Dialog EINFÜGEN|SYMBOL. Wählen Sie als Schriftart ARIAL, die benötigten Symbole befinden sich im Subset PFEILE. Fügen Sie die Zeichen in das Word-Dokument ein, kopieren Sie sie in die Zwischenablage und fügen Sie sie dann im Visual Studio ein. Alternativ können Sie auch den direkten Weg gehen. Der Pfeil nach oben besitzt den Code 9650, der Pfeil nach unten den Code 9660. Halten Sie die (Alt)-Taste gedrückt und geben Sie den Code auf dem Nummernblock (wichtig) ein. Lassen Sie die(Alt)Taste wieder los. Das Zeichen sollte jetzt erscheinen. Den Umweg über die Zwischenablage müssen Sie dennoch gehen. f Damit das Unicode-Zeichen im Code auch gespeichert wird, führen Sie in der Entwicklungsumgebung DATEI|ERWEITERTE SPEICHEROPTIONEN aus und wählen als Kodierung z.B. UNICODE (UTF-8 MIT SIGNATUR). Andernfalls verwendet die Entwicklungsumgebung zum Speichern das ANSI-Format, und das Pfeilzeichen ginge wieder verloren. f Zu guter Letzt müssen Sie noch die Schriftart des ganzen Formulars oder zumindest des ListView-Steuerelements von MICROSOFT SANS SERIF auf ARIAL umstellen. Zwar sind grundsätzlich beide Schriften Unicode-kompatibel, die Sonderzeichen wurden aber aus der Schriftart ARIAL entnommen und sind in SANS SERIF nicht enthalten.
Sandini Bib
HINWEIS
Listen
565
Wenn das Programm unter Windows 98/ME ausgeführt wird, kann es sein, dass die Pfeile nicht korrekt dargestellt werden. Das liegt daran, dass das ListViewSteuerelement von einer Betriebssystembibliothek gezeichnet wird. Bei Windows 98/ME ist diese Bibliothek nicht Unicode-kompatibel. Diese Betriebssysteme sollten aber mittlerweile größtenteils durch Windows XP ersetzt worden sein.
Nach dem Einfügen der Sonderzeichen speichern Sie die Datei ab. Die folgenden zwei Codezeilen zeigen, wie die Sonderzeichen in einem String verwendet werden können: string arrowDown = " ▼"; string arrowUp = " ▲";
Listeneinträge bearbeiten Natürlich sollen Listeneinträge nicht nur angezeigt, sondern auch bearbeitet werden können. Beachten Sie hierbei, dass lediglich das Hauptelement bearbeitet werden kann, nicht die zusätzlichen Informationen (eben auch wieder wie im Windows-Explorer). Die Eigenschaft LabelEdit gibt an, ob der Anwender die Texte der Listeneinträge verändern kann. Wenn die Eigenschaft auf true gesetzt wird, kann der Listeneintrag über die Tastatur verändert werden, nachdem er ein zweites Mal angeklickt wurde. Das allgemein übliche Tastenkürzel (F2) funktioniert leider nicht automatisch, hier müssen Sie Hand anlegen. Zum Aktivieren des Tastenkürzels verwenden Sie das Ereignis KeyDown des Steuerelements. Wenn die gedrückte Taste die Taste (F2) ist, wird der aktuell markierte Listeneintrag zum Bearbeiten angezeigt. Zuständig dafür ist die Methode BeginEdit(). Ist kein Element markiert, geschieht nichts. private void listView1_KeyDown(object sender, System.Windows.Forms.KeyEventArgs e) { if ( (listView1.FocusedItem != null) && (e.KeyCode == Keys.F2) ) listView1.FocusedItem.BeginEdit(); }
Die Änderung des Listeneintrags erfolgt zwar automatisch, die Änderung der zugrunde liegenden Daten aber unter Umständen nicht. Im Beispielprogramm werden z.B. Dateinamen in einem ListView-Steuerelement aufgelistet. Bei einer Änderung des angezeigten Werts müsste dann auch der Dateiname geändert werden bzw. es müsste kontrolliert werden, ob der Dateiname geändert werden darf. Hierfür liefert das ListView-Steuerelement zwei Ereignisse, nämlich BeforeLabelEdit und AfterLabelEdit. In Ersterem können Sie kontrollieren, ob die Bearbeitung überhaupt zulässig ist und sie ggf. durch Zuweisung des Werts true an e.CancelEdit verhindern. Das Bearbeiten ist dann hierfür nicht möglich (Sie sollten das dem Benutzer durch eine MessageBox mitteilen). In AfterLabelEdit können Sie die durchgeführten Änderungen direkt an die zugrunde liegenden Daten weiterreichen, beispielsweise den Namen der Datei ändern. Der Parameter e liefert in der Eigenschaft Label den neu zugewiesenen Wert. Auch hier können Sie noch
Sandini Bib
566
18 Standard-Steuerelemente
HINWEIS
eingreifen. Sollte der Name der Datei nicht geändert werden können, können Sie über eine Zuweisung des Werts true an e.CancelEdit den ursprünglichen Wert wieder herstellen. Veränderte Listeneinträge behalten auch dann ihre Position, wenn die Sortierreihenfolge nicht mehr stimmt. Gegebenenfalls müssen Sie eine Neusortierung explizit auslösen.
Bitmaps für die Listeneinträge Wie es sich für eine Listenansicht gehört können Sie natürlich Bitmaps zur Anzeige verwenden. Diese werden allerdings nicht direkt zugewiesen, sondern über das Steuerelement ImageList. Dabei gibt es drei verschiedene Arten von Bildern: f große Bilder, in der Regel in der Größe 32x32 Pixel, für die View.LargeIcon-Darstellung. Die entsprechende ImageList wird der Eigenschaft LargeImageList zugeordnet. f kleine Bilder, in der Regel 16x16 Pixel, für die übrigen Ansichten. Die entsprechende ImageList wird der Eigenschaft SmallImageList zugeordnet. f Bilder, die ihren Status ändern können. Diese werden für jedes Element gleich angezeigt, die in der entsprechenden ImageList enthaltenen Grafiken werden bei jedem Klick durchgetaktet. Die ImageList wird der Eigenschaft StateImageList zugeordnet. Es muss sich natürlich um getrennte ImageList-Steuerelemente handeln, da diese nicht verschiedene Bildgrößen verwalten können. Allerdings sollten die Indizes der Bilder gleich sein, also beispielsweise der Index 1 einem geschlossenen Ordner entsprechen – in der ImageList für kleine Bilder ebenso wie in der für große Bilder. Zugewiesen wird eine Grafik zur Entwurfs- oder Laufzeit über die Eigenschaft ImageIndex des Listeneintrags oder aber, was vermutlich häufiger der Fall sein wird, zur Laufzeit bei der Initialisierung des Listeneintrags. Hierzu wird ein neuer Listeneintrag erstellt, dem der anzuzeigende Text und der Index des zu verwendenden Bilds übergeben wird: listView1.Items.Add( "listenEintrag", 2 )
oder alternativ auch über die Eigenschaft ImageIndex: ListViewItem it = listView1.Items.Add("listenEintrag" ); it.ImageIndex = 2;
Die Methode Add() des Steuerelements ListView liefert nicht wie etwa bei einer ArrayList den Index des hinzugefügten Elements zurück, sondern das Element selbst. Der Sinn besteht darin, diesem Element dann gleich weitere Unterelemente hinzufügen zu können. Die im Beispiel verwendeten Bitmaps werden nicht mit dem Visual Studio mitgeliefert, dabei handelt es sich um Icons aus einer anderen, öffentlichen Quelle – dem Internet. Dennoch liefert auch das Visual Studio zahlreiche Bitmaps, Icons und Animationen mit (die im Vergleich zum Vorgänger endlich ein modernes Aussehen und eine hervorragende Qualität besitzen). Sie finden sie bei einer Standardinstallation im Verzeichnis C:\Programme\Microsoft Visual Studio 8\Common7\VS2005ImageLibrary.
Sandini Bib
ACHTUNG
Listen
567
Achten Sie darauf, vor dem Hinzufügen der Grafiken zur ImageList deren Eigenschaften ColorDepth und ImageSize einzustellen. Vor allem bei der Einstellung von ImageSize werden Grafiken auch dann entsprechend der dort eingestellten Größe angezeigt, wenn sie in Wirklichkeit größer oder kleiner sind. Eine nachträgliche Änderung bewirkt nichts und Sie müssen die Grafiken neu hinzufügen.
Gruppieren von Listenelementen Die Gruppierung ist ausschließlich unter Windows XP (Home oder Professional) und Windows Server 2003 möglich. Hierzu müssen dem ListView-Steuerelement zunächst Gruppen hinzugefügt werden. Dazu dient die Auflistung Groups, die Elemente sind vom Typ ListViewGroup und können entweder über den Index oder einen frei zu vergebenden Key-Wert (einen String) angesprochen werden. Listenelemente, die zu einer der Gruppen gehören sollen, müssen dieser dann ebenfalls hinzugefügt werden. Jedes Gruppenelement besitzt dazu eine interne Liste namens Items, dem das ListViewItem einfach hinzugefügt wird. Sobald ShowGroups auf true gesetzt wird, erscheint das Element in der Gruppe. Die Gruppierung wirkt sich auch auf eine evtl. Sortierung aus. Sortiert wird immer nur innerhalb der Gruppen, was eine Vermischung verhindert.
Verwaltung der Listendaten Anders als die in den vorigen Abschnitten beschriebenen ListBox-Varianten kann ListView in den Listenelementen – also in den ListViewItem- und ListViewSubItem-Klassen – nur gewöhnliche Zeichenketten speichern, keine Objekte. Sehr oft ist es aber notwendig, für jeden Listeneintrag außer den Zeichenketten für die einzelnen Spalten auch Zusatzinformationen zu verwalten. Dazu gibt es zwei Möglichkeiten. Die erste Möglichkeit besteht darin, die Eigenschaft Tag des ListViewItem-Objekts zu verwenden. Da diese vom Typ object ist, kann jedes beliebige Objekt darin gespeichert und später auch wieder verwendet werden. Im nachfolgenden Beispielprogramm wurde diese Vorgehensweise gewählt, um FileInfo-Objekte zusammen mit den ListView-Einträgen zu speichern. Bei der Auswertung muss allerdings wieder auf eine der bekannten Arten gecastet bzw. kontrolliert werden, ob Tag wirklich ein gültiges Objekt enthält. Die andere Variante besteht darin, eine neue Klasse zu bilden, die von der ListViewItemKlasse abgeleitet ist, die aber zusätzliche Eigenschaften besitzt, um die erforderlichen Zusatzinformationen zu speichern. Diese Variante bietet deutlich mehr Flexibilität: So können Sie die neue Klasse mit eigenen Methoden zur Initialisierung, Verwaltung etc. ausstatten. Beim Hinzufügen zur ListView ergeben sich keine Probleme, da die neue Klasse von ListViewItem abgeleitet ist (und ListViewItem somit ein Objekt der davon abgeleiteten Klasse aufnehmen kann).
Sandini Bib
568
18 Standard-Steuerelemente
18.6.5
Beispielprogramm
Trotz aller Theorie zeigt ein Beispielprogramm immer noch am meisten. In diesem kleinen Programm wird ein ListView-Steuerelement dazu verwendet, Ordner und Dateien der Festplatte aufzulisten. Ein Doppelklick auf einen Ordner wechselt das Verzeichnis. Eine Änderung der Dateinamen ist allerdings nicht möglich. Zusätzlich können Sie in der Detailansicht noch die Sortierung festlegen. Sortiert werden kann nach Name, Datum oder Größe, wie aus Windows gewohnt durch einen Klick auf die Spaltenüberschrift. Ein zweiter Klick auf die gleiche Spalte dreht die Sortierreihenfolge um. Die Reihenfolge wird selbstverständlich angezeigt, und zwar mit den Sonderzeichen für die Pfeile. Dabei handelt es sich nicht exakt um die gleiche Ansicht wie sonst unter Windows üblich, denn um das zu erreichen wären umfangreichere Schritte notwendig. Bei der Sortierung nach Namen oder Datum werden Dateien und Verzeichnisse gemischt (ansonsten würde sich Verwirrung beim Anwender einstellen). Bei der Sortierung nach der Dateigröße allerdings werden Dateien und Ordner wie in Gruppen behandelt. Realisiert wird das dadurch, dass die Größe eines Ordners grundsätzlich auf -1 gesetzt wird.
HINWEIS
Eine Gruppierung ist ebenfalls enthalten; über einen Button kann diese ein- oder ausgeschaltet werden. Selbstverständlich ist in den genannten Ansichten auch die Sortierung weiterhin möglich; die Gruppenzugehörigkeit wird dann automatisch berücksichtigt, auch bei einer Sortierung nach dem Datei- bzw. Verzeichnisnamen. Das Programm ist nicht gegen Zugriffsverletzungen abgesichert. Wenn Sie sich ein Verzeichnis ansehen möchten, zu dem Sie keine Leserechte haben, dann wird das Programm mit einer Fehlermeldung beendet. Sie finden es auf der beiliegenden CD unter \Buchdaten\Beispiele\Kapitel_18\ListViewExample.
Überblick Dieses Beispielprogramm ist ein wenig umfangreicher als die bisher vorgestellten. Wir gehen daher Schritt für Schritt vor. Zunächst benötigen wir die Elemente zur Oberflächengestaltung. Dazu gehören sechs Buttons zum Einstellen der Ansichten, ein Button zum Beenden des Programms und – natürlich – das ListView-Steuerelement. Die Spaltenüberschriften legen wir zur Entwurfszeit fest. Wenn Sie auf den Button neben der Eigenschaft Columns klicken, öffnet sich ein Dialog, in dem Sie die Spaltenüberschriften einfügen können. Alternativ können Sie selbstverständlich auch das Aufgabenmenü des Steuerelements verwenden, das hierfür ebenfalls einen Eintrag besitzt. Abbildung 18.16 zeigt den Dialog.
Sandini Bib
Listen
569
Abbildung 18.16: Der Dialog zum Einstellen der Spaltenüberschriften
Später, zur Laufzeit, werden wir die Spaltenüberschriften noch ändern, aber das geschieht zur Laufzeit und hat im Moment noch keine Bedeutung. Was wir tun werden, ist, die Sortierrichtung durch Pfeile anzuzeigen, ebenfalls ganz ähnlich wie in anderen Programmen. Den Aufbau des Programms zeigt Abbildung 18.17. Sie können sich natürlich auch für eine andere Anordnung der Elemente entscheiden. Wichtig ist bei diesem Aufbau, dass das ListView-Steuerelement über die Eigenschaft Anchor an allen vier Kanten des Formulars verankert wird, sodass sie auch eine Größenänderung mitmacht. Folgende Eigenschaften wurden ebenfalls noch eingestellt: f FullRowSelect auf true f View auf View.Details f HeaderStyle auf ColumnHeaderStyle.Clickable (das ist die Standardeinstellung) Die Namen der Buttons und der ListView wurden geändert, das Hauptformular trägt den Namen FrmMain. Die Bezeichnungen für die Buttons sollten eindeutig sein. Der nächste Schritt ist das Hinzufügen zweier ImageList-Steuerelemente, die unsere Bilder für die Einträge in der ListView enthalten sollen. Auch diese wurden umbenannt in imlSmall und imlLarge. Zum Verwenden der mitgelieferten Bilder stellen Sie bitte die Eigenschaft ColorDepth von imlSmall auf ColorDepth.Depth8Bit, die von imlLarge auf ColorDepth.Depth24Bit. Die Größe der Grafiken in imlLarge muss außerdem auf 32x32 Pixel eingestellt werden, und zwar vor dem Einfügen der Grafiken.
Sandini Bib
570
18 Standard-Steuerelemente
Abbildung 18.17: Die Entwurfsansicht für das Hauptformular
So gerüstet können wir die Grafiken hinzufügen. Sie finden die verwendeten Bilder im Verzeichnis \Buchdaten\Beispiele\Kapitel_18\ListViewExample\Programmgrafiken. Sie werden zur Entwurfszeit hinzugefügt, ein Klick auf den Button neben der Eigenschaft Images öffnet wieder einen Dialog, in dem Sie die Grafiken komfortabel hinzufügen können (siehe Abbildung 18.18).
Abbildung 18.18: Der Dialog zum Hinzufügen von Grafiken
Sandini Bib
Listen
571
Jetzt müssen wir die Bilderlisten nur noch zuordnen. Zuständig dafür sind die Eigenschaften SmallImageList und LargeImageList der ListView. Über eine Auswahlliste können Sie die gewünschte ImageList auswählen. Damit sind die Vorbereitungen getroffen und wir können an den eigentlichen Programmcode gehen.
Programmcode Auch hier müssen wir zunächst grundsätzliche Überlegungen anstellen. Das Hauptformular benötigt Zugriff auf das Dateisystem. Der Namespace System.IO muss daher per using eingebunden werden. Weiterhin benötigen wir einige Felder zum Zwischenspeichern von Werten. Einer der Grundsätze der Programmierung lautet: Verwende nie absolute Werte, denn es könnte sich etwas ändern – und dann muss die Änderung überall dort erfolgen, wo ein absoluter Wert verwendet wurde. Wir legen also drei Felder fest, die die Indizes der Grafiken angeben. Weiterhin müssen wir uns merken, nach welcher Spalte zuletzt sortiert wurde. Dazu verwenden wir den Index der Spalte. Für die Sortierung benötigen wir nun noch weitere Werte, nämlich die Überschriftentexte und die Pfeile, einen nach oben und einen nach unten. int imgIndexFile = 0; int imgIndexFolder = 1; int imgIndexFolderUp = 2;
// Grafik Datei // Grafik Verzeichnis // Grafik Verzeichnis hoch
int sortColumn = -1;
// sortierte Spalte
string[] columnValue = { "Name", "Größe", "Datum" };
// Standardwerte Spalten
// Achtung: Datei muss als Unicode gespeichert sein string arrowDown = " ▼"; string arrowUp = " ▲";
// Pfeil nach oben // Pfeil nach unten
Listeneinträge hinzufügen Das erste Stück Programmcode betrifft die Listeneinträge selbst. Zum Hinzufügen eines Listeneintrags, der bekanntlich aus einem FileInfo oder einem DirectoryInfo-Objekt bestehen kann, schreiben wir uns eine eigene Routine. Diese tut nichts weiter als ermitteln, um welche Art Eintrag es sich handelt, und diesen der ListView hinzufügen. Dabei wird als kleines Schmankerl noch kontrolliert, ob eine Datei komprimiert ist, in dem Fall wird der Dateiname in blauer Farbe dargestellt. Übergeben werden muss der Methode einmal das FileInfo- bzw. DirectoryInfo-Objekt und zum zweiten der Index des Bilds, das verwendet werden soll. Anhand des Index wird auch ermittelt, ob es sich um einen Eintrag handelt, der zum darüber liegenden Verzeichnis führen soll. In dem Fall wird statt des Verzeichnisnamens der String " .." eingetragen. Das Leerzeichen am Anfang ist gewollt, so stellen wir sicher, dass dieser Eintrag, wenn vorhanden, immer an erster Stelle steht.
Sandini Bib
572
18 Standard-Steuerelemente
Sofern es sich um ein Verzeichnis handelt, wird der Dateisystemeintrag auch sofort der entsprechenden Gruppe zugeordnet. Die Gruppen werden in der Ereignisbehandlungsroutine zu Form_Load festgelegt. Es existiert eine Gruppe mit dem Key Folders und eine mit dem Key Files. Der Verwendungszweck dürfte klar sein. Da es sich sowohl bei FileInfo als auch bei DirectoryInfo um Klassen handelt, die von FileSystemInfo abgeleitet sind, verwenden wir diese Klasse als Übergabeparameter. lvwFiles bezeichnet das ListView-Steuerelement. Hier der Code der Methode: private void AddListItem( FileSystemInfo fileSysInfo, int index ) { ListViewItem li;
// Ein ListItem
// Wenn vorhergehendes Verzeichnis, ".." eintragen, // ansonsten Namen des übergebenen Objekts eintragen if ( index == this.imgIndexFolderUp ) li = this.lvwFiles.Items.Add( " .." ); else li = this.lvwFiles.Items.Add( fileSysInfo.Name, index ); // Wenn FileInfo-Objekt, Größe eintragen, ansonsten 0. if ( fileSysInfo is FileInfo ) { li.SubItems.Add( ( (FileInfo)fileSysInfo ).Length.ToString() ); this.lvwFiles.Groups["Files"].Items.Add( li ); } else { li.SubItems.Add( "0" ); this.lvwFiles.Groups["Folders"].Items.Add( li ); } // Datum des letzten Zugriffs eintragen li.SubItems.Add( fileSysInfo.LastWriteTime.ToString() ); // Das übergebene Objekt der Eigenschaft Tag zuweisen li.Tag = fileSysInfo; // komprimierte Dateien/Verzeichnisse blau schreiben if ( ( fileSysInfo.Attributes & FileAttributes.Compressed ) == FileAttributes.Compressed ) li.ForeColor = Color.Blue; }
Sandini Bib
Listen
573
Die Listeneinträge kommen vom Dateisystem. Eine weitere Methode durchsucht das gerade aktuelle Verzeichnis und ruft für jedes gefundene Element die Methode AddListItem() auf. Der einzige Parameter, den diese Methode benötigt, ist das zu durchsuchende Verzeichnis. private void ListDirectories( DirectoryInfo currentFolder ) { // Liest Dateien und Verzeichnisse ein this.Text = currentFolder.FullName; // Update erst am Schluss this.lvwFiles.BeginUpdate(); // Liste löschen this.lvwFiles.Items.Clear(); // Parent-Verzeichnis schreiben if ( currentFolder.Parent != null ) AddListItem( currentFolder.Parent, imgIndexFolderUp ); // Unterverzeichnisse foreach ( DirectoryInfo di in currentFolder.GetDirectories() ) AddListItem( di, imgIndexFolder ); // Dateien foreach ( FileInfo fi in currentFolder.GetFiles() ) AddListItem( fi, imgIndexFile ); // Update this.lvwFiles.EndUpdate(); }
Da es in einem Verzeichnis schon zu einer erklecklichen Anzahl an Dateien und Unterverzeichnissen kommen kann, werden die Methoden BeginUpdate() und EndUpdate() verwendet, damit die Darstellung nicht flackert.
Wechseln des Verzeichnisses und Anzeige Dateidaten Ein Doppelklick innerhalb der ListView kann zwei Ziele haben, nämlich entweder ein Verzeichnis oder eine Datei. Da in der Eigenschaft Tag eines ListViewItems das Objekt gespeichert ist, können wir dies leicht herausfinden. Wird ein Verzeichnis angeklickt, rufen wir einfach ListDirectory() mit dem DirectoryInfo-Objekt aus der Eigenschaft Tag des angeklickten Elements auf. Wird hingegen eine Datei angeklickt, werden die Detaildaten der Datei in einer MessageBox angezeigt.
Sandini Bib
574
18 Standard-Steuerelemente
Keine Aktion soll passieren, wenn kein Element den Fokus hat. Damit kann es nur zwei Auswahlmöglichkeiten geben, eine einfache if-Anweisung genügt daher zur Unterscheidung. Der Code ist verständlicherweise im Ereignis DoubleClick der ListView untergebracht und sollte keine Schwierigkeiten bereiten. private void LvwFiles_DoubleClick( object sender, System.EventArgs e ) { // Aktion nur, wenn ein Element fokussiert if ( this.lvwFiles.FocusedItem != null ) { // Verzeichnis geklickt, Verzeichnis wechseln if ( this.lvwFiles.FocusedItem.Tag is DirectoryInfo ) { ListDirectories( (DirectoryInfo)this.lvwFiles.FocusedItem.Tag ); this.lvwFiles.Sort(); } else { // Datei geklickt, Daten anzeigen FileInfo fi = (FileInfo)this.lvwFiles.FocusedItem.Tag; string msg = "Datei: " + fi.Name + "\r\n" + "Größe: " + fi.Length.ToString() + " Bytes\r\n" + "Datum: " + fi.LastWriteTime.ToShortDateString(); MessageBox.Show( msg, "Dateiinformationen", MessageBoxButtons.OK, MessageBoxIcon.Information ); } } }
Ansichtswechsel Der Einfachheit halber wurden die Ereignisbehandlungsroutinen für die Ansichts-Buttons nicht zusammengelegt, sondern ausnahmsweise ausprogrammiert. Eine andere Ansicht erreichen Sie einfach durch Zuweisung des entsprechenden Werts an die Eigenschaft View des ListView-Steuerelements. Die Gruppierungsansicht wird über die Eigenschaft ShowGroups geändert, der einfach nur ihr negierter Wert zugewiesen wird (d.h. aus true wird false und umgekehrt). private void BtnSmallView_Click( object sender, System.EventArgs e ) { this.lvwFiles.View = View.SmallIcon; } private void BtnLargeView_Click( object sender, System.EventArgs e ) { this.lvwFiles.View = View.LargeIcon; } private void BtnListView_Click( object sender, System.EventArgs e ) { this.lvwFiles.View = View.List; }
Sandini Bib
Listen
575
private void BtnDetailView_Click( object sender, System.EventArgs e ) { this.lvwFiles.View = View.Details; } private void BtnTileView_Click( object sender, EventArgs e ) { this.lvwFiles.View = View.Tile; // Only supported by WindowsXP/2003 } private void BtnGrouped_Click( object sender, EventArgs e ) { this.lvwFiles.ShowGroups = !this.lvwFiles.ShowGroups; }
Sortieren der Listeneinträge Das Hinzufügen der Elemente wäre nun erledigt, auch der Verzeichniswechsel. Kommen wir zum umfangreichen Teil der Sortierung. Hierfür werden zwei Klassen benötigt, die beide das Interface IComparer implementieren müssen. Mit der einen Klasse sortieren wir nach der Größe, mit der anderen nach dem Datum. Die Sortierreihenfolge wird dem Konstruktor der Klasse mitgegeben und innerhalb der Methode Compare() ausgewertet. Nachdem die Größen der beiden Objekte ermittelt sind, kann eine Umkehrung der Sortierung ganz einfach dadurch erreicht werden, dass man die Größen vertauscht. Gleiches gilt für das Datum. Natürlich könnte man genauso gut das Ergebnis mit -1 multiplizieren. Bevor wir also zur eigentlichen Methode kommen, die die Sortierung vornimmt, hier der Code für die beiden Sortiererklassen. Beachten Sie bitte, dass bei beiden Klassen die Namespaces System.Windows.Forms, System.IO und System.Collections eingebunden werden müssen. Grund hierfür sind das Interface IComparer (aus System.Collections), die Aufzählung SortOrder (aus System.Windows.Forms) und natürlich die Klassen FileInfo und DirectoryInfo (aus System.IO). Hier zunächst der Code für die Klasse SizeSorter. public class SizeSorter : IComparer { private SortOrder sortOrder; // SortOrder der ListView public int Compare( object a, object b ) { // Gespeicherte Objekte ermitteln object o1 = ( a as ListViewItem ).Tag; object o2 = ( b as ListViewItem ).Tag; // Standardgrößen - Ordner sind immer am kleinsten, // sie haben immer die Größe -1 und werden so am Anfang angezeigt long size1 = -1; long size2 = -1;
Sandini Bib
576
18 Standard-Steuerelemente // Größen aus den FileInfo-Objekten auslesen if ( o1 is FileInfo ) size1 = ( (FileInfo)o1 ).Length; if ( o2 is FileInfo ) size2 = ( (FileInfo)o2 ).Length; // Sortierreihenfolge festlegen (einfach die Größen tauschen) if ( this.sortOrder == SortOrder.Descending ) { long x = size1; size1 = size2; size2 = x; } // Sortieren return size1.CompareTo( size2 );
} public SizeSorter( SortOrder so ) { this.sortOrder = so; } }
Die Klasse DateSorter befindet sich selbstverständlich in einer anderen Datei. Sie funktioniert fast auf die gleiche Weise wie SizeSorter, nur dass diesmal auch die Verzeichnisse in die Sortierung mit einfließen (bei der Sortierung nach Größe wurde für Verzeichnisse die Größe -1 verwendet). public class DateSorter : IComparer { private SortOrder sortOrder; public int Compare( object a, object b ) { // Hilfsvariable - 100 Jahre TimeSpan ts = new TimeSpan( 36500, 0, 0, 0, 0 ); // Gespeicherte Objekte ermitteln object o1 = ( a as ListViewItem ).Tag; object o2 = ( b as ListViewItem ).Tag; DateTime dt1 = ( (FileSystemInfo)o1 ).LastWriteTime; DateTime dt2 = ( (FileSystemInfo)o2 ).LastWriteTime; // Bei DirectoryInfo-Objekten 100 Jahre vom Datum abziehen // Nur wenn gewünscht - stiftet u.U. Verwirrung /* if ( (o1 is DirectoryInfo) ) dt1 = dt1.Subtract(ts);
Sandini Bib
Listen
577
if ( (o2 is DirectoryInfo) ) dt2 = dt2.Subtract(ts); */ // Sortierreihenfolge festlegen (einfach die Größen tauschen) if ( this.sortOrder == SortOrder.Descending ) { DateTime x = dt1; dt1 = dt2; dt2 = x; } // Sortieren return dt1.CompareTo( dt2 ); } public DateSorter( SortOrder so ) { this.sortOrder = so; } }
Der eigentliche Code zum Sortieren befindet sich im Hauptformular. Wir verwenden das Ereignis ColumnClick des ListView-Steuerelements. private void LvwFiles_ColumnClick( object sender, ColumnClickEventArgs e ) { // Hier wird die Sortierreihenfolge eingestellt // Wenn Spalte schonmal angeklickt, nur Sortierreihenfolge ändern if ( this.sortColumn == e.Column ) { if ( lvwFiles.Sorting == SortOrder.Ascending ) this.lvwFiles.Sorting = SortOrder.Descending; else this.lvwFiles.Sorting = SortOrder.Ascending; } else { this.sortColumn = e.Column; this.lvwFiles.Sorting = SortOrder.Ascending; } switch ( e.Column ) { case 0: this.lvwFiles.ListViewItemSorter = null; break; case 1: this.lvwFiles.ListViewItemSorter = new SizeSorter( this.lvwFiles.Sorting ); break; case 2: this.lvwFiles.ListViewItemSorter = new DateSorter( this.lvwFiles.Sorting ); break; }
Sandini Bib
578
18 Standard-Steuerelemente
// Column-Texte zurücksetzen for ( int i = 0; i < lvwFiles.Columns.Count; i++ ) this.lvwFiles.Columns[i].Text = columnValue[i]; // Column-Text mit Up/Down Arrow this.lvwFiles.Columns[e.Column].Text = GetColumnString( e.Column, lvwFiles.Sorting ); this.lvwFiles.Sort(); }
Das letzte Geheimnis des Codes ist die Methode GetColumnString(). Sie liefert zum Spaltenindex den passenden String zurück, der als Text dargestellt werden muss. Über den Parameter des Typs SortOrder wird die Pfeilrichtung ermittelt. private string GetColumnString( int columnIndex, SortOrder sortOrder ) { // Liefert den String zum Spaltenindex if ( sortOrder == SortOrder.None ) return this.columnValue[columnIndex]; if ( sortOrder == SortOrder.Ascending ) return this.columnValue[columnIndex] + arrowUp; else return this.columnValue[columnIndex] + arrowDown; }
Initialisierung Bevor irgendetwas angezeigt werden kann, müssen erst einmal Daten her – die Anwendung muss also initialisiert werden. Das geschieht im Load-Ereignis des Formulars. Hier werden nicht nur die Grundeinstellungen festgelegt, auch die Gruppen für das ListViewSteuerelement werden erzeugt und eingetragen. private void FrmMain_Load( object sender, System.EventArgs e ) { // Headergrößen hdrName.Width = hdrSize.Width = hdrDate.Width =
einstellen lvwFiles.ClientSize.Width - 250; 80; 160;
// Gruppen für ListView aufbauen ListViewGroup newGroup = new ListViewGroup( "Folders", "Verzeichnisse" ); newGroup.HeaderAlignment = HorizontalAlignment.Left; this.lvwFiles.Groups.Add( newGroup ); newGroup = new ListViewGroup( "Files", "Dateien" ); newGroup.HeaderAlignment = HorizontalAlignment.Left; this.lvwFiles.Groups.Add( newGroup );
Sandini Bib
Listen
579
// C:\ - Einträge listen ListDirectories( new DirectoryInfo( @"C:\" ) ); // Sortieren einstellen - nach Name this.lvwFiles.ListViewItemSorter = null; this.lvwFiles.Sort(); }
Damit wären alle Schritte erledigt. Das Resultat ist ein kleines Programm, mit dem Sie sich die Eigenschaften einer Datei anzeigen lassen können. Abbildung 18.19 zeigt einen Screenshot des laufenden Programms.
Abbildung 18.19: Das Programm ListViewExample zur Laufzeit
18.6.6
TreeView
HINWEIS
Auch das Steuerelement TreeView ist aus dem Windows-Explorer bekannt. Handelte es sich bei der ListView um die rechte Seite des Explorerfensters, entspricht die linke Seite einer TreeView. Das Steuerelement hat, was die Programmierung betrifft, eine gewisse Ähnlichkeit mit der ListView, ist aber bei weitem nicht so komplex. Die interne Verwaltung der Elemente ist auch eine andere, hierbei handelt es sich um eine hierarchische Liste. Das hat unter anderem den Nachteil, dass Sie nicht auf alle Elemente in einem Rutsch zugreifen können. Wie bei einer rekursiven Suche durch mehrere Verzeichnisse müssen Sie auch hier durch alle Elemente traversieren. Listeneinträge in einer TreeView werden häufig als Nodes oder Knoten bezeichnet. Diese Bezeichnungen sind identisch zu Listeneintrag oder TreeView-Element.
Sandini Bib
580
18 Standard-Steuerelemente
Listeneinträge (Nodes) Die Einträge einer TreeView werden als Nodes bezeichnet und sind vom Typ TreeNode. Jeder Node besitzt eine Eigenschaft Nodes vom Typ TreeNodeCollection, die ihrerseits eine Liste weiterer, darunter angeordneter Nodes beinhaltet. Da diese ebenfalls wieder eine Eigenschaft Nodes besitzen, kann die Hierarchie praktisch uneingeschränkt groß werden. Um Einträge hinzuzufügen gibt es zwei Möglichkeiten. Sind die Einträge bekannt, empfiehlt sich der Weg über die Entwurfsansicht. Über die Eigenschaft Nodes des Steuerelements erhalten Sie einen recht intuitiven Dialog, mit dem Sie Haupt- und Untereinträge schnell und komfortabel hinzufügen können. Abbildung 18.20 zeigt das Dialogfenster. Der Button STAMM HINZUFÜGEN fügt einen neuen Haupteintrag hinzu, UNTERORDNER HINZUFÜGEN fügt einen Eintrag unterhalb des gerade markierten Nodes hinzu. Sie können sowohl den Text des Nodes ändern als auch ein Bild für den aktuellen Node wählen. Da die Bildauswahl wie bei der ListView auch über ein ImageList-Steuerelement funktioniert, ist diese Option nur verfügbar, wenn Sie der Eigenschaft ImageList eine solche zugeordnet haben. Wenn die Listeneinträge dynamisch hinzugefügt werden sollen, ist der TreeNode-Editor natürlich nicht zu gebrauchen. In diesem Fall müssen Sie die einzelnen Einträge zur Laufzeit hinzufügen. Wie bei anderen Listen auch dient die Methode Add() dem Hinzufügen eines Elements. Sie liefert auch eine Referenz auf das gerade eingefügte Element zurück, sodass Sie weitere Unterelemente direkt hinzufügen oder die Eigenschaften des Nodes verändern können. Die Eigenschaft Nodes enthält die Hauptelemente. Ist einer der Nodes ausgewählt, können Sie über die Eigenschaft SelectedNode darauf zugreifen. Die Methode Clear() der Eigenschaft Nodes löscht die enthaltenen Elemente. Über die Eigenschaft Parent können Sie auf den übergeordneten Eintrag zugreifen. Weiterhin gibt es noch zwei wichtige Methoden, nämlich ExpandAll() und CollapseAll(). Erstere öffnet sämtliche Knoten und Unterknoten, die zweite schließt alle.
Abbildung 18.20: Der TreeNode-Editor zur Entwurfszeit
Sandini Bib
Listen
581
Die folgenden Codezeilen fügen einem TreeView-Steuerelement einige Nodes hinzu. Die Methode Add() liefert, wenn ihr ein String übergeben wird, das TreeNode-Objekt zurück, das gerade eingefügt wurde. Dadurch besitzen Sie eine Referenz darauf und können nun weitere Unternodes hinzufügen. Über die Eigenschaft Parent greifen Sie auf den übergeordneten Node zu und können diesem ebenfalls Elemente hinzufügen.
TIPP
private void FillTreeView() { // Nodes löschen this.trvList.Nodes.Clear(); // Hauptnode TreeNode tn = trvList.Nodes.Add( "Haupteintrag" ); // Untereinträge tn = tn.Nodes.Add( "Untereintrag 1" ); tn.Parent.Nodes.Add( "Untereintrag 2" ); tn = tn.Nodes.Add( "Untereintrag 1.1" ); tn.Nodes.Add( "Untereintrag 1.1.1" ); tn = tn.Parent.Nodes.Add( "Untereintrag 1.2" ); tn.Nodes.Add( "Untereintrag 1.2.1" ); tn.Nodes.Add( "Untereintrag 1.2.2" ); tn.Nodes.Add( "Untereintrag 1.2.3" ); tn.Nodes.Add( "Untereintrag 1.2.4" ); tn = tn.Parent.Nodes.Add( "Untereintrag 1.3" ); tn.Nodes.Add( "Untereintrag 1.3.1" ); tn.Nodes.Add( "Untereintrag 1.3.2" ); tn.Nodes.Add( "Untereintrag 1.3.3" ); tn.Nodes.Add( "Untereintrag 1.3.4" ); this.trvList.ExpandAll(); }
Wie beim ListView-Steuerelement sollten Sie umfangreiche Einfügeoperationen durch BeginUpdate() einleiten und durch EndUpdate() abschließen. Darüber hinaus können Sie auch bei diesem Steuerelement auf die Methode AddRange() zurückgreifen, um mehrere Listeneinträge besonders effizient einzufügen.
Es ist mithilfe des Konstruktors der Klasse TreeNode auch möglich, gleich die Unterelemente mit anzugeben. Dies erfolgt in Form eines Arrays aus TreeNode-Objekten. Somit können Sie auch auf diese Weise eine TreeView recht schnell füllen.
Verwaltung der TreeView-Hierarchie Die hierarchische Anordnung der TreeView-Elemente bringt einige Schwierigkeiten mit sich, was den Zugriff betrifft. Jede Eigenschaft Nodes enthält nur die Elemente, die sich auf der gleichen Hierarchieebene befinden. Es ist zwar möglich, über die Methode GetNodeCount() der TreeView zu ermitteln, wie viele Elemente insgesamt (also mit Unterelementen) enthalten sind, es ist jedoch nicht möglich, sie zu durchlaufen.
Sandini Bib
582
18 Standard-Steuerelemente
Innerhalb einer Hierarchieebene ist die Navigation recht einfach. Die Klasse TreeNode enthält einige Eigenschaften, durch die sie (wie eine verkettete Liste) mit den anderen Nodes zusammenhängt. FirstNode liefert den ersten Knoten der Hierarchieebene, LastNode den letzten Knoten. NextNode und PrevNode enthalten das jeweils folgende und das vorherige Element. Über die Eigenschaft Parent können Sie auf den in der Hierarchie darüber liegenden Node zugreifen (davon wurde in der oben gezeigten Methode FillTreeView() Gebrauch gemacht). Die Methode Contains() der Eigenschaft Nodes überprüft, ob sich ein angegebenes TreeNodeObjekt in dieser Auflistung befindet. Allerdings arbeitet auch Contains() nicht rekursiv, sondern durchsucht nur die aktuelle Hierarchieebene. Außerdem muss es sich wirklich um das gleiche Objekt handeln, ein neu erzeugter TreeNode mit gleichem Text reicht nicht aus. Meist wird es aber so sein, dass Sie genau so einen Node suchen müssen. Um wirklich innerhalb der gesamten Hierarchie suchen zu können, müssen Sie durch alle enthaltenen Nodes traversieren. Auch das TreeView-Steuerelement unterstützt die Anzeige von Checkboxen, wie es auch bei der ListView der Fall ist. Über die Eigenschaft CheckBoxes können Sie diese anzeigen. Ob das Auswahlkästchen eines Eintrags markiert ist, können Sie über dessen Eigenschaft Checked ermitteln. Leider ergibt sich hierbei ebenfalls das Problem, dass es keine Eigenschaft gibt, über die Sie auf alle Elemente auf einen Schlag zugreifen können. Stattdessen müssen Sie alle Nodes der TreeView durchlaufen und die Eigenschaft Checked auswerten. Es ist allerdings möglich, sich zu einem Eintrag den kompletten »Pfad« anzeigen zu lassen. Dabei werden die Texte aller übergeordneten Nodes ermittelt (bis zum ersten) und durch das Zeichen getrennt, das in der Eigenschaft PathSeparator angegeben ist. Den Pfad selbst erhalten Sie aus der Eigenschaft FullPath. Standard für PathSeparator ist der Backslash.
Beispielprogramm: Nodes suchen
CD
Das folgende kleine Beispielprogramm überprüft, ob ein TreeNode-Objekt in einem TreeView enthalten ist. Zur Überprüfung wird dabei nicht das TreeNode-Objekt selbst, sondern der enthaltene Text herangezogen. Die enthaltenen Unterelemente werden dabei rekursiv durchlaufen. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\TreeNodeSearch.
private bool HasNode( TreeNode baseNode, string searchText ) { // Initialisierung bool result = false; // Aktuellen Node testen if ( baseNode.Text.ToUpper().Contains( searchText.ToUpper() ) ) return true;
Sandini Bib
Listen
583
// Unterelemente testen if ( baseNode.Nodes.Count > 0 ) { foreach ( TreeNode subNode in baseNode.Nodes ) result = result | HasNode( subNode, searchText ); } return result; } private bool IsNodeContained( TreeView trv, string searchText ) { // Ergebnisvariable bool result = false; // Nodes des Hauptstamms durchsuchen foreach ( TreeNode subNode in trv.Nodes ) { result = result | HasNode( subNode, searchText ); // Bei true kann die Schleife verlassen werden if ( result ) break; } return result; }
Der Aufruf der Suchmethode erfolgt über die Methode IsNodeContained(). Da es sich bei einer TreeView eben nicht um einen Abkömmling der Klasse TreeNode handelt, werden zwei Methoden benötigt. Einmal wird die Auflistung Nodes des TreeView-Steuerelements durchsucht, für jeden Node wird aber HasNode() aufgerufen, wodurch die darunter liegenden Elemente rekursiv durchlaufen werden. Das Ergebnis wird zu Anfang auf false gestellt und mit dem zurückgelieferten Ergebnis oder-verknüpft. Dadurch bleibt das Gesamtergebnis immer true, wenn es auch nur einen einzigen Node gibt, dessen Text dem Suchtext entspricht bzw. diesen beinhaltet. Abbildung 18.21 zeigt eine Abbildung der Anwendung zur Laufzeit.
Abbildung 18.21: Das Durchsuchen einer TreeView
Sandini Bib
584
18 Standard-Steuerelemente
Bitmaps für die Listeneinträge Vor jedem Listeneintrag kann eine (üblicherweise recht kleine) Bitmap angezeigt werden. Dies erfolgt ähnlich wie beim ListView-Steuerelement über eine ImageList, die der gleichnamigen Eigenschaft zugewiesen wird. Da sich hier die Größen der Bilder allerdings nicht ändern, muss nur eine einzige ImageList zugewiesen werden. Dennoch können zwei verschiedene Arten von Bildern angezeigt werden. Die Eigenschaft ImageIndex legt fest, welche Grafik angezeigt wird, wenn das Element nicht markiert ist. Die Eigenschaft SelectedImageIndex gibt den Index für die Grafik bei markiertem Element
an. Die Grafiken können selbstverständlich jedem Element separat zugewiesen werden. Das funktioniert auch schon beim Erzeugen des TreeNode-Objekts. Der Konstruktor empfängt optional Parameter für ImageIndex und SelectedImageIndex. Je nach Bitmap-Größe können Sie mit der Eigenschaft ItemHeight des TreeView-Steuerelements den Zeilenabstand zwischen den Listeneinträgen vergrößern und so vermeiden, dass sich größere Bitmaps überlappen. Nicht möglich ist es aber, wie beim ListView-Steuerelement Bitmaps als Ersatz für die anzeigbaren Checkboxen zu verwenden. Es gibt also keine Eigenschaft StateImages. Ebenso fehlt eine Eigenschaft MultiSelect, d.h. auch die Auswahl mehrerer Elemente auf einen Schlag ist nicht vorgesehen.
Verwaltung der Listendaten Wie beim ListView-Steuerelement sind auch beim TreeView-Steuerelement pro Listeneintrag nur die Eigenschaften Text und Tag zur Speicherung von Daten vorgesehen. Wenn Sie außer dem Beschriftungstext zusätzliche Verwaltungsdaten benötigen, können Sie entweder ein beliebiges Objekt in der Tag-Eigenschaft speichern, oder Sie bilden eine neue Klasse, die von der TreeNode-Klasse abgeleitet wird. Beim Einfügen von Listeneinträgen übergeben Sie dann Objekte dieser Klasse an die Methode Add() der Nodes-Auflistung. Diese Funktionsweise ist die gleiche wie bei der ListView. Wenn Sie die Eigenschaft LabelEdit auf true einstellen, ist es auch möglich, dass der Anwender die Bezeichnungen der Elemente zur Laufzeit ändern kann. Allerdings versagt dann die automatische Sortierung. Falls Sorted ständig auf true eingestellt ist, werden neu hinzu gekommene Elemente zwar immer noch korrekt eingefügt, die geänderten Elemente jedoch behalten unabhängig von der neuen Bezeichnung ihre Position. Abhilfe schafft nur eine komplette Neusortierung, die Sie durch Setzen von Sorted auf false und dann wieder auf true erreichen können.
Sortieren der TreeView-Einträge Die Elemente der TreeView können auch sortiert werden. Dazu dient die Eigenschaft Sorted, die standardmäßig auf false eingestellt ist. Wenn Sie diese auf true setzen, werden neu eingefügte Einträge innerhalb der gleichen Hierarchieebene automatisch an der korrekten Stelle eingefügt. Die Sortierung erfolgt in diesem Falle immer alphabetisch. Eine Anpas-
Sandini Bib
Listen
585
sung der Sortierung ist nur auf einem Umweg möglich, und dann auch nur für jeweils eine Hierarchieebene. Bei diesem Umweg müssen Sie eine eigene Sortierroutine schreiben, die als einzigen Parameter eine TreeNodeCollection erwartet. Diese implementiert das Interface IList, d.h. es ist möglich über die statische Methode ArrayList.Adapter() einen Wrapper darum zu legen und dann die Methode Sort() der ArrayList verwenden. Hierbei ergibt sich aber ein Problem, denn die einzelnen Einträge implementieren nicht das Interface IComparable. Um die Art der Sortierung müssen wir uns also selbst kümmern, in Form einere eigenen Klasse, die IComparer implementiert. Die folgende Klasse dient zum Sortieren nach dem Text des jeweiligen Elements. public class NodeComparer : IComparer { public int Compare( object a, object b ) { TreeNode tn1 = (TreeNode)a; TreeNode tn2 = (TreeNode)b; return tn1.Text.CompareTo( tn2.Text ); } public NodeComparer() { } }
Sie müssen den Node, dessen Untereinträge sortiert werden sollen, selbst herausfinden und dann dessen Eigenschaft Nodes der Methode zum Sortieren übergeben. Die Sortiermethode selbst besteht eigentlich nur aus 2 Zeilen: private void SortNodes( TreeNodeCollection nodes ) { ArrayList arl = ArrayList.Adapter( nodes ); arl.Sort( new NodeComparer() ); }
Wenn Sie die gesamte TreeView auf diese Art sortieren wollen, müssen Sie wieder durch alle Elemente traversieren. Dabei empfiehlt es sich allerdings, zuerst zu sortieren und dann die Elemente zu durchlaufen.
Weitere Gestaltungs- und Bedienungsdetails Das Aussehen und die Funktion der TreeView kann durch eine Reihe weiterer Eigenschaften beeinflusst werden. f Mit der Eigenschaft Indent legen Sie fest, wie stark die Elemente pro Hierarchie eingerückt werden sollen. Der Standardwert ist 19 Pixel pro Ebene. f Die Eigenschaft ItemHeight gibt die Zeilenhöhe an. Die Eigenschaft wird automatisch an die Textgröße angepasst und muss nur dann manuell verändert werden, wenn Sie Bitmaps verwenden und die Bitmap-Höhe größer als die Texthöhe ist. f Die Eigenschaft ShowLines gibt an, ob die Listeneinträge durch Linien zur Darstellung der Hierarchiestruktur verbunden werden sollen. Die Standardeinstellung ist true.
Sandini Bib
586
18 Standard-Steuerelemente
f Mit der Eigenschaft ShowPlusMinus können Sie festlegen, ob die aus dem WindowsExplorer bekannten Plus- und Minuszeichen zum Ein- bzw. Ausblenden einer Hierarchieebene angezeigt werden sollen. Der Standardwert ist auch hier true. Wenn Sie diese Eigenschaft auf false setzen, erfolgt das Ein- und Ausklappen mittels Doppelklick auf das betreffende Element. f Die Eigenschaft ShowRootLines gibt an, ob auch vor den Wurzeleinträgen eine Hierarchielinie und das Plus-/Minuszeichen angezeigt werden sollen. Der Standardwert ist auch hier true. Neben diesen Einstellungen, die für das gesamte Steuerelement gelten, können Sie einzelne Listeneinträge durch die Veränderung der Schrift bzw. der Vorder- und Hintergrundfarbe hervorheben. Dazu verändern Sie einfach die Eigenschaften NodeFont, ForeColor und BackColor des entsprechenden TreeNode-Objekts. Nicht verständlich ist an dieser Stelle, warum NodeFont nicht einfach Font heißt, wie das auch bei den anderen Klassen des .NET Frameworks der Fall ist.
Auswertung der Listenauswahl, Ereignisse Anders als bei den bisher beschriebenen Listenfeldern fehlt beim TreeView-Steuerelement das SelectedIndexChanged-Ereignis. Stattdessen gibt es die Ereignisse BeforeSelect und AfterSelect, die vor bzw. nach der Veränderung des aktiven Listeneintrags eintreffen. Im Ereignis BeforeSelect können Sie den Fokuswechsel verhindern, indem Sie der Eigenschaft Cancel des Parameters e den Wert true zuweisen. Auch beim Ausklappen oder Zusammenklappen einer Hierarchiegruppe werden entsprechende Ereignisse ausgelöst, nämlich BeforeCollapse und AfterCollapse bzw. BeforeExpand und AfterExpand. Allen diesen Ereignissen wird der aktuelle Node in der Eigenschaft Node des Parameters e übergeben. Deutlich schwieriger ist es, einen Doppelklick auf ein Listenelement korrekt festzustellen: Das Steuerelement kennt zwar das DoubleClick-Ereignis, dieses wird aber auch dann ausgelöst, wenn der Anwender irgendwo im Listenfeld einen Doppelklick durchführt. An das DoubleClick-Ereignis werden weder das eventuell angeklickte Listenelement noch lokale Mauskoordinaten übergeben. Diese müssen daher manuell ermittelt werden. Das scheint im ersten Moment gar nicht so einfach, denn der Parameter e, der ja bei einem Ereignis für die Übergabe benötigter Werte zuständig ist, ist vom Typ EventArgs – er enthält also gar keine Werte. Daher muss ein Umweg gegangen werden. Im Ereignis MouseDown (oder auch in MouseUp) der TreeView kann die aktuelle Mausposition ermittelt und im Formular zwischengespeichert werden. Die Methode GetNodeAt() schließlich ermittelt aus den gegebenen Koordinaten den Node, auf den geklickt wurde. Falls der Klick nicht auf einem Node ausgeführt wurde, wird null zurückgeliefert. Die folgenden Programmzeilen ermitteln das angeklickte Element auf die beschriebene Weise und geben dessen kompletten Pfad in einer Messagebox aus. Das Feld, in dem die Mauskoordinaten zwischengespeichert werden, heißt mousePosition und ist vom Typ Point. Abgedruckt werden wieder nur die relevanten Codezeilen.
Sandini Bib
Listen
587
Point mousePosition = new Point(0,0); private void trvList_MouseDown( object sender, System.Windows.Forms.MouseEventArgs e ) { this.mousePosition = new Point( e.X, e.Y ); } private void trvList_DoubleClick( object sender, System.EventArgs e ) { TreeNode tn = trvList.GetNodeAt( this.mousePosition ); string msg = "Angeklickter Node: "; if ( tn != null ) msg += tn.FullPath; else msg += "Kein Node, ins Leere geklickt."; MessageBox.Show(msg, "Klicken", MessageBoxButtons.OK, MessageBoxIcon.Information); }
HINWEIS
In der Praxis sieht es meist so aus, dass das DoubleClick-Ereignis aus Performancegründen dazu verwendet wird, die Untereinträge des jeweiligen Knotens zu ermitteln und anzuzeigen. Sie haben bereits im Laufe des Buchs gesehen, dass es beispielsweise ziemlich lange dauert, alle Dateien und Verzeichnisse einer Festplatte einzulesen. Wenn dies nur dann geschieht, wenn der Anwender auf einen Node doppelklickt, wird viel Zeit gespart und das Programm reagiert schneller. Bei dieser Vorgehensweise müssen Sie darauf achten, dass Nodes nicht doppelt eingefügt werden. Wenn bereits Nodes vorhanden sind, können Sie die Methode einfach verlassen, das Aufklappen oder Zusammenklappen der Hierarchieebene erledigt das Steuerelement von alleine.
18.6.7
Beispielprogramm: Festplatteninhalt ermitteln
In diesem Beispielprogramm wird innerhalb eines TreeView-Steuerelements der Festplatteninhalt angezeigt. Allerdings nicht wie bei einer ListView sowohl Verzeichnisse als auch Dateien, sondern lediglich die Verzeichnisse – dafür aber für alle Laufwerke. Über Checkboxen haben Sie die Möglichkeit, die Verbindungslinien zwischen den Hierarchien und die Plus-/Minuszeichen auszublenden. Um das Programm halbwegs performant zu gestalten, werden die Unterverzeichnisse erst dann eingelesen, wenn ein Verzeichnis durch Doppelklick geöffnet wird. Da die Eigenschaft FullPath den gesamten Pfad zurückliefert, der zwangsläufig dem physikalischen Pfad auf der Festplatte entspricht, ist es leicht, ein entsprechendes DirectoryInfo-Objekt zu erzeugen und die darin enthaltenen Unterverzeichnisse zu ermitteln.
Sandini Bib
588
18 Standard-Steuerelemente
HINWEIS
Wenn Sie alle Verzeichnisse einer Festplatte einlesen, kann das relativ lange dauern. Zeiten von einer Minute und mehr sind hier keine Seltenheit (wie Sie bereits in Abschnitt 14.2.2 ab Seite 323 gesehen haben. Der Großteil dieser Zeit geht nicht auf das Konto des Beispielprogramms oder auf die Verwaltung der TreeView-Daten, sondern auf das Einlesen der Verzeichnisse von der Festplatte. Eine Möglichkeit, dies zu beschleunigen, gibt es leider nicht – auch Windows geht so vor, wie man bei der Ermittlung der Eigenschaften eines Ordners leicht erkennen kann.
CD
Über einen Button können Sie auch alle Unterverzeichnisse eines Elements auf einen Schlag einlesen. Da das mitunter etwas Zeit beanspruchen kann, wird in diesem Fall der Cursor verändert und die Aktualisierung der TreeView unterbunden (mittels BeginUpdate() bzw. EndUpdate()).
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\ListDirectories.
Formulardesign Die Anwendung besteht lediglich aus einem Formular mit der TreeView, einem Button für das Einlesen des gesamten Verzeichnisbaums, drei Checkboxen zum Ein- und Ausschalten der Liniendarstellung, einem Button zum Beenden und einer Anzeigemöglichkeit für die Anzahl eingelesener Knoten. Der Name des Hauptformulars ist wie bei den anderen Beispielen auch FrmMain. Zusätzlich benötigen wir noch ein ImageList-Steuerelement mit den Bitmaps für die TreeView. Drei Bitmaps werden benötigt, eine für das Laufwerk (Index 0), eine für ein geschlossenes Verzeichnis (Index 1) und eine für ein offenes Verzeichnis (Index 2). Die Eigenschaft ImageList des TreeView-Steuerelements wird bereits zur Entwurfszeit auf die hinzugefügte ImageList eingestellt. Die Eigenschaft Checked aller drei Checkboxen wird auf true eingestellt (das entspricht der Standardeinstellung der TreeView, sodass wir hier nichts mehr ändern müssen). Abbildung 18.17 zeigt einen Screenshot des Formulars zur Entwurfszeit.
Sandini Bib
Listen
589
Abbildung 18.22: Die Entwurfsansicht des Beispielprogramms
Vorbereitungen Zuerst werden für die zu verwendenden Bitmaps Indizes in Form von Feldern des Formulars festgelegt. Das hat den Vorteil, dass man bei einer etwaigen Änderung (z.B. wenn weitere Bitmaps benötigt werden) einfach die Werte dieser Felder verändern kann und ansonsten nichts im Programmcode tun muss. Bei diesem Beispiel dürfte das zwar weniger der Fall sein, aber je eher man sich an solche Vorgehensweisen gewöhnt, desto besser. int iconDrive = 0; int iconFolderClosed = 1; int iconFolderOpen = 2; Point mousePosition = new Point( 0, 0 );
Das letzte Feld, mousePosition, dient der Zwischenspeicherung der Mausposition. Diese wird aus dem Ereignis MouseDown des TreeView-Steuerelements entnommen. private void TrvDirectories_MouseDown( object sender, MouseEventArgs e ) { this.mousePosition.X = e.X; this.mousePosition.Y = e.Y; }
Der Name der TreeView ist trvDirectories. Beim Programmstart müssen wir die TreeView erst einmal mit allen Laufwerken füllen, die das System bereitstellt. Diese können auf zwei Arten ermittelt werden, nämlich einmal in Form von DriveInfo-Objekten mithilfe der gleichnamigen neuen Klasse in .NET 2.0, zum Anderen über die Methode Environment.GetLogicalDrives(), die allerdings nur die Namen aller angeschlossenen Laufwerke liefert.
Sandini Bib
590
18 Standard-Steuerelemente
In diesem Beispiel wird die DriveInfo-Klasse verwendet, da es mit ihr leichter ist, zu kontrollieren, ob ein Laufwerk bereit ist (im Falle von Disketten- oder CD-ROM-Laufwerken bedeutet das eine eingelegte Diskette oder CD-ROM). Angezeigt werden nur die Laufwerke, auf die auch zugegriffen werden kann. Danach werden die benötigten TreeNode-Objekte erzeugt und entsprechende ImageIndexWerte zugewiesen. Das kann schon beim Initialisieren eines TreeNode-Objekts geschehen, die entsprechenden Werte können an den Konstruktor übergeben werden. Außerdem wird die erste Ebene der Verzeichnisstruktur bereits eingelesen, um auch die Plus- und Minuszeichen im TreeView anzuzeigen (was erst der Fall ist, wenn Unterelemente vorhanden sind). private void FrmMain_Load( object sender, System.EventArgs e ) { DriveInfo[] drives = DriveInfo.GetDrives(); trvDirectories.Nodes.Clear(); foreach ( DriveInfo drive in drives ) { if ( drive.IsReady ) { TreeNode newNode = new TreeNode( drive.Name, iconDrive, iconDrive ); trvDirectories.Nodes.Add( newNode ); GetDirectories( newNode ); } } }
Eine Hilfsmethode zeigt die Anzahl der eingelesenen Knoten an. Dabei handelt es sich zwangsläufig um die Gesamtanzahl der in der TreeView enthaltenen TreeNode-Objekte, die sehr einfach über die Methode GetNodeCount() ermittelt werden können. Der Wert wird in die Textbox mit Namen txtDirCount geschrieben, die zur Anzeige dient. private void UpdateDirectoryCount() { txtDirCount.Text = trvDirectories.GetNodeCount( true ).ToString(); }
Einlesen der Verzeichnisse Zum Einlesen der Verzeichnisse werden zwei Methoden benötigt. Die erste liest nur die Unterverzeichnisse des aktuellen Verzeichnisses ein, die zweite arbeitet sich rekursiv durch die gesamte Verzeichnisstruktur. Über eine try-catch-Anweisung wird in der ersten der beiden Methoden, GetDirectories(), überprüft, ob der Zugriff auf das angegebene Laufwerk bzw. Verzeichnis überhaupt erlaubt ist. Falls nicht, wird auch nichts eingelesen. GetDirectories() benötigt als einzigen Parameter den aktuellen Knoten, dessen Unterver-
zeichnisse eingelesen werden sollen. Der Gesamtname des Knotens entspricht auch der korrekten Bezeichnung des Verzeichnisses, wenn der Backslash als Pfadtrenner verwendet wird. Vor dem Einlesen wird die enthaltene Nodes-Liste des Knotens gelöscht, um doppelte Vorkommen zu verhindern.
Sandini Bib
Listen
591
private void GetDirectories( TreeNode currentNode ) { currentNode.Nodes.Clear(); DirectoryInfo folder = new DirectoryInfo( currentNode.FullPath ); DriveInfo drive = new DriveInfo( currentNode.FullPath[0].ToString() ); if ( drive.IsReady ) { foreach ( DirectoryInfo subFolder in folder.GetDirectories() ) { try { currentNode.Nodes.Add( new TreeNode( subFolder.Name, iconFolderClosed, iconFolderOpen ) ); } catch { // Fehler ignorieren - Zugriff verweigert } } } UpdateDirectoryCount(); }
Zum rekursiven Einlesen aller Verzeichnisse müssen diese lediglich traversiert und für jedes Verzeichnis die Methode GetDirectories() aufgerufen werden. Da die Verzeichnisse durch TreeNode-Objekte repräsentiert werden, ist diese Methode sehr einfach. private void GetAllDirectories( TreeNode tn ) { if ( tn.Nodes.Count == 0 ) GetDirectories( tn ); foreach ( TreeNode t in tn.Nodes ) GetAllDirectories( t ); }
Einlesen bei Doppelklick Bei einem Doppelklick wird wie im vorangegangenen Beispiel der angeklickte Node ermittelt und mit diesem GetDirectories() aufgerufen. Die Mausposition erhalten wir über das Feld mousePosition. private void TrvDirectories_DoubleClick( object sender, System.EventArgs e ) { TreeNode currentNode = trvDirectories.GetNodeAt( this.mousePosition ); if ( currentNode != null ) { if ( currentNode.Nodes.Count == 0 ) { GetDirectories( currentNode ); currentNode.Toggle(); } } }
Sandini Bib
592
18 Standard-Steuerelemente
Hier scheint es noch einen kleinen Bug zu geben. Überprüft wird in diesem Beispiel nämlich (eigentlich unrichtigerweise), ob der Node bereits Unterelemente besitzt. In dem Fall wird dann die Methode Toggle() aufgerufen, die die untergeordneten Nodes anzeigt oder verbirgt (ja nach aktuellem Status). Wird korrekterweise IsExpanded überprüft, funktioniert das Beispiel nicht mehr – die Elemente lassen sich dann nicht mehr per Doppelklick aufklappen. Die Aussage in der Online-Hilfe, dass mittels Expand() aufgeklappt und mittels Collapse() zugeklappt werden kann ist zwar grundsätzlich richtig – leider besitzt aber das TreeView-Steuerelement einen eingebauten Automatismus. Tests haben ergeben, dass der Node beim Zuklappen bereits zugeklappt war, bevor das Ereignis überhaupt aufgetreten ist. Abschalten lies sich dieses Verhalten leider nicht.
Einlesen aller Unterverzeichnisse Alle Unterverzeichnisse werden über einen Aufruf von GetAllDirectories() eingelesen. Da dies einige Zeit dauern kann, arbeiten wir hier mit BeginUpdate() bzw. EndUpdate() und stellen auch den Cursor auf die berühmte Sanduhr um. private void BtnReadAll_Click( object sender, System.EventArgs e ) { // Alle Unterverzeichnisse des Knotens einlesen TreeNode tn = trvDirectories.SelectedNode; if ( tn != null ) { trvDirectories.BeginUpdate(); this.Cursor = Cursors.WaitCursor; // Hier werden die Verzeichnisse ermittelt GetAllDirectories( tn ); this.Cursor = Cursors.Default; trvDirectories.EndUpdate(); tn.ExpandAll(); } }
Jetzt fehlen nur noch die Ereignisbehandlungsroutinen für die Checkboxen, mit denen die Darstellung der TreeView verändert werden kann. Der Code ist recht einfach, da es sich nur um Zuweisungen an die entsprechenden Eigenschaften der TreeView handelt. private void ChkRootLines_Click( object sender, System.EventArgs e ) { trvDirectories.ShowRootLines = chkRootLines.Checked; } private void ChkPlusMinus_Click( object sender, System.EventArgs e ) { trvDirectories.ShowPlusMinus = chkPlusMinus.Checked; } private void ChkLines_Click( object sender, System.EventArgs e ) { trvDirectories.ShowLines = chkLines.Checked; }
Sandini Bib
Datum und Zeit
593
Damit wäre das Programm fertig. Abbildung 18.23 zeigt einen Screenshot des laufenden Programms.
Abbildung 18.23: Das Beispielprogramm zur Laufzeit
18.7
Datum und Zeit
Die Steuerelemente MonthCalender und DateTimePicker helfen bei der benutzerfreundlichen Eingabe bzw. Auswahl von Daten und Zeiten. Beide Steuerelemente sind direkt von Control abgeleitet, basieren aber intern offensichtlich dennoch auf einem gemeinsamen Code. So erscheint beim Ausklappen des DateTimePicker diesselbe Datumsansicht wie beim MonthCalender.
18.7.1
MonthCalendar
Das Steuerelement MonthCalendar zeigt einen Kalender für einen oder mehrere Monate an. Über die integrierten Pfeilbuttons können Sie den Monat weiterschalten. Weiterhin ist es möglich, den aktuellen Tag zu markieren oder auch mehrere Tage, z.B. für anstehende Termine. Dabei können Sie unter anderem festlegen, dass ein Termin jährlich, monatlich oder wöchentlich markiert werden soll. Leider gibt es kein Steuerelement, das die Anzeige wie z.B. in Outlook auf den Tag herunterbricht und eine detailliertere Zeitangabe ermöglicht.
Sandini Bib
594
18 Standard-Steuerelemente
Gestaltung Die meisten Farben des Steuerelements können mithilfe von Eigenschaften angepasst werden. Die Eigenschaften ForeColor und BackColor sind Ihnen schon von anderen Steuerelementen bekannt. Weitere Eigenschaften sind TitleForeColor für die Schriftfarbe im Titel und TitleBackColor für die entsprechende Hintergrundfarbe. Mithilfe der Eigenschaft TrailingForeColor geben Sie an, mit welcher Farbe die Tage dargestellt werden sollen, die sich vor dem Monatsanfang bzw. nach dem Monatsende befinden. Die Eigenschaft CalendarDimension vom Typ Size gibt an, wie viele Zeilen bzw. Spalten für die Anzeige verwendet werden sollen. Dementsprechend können Sie mehrere Monate auf einen Schlag anzeigen. Stellen Sie beispielsweise für CalendarDimension.Width und CalendarDimension.Height jeweils den Wert 2 ein, werden 4 Monate angezeigt (2 Reihen, 2 Spalten). Die Eigenschaft FirstDayOfWeek gibt an, welcher Wochentag bei der Monatsansicht in der ersten Spalte angezeigt wird. Als Standard gilt die Einstellung des Betriebssystems. Mit der Eigenschaft ShowToday geben Sie an, ob das aktuelle Datum unter der Monatsansicht angezeigt werden soll. Im Kalender selbst ist es hervorgehoben. Über die Eigenschaft ShowTodayCircle können Sie dieses Datum im Kalender rot umranden. Falls Sie sich auch die Nummer der aktuellen Woche anzeigen lassen möchten, können Sie das über die Eigenschaft ShowWeekNumbers einstellen. Wenn Sie Termine markieren wollen, die sich wiederholen, können Sie dazu die Eigenschaften BoldedDates, AnnuallyBoldedDates und MonthlyBoldedDates verwenden. Bei den Eigenschaften handelt es sich um Arrays des Typs DateTime, jeder Eintrag im zugewiesenen Array wird entsprechend der Eigenschaft fett hervorgehoben.
CD
Der folgende Code markiert einige Datumsangaben, die sich jährlich bzw. monatlich wiederholen. So soll der 15. jeden Monats wiederholt markiert werden, zusätzlich einige Feiertage jährlich. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\CalendarControls.
private void btnMark_Click(object sender, System.EventArgs e) { DateTime[] yearly = { new DateTime(2003,12,24), new DateTime(2003,12,25),new DateTime(2003,12,26), new DateTime(2003,11,3), new DateTime(2003,12,31), new DateTime(2003,1,1) }; DateTime[] monthly = { new DateTime(2003,1,15) }; this.calendar.AnnuallyBoldedDates = yearly; this.calendar.MonthlyBoldedDates = monthly; }
Sandini Bib
Datum und Zeit
595
Abbildung 18.24 zeigt die Anzeige im Programm zur Laufzeit.
Abbildung 18.24: Das MonthCalendar-Steuerelement mit markierten Datumsangaben
Sie müssen nicht immer ein komplettes Array angeben. Das wäre nicht besonders benutzerfreundlich, da Sie auch die bereits eingetragenen Daten immer wieder angeben müssten. Stattdessen können Sie auch die Methoden AddBoldedDate(), AddMonthlyBoldedDate() und AddAnnuallyBoldedDate() verwenden, um Daten hinzuzufügen. Die Methoden RemoveBoldedDate(), RemoveMonthlyBoldedDate() und RemoveAnnuallyBoldedDate() dienen entsprechend dazu, markierte Datumsangaben wieder zu entfernen. Die Methode UpdateBoldedDates() aktualisiert nach dem Hinzufügen die Darstellung.
Auswertung des Steuerelements Sie können den Bereich, aus dem ein Datum ausgewählt werden kann, über MinDate und MaxDate eingrenzen. MaxSelectionCount gibt weiterhin an, wie viele aufeinander folgende Tage gleichzeitig markiert werden dürfen. Der Standardwert ist 7 Tage. Auf diese Weise können Sie eine ganze Zeitspanne markieren, z.B. für einen Urlaub. Den ausgewählten Zeitbereich können Sie den Eigenschaften SelectionStart und SelectionEnd entnehmen. Beide Eigenschaften liefern einen Wert vom Typ DateTime, durch Subtraktion können Sie aber auch einen Wert vom Typ TimeSpan ermitteln. Falls Sie es nicht erlauben wollen, dass der Anwender ganze Zeitbereiche markiert, können Sie MaxSelectionCount auf 1 setzen. In diesem Fall enthält die Eigenschaft Value den Wert des markierten Datums. Während jeder Veränderung des markierten Datumsbereichs treten DateChanged-Ereignisse auf sowie zum Abschluss einer Markierung außerdem ein DateSelected-Ereignis.
Sandini Bib
596
18.7.2
18 Standard-Steuerelemente
DateTimePicker
Das DateTimePicker-Steuerelement erscheint standardmäßig als ausklappbares Listenfeld zur Datumseingabe. Daneben gibt es allerdings noch eine Reihe anderer Gestaltungsmöglichkeiten. Im ausgeklappten Zustand erscheint eine Monatsansicht wie bei MonthCalender, wobei allerdings die zahlreichen MonthCalender-spezifischen Eigenschaften zur Gestaltung der Monatsansicht nicht zur Verfügung stehen. Abbildung 18.25 zeigt beispielhaft einige Ansichten des DateTimePicker-Steuerelements.
Abbildung 18.25: Verschiedene Ansichten des DateTimePicker-Steuerelements
Die Art der Darstellung von Datum und Zeit steuern Sie über die Eigenschaft Format vom Typ DateTimePickerFormat. Es gibt drei voreingestellte Formate (DateTimePickerFormat.Long, DateTimePickerFormat.Short, DateTimePickerFormat.Time) und eines, mit dem Sie einen eigenen Formatierungsstring angeben können (DateTimePickerFormat.Custom). Dieser String wird in der Eigenschaft CustomFormat angegeben. Dabei kommen die gleichen Formatierungszeichen zum Einsatz, wie sie in Abschnitt 12.4.3 auf Seite 278 beschrieben sind. Wenn Sie DateTimePickerFormat.Custom angeben und in CustomFormat keine Eingabe machen, wird die Standardansicht verwendet (entspricht DateTimePickerFormat.Long). Mit der Eigenschaft ShowUpDown können Sie festlegen, dass statt des Combobox-ähnlichen Aussehens zwei Pfeile angezeigt werden, mit denen Sie das Datum schrittweise erhöhen oder erniedrigen können. Innerhalb des Steuerelements markieren Sie dazu den gewünschten Teil (Jahr, Monat, Tag, Minute oder Stunde), den Sie erhöhen bzw. erniedrigen wollen (es wird also nicht täglich weitergeschaltet, sondern nur der Part, den Sie wählen, im Wert verändert). Normalerweise wird diese Ansicht für die Zeitanzeige verwendet, da das Aufklappen des Kalenders dort keinen Sinn macht. ShowCheckBox bewirkt, dass neben dem Datum ein Auswahlkästchen angezeigt wird. Der Zustand dieses Kästchens kann über Checked gelesen bzw. verändert werden. MinDate und MaxDate grenzen wie auch beim MonthCalendar den Bereich ein, aus dem das Da-
tum ausgewählt werden kann. Nach der Auswahl eines Datums bzw. einer neuen Zeit tritt ein ValueChanged-Ereignis auf. Bevor die Monatsansicht ausgeklappt wird bzw. nachdem sie wieder eingeklappt wurde, treten die Ereignisse DropDown und CloseUp auf. Das ausgewählte Datum bzw. die Zeit können aus der Eigenschaft Value vom Typ DateTime gelesen werden.
Sandini Bib
Schiebe- und Zustandsbalken, Drehfelder
18.8
597
Schiebe- und Zustandsbalken, Drehfelder
Dieser Abschnitt beschreibt eine Gruppe von Steuerelementen, die vollkommen unterschiedlich aussehen. Ihr gemeinsames Merkmal besteht darin, dass sie bei der Einstellung oder bei der Anzeige eines Werts helfen.
18.8.1
HScrollBar, VScrollBar
Die Steuerelemente HScrollBar und VScrollBar dienen zur Darstellung eines horizontalen bzw. eines vertikalen Schiebebalkens, wie Sie sie aus nahezu jeder Windows-Anwendung kennen. Die Eigenschaften Minimum und Maximum legen die Grenzen fest, innerhalb derer ein Wert eingestellt werden kann. Die Eigenschaft Value enthält den aktuellen Wert, die Eigenschaften SmallChange und LargeChange legen fest, um welchen Betrag dieser Wert beim Klicken verändert wird. SmallChange steht für eine Wertänderung beim Anklicken eines der Pfeile, LargeChange für die Wertänderung, wenn in das Steuerelement geklickt wird. Etwas gewöhnungsbedürftig ist der Umstand, dass der Wertebereich von Value von Minimum bis Maximum-LargeChange+1 reicht (und nicht einfach von Minimum bis Maximum, wie man vielleicht vermuten würde). Dieses Verhalten ist nur dann sinnvoll, wenn Maximum die Länge eines Dokuments angibt und LargeChange die Größe des sichtbaren Bereichs. Wenn Sie also einen Text mit 100 Zeilen darstellen möchten (Maximum=100), aber immer nur 10 Zeilen maximal Platz haben (LargeChange=10), dann beträgt der maximale Wert für Value=10010+1=91. Wenn Sie in Ihrem Programm nun die 91. bis zur 100. Zeile anzeigen, ist tatsächlich die letzte Seite des Texts sichtbar. Wenn Sie den Schiebebalken mit der Maus bewegen, tritt das Ereignis Scroll auf, beim Abschluss der Einstellung das Ereignis ValueChanged. Im folgenden Beispiel dienen drei Scrollbars zur Änderung des Rot-, Grün- und BlauWerts einer Farbe. Die Farbe selbst wird auf einem Panel angezeigt. Die Einstellungen der Scrollbars sind Min=0, Max=270, SmallChange=1, LargeChange=16, sodass der tatsächlich erreichbare Maximalwert 255 beträgt. Zusätzlich zu den drei Scrollbars wird der Wert noch in einem Label-Steuerelement angezeigt. Verwendet werden vertikale Scrollbars. Der Nachteil ist, dass man den Nullpunkt nicht einstellen kann, es ist also nicht möglich, anzugeben, dass der Nullpunkt oben oder unten sein soll. Natürlich befindet er sich oben und damit für das mittlerweile eingeführte Verständnis einer Farbänderung auf der falschen Seite (der Wert soll ja umso höher sein, je weiter oben die Scrollbar ist). Daher muss der Wert umgebaut werden.
CD
Die eigentliche Wertumrechnung und -zuweisung geschieht in der Methode ChangeValue(), die selbst geschrieben ist. Aufgerufen wird sie aus den Ereignissen ValueChanged und Scroll. Alle drei Scrollbars verwenden das gleiche ValueChanged-/Scroll-Ereignis. Gezeigt wird wieder nur der relevante Teil des Programms. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\ScrollbarExample.
Sandini Bib
598
18 Standard-Steuerelemente
private void ChangeValue() { // Farbwerte umrechnen int valueRed = 255-scbRed.Value; int valueGreen = 255-scbGreen.Value; int valueBlue = 255-scbBlue.Value; // Farbe ändern im Panel pnlColor.BackColor = Color.FromArgb(valueRed, valueGreen, valueBlue); // Farbwert in Label anzeigen lblColor.Text = String.Format("R: {0}, G: {1}, B: {2}", valueRed, valueGreen, valueBlue); // Updaten pnlColor.Update(); lblColor.Update(); }
Abbildung 18.26 zeigt einen Screenshot des laufenden Programms.
Abbildung 18.26: Farbeinstellung mittels Scrollbars
Sandini Bib
Schiebe- und Zustandsbalken, Drehfelder
18.8.2
599
TrackBar
Das Steuerelement TrackBar ist ähnlich leicht zu bedienen und zu programmieren wie die ScrollBar-Variante. Allerdings gibt es nur das eine Steuerelement, die Ausrichtung wird über die Eigenschaft Orientation festgelegt. Das TrackBar-Steuerelement besteht aus einer Skala mit Strichen, deren Anzahl Sie festlegen können, und dem eigentlichen Schieberegler. Auch hier enthält Value den aktuellen Wert, Minimum und Maximum legen die Grenzen fest. Mit der Eigenschaft TickStyle können Sie festlegen, auf welcher Seite der Skala sich der Schieberegler befinden soll. Mögliche Einstellungen sind TickStyle.BottomRight, TickStyle.TopLeft oder TickStyle.Both. Im Gegensatz zu den Scrollbars gibt es allerdings einen gravierenden Unterschied – bei der TrackBar ist nämlich in vertikaler Darstellung der Nullpunkt tatsächlich unten. Mit der Eigenschaft TickFrequency können Sie festlegen, für wie viele Einheiten jeweils ein Skalenstrich angezeigt werden soll. Die Eigenschaften SmallChange und LargeChange sind ebenfalls vorhanden, funktionieren allerdings hier ein wenig anders als bei der Scrollbar, denn es gibt ja keine Pfeile an den Seiten. SmallChange bezeichnet hier die Wertänderung, die vorgenommen wird, wenn das Steuerelement den Fokus besitzt und die Pfeiltasten der Tastatur verwendet werden. LargeChange entspricht der Änderung, die eintritt, wenn Sie auf die Skala klicken. Natürlich haben Sie auch wieder Ereignisse zur Verfügung, mit denen Sie auf eine Werteänderung reagieren können. Das Ereignis Scroll tritt auf, wenn der Schieberegler verschoben wird, das Ereignis ValueChanged, wenn der Wert verändert wurde.
Beispiel Ein kleines Beispielprogramm soll wieder zeigen, wie das TrackBar-Steuerelement programmiert werden kann. Die Vorgehensweise ist recht unkompliziert, wie auch bei den Scrollbar-Steuerelementen. In einem Panel soll ein Kreis gezeichnet werden. Der Mittelpunkt des Kreises wird durch TrackBar-Einstellungen festgelegt, wobei eine TrackBar vertikal und eine horizontal dargestellt wird. Zum Zeichnen selbst wird GDI+ verwendet. Die Zeichenmethode selbst ist nicht besonders kompliziert, allerdings wurden die entsprechenden Klassen noch nicht behandelt. Nähere Informationen zu den Grafikklassen erhalten Sie in Kapitel 21 ab Seite 691. Minimum und Maximum werden im Load-Ereignis des Formulars ermittelt und entsprechen
CD
natürlich der Höhe und Breite des Panels, auf dem die Anzeige durchgeführt werden muss. LargeChange ist auf den Wert 10 eingestellt, SmallChange auf 1. TickStyle ist für beide Steuerelemente auf TickStyle.Both eingestellt. Abbildung 18.27 zeigt den Aufbau des Beispielprogramms im Entwurfsmodus. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\TrackbarExample.
Sandini Bib
600
18 Standard-Steuerelemente
Abbildung 18.27: Der Entwurf des Beispielprogramms
Die Methode DrawGraphic() ist für das Zeichnen des Zielkreises verantwortlich. Hier werden wieder Grafikmethoden verwendet, die erst später detailliert erklärt werden. Dennoch sollte der Quelltext leicht verständlich sein. Die Methode TrackBar_ValueChanged() ist die Ereignisbehandlungsroutine für das Ereignis ValueChanged beider Trackbars. private void FrmMain_Load( object sender, System.EventArgs e ) { // Maximalwerte festlegen trbHorizontal.Maximum = pnlShow.Width; trbVertical.Maximum = pnlShow.Height; } private void DrawGraphic() { // X-/Y-Wert berechnen int y = pnlShow.Height - trbVertical.Value; int x = trbHorizontal.Value; // Zeichnen Graphics g = pnlShow.CreateGraphics(); g.Clear( Color.White ); g.FillEllipse( Brushes.Blue, x - 2, y - 2, 4, 4 ); g.DrawEllipse( Pens.Black, x - 10, y - 10, 20, 20 ); g.DrawEllipse( Pens.Black, x - 20, y - 20, 40, 40 ); g.DrawEllipse( Pens.Black, x - 30, y - 30, 60, 60 ); g.Dispose(); } private void TrackBar_ValueChanged( object sender, System.EventArgs e ) { DrawGraphic(); }
Sandini Bib
Schiebe- und Zustandsbalken, Drehfelder
18.8.3
601
ProgressBar
Das ProgressBar-Steuerelement dient dazu, den Fortschritt einer länger andauernden Aktion anzuzeigen. Auch das ist aus Windows hinreichend bekannt. Die Eigenschaft Value gibt an, welcher Fortschritt bisher erreicht wurde, Minimum und Maximum bestimmen die Grenzen. In der Regel werden Sie allerdings die Eigenschaft Value nicht direkt ändern, sondern die Methode PerformStep() anwenden. Der Wert von Value wird dann um den Wert erhöht, der in der Eigenschaft Step festgelegt ist. Die Standardeinstellung hierfür ist 10. Dieses Steuerelement dient nur der Anzeige und hat keine weiteren relevanten Eigenschaften, weshalb sich ein Beispielprogramm an dieser Stelle erübrigt.
18.8.4
NumericUpDown
Das Steuerelement NumericUpDown ermöglicht das Festlegen einer Zahl zwischen Minimum und Maximum entweder über zwei Pfeilbuttons oder auch über die Pfeiltasten der Tastatur. Werte können auch eingegeben werden. Bei Betätigung der Pfeilbuttons wird der aktuelle Wert um den Betrag erhöht oder erniedrigt, der in der Eigenschaft Increment festgelegt ist. Wenn Sie die direkte Eingabe einer Zahl über die Tastatur verhindern möchten, müssen Sie der Eigenschaft Readonly den Wert true zuweisen. Da es sich um ein numerisches Steuerelement handelt, ist die Eingabe von Buchstaben nicht möglich. Die Darstellung der Zahlen kann ebenfalls variiert werden. Mithilfe der booleschen Eigenschaften Hexadecimal und ThousandsSeparator können Sie eine hexadezimale Darstellung bzw. die Anzeige des Tausendertrennzeichens bestimmen. Über die Eigenschaft DecimalPlaces können Sie weiterhin festlegen, wie viele Nachkommastellen angezeigt werden sollen. Den enthaltenen Wert können Sie entweder aus der Eigenschaft Text oder aus der Eigenschaft Value auslesen. Allerdings bietet das Steuerelement auch einige ungewöhnliche Verhaltensweisen. f Bei der Zahleneingabe per Tastatur ist es möglich, einen Wert anzugeben, der größer ist als der in der Eigenschaft Maximum angegebene. Oder kleiner als der in der Eigenschaft Minimum angegebene. Solche Eingaben müssen Sie über das Ereignis Validating abfangen. f Bei Veränderungen des Werts über die Pfeiltasten tritt das Ereignis ValueChanged auf, bei der Eingabe eines Werts allerdings nur das Ereignis TextChanged. Value ändert sich aber trotzdem (warum auch nicht).
18.8.5
DomainUpDown
Das DomainUpDown-Steuerelement ist eine Mischung aus dem NumericUpDown- und dem ComboBox-Steuerelement. Mit den Pfeilbuttons kann ein Element aus einer Liste ausgewählt werden. Über die Tastatur kann auch ein beliebiger Text eingegeben werden, solange die Eigenschaft Readonly nicht auf true eingestellt ist.
Sandini Bib
602
18 Standard-Steuerelemente
Die Listenelemente können im Eigenschaftsfenster über die Items-Eigenschaft eingegeben und über die Eigenschaft Sorted sortiert werden. Intern verweist Items auf ein Objekt der Klasse DomainUpDown.DomainUpDownItemCollection, Items[n]) auf ein allgemeines Objekt (Klasse Object). SelectedItem verweist auf das zuletzt ausgewählte Element, SelectedIndex gibt dessen Indexnummer zurück. Bei der Auswahl eines Listenelements tritt ein SelectedItemChanged-Ereignis auf, bei der Texteingabe ein TextChanged-Ereignis. Beim Programmstart wird die Eigenschaft Text nicht verändert. Wenn Sie möchten, dass dort der Text des ersten Listenelements angezeigt wird, führen Sie die folgende Anweisung aus:
VERWEIS
domainUpDown1.SelectedIndex = 0;
Wenn Sie ein Drehfeld zur Datums- oder Zeiteingabe benötigen, können Sie dazu das DateTimePicker-Steuerelement mit der Einstellung ShowUpDown=True verwenden (siehe Abschnitt 18.7.2 ab Seite 596).
18.9
Gruppieren von Steuerelementen
Zum Gruppieren von Steuerelementen stehen drei Steuerelemente zur Verfügung: Panel, GroupBox und TabControl. Gruppierungen sind dann sinnvoll, wenn entweder die Übersicht über die Elemente eines Formulars nicht mehr gewährleistet ist oder wenn Sie aus kosmetischen Gründen das Formular ansprechender gestalten wollen. Außerdem wird eine Gruppierung für Steuerelemente des Typs RadioButton benötigt, bei denen ja alle Steuerelemente, die sich im gleichen Container befinden, zur gleichen Gruppe gehören. Alle in diesem Abschnitt vorgestellten Steuerelemente sind Container. Auch wenn die Steuerelemente nicht dieselbe Basisklasse haben, zeichnen sie sich durch eine Menge gemeinsamer Eigenschaften aus. Die enthaltenen Steuerelemente können über die Controls-Aufzählung ermittelt werden. Darüber hinaus erfolgt die Positionierung der enthaltenen Steuerelemente in einem lokalen Koordinatensystem, dessen Nullpunkt das linke obere Eck des Containers ist.
18.9.1
Panel
Die Standardeinstellungen des Panel-Steuerelements bewirken, dass dieses zur Laufzeit unsichtbar ist. Damit können Sie also beispielsweise eine Gruppierung ermöglichen, ohne eine visuelle Unterscheidung zu erreichen. Einen Rand können Sie mithilfe der Eigenschaft BorderStyle einstellen. Leider gibt es hier nur drei Möglichkeiten, BorderStyle.None (die Standardeinstellung), BorderStyle.FixedSingle (eine Randlinie) und BorderStyle.Fixed3D (gesunkener Kasten). Das ist leider etwas wenig in der heutigen Zeit, in der ja die meisten Programme vielfältige Designmöglichkeiten beweisen.
Sandini Bib
VERWEIS
Gruppieren von Steuerelementen
603
Im Abschnitt 19.3.1 ab Seite 636 wird eine eigene Panel-Komponente entwickelt, die mehr Möglichkeiten bietet, z.B. verschiedene Rahmenarten oder auch einen Farbverlauf.
Wenn die in einem Panel enthaltenen Steuerelemente mehr Platz einnehmen als vorhanden ist, können Sie Scrollbalken hinzufügen. Das Panel macht dies automatisch, Sie müssen lediglich die Eigenschaft AutoScroll auf true setzen. Eine Beschriftung ist nicht vorgesehen, Sie können jedoch leicht ein Label verwenden und das Panel auf diese Weise beschriften.
Beispielprogramm
CD
Das folgende Beispielprogramm nutzt ein Panel zur Anzeige mehrerer Checkboxen. Diese werden dynamisch erzeugt und dem Panel hinzugefügt. Jedes Steuerelement muss dazu der Eigenschaft Controls des Containers hinzugefügt werden, der dem Steuerelement übergeordnet ist. Da es sich um eine Liste handelt, steht natürlich eine Methode Add() zur Verfügung. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\PanelExample.
Das Panel wurde weiß eingefärbt. Die hinzugefügten CheckBox-Elemente nehmen automatisch die Hintergrundfarbe des übergeordneten Containers an. Dabei handelt es sich um das Panel, daher ist hier keine besondere Einstellung notwendig. Die Eigenschaft AutoScroll des Panels wurde auf true eingestellt. private void BtnAdd_Click( object sender, System.EventArgs e ) { for ( int i = 0; i < 20; i++ ) { CheckBox cbx = new CheckBox(); cbx.Top = i * 20; cbx.Left = 10; cbx.Width = 200; cbx.Text = "Checkbox Nummer " + ( i + 1 ).ToString(); pnlList.Controls.Add( cbx ); } }
Abbildung 18.28 zeigt das Programm zur Laufzeit.
Sandini Bib
604
18 Standard-Steuerelemente
Abbildung 18.28: Ein Panel mit automatischen Scrollbalken
18.9.2
GroupBox
Die GroupBox ähnelt der Panel-Komponente, besitzt allerdings einen Rahmen und bietet die Möglichkeit, eine Überschrift festzulegen. Eine Eigenschaft BorderStyle findet sich nicht, d.h. Sie sind immer auf die gleiche Umrandung angewiesen. Auch die Eigenschaft FlatStyle, die vorhanden ist, bietet nicht wirklich eine Funktion (zumindest ist kein Unterschied zu sehen). Die Eigenschaft AutoScroll ist ebenfalls nicht vorhanden. Außer der gefälligeren Umrandung und dem Titeltext bietet die GroupBox also eigentlich keine besondere Funktionalität. Falls Sie dennoch ähnlich wie im vorherigen Beispiel mehrere Elemente hinzufügen und auch Scrollbalken anzeigen wollen, können Sie ein Panel auf die GroupBox legen, dessen Eigenschaft Dock auf DockStyle.Fill einstellen und die Elemente dann dem Panel hinzufügen.
18.9.3
TabControl (Dialogblätter)
Mithilfe des TabControl-Steuerelements haben Sie die Möglichkeit, mehrere Seiten auf einem Dialog zu platzieren. Die einzelnen Seiten sind durch Register anwählbar und bestehen aus Objekten der Klasse TabPage. Eine Seite können Sie auf mehrere Arten hinzufügen: einmal durch den Menüeintrag REGISTERKARTE HINZUFÜGEN des Kontextmenüs, weiterhin durch den gleichnamigen Link im Hinweisbereich des Eigenschaftsfensters oder aber über die Eigenschaft TabPages des Steuerelements. Diese ermöglicht das Hinzufügen mehrerer Seiten über einen Dialog. Über die Eigenschaft SelectedTab können Sie auf die gerade aktive Registerkarte zugreifen. Die Eigenschaft SelectedIndex liefert entsprechend die Indexnummer der aktiven Registerkarte. Über diese können Sie auch zur Laufzeit die gerade aktive Registerkarte wechseln. Die Eigenschaft TabCount liefert noch die Anzahl der Registerkarten.
Sandini Bib
Gruppieren von Steuerelementen
605
Gestaltung des Steuerelements Das Steuerelement besitzt die üblichen Eigenschaften zur Anordnung auf dem untergeordneten Container. Zusätzlich können Sie über die Eigenschaft Alignment vom Typ TabAlignment festlegen, an welcher Stelle sich die Kartenreiter befinden sollen, die Standardeinstellung ist TabAlignment.Top, also oben. Sollten Sie hier eine seitliche Einstellung wählen, wird der Text entsprechend hochkant angezeigt. In diesem Fall empfiehlt sich auch ein Wechsel der Schriftart, z.B. zu Arial. Die Eigenschaft Appearance vom Typ TabAppearance steuert das Aussehen der Kartenreiter. Zur Auswahl stehen die Werte TabAppearance.Normal, TabAppearance.Buttons und TabAppearance.FlatButtons. Die Möglichkeit einer exakten Darstellung im XP-Stil steht leider nicht zur Verfügung. Über die Eigenschaft MultiLine können Sie festlegen, dass die Register in mehreren Zeilen angeordnet werden. In diesem Fall können Sie über die Eigenschaft RowCount die Anzahl der durch die Beschriftung beanspruchten Zeilen ermitteln. Wenn MultiLine wie in der Standardeinstellung auf false steht und der Platz für die Register nicht ausreicht, werden automatisch Pfeile zum Scrollen angezeigt. Mittels der Eigenschaft SizeMode vom Typ TabSizeMode legen Sie fest, wie die Breite der einzelnen Register bestimmt wird. In der Standardeinstellung sind diese gerade so groß, dass der dargestellte Text Platz findet. Die Einstellung TabSizeMode.Fixed ermöglicht Ihnen, die Größe über die Eigenschaft ItemSize einzustellen. Die Einstellung TabSizeMode.FillToRight ist nur dann relevant, wenn die Reiter über mehrere Zeilen verteilt sind. In diesem Fall wird die Größe der einzelnen Reiter so gewählt, dass jeweils die gesamte Zeile ausgefüllt wird. Zusätzlich können Sie noch über die Eigenschaft Padding festlegen, wie viele Pixel der Beschriftungstext der Dialogblätter vom Rand der Tabellenreiter eingerückt sein soll. Das TabControl-Steuerelement besitzt auch eine Eigenschaft ImageList, der eine entsprechende Komponente zugewiesen werden kann. Für jede einzelne Seite kann dann eines der enthaltenen Bilder ausgewählt werden. Angezeigt wird dieses dann links vom Text auf dem Register der entsprechenden Seite. Die Zuweisung erfolgt allerdings über die Eigenschaft ImageIndex des jeweiligen TabPage-Objekts. Auch die Beschriftung wird über das TabPage-Objekt festgelegt, mithilfe der Eigenschaft Text. Wenn Sie die Eigenschaft ShowToolTips des Steuerelements auf true setzen, können Sie automatisch Hinweistexte anzeigen, wenn der Mauszeiger längere Zeit über einem Register steht. Wieder erfolgt die Zuweisung des eigentlichen Hinweistextes über das TabPageObjekt, nämlich über die Eigenschaft ToolTipText. Falls Ihnen diese Eigenschaften zu wenig Gestaltungsfreiraum geben, können Sie die Tabellenreiter auch komplett selbst zeichnen. Dazu setzen Sie die Eigenschaft DrawMode des TabControl-Steuerelements auf TabDrawMode.OwnerDrawFixed und fügen in die DrawItemEreignisprozedur den erforderlichen Code zum Zeichnen der Reiter ein. Die prinzipielle Vorgehensweise hierfür wird detaillierter in Abschnitt 19.3.1 ab Seite 636 behandelt, wo ein Steuerelement selbst gezeichnet wird.
Sandini Bib
606
18 Standard-Steuerelemente
Gestaltung des Innenbereichs Eine Reihe weiterer Eigenschaften des Klasse TabPage steuert das Aussehen des Innenbereichs einer Registerkarte. f Die Eigenschaften ForeColor, BackColor und Font bestimmen Vorder- und Hintergrundfarbe sowie die verwendete Schriftart. UseVisualStyleBackColor nutzt die Hintergrunddarstellung, wie sie im aktuell eingestellten Windows-Theme festgelegt ist. f Die Eigenschaft BorderStyle legt fest, ob und wie der Innenbereich umrandet wird. f Mithilfe von BackgroundImage können Sie ein Hintergrundbild festlegen. Die Art der Darstellung (gekachelt, gestreckt, zentriert usw.) legen Sie über BackgroundImageLayout fest. f Die Eigenschaft AutoScroll gibt wie beim Panel-Steuerelement an, ob der Innenbereich automatisch mit Scrollbalken ausgestattet werden soll, wenn die darin enthaltenen Steuerelemente sonst keinen Platz mehr finden. Falls die Größe Ihres Dialogfensters nicht variabel ist, sollten Sie diese Eigenschaft auf true setzen: Der Platzbedarf der Steuerelemente ist nämlich auch von der DPI-Systemeinstellung abhängig, und nichts ist lästiger für den Anwender als Dialoge, die wegen der Verwendung augenfreundlicher (also großer) Schriften nur teilweise sichtbar sind.
18.10 Weitere Steuerelemente In diesem Abschnitt werden einige Steuerelemente behandelt, die nicht in eine der Kategorien eingeordnet werden konnten. Dabei handelt es sich um die Steuerelemente SplitContainer, TableLayoutPanel, FlowLayoutPanel, ErrorProvider, ToolTip, HelpProvider, Timer und NotifyIcon.
18.10.1 SplitContainer Der SplitContainer ersetzt das Splitter-Steuerelement aus der Vorgängerversion des Visual Studio. Wie alle ersetzten Steuerelemente (beispielsweise auch sämtliche Menüelemente) existiert das Steuerelement Splitter noch im System, erscheint aber nicht mehr in der Toolbox. Bestehende Applikationen werden also problemlos umgesetzt. Der SplitContainer besteht im Grundsatz aus einem Splitter mit zwei angedockten Panels. Das erleichtert die Bedienung im Vergleich zum Splitter, der ja immer angedockte Panels benötigte, um korrekt zu arbeiten. Das ist nun nicht mehr nötig, die Panels sind implizit vorhanden, die Bedienung gestaltet sich einfacher. f Beide Panels sind in der Tat vollwertige Panel-Objekte, deren Eigenschaften auch verändert werden können. f Über die Eigenschaften Panel1Collapsed und Panel2Collapsed können die Panels jeweils unsichtbar geschaltet werden. f Panel1MinSize und Panel2MinSize geben die Minimalgrößen für die Panels an.
Sandini Bib
Weitere Steuerelemente
607
f Über FixedPanel können Sie ein Panel angeben, dessen Größe bei einem Verschiebevorgang nicht geändert werden soll. f Mittels IsSplitterFixed können Sie den Splitter fixieren, sodass keine Größenänderung möglich ist. f SplitterDistance gibt die aktuelle Position des Splitters an. f SplitterIncrement gibt an, welche Anzahl von Pixeln der Splitter bei einem Verschiebevorgang überspringt, wobei der Standardwert 1 einer kontinuierlichen Verschiebung entspricht. f SplitterWidth gibt die Breite des Splitters an.
18.10.2 TableLayoutPanel Das TableLayoutPanel ist neu zur Komponentensammlung hinzugekommen. Es ermöglicht eine tabellarische Anordnung der Elemente auf einem Formular, und das automatisch. Letztendlich handelt es sich um eine virtuelle Tabelle, die auf dem Formular platziert wird. Die Formularelemente werden in den einzelnen Tabellenzellen platziert und ändern somit ihre relative Position zueinander auch bei einer Größenänderung des Formulars nicht mehr.
18.10.3 FlowLayoutPanel Das FlowLayoutPanel ermöglicht eine Platzierung der Komponenten wie im Internet - direkt nebeneinander. Über die Eigenschaft FlowDirection können Sie die Richtung des Flusses angeben, z.B. von links nach rechts oder von oben nach unten. Damit lassen sich Komponenten sauber und eng neben- oder untereinander anordnen. FlowLayoutPanel und TableLayoutPanel finden in den Beispielen dieses Buchs keine Anwen-
dung. Sie lassen sich allerdings sehr einfach verwenden, falls Sie das möchten. Während das TableLayoutPanel noch einen gewissen Zweck erfüllt, sobald es um größenveränderliche Formulare geht, ist das beim FlowLayoutPanel nicht wirklich gegeben. Gerade die Möglichkeit, Komponenten mit absoluten Werten zu positionieren ist ja einer der Vorteile von Windows.Forms.
18.10.4 Timer Das Timer-Steuerelement könnte man auch als einen Taktgeber bezeichnen. Es tut im Prinzip nichts anderes, als nach der eingestellten Zeit ein Ereignis auszulösen, solange die Eigenschaft Enabled auf true eingestellt ist. In der Standardeinstellung ist der Timer allerdings deaktiviert. Die Einstellung für das Intervall erfolgt in Millisekunden in der Eigenschaft Interval. Sie sollten diese Eigenschaft nicht zu klein einstellen, in der Realität sollten Sie oberhalb von 10-15 Millisekunden bleiben. Eine genaue Zeitmessung ist damit nicht möglich, dafür ist das Steuerelement zu ungenau.
Sandini Bib
HINWEIS
608
18 Standard-Steuerelemente
Wenn das Programm gerade beschäftigt ist (z.B. weil gerade eine Ereignisprozedur ausgeführt wird, die längere Zeit dauert), ist die automatische Ausführung der Tick-Ereignisse so lange unterbrochen. Die Ereignisse werden nicht nachgeholt.
Zeitmessungen Timer werden in der Regel für Zeitmessungen herangezogen. Hierfür existiert in .NET 2.0 eine neue Klasse namens Stopwatch aus dem Namespace System.Diagnostics, die automatisch den genauesten Timer des Systems für die Zeitmessung heranzieht. Die Methode Start() startet die Zeitmessung, Stop() beendet die Zeitmessung. Über die Eigenschaften Elapsed, ElapsedMilliseconds oder ElapsedTicks können Sie die benötigte Zeit ermitteln.
18.10.5 ToolTip Das Steuerelement ToolTip dient zur Anzeige von Hilfsinformationen. Sie kennen diese Anzeigen in kleinen, in der Regel gelben Fenster aus zahlreichen Windows-Applikationen. Es handelt sich dabei um ein unsichtbares Steuerelement.
HINWEIS
Nach dem Einfügen des ToolTip-Elements erhalten die Steuerelemente, die sich auf dem Formular befinden, eine weitere Eigenschaft, ToolTip auf ToolTip1. In dieser Eigenschaft können Sie den Hilfstext eintragen, der angezeigt werden soll, wenn die Maus über dem Steuerelement verharrt. Bei dieser Eigenschaft handelt es sich um eine »virtuelle« Eigenschaft. Eine Einstellung darin bewirkt, dass im »geschützten« Codebereich (also in dem, der vom Windows-Designer generiert wird) für die einzelnen Steuerelemente eine entsprechende Anweisung erstellt wird, z.B. für button1: toolTip1.SetToolTip( button1, "Das ist Button 1" );
Wenn Sie die Hilfetexte zur Laufzeit hinzufügen wollen, können Sie die Methode SetToolTip() des ToolTip-Steuerelements verwenden. Als Parameter werden der Name des Steuerelements, für das der Hinweistext angegeben werden soll, und der Hinweistext selbst erwartet. Natürlich können solche Tooltips auch mehrere Zeilen beanspruchen. private void Form1_Load(object sender, System.EventArgs e) { toolTip1.SetToolTip(textBox1, "Tooltip für TextBox1"); toolTip1.SetToolTip(checkBox1, "Tooltip mit \r\nZeilenumbruch"); } SetToolTip() überschreibt einen bereits vorhandenen ToolTip-Text. Sie können diese Methode also auch dazu verwenden, den ToolTip-Text eines Steuerelements zu verändern. RemoveAll() löscht alle vom ToolTip-Steuerelement verwalteten ToolTips.
Sandini Bib
Weitere Steuerelemente
609
Verhalten Das Verhalten des ToolTip-Steuerelements kann durch einige wenige Eigenschaften beeinflusst werden. f Die Eigenschaft Active gibt an, ob die Hinweistexte automatisch angezeigt werden sollen. Das ist standardmäßig der Fall. f Mit der Eigenschaft InitialDelay können Sie festlegen, nach wie vielen Millisekunden der Hinweistext erscheinen soll. Die Standardeinstellung sind 500 Millisekunden. f Die Eigenschaft AutoPopDelay gibt an, wie lange der Hinweistext sichtbar bleibt. Hier ist die Standardeinstellung fünf Sekunden. f Die Eigenschaft ReshowDelay legt den Zeitraum fest, der bis zur Anzeige des nächsten Tooltips gewartet wird, wenn die Maus in einen anderen Bereich verschoben wird. f IsBalloon legt fest, ob der Tooltip als »Balloon« angezeigt werden soll (Sie kennen sicher diese lästigen Dinger aus der Windows-Startleiste) oder normal. f ToolTipIcon legt ein Icon für den Tooltip fest. Die verfügbaren Icons sind vorgegeben (Information, Warnung und Fehler). f ToolTipTitle legt einen Titel für den Tooltip fest f StripAmpersands legt fest, ob enthaltene kaufmännische Und-Zeichen entfernt werden sollen. f ShowAlways ist die »Nerv«-Einstellung. Wenn diese Eigenschaft auf true eingestellt wird, wird der Tooltip immer angezeigt. Im Falle eines Balloons kann das wirklich lästig werden. Ein Beispiel für einen Tooltip, der mit einem Icon und einem Titel ausgestattet ist und als Ballon angezeigt wird, sehen sie in Abbildung 18.29.
Abbildung 18.29: Ein lästiger und unnötiger Ballon-Tooltip …
Sandini Bib
610
18 Standard-Steuerelemente
ToolTips für einzelne Listeneinträge Bei Listenfeldern wäre es praktisch, Hinweistexte für jeden einzelnen Listeneintrag anzeigen zu können. Leider sieht keines der .NET-Listenfelder diese Möglichkeit vor. Um den gewünschten Effekt dennoch zu erzielen, müssen Sie selbst Hand anlegen. Basis für die ursprüngliche Überlegung war es, einfach in der Methode MouseMove einen entsprechenden Text für das ToolTip-Steuerelement festzulegen. Normalerweise würde dieses sich dann darum kümmern, dass die Zeiten eingehalten und der entsprechende Text angezeigt wird. Leider ist dem nicht so, stattdessen wird sofort ein Hilfstext angezeigt – offensichtlich noch ein kleiner Fehler im Steuerelement selbst. Die folgende Möglichkeit zeigt einen Umweg mithilfe eines Timers, dessen Intervall auf den gleichen Wert eingestellt wird wie die Eigenschaft InitialDelay des ToolTip-Steuerelements. Wird die Maus bewegt, wird der Timer zurückgesetzt. Befindet sich die Maus über einem Node, wird der Text festgelegt, aber erst im Tick-Ereignis des Timers zugewiesen (was auch eine sofortige Anzeige zur Folge hat). Hier der Quelltext des Hauptformulars. private bool mouseInTreeView = false; private string toolTipText = ""; private void TrvList_MouseMove( object sender, System.Windows.Forms.MouseEventArgs e ) { TreeNode tn = trvList.GetNodeAt( e.X, e.Y ); if ( tn != null ) this.toolTipText = "Maus ist über " + tn.Text; else this.toolTipText = "Maus ist über TreeView"; this.timer1.Enabled = false; this.timer1.Enabled = true; } private void TrvList_MouseEnter( object sender, System.EventArgs e ) { this.mouseInTreeView = true; } private void TrvList_MouseLeave( object sender, System.EventArgs e ) { this.mouseInTreeView = false; } private void Timer1_Tick( object sender, System.EventArgs e ) { if ( mouseInTreeView ) this.ttTreeView.SetToolTip( trvList, toolTipText ); }
Sandini Bib
Weitere Steuerelemente
611
CD
Zwar ist das Ergebnis immer noch nicht richtig befriedigend, aber immerhin besser als eine sofortige Anzeige. Weitere Änderungen am Code können durchaus noch Verbesserungen mit sich bringen. Abbildung 18.30 zeigt einen Screenshot des Programms zur Laufzeit. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\ToolTipExample.
Abbildung 18.30: Ein selbst definierter Tooltip
18.10.6 HelpProvider Das HelpProvider-Steuerelement hilft dabei, eine Online-Hilfe zu Ihrem Programm bzw. zu einem bestimmten Steuerelement anzuzeigen. Die Hilfe erscheint normalerweise in einem eigenen Hilfefenster, das automatisch geschlossen wird, wenn das Programm beendet wird.
Voraussetzungen Der erste Schritt zu einem eigenen Hilfesystem besteht im Regelfall darin, eine HTMLHelp-Hilfedatei zu erzeugen (Dateikennung *.chm). Dazu können Sie den mit VS.NET mitgelieferten HTML-Help-Workshop verwenden (Programme\Visual Studio .NET\Common7\Tools\ hcw.exe). Dieses Programm ist allerdings ziemlich alt und ausgesprochen umständlich zu bedienen. Professionelle Hilfedateien für große Projekte werden Sie damit kaum erstellen können. Als Alternative können Sie ein so genanntes Help-Authoring-Programm erwerben. Derartige Programme (z.B. RoboHelp, Doc-to-Help, Help Magician) haben mehrere SoftwareAnbieter im Angebot. Sie bieten ungleich mehr Komfort und Funktionen als hcw.exe. Ihr Hauptnachteil ist aber ihr hoher Preis.
Sandini Bib
612
18 Standard-Steuerelemente
Eine andere Alternative besteht darin, statt einer Hilfedatei eine Website anzugeben, die Hilfedokumente enthält. Der offensichtliche Nachteil dieser Vorgehensweise besteht darin, dass die Hilfe nur dann verfügbar ist, wenn eine Internet-Verbindung besteht. Dieses Buch geht aus Platzgründen nicht weiter auf das Erstellen von Hilfedateien ein. Es setzt vielmehr voraus, dass eine fertige *.chm-Hilfedatei vorliegt. Beachten Sie bitte, dass HelpProvider mit dem älteren Hilfeformat *.hlp aus der Zeit vor Windows 98/2000 nicht mehr zurechtkommt.
Formular und Hilfedatei miteinander verbinden Das HelpProvider-Steuerelement stellt die Verbindung zwischen Ihrem Formular und der Hilfedatei her. Nach dem Einfügen des Steuerelements müssen Sie lediglich dessen Eigenschaft HelpNameSpace so einstellen, dass sie den Dateinamen der Hilfedatei enthält. Diese Einstellung bewirkt allerdings lediglich eine Verknüpfung mit der Hilfedatei und noch nicht die Anzeige durch die Taste (F1). Dazu müssen weitere Eigenschaften eingestellt werden, denn eine Hilfe ist in der Regel kontextsensitiv, d.h. es soll ja auch die korrekte Hilfeseite entsprechend des gerade fokussierten Steuerelements angezeigt werden.
Ort der Hilfedatei beim Programmstart einstellen Da sich der Ort der Hilfedatei bei der Auslieferung des Programms meist ändert, ist es eine gute Idee, die Hilfedatei in dasselbe Verzeichnis wie das Programm zu installieren. Dann können Sie HelpNameSpace beim Programmstart in Form1_Load an den Installationsort anpassen. Der Dateiname des laufenden Programms wird dazu über die Eigenschaft GetExecutingAssembly.Location ermittelt. Dazu müssen die Namespaces System.Reflection und System.IO eingebunden sein. private void Form1_Load( object sender, System.EventArgs e ) { FileInfo fi = new FileInfo( Assembly.GetExecutingAssembly.Location ); helpProvider1.Namespace = fi.DirectoryName + @"\hilfedatei.chm"; }
Hilfetext automatisch durch F1 anzeigen
HINWEIS
Nachdem das HelpProvider-Steuerelement auf dem Formular platziert wurde, finden Sie im Eigenschaftsfenster für die einzelnen Steuerelemente zusätzliche Eigenschaften, nämlich HelpNavigator, HelpKeyWord, HelpString und ShowHelp. Mithilfe dieser Eigenschaften stellen Sie ein, welcher Hilfetext angezeigt werden soll, wenn der Benutzer die Taste (F1) drückt, bzw. ob überhaupt ein Hilfetext angezeigt werden soll. Bei diesen Eigenschaften handelt es sich um fiktive Eigenschaften. Tatsächlich bewirkt eine Einstellung eines Werts dort nur, dass im Designer entsprechender Code eingefügt wird. Das funktioniert im Prinzip analog zum ToolTip-Steuerelement.
Sandini Bib
Weitere Steuerelemente
613
Der Zugriff auf die eigentliche Hilfedatei erfolgt durch eine Kombination aus Einstellungen für HelpKeyword und HelpNavigator. HelpKeyword wird dabei nicht immer benötigt. Hilfe für ein Steuerelement wird nur dann angezeigt, wenn ShowHelp für dieses Steuerelement auf true eingestellt ist. Die folgenden Einstellungen für HelpNavigator sind unter anderem möglich: f HelpNavigator.TableOfContents. Bei dieser Einstellung werden im Hilfefenster die Startseite und das Inhaltsverzeichnis angezeigt. f HelpNavigator.Index. In diesem Fall wird ebenfalls die Startseite, allerdings zusammen mit dem Index, angezeigt. f HelpNavigator.Find. Mit dieser Einstellung können Sie eine Volltextsuche durch die gesamte Hilfedatei durchführen. Der gesuchte Begriff wird in der Eigenschaft HelpKeyword angegeben. f HelpNavigator.KeywordIndex. In dieser Einstellung wird im Hilfefenster der Hilfetext zum Stichwort angezeigt, das in der Eigenschaft HelpKeyword angegeben wird. Das funktioniert natürlich nur, wenn dieser Begriff tatsächlich im Index der Hilfedatei vorhanden ist.
HTML-Datei als Hilfetext verwenden Das HelpProvider-Steuerelement kann statt auf eine Hilfedatei auch auf eine lokale HTMLDatei verweisen. Dazu geben Sie als HelpNameSpace einfach die HTTP-Adresse an, z.B.: helpProvider1.HelpNameSpace = @"C:\infos\index.html"
Damit diese Seite durch (F1) im Standardbrowser des Systems angezeigt wird, verwenden Sie für HelpNavigator die Einstellung HelpNavigator.TableOfContents.
Hilfetexte aus dem Internet anzeigen Eine weitere Variante besteht darin, dass das HelpProvider-Steuerelement auf eine Website verweist. Dazu geben Sie als HelpNameSpace die HTTP-Adresse an, z.B.: helpProvider1.HelpNameSpace="http://www.frankeller.de"
Durch die Einstellung HelpNavigator.Topic für die Eigenschaft HelpNavigator und index.html für HelpKeyword erreichen Sie, dass im Webbrowser die Seite http://www.frankeller.de/index.html angezeigt wird.
Hilfe in einem Popup-Fenster anzeigen Die Möglichkeit, HelpString einzustellen, wurde bisher nicht verwendet. Tatsächlich handelt es sich hierbei um eine spezielle Hilfefunktion, die zwar auch kontextsensitiv ist, aber beispielsweise weder eine Hilfedatei benötigt noch auf eine Volltextsuche oder Ähnliches zurückgreifen kann.
Sandini Bib
614
18 Standard-Steuerelemente
Bei vielen Dialogen haben Sie sicherlich schon den kleinen Fragezeichen-Button in der Titelleiste gesehen. Er erscheint nur dann, wenn die Buttons zum Minimieren und Maximieren ausgeblendet sind, und auch in eigenen Programmen haben Sie die Möglichkeit, diesen Button einzublenden. Wird er angeklickt, verwandelt sich der Mauszeiger in ein Fragezeichen und liefert Hilfeinformationen, wenn ein Steuerelement angeklickt wird. Der Hilfebutton wird über die Eigenschaft HelpButton eines Formulars eingestellt. Wie angesprochen müssen MaximizeBox und MinimizeBox auf false eingestellt werden. Die Texte, die für ein Steuerelement angezeigt werden sollen, werden der Eigenschaft HelpString zugewiesen. Bei dieser Art der Hilfe ist es nicht nötig, eine Hilfedatei zuzuweisen, d.h. die Eigenschaft HelpNamespace des HelpProvider-Steuerelements muss nicht zugewiesen werden.
Die fiktiven Eigenschaften HelpString, HelpNavigator und HelpKeyword Wie bereits angesprochen sind die Eigenschaften HelpNavigator, HelpKeyword, HelpString und ShowHelp fiktiv und werden im Falle eines Eintrags lediglich in Code umgesetzt, der sich in der Designer-Codedatei befindet. Bei diesem Code handelt es sich um Aufrufe von Methoden des HelpProvider-Steuerelements. this.helpProvider1.SetHelpKeyword( this.listBox1, "index.html" ); this.helpProvider1.SetHelpNavigator( this.listBox1, HelpNavigator.Index );
Natürlich können Sie diese Methoden auch in Ihrem eigenen Code verwenden und so die benötigten Einstellungen zur Laufzeit durchführen.
Hilfe explizit aufrufen Das HelpProvider-Steuerelement ermöglicht es, dass Hilfetexte automatisch aufgerufen werden, sobald der Anwender (F1) drückt. Manchmal ist dieser Automatismus unzureichend – etwa wenn Sie möchten, dass das Hilfefenster als Reaktion auf einen Button-Klick oder auf eine Menüauswahl erscheint. Der eigentliche Aufruf der Hilfedatei geschieht auch bei Verwendung des HelpProviderSteuerelements eigentlich über die Klasse Help aus dem Namespace System.Windows.Forms. Die wichtigste Methode dieser Klasse ist ShowHelp(). Zu ShowHelp() gibt es eine Reihe von Syntaxvarianten, die es ermöglichen, eine ganz spezielle Hilfeseite anzuzeigen. Die dabei übergebenen Parameter entsprechen den oben beschriebenen HelpNavigator- und HelpKeyword-Eigenschaften. Ein beispielhafter Aufruf könnte z.B. so aussehen: Help.ShowHelp( this, @"c:\verzeichnis\hilfedatei.chm" )
Zur Anwendung der Klasse Help ist es nicht erforderlich, dass das Formular ein HelpProvider-Steuerelement enthält. Wenn das aber der Fall ist, können Sie dem Steuerelement den Dateinamen der Hilfedatei entnehmen.
18.10.7 ErrorProvider Das Steuerelement ErrorProvider ist eigentlich kein richtiges Steuerelement, denn die Klasse ist direkt von Component abgeleitet. Diejenigen, die den Unterschied zwischen Kompo-
Sandini Bib
Weitere Steuerelemente
615
nenten und Steuerelementen groß schreiben, mögen uns verzeihen, dass wir hier dennoch die Bezeichnung Steuerelement verwenden. Angewendet wird ein ErrorProvider, indem er einfach ins Formular eingefügt wird. Auf die gleiche Art wie bei ToolTip oder bei HelpProvider kann dann ein Fehlertext für jedes Steuerelement zugewiesen werden. Es gibt auch wieder eine fiktive Eigenschaft dafür, deren Verwendung schließt sich allerdings im Regelfall aus, da ein zugewiesener Text automatisch bedeutet, dass ein Fehler vorliegt – und das ist standardmäßig ja nicht der Fall.
TIPP
Die Zuweisung erfolgt normalerweise dort, wo der Fehler auch gefunden wird. Im Falle von TextBox-Steuerelementen beispielsweise in einem der Ereignisse Validating oder TextChanged. Zur Zuweisung wird die Methode SetError() verwendet, die das Steuerelement, das den Fehler beinhaltet, und einen Fehlertext erwartet. Ist der Text zugewiesen, wird neben dem Steuerelement ein Symbol angezeigt, wenn die Maus darüber gezogen wird, erscheint der angegebene Text in Form eines Tooltips. Das Symbol wird entfernt, indem eine leere Zeichenkette zugewiesen wird. Das ErrorProvider-Steuerelement kann auch als gebundenes Steuerelement in Datenbankanwendungen eingesetzt werden. Dazu dienen die Eigenschaften DataSource und DataMember.
Gestaltung In der Standardeinstellung erscheint der Fehlerindikator als Ausrufezeichen in einem roten Kreis. Über die Eigenschaft Icon des Steuerelements können Sie das Erscheinungsbild ändern. Mithilfe der Eigenschaft BlinkStyle können Sie festlegen, unter welchen Voraussetzungen das Icon blinken soll, mit BlinkRate die Geschwindigkeit des Blinkens.
Beispielprogramm
CD
Das Beispielprogramm berechnet die Summe zweier Zahlen. Wenn in einem der Textfelder ein Fehler auftritt, wird das durch den ErrorProvider dargestellt. Dazu wird das Ereignis Validating verwendet, d.h. der Fehler erscheint erst beim Verlassen der Textbox. Im Programm gibt es nur ein Validating-Ereignis, das an beide Textboxen gebunden ist. Die Oberfläche besteht aus drei Textboxen (für die zwei Werte und das Ergebnis) und zwei Buttons (zum Addieren und zum Beenden). Natürlich wird auch ein ErrorProviderSteuerelement eingefügt. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\ErrorProviderExample.
Verwendet wird im Beispiel auch eine Methode IsNumeric(), die VB-Programmierern bekannt vorkommen dürfte, die es aber in C# nicht gibt. Sie ist natürlich selbst programmiert, erfüllt aber den gleichen Zweck wie die VB-Methode, nämlich die Kontrolle, ob ein
Sandini Bib
616
18 Standard-Steuerelemente
String eine Zahl enthält. IsNumeric() verwendet hierfür die Methode TryParse() der Klasse Int32 und ermittelt so die Korrektheit der Eingabe: private bool IsNumeric( string value ) { int theResult; return Int32.TryParse( value, out theResult ); }
Die Methode TryParse() war im Vorgänger lediglich im Datentyp Double zu finden, in .NET 2.0 gibt es sie in jedem Zahlendatentyp. Sie existiert in zwei Varianten, einmal speziell zugeschnitten auf den Datentyp (wobei lediglich der Eingabestring sowie ein out-Parameter angegeben werden muss, der bei einer erfolgreichen Konvertierung das Ergebnis enthält) und in einer allgemeineren Variante. Letztere erfordert als weitere Parameter einen Wert vom Typ NumberStyles, der in System.Globalization deklariert ist, sowie ein Objekt, das IFormatProvider implementiert. Hier können Sie das aktuelle CultureInfo-Objekt verwenden, das Sie über Thread.CurrentThread.CurrentCulture ermitteln können. Der NumberStylesWert gibt an, auf welche Art von Zahlen kontrolliert werden soll (in diesem Fall Ganzzahlen). Sie müssen also die Namespaces System.Threading und System.Globalization einbinden, damit das Programm funktioniert. Hier nun der restliche relevante Code des Programms: private void TxtValue1_Validating( object sender, System.ComponentModel.CancelEventArgs e ) { // Kontrollieren, ob Eintrag gültig ist // Eingabe muss numerisch sein // Ereignis wird nur von Textboxen aufgerufen // daher keine Fehlerkontrolle an dieser Stelle TextBox txt = ( sender as TextBox ); if ( !IsNumeric( txt.Text ) ) errTextBox.SetError( txt, "Es muss eine Zahl angegeben werden" ); else errTextBox.SetError( txt, "" ); // Icon verschwindet } private void BtnAdd_Click( object sender, System.EventArgs e ) { if ( IsNumeric( txtValue1.Text ) && IsNumeric( txtValue2.Text ) ) { int result = Int32.Parse( txtValue1.Text ) + Int32.Parse( txtValue2.Text ); txtResult.Text = result.ToString(); } }
Abbildung 18.31 zeigt einen Screenshot des Programms mit einem Fehler.
Sandini Bib
Weitere Steuerelemente
617
Abbildung 18.31: Der ErrorProvider im Einsatz
18.10.8 NotifyIcon Mithilfe des NotyfyIcon-Steuerelements können Sie ein Icon im so genannten System Tray der Taskbar anzeigen (der kleine Abschnitt, in dem sich auch die Uhr befindet). Das ist vor allem für Programme sinnvoll, die normalerweise im Hintergrund laufen und unsichtbar sind. NotifiyIcon kennt nur zwei Eigenschaften, die von Bedeutung sind: Icon gibt das Symbol an, das angezeigt werden soll, Visible bestimmt, ob das Symbol tatsächlich angezeigt wird.
Das Icon kann wie alle anderen Steuerelemente mit einem Kontextmenü und mit einem ToolTip-Text (mit der Hilfe des ToolTip-Steuerelements) ausgestattet werden.
Das Steuerelement kann die Ereignisse Click, DoubleClick sowie MouseMove, MouseDown und MouseUp verarbeiten. Die DoubleClick-Ereignisprozedur wird üblicherweise dazu verwendet, die Benutzeroberfläche des Programms sichtbar zu machen.
Beispielprogramm
CD
Das Beispielprogramm ist lediglich ein einfacher Wecker, der zur eingestellten Zeit in roter Farbe angezeigt wird. In der übrigen Zeit soll er ein Dasein im System Tray fristen. Zur Einstellung der Zeit wird ein DateTimePicker-Steuerelement benutzt. Da wir nur Stunden und Minuten einstellen wollen, verwenden wir ein benutzerdefiniertes Format: HH:mm. Über einen Button wird das Fenster in den System Tray verkleinert. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_18\NotifyIconExample.
Benötigt wird weiterhin eine TextBox, deren Eigenschaft Readonly auf true eingestellt wird. In ihr wird die aktuelle Zeit angezeigt. Die Kontrolle der Zeit erfolgt jede Sekunde, was über ein Timer-Steuerelement realisiert wird. Und natürlich muss auch noch ein NotifyIconSteuerelement hinzugefügt werden, um das Programm im System Tray anzuzeigen. Damit unser Fenster nicht in der Taskleiste erscheint, setzen wir die Eigenschaft ShowInTaskbar im Ereignis Load des Fomulars auf false.
Sandini Bib
618
18 Standard-Steuerelemente
private void FrmMain_Load( object sender, System.EventArgs e ) { this.ShowInTaskbar = false; }
Über das Ereignis DoubleClick des NotifyIcon-Steuerelements wird das Fenster angezeigt. Dann kann die Zeiteinstellung erfolgen. private void NfiClock_DoubleClick( object sender, System.EventArgs e ) { this.WindowState = FormWindowState.Normal; this.Activate(); }
Nach der Zeiteinstellung müssen wir noch dafür sorgen, dass die Sekunden nicht berücksichtigt werden. Genauer gesagt, sie müssen auf 0 gestellt werden. Das wird im Ereignis ValueChanged des DateTimePicker-Steuerelements erledigt. private void DtpTime_ValueChanged( object sender, System.EventArgs e ) { dtpTime.Value = dtpTime.Value.AddSeconds( -dtpTime.Value.Second ); }
Ein Klick auf den Button btnActivate aktiviert den Wecker und minimiert das Hauptformular. Es ist jetzt nur noch im System Tray zu sehen. Abbildung 18.32 zeigt den System Tray mit aktiviertem Programm.
Abbildung 18.32: Der System Tray mit aktiviertem Wecker
Die wichtigste Methode des Programms ist das Ereignis Tick des Timer-Steuerelements. Hier werden Weckzeit und aktuelle Zeit verglichen und das Programm ggf. angezeigt. Dabei wird der Hintergrund des Formulars rot dargestellt, die Eigenschaft TopMost wird auf true eingestellt und das Formular aktiviert. private void Timer1_Tick( object sender, System.EventArgs e ) { // Zeit kontrollieren und Fenster anzeigen if ( this.WindowState == FormWindowState.Minimized ) { if ( DateTime.Now.TimeOfDay.CompareTo( dtpTime.Value.TimeOfDay ) >= 0 ) { this.WindowState = FormWindowState.Normal; this.Activate(); this.TopMost = true; this.BackColor = Color.Red; } } else { this.txtTime.Text = DateTime.Now.ToShortTimeString(); } }
Sandini Bib
Weitere Steuerelemente
619
Der Hintergrund wird wieder hergestellt, wenn der Anwender auf das Formular klickt. private void FrmMain_Click( object sender, System.EventArgs e ) { this.TopMost = false; this.BackColor = SystemColors.Control; }
Damit wäre der Wecker fertig. Abbildung 18.33 zeigt einen Screenshot des Programms zur Laufzeit.
Abbildung 18.33: Der Wecker zur Laufzeit
Somit wären wir am Ende des Kapitels über die Standard-Steuerelemente. Das .NET Framework hat aber den Anspruch, stark und einfach erweiterbar zu sein, und das gilt natürlich auch für Steuerelemente bzw. Komponenten. Mit selbst definierten Steuerelementen werden wir uns im nächsten Kapitel beschäftigen.
Sandini Bib
Sandini Bib
19 Eigene Steuerelemente erstellen In diesem Kapitel erfahren Sie, wie Sie zusätzlich zu den vorhandenen Steuerelementen auch noch eigene Steuerelemente oder Komponenten erstellen können. Das Thema ist allerdings sehr umfangreich (man könnte ein eigenes Buch dazu schreiben), weshalb hier nur die Grundlagen beschrieben werden können. In diesem Kapitel werden zwei Steuerelemente entwickelt.
19.1
Grundlagen zu Steuerelementen
Wenn es um die Programmierung von Steuerelementen geht, müssen Sie zunächst zwischen Steuerelementen und Komponenten unterscheiden. Alle sichtbaren Steuerelemente sind von der Klasse Control abgeleitet, während die »unsichtbaren« Steuerelemente (Komponenten) von der Klasse Component abstammen. In diesem Kapitel wird es hauptsächlich um die Erstellung eigener sichtbarer Komponenten gehen. Es gibt mehrere Möglichkeiten, eigene Steuerelemente zu erstellen: f Ableiten von einem bestehenden Steuerelement f Ableiten von der Klasse Control f Zusammenfassen mehrerer bereits bestehender Steuerelemente f Einen Wrapper um ein bestehendes oder abgeleitetes Steuerelement legen (für Steuerelemente, die in einem ToolStrip angezeigt werden sollen)
19.1.1
Arten von Steuerelementen
Ableiten von einem bestehenden Steuerelement Wenn Sie die Funktionalität eines bestehenden Steuerelements lediglich erweitern wollen, ist es sinnvoll, eine eigene Klasse zu erstellen, die von diesem Steuerelement erbt. Danach können Sie weitere Eigenschaften, Methoden oder Ereignisse hinzufügen. Diese Möglichkeit ist deshalb recht interessant, weil Sie nicht alles von Grund auf neu erstellen müssen, sondern bereits vorhandene Features verwenden können.
Ableiten von der Klasse Control Diese Vorgehensweise wird nicht sehr häufig verwendet, denn hier müssen Sie die gesamte Funktionalität des Steuerelements inklusive der grafischen Darstellung auf dem Bildschirm selbst programmieren. Von Control abzuleiten empfiehlt sich daher nur dann, wenn Sie ein vollkommen eigenständiges Steuerelement programmieren wollen, dessen Funktionalität bisher noch nicht im .NET Framework vorhanden ist. Als Beispiel sei hier etwa ein Steuerelement zur grafischen Anzeige statistischer Daten genannt.
Sandini Bib
622
19 Eigene Steuerelemente erstellen
Ableiten von der Klasse UserControl Die Klasse UserControl ist eigentlich nur ein Container, in dem mehrere bereits vorhandene Steuerelemente zusammengefasst werden können. Nach der Kompilierung stehen diese in ihrer Gesamtheit als ein Steuerelement zur Verfügung. Ein Beispiel für ein solches Control wäre z.B. eine Bilderliste, die automatisch angezeigt wird. Da bei dieser Vorgehensweise in großem Maße auf grafischer Ebene gearbeitet wird, nennt man sie auch Visual Inheritance. Die Klasse UserControl stammt im Übrigen letztlich von der Klasse Control ab, es handelt sich also auch hierbei um ein vollwertiges Steuerelement, das Sie ebenso in anderen Applikationen einsetzen können. Beim Erstellen eigener Steuerelemente sind einige Dinge zu beachten, die allerdings am besten anhand realer Beispiele erklärt werden. In den folgenden Abschnitten werden zwei neue Steuerelemente entwickelt. Einmal handelt es sich dabei um ein zusammengesetztes Steuerelement, ein anderes Mal soll ein bereits bestehendes Steuerelement erweitert werden.
Ein Wrapper um ein Steuerelement Die Möglichkeit ein Steuerelement zu »wrappen« findet nur ein einziges Einsatzgebiet, nämlich die Verwendung eben dieses Steuerelements in einem Toolstrip oder MenuStrip. Um genau zu sein können Sie jedes beliebige Steuerelement, auch selbst definierte, in einer beliebigen Strip-Komponente anzeigen. Mehr zu diesem Thema erfahren Sie in Abschnitt 20.1.6 ab Seite 661.
19.1.2
Vorbereitung
Wenn es darum geht, Steuerelemente zu erstellen, müssen Sie ein wenig umdenken. Bei der herkömmlichen Anwendungsentwicklung geht es eher darum, dem Anwender Funktionalität zur Verfügung zu stellen, die dieser lediglich anwendet. Der Anwender wird kein neues Programm basierend auf dem Ihren erstellen. Bei Steuerelementen oder ganz allgemein bei Komponenten ist das nicht so. Diese müssen von Grund auf erweiterbar sein, und Sie müssen demjenigen, der das Steuerelement verwendet, die Möglichkeit geben, auf die wichtigen Bestandteile zugreifen zu können. Der Benutzer eines Steuerelements ist ebenfalls ein Programmierer. Die erste Überlegung ist also die Wahl der richtigen Sichtbarkeitsstufe für die Elemente Ihres Steuerelements. Funktionen, die Sie wirklich nur intern nutzen, dürfen ohne weiteres mit der Sichtbarkeitsstufe private belegt werden. Alles aber, was der spätere Anwender möglicherweise nutzen möchte, muss für ihn sichtbar gemacht werden. Da diese Sichtbarkeit nur für abgeleitete Klassen gelten soll (und nicht für denjenigen, der das Steuerelement lediglich einsetzt), bietet sich die Sichtbarkeitsstufe protected an (die ja auch in den .NET-Klassen zu diesem Zweck verwendet wird). Wenn Sie eigene Elemente als protected kennzeichnen, sollten Sie sie auch gleich als virtual kennzeichnen, damit die Funktionalität überschrieben werden kann.
Sandini Bib
Grundlagen zu Steuerelementen
623
Ein anderer Punkt ist möglicherweise noch wichtiger, nämlich der, dass Sie sich an die Regeln der CLS halten. Das bedeutet, dass z.B. nur die Datentypen verwendet werden sollten, die auch in der CLS definiert sind, und dass die öffentlichen Bestandteile Ihres Steuerelements sich nicht allein durch Groß-/Kleinschreibung unterscheiden dürfen. Das ist ein sehr wichtiger Punkt, da Sprachen wie Visual Basic .NET hier keinen Unterschied machen. Ihr Steuerelement wäre in diesen Sprachen dann nicht verwendbar.
Wiederverwendung Das .NET Framework ist konsequent auf wiederverwendbare Bestandteile aufgebaut, und so ist es natürlich auch bei den Steuerelementen. Einerseits gilt daher natürlich, dass Sie das Ganze wiederverwendbar und erweiterbar aufbauen sollen, andererseits wiederum, dass Sie bestehende Teile, die die gewünschte Funktionalität zur Verfügung stellen, ruhig nutzen sollen. Über das reservierte Wort base haben Sie Zugriff auf alle öffentlichen und protected Member der Basisklasse. Ein gutes Beispiel sind die Ereignisse, die bei der Verwendung eines Steuerelements auftreten. Wenn ein Ereignis existiert, sollten Sie dieses nicht neu erfinden, sondern einfach das bestehende verwenden. Dazu überschreiben Sie die Methode, die das Ereignis auslöst (Sie erinnern sich – diese Methode beginnt mit On zuzüglich des Namens des Ereignisses), fügen die gewünschte Funktionalität hinzu und rufen dann die ensprechende Methode der Basisklasse auf. Wir werden diese Vorgehensweise gleich noch anhand eines Beispiels sehen.
Attribute Bei der Entwicklung von Steuerelementen wird sehr stark mit Attributen gearbeitet. Attribute sind unter anderem dafür zuständig, dass eine Eigenschaft im Eigenschaftsfenster sichtbar wird, dass sie eine Beschreibung erhält, oder auch für die Bitmap, die für ihr Steuerelement in der Toolbox erscheint. Sie sollten sich daher mit einigen wichtigen Attributklassen vertraut machen. Falls nicht anders erwähnt, befinden sich die angegebenen Attributklassen im Namespace System.Componentmodel. f Das Attribut Browsable gibt an, ob eine Eigenschaft im Eigenschaftsfenster erscheint oder nicht. Soll die Eigenschaft erscheinen, ist die Verwendung dieses Attributs nicht notwendig, nur wenn Sie eine Eigenschaft ausblenden möchten, müssen Sie es verwenden. f Das Attribut Category steht für die Kategorie, in der die Eigenschaft im Eigenschaftsfenster erscheinen soll. Die Kategorie wird durch einen String angegeben. f Das Attribut Description steht für den Beschreibungstext, der im Eigenschaftsfenster (im unteren Bereich) angezeigt wird. Auch dieser wird als String angegeben. Es handelt sich dabei nicht um den Text, der im Editor mittels IntelliSense angezeigt wird; um diesen Text zu schreiben müssen Sie jeder Eigenschaft einen Dokumentationskommentar verpassen, worauf in diesem Fall aus Platzgründen verzichtet wurde. f Das Attribut ToolboxBitmap aus dem Namespace System.Drawing gibt die Bitmap an, die für das Steuerelement in der Toolbox angezeigt werden soll. Für diese Bitmap gelten einige Vorgaben. Sie können entweder eine Datei angeben oder das Bild aus einer Res-
Sandini Bib
624
19 Eigene Steuerelemente erstellen
sourcendatei des Projekts laden. In diesem Fall muss allerdings der Name des Bilds mit dem Namen des Steuerelements übereinstimmen. Auch hierzu werden Sie in den Beispielprojekten die genaue Vorgehensweise kennen lernen. f Mit dem Attribut DefaultEvent können Sie angeben, welches Ereignis standardmäßig verwendet werden soll. Das Standardereignis ist das Ereignis, das beim Doppelklick auf ein Steuerelement im Entwurfsmodus eingefügt wird. f Das Attribut EditorBrowsable ermöglicht es, eine Eigenschaft oder ein anderes Element aus der IntelliSense-Hilfe auszublenden. Es funktioniert ähnlich wie das Attribut Browsable, erwartet aber einen Parameter vom Typ EditorBrowsableState. f Das Attribut DebuggerHidden aus dem Namespace System.Diagnostics ermöglicht es Ihnen, Code vor dem Debugger zu »verstecken«. Genauer gesagt ist es nicht möglich, innerhalb dieses Codes einen Haltepunkt zu setzen. f Das Attribut DebuggerStepThrough, ebenfalls aus System.Diagnostics, lässt den Debugger den so markierten Code überspringen, d.h. es ist nicht möglich mittels Einzelschritt in diesen Bereich zu springen. Diese Übersicht ist natürlich bei weitem noch nicht komplett, gibt Ihnen aber einen guten Überblick darüber, was alles bei der Komponentenentwicklung möglich ist. In diesem Buch bzw. in diesem Kapitel können wir die Entwicklung eigener Steuerelemente leider nicht bis ganz in die Tiefe behandeln, dazu fehlt schlicht der Platz. Wir denken aber, dass Ihnen zwei Beispiele für eigene Steuerelemente einen guten Einstieg verschaffen werden.
19.2
Zusammengesetzte Steuerelemente
CD
Die Erstellung eines eigenen Steuerelements aus bereits vorhandenen Steuerelementen ist die unkomplizierteste Möglichkeit, eigene Steuerelemente zu erstellen. Der Vorteil liegt dabei in der Möglichkeit, weitgehend visuell zu arbeiten. Das Steuerelement, das hier entwickelt werden soll, ist eine Textbox für IP-Adressen. Diese kann selbstverständlich sehr leicht aus bestehenden Textboxen zusammengesetzt werden, daher ist ein zusammengesetztes Steuerelement die richtige Wahl. Mit dem Steuerelement soll es sowohl möglich sein, IP-Adressen einzugeben (bzw. sie der Text-Eigenschaft zuzuweisen) als auch diese in Form eines Strings und in Form einer IP-Adresse auszugeben. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_19\ExtendedTextBoxes.
19.2.1
Eine IP-Textbox als Steuerelement
Das Projekt beginnt mit der Auswahl des Projekttyps, in diesem Fall wie in Abbildung 19.1 zu sehen eine WINDOWS-STEUERELEMENT-BIBLIOTHEK mit dem Namen IPBox. Beachten Sie hierbei unbedingt den Automatismus des Visual Studio. Verwenden Sie ein Projektmap-
Sandini Bib
Zusammengesetzte Steuerelemente
625
penverzeichnis, das einen anderen Namen trägt als das Projektverzeichnis (damit auch das Testprojekt darin abgelegt werden kann), und legen Sie als Namen für das Projekt etwas anderes fest als den Namen der Klasse, die erstellt wird. Der Name des Projekts ist gleichzeitig der Name des Namensraums, der verwendet wird. Dieser sollte sich unbedingt vom Namen der Klasse unterscheiden, da es ansonsten zu Namenskonflikten kommen kann. Genauer erläutert wird dies noch in Abschnitt 19.3.1 auf Seite 636, wo ein erweitertes Panel erstellt wird. Im Beispielprojekt ist der Namespace für das Projekt Textboxes, der Name der Klasse IPBox.
Abbildung 19.1: Die Auswahl des Projekttyps für das zusammengesetzte Steuerelement
Das Design des Steuerelements Die Entwicklungsumgebung schaltet danach in den Entwurfsmodus und liefert eine Entwurfsansicht ähnlich eines Formulars, allerdings ohne jeglichen Rahmen oder Formularkopf. Es handelt sich in der Tat bereits um ein Steuerelement, das allerdings noch keinerlei Funktionalität beinhaltet. Jedes der vorhandenen Steuerelemente – auch die unsichtbaren Komponenten – kann nun auf diesem Steuerelement-Entwurf abgelegt werden. Zunächst sollten Sie das Steuerelement aber gleich umbenennen. Im Gegensatz zur Vorgängerversion des Visual Studios gibt es hier keine Probleme mehr, sollten Sie diese Umbenennung später vornehmen. Man vergisst solche Kleinigkeiten aber sehr leicht, deshalb machen Sie es am besten gleich jetzt. Der Name des Steuerelements in diesem Beispiel ist IpTextBox. Wenn Sie die Codedatei umbenennen führt das automatische Refactoring dazu, dass auch die Klasse umbenannt wird. Beachten Sie, dass der Designer auch bei einer neuen Komponente das Partial-Class-Feature verwendet und sowohl eine Codedatei als auch eine Designerdatei anlegt.
Sandini Bib
626
19 Eigene Steuerelemente erstellen
HINWEIS
Das neue Steuerelement stellt bereits einige Standard-Eigenschaften zur Verfügung, die allen Steuerelementen gemein sind. Die meisten davon werden voraussichtlich automatisch funktionieren, es sei denn, Sie möchten oder müssen die Funktionalität einschränken. Mitunter werden Sie Funktionalität später aber auch hinzufügen müssen, beispielsweise beim Wechsel der Hintergrundfarbe. Die Implementierung sämtlicher Eigenschaften, die nötig wären, um das Steuerelement wirklich komplett zu machen, ist für den hier zur Verfügung stehenden Platz zu umfangreich. Es werden nur einige wichtige Eigenschaften exemplarisch implementiert. Die übrigen können Sie danach mit wenig Aufwand selbst hinzufügen, falls Sie das möchten. In diesem Beispiel steht vor allem die Funktionalität im Vordergrund.
Unsere IpTextBox benötigt vier Steuerelemente vom Typ TextBox. Dazu benötigen wir für die Punkte, die die Trennung zwischen den Zahlen darstellen, noch vier Labels. Alle können direkt auf dem Steuerelement-Entwurf abgelegt werden. Damit die Reihenfolge gleich stimmt, sollten Sie immer abwechselnd eine Textbox und ein Label, jeweils angedockt an den linken Rand, hinzufügen. Die Eigenschaft Text der Label-Steuerelemente enthält nur den Punkt, der später als Trenner dienen soll. Die Eigenschaft AutoSize ist auf true eingestellt. Die Textboxen sollten nicht zu breit sein, immerhin werden hier später nur drei Ziffern eingegeben. Damit wäre das Design bereits fertig. Wenn Sie sich bereits an dieser Stelle das neue Steuerelement anschauen wollen müssen Sie der aktuellen Projektmappe lediglich ein Windows.Forms-Projekt hinzufügen. Auf diesem können Sie das neue Steuerelement dann ablegen und testen. Es erscheint sofort in der Toolbox in einem eigenen Bereich und wird automatisch aktualisiert, sobald Sie die DLL neu kompilieren. Als Nächstes muss nun die Grundfunktionalität hinzugefügt werden. Alle folgenden Codebestandteile befinden sich innerhalb der automatisch angelegten Klasse (die Sie bereits umbenannt haben sollten).
19.2.2
Funktionalität hinzufügen
Zu den grundlegenden Dingen gehört natürlich die interne Verwaltung des Steuerelements. Alle vier Textboxen müssen sich letztendlich verhalten wie eine einzige. Da wir häufig auf die vier Textboxen zugreifen müssen, legen wir sie bereits am Anfang in einem Array ab, das als Instanzvariable der Klasse definiert wird. Weiterhin wird die Eigenschaft Tag der Textbox mit einer Nummer belegt, die ihre Position widerspiegelt. Das macht es später einfacher, die Position der Textbox zu identifizieren. Hinzugefügt werden vier Felder. Einmal das Array für die Textboxen, dann ein String für den eingegebenen Text, ein Feld für die Hintergrundfarbe und eine boolesche Hilfsvariable, die wir später noch benötigen.
Sandini Bib
Zusammengesetzte Steuerelemente
627
string ipText= String.Empty; // Der IP-Text TextBox[] textBoxes = new TextBox[4]; // Array für die TextBoxen bool valueWasOk = false; // Hilfswert - leider umständlich Color backColor = SystemColors.Window; // Hintergrundfarbe
HINWEIS
Im Konstruktor der Klasse wird das Array nun gefüllt, d.h. unsere Textboxen werden zugewiesen. Für alle Textboxen werden bei dieser Gelegenheit auch gleich Ereignisse zur Verfügung gestellt, die später noch programmiert werden. Dabei handelt es sich um die Ereignisse KeyPress, KeyUp und KeyDown. Diese Ereignisse werden intern verwendet und so nicht nach außen weitergegeben (obwohl natürlich das endgültige Steuerelement auch ein KeyPress-Ereignis haben darf). An dieser Stelle zeigt sich, dass es manchmal nicht so einfach ist, die Linearität eines Buches mit der Art und Weise, in der man programmiert, in Einklang zu bringen. Üblicherweise würden Sie die Ereignisbehandlungsroutinen zuerst erstellen und dann im Konstruktor den Ereignissen zuweisen. IntelliSense hilft jedoch, wenn Sie die Zuweisung ohne bestehende Ereignismethoden durchführen; leere Methodenrümpfe können Sie sofort erstellen, Sie müssen lediglich die Tab-Taste bemühen.
public IpTextBox() { InitializeComponent(); // TextBox-Array initialisieren textBoxes[0] = txtIP1; textBoxes[0].Tag = 0; textBoxes[1] = txtIP2; textBoxes[1].Tag = 1; textBoxes[2] = txtIP3; textBoxes[2].Tag = 2; textBoxes[3] = txtIP4; textBoxes[3].Tag = 3; // Event-Handler festlegen foreach ( TextBox txt in textBoxes ) { txt.KeyPress += new KeyPressEventHandler( this.Internal_KeyPress ); txt.KeyDown += new KeyEventHandler( this.Internal_KeyDown ); txt.KeyUp += new KeyEventHandler( this.Internal_KeyUp ); } this.ipText = "0.0.0.0"; this.BorderStyle = BorderStyle.Fixed3D; FillTextBoxes(); }
Sandini Bib
628
19 Eigene Steuerelemente erstellen
Um zu zeigen, dass diese Ereignisse intern sind, werden eigens dafür Methoden zur Verfügung gestellt, die mit Internal_ beginnen. Der Name ist ohnehin irrelevant, lediglich die Signatur muss übereinstimmen. Auf diese Methoden zur Ereignisbehandlung kommen wir gleich zurück.
Hilfsmethoden Als Nächstes soll es um die Hilfsmethoden gehen, die wir benötigen. Der Programmierer, der unsere IP-Textbox verwendet, soll die Möglichkeit haben, über die Eigenschaft Text eine neue IP zuzuweisen. Da diese aber einem bestimmten Format entsprechen muss, müssen wir dieses Format auch kontrollieren. Dazu bedienen wir uns der Methode Parse() der Klasse IPAddress, die tut nämlich genau das. Wenn die IP-Adresse korrekt ist, wird dabei ein IPAddress-Objekt geliefert, andernfalls eine Exception ausgelöst. Über einen try-catchBlock fangen wir die Exception ab und liefern im Fehlerfall den String 0.0.0.0 zurück. Um die Klasse IPAddress verwenden zu können müssen Sie den Namespace System.Net einbinden. private string GetIpText(string aText) { IPAddress currentIpAddress = null; try { currentIpAddress = IPAddress.Parse(aText); } catch { return "0.0.0.0"; } return currentIpAddress.ToString(); }
HINWEIS
Eine weitere Hilfsmethode wird bereits während der Eingabe benötigt, denn schon beim Tastendruck soll kontrolliert werden, ob der eingegebene Wert wirklich zwischen 0 und 255 liegt. Auch diese Hilfsmethode ist recht einfach und erfordert keine weitere Erklärung. Normalerweise würde eine solche Überprüfung im Ereignis Validating durchgeführt, für dieses Steuerelement ist es jedoch sinnvoll, dass die Eingaben, die nicht erlaubt sind, von vornherein nicht gemacht werden können. Nicht abgefangen wird auf diese Art das Einfügen eines Textes über die Zwischenablage. Das erfordert eine umfangreichere Behandlung. Alle TextBoxen müssen das Signal empfangen können, der eingefügte Wert muss kontrolliert und ggf. umgewandelt werden, gleichzeitig darf aber nicht das Ereignis TextChanged verwendet werden, das ja auch bei einem herkömmmlichen Tastendruck ausgelöst würde. Aus diesem Grund ist diese Vorgehensweise nicht in diesem Beispiel enthalten.
private bool ValueIsValid( string aText ) { int theValue = Int32.Parse( aText ); return ( ( theValue > 0 ) && ( theValue < 256 ) ) ? true : false; }
Sandini Bib
Zusammengesetzte Steuerelemente
629
Sie werden feststellen, dass hier keine Überprüfung darauf stattfindet, ob der übergebene String wirklich nur eine Zahl enthält. Das ist auch nicht nötig, denn in der Methode Internal_KeyPress, die ja dem KeyPress-Ereignis aller Textboxen des Steuerelements zugeordnet ist, wird nur die Eingabe von Ziffern erlaubt – dadurch können wir sicher sein, dass die Textboxen nie etwas anderes als Zahlen enthalten. Die dritte und letzte Hilfsmethode füllt die Textboxen mit dem Wert, der in ipText gespeichert ist. Dieser wird über die Methode String.Split() aufgeteilt und dann den einzelnen Textboxen zugewiesen. private void FillTextBoxes() { // Füllt die Textboxen mit dem neuen Wert string[] values = this.ipText.Split( '.' ); for ( int i = 0; i < 4; i++ ) textBoxes[i].Text = values[i]; // Text wurde geändert this.OnTextChanged( new EventArgs() ); }
Bei dieser Aktion wird der Text natürlich geändert, daher wird hier auch die Methode OnTextChanged() aufgerufen, die wir allerdings noch programmieren müssen. Sie ruft letztendlich das Ereignis TextChanged auf.
Eigenschaften Kommen wir nun zu den Eigenschaften unseres Steuerelements. Zwei Eigenschaften werden benötigt, einmal die Eigenschaft Text und dann die Eigenschaft IP, die die IP-Adresse als Objekt vom Typ IPAddress liefern soll. Die Eigenschaft Text wird bereits von der Basisklasse zur Verfügung gestellt, weshalb wir sie überschreiben müssen. Die Eigenschaft IP allerdings stammt von uns selbst, sie deklarieren wir als virtual, sodass sie bei einer möglichen weiteren Ableitung überschrieben werden kann. Weiterhin verwenden wir nun auch Attribute, denn die Eigenschaft IP soll nicht im Eigenschaftsfenster erscheinen, die Eigenschaft Text allerdings schon. Der Konsistenz halber verwenden wir auch hier das Attribut Browsable, es wäre aber im Prinzip nicht nötig. Weiterhin legen wir fest, dass Text in der Kategorie APPEARANCE erscheinen soll (in der deutschen Version ist das die Kategorie DARSTELLUNG) und legen einen beschreibenden Text fest. Die Eigenschaft BackColor wird ebenfalls implementiert. Und hier gibt es eine Besonderheit. Natürlich müssen die Hintergrundfarben aller Elemente, also sowohl des Controls selbst als auch der TextBox-Elemente und der Labels angepasst werden. Sollten Sie, wie im Beispiel geschehen, die Hintergrundfarbe aller Elemente per Eigenschaftsfenster gleich auf SystemColors.Windows festgelegt haben, wird diese Änderung in der Designer-Datei eingefügt. Und damit ergibt sich ein Problem folgender Art:
Sandini Bib
630
19 Eigene Steuerelemente erstellen
Im Constructor wird InitializeComponent() aufgerufen, bevor unsere eigenen Initialisierungen erfolgen. Das ist richtig so und die einzig mögliche Vorgehensweise. Damit ist aber das Array der Textboxen noch nicht initialisiert. Innerhalb von InitializeComponent() taucht nun aber eine Zuweisung an unsere eigene Eigenschaft BackColor auf, in der genau dieses Array verwendet wird, um den Textboxen eine Farbe zuzuweisen. Es gibt zwei Möglichkeiten. Entweder Sie verwenden das Array nicht, sondern weisen den Textboxen direkt Werte zu oder Sie überprüfen die Textboxen auf null. [Browsable( true )] [Category( "Appearance" )] [Description( "Die IP-Adresse im dotted-quad-Format" )] public override string Text { get { return this.ipText; } set { this.ipText = GetIpText( value ); FillTextBoxes(); } } [Browsable( false )] public virtual IPAddress IP { get { return IPAddress.Parse( this.ipText ); } set { this.ipText = value.ToString(); } } public override Color BackColor { get { return this.backColor; } set { this.backColor = value; // TextBoxen und Labels auf die Hintergrundfarbe setzen foreach ( TextBox tb in this.textBoxes ) { if ( tb != null ) tb.BackColor = value; } this.lblFirstDot.BackColor = value; this.lblSecondDot.BackColor = value; this.lblThirdDot.BackColor = value; } }
Sandini Bib
Zusammengesetzte Steuerelemente
631
HINWEIS
Damit haben wir das Steuerelement fast fertig programmiert. Es fehlen nur noch die Aufrufe für die Ereignisse KeyPress (des Steuerelements selbst, nicht die interne Methode) und alle Ereignisbehandlungsroutinen, die intern verwendet werden und somit nach außen hin nicht sichtbar sind. Der Name der Kategorie, den Sie beim Category-Attribut angeben können, ist nicht festgelegt. Sie können jede beliebige Bezeichnung verwenden. Innerhalb des Eigenschaftsfensters werden auch die auf diese Art und Weise selbst definierten Kategorien angezeigt.
Interne Ereignisse Zu programmieren sind drei Ereignisbehandlungsroutinen, nämlich Internal_KeyPress(), Internal_KeyUp() und Internal_KeyDown(). Hier kommt jetzt auch die Hilfsvariable ins Spiel, die wir festgelegt haben. Das komplexeste Ereignis ist Internal_KeyPress(). Hier muss auf mehrere Dinge geachtet werden: f Nur die Eingabe von Zahlen ist erlaubt f Die Tasten (æ) und (Entf) müssen funktionieren f Bei der Eingabe eines Punkts muss zum nächsten Element gesprungen werden Hier die etwas umfangreichere Methode in der Gesamtheit. Sie sollte dennoch verständlich sein. protected void Internal_KeyPress( object sender, KeyPressEventArgs e ) { // KeyPress-Ereignis für alle TextBoxen – Index liegt in Tag TextBox currentTextBox = ( sender as TextBox ); if ( currentTextBox != null ) { int txtIndex = (int)currentTextBox.Tag; if ( char.IsDigit( e.KeyChar ) ) { // Kontrolle Ziffer e.Handled = !( ValueIsValid( ( (TextBox)sender ).Text + e.KeyChar ) ); } else if ( ( e.KeyChar == '.' ) && ( txtIndex < 3 ) ) { // Kontrolle Punkt textBoxes[txtIndex + 1].Focus(); e.Handled = true; } else if ( ( !Char.IsDigit( e.KeyChar ) ) && // Kontrolle Sondertasten ( !e.KeyChar.Equals( (char)( (int)Keys.Back ) ) ) && ( !e.KeyChar.Equals( (char)( (int)Keys.Delete ) ) ) ) { e.Handled = true; } // Text wurde geändert - Hilfsvariable auf true setzen this.valueWasOk = !e.Handled; } }
Sandini Bib
632
19 Eigene Steuerelemente erstellen
Am Ende der Methode wird die Hilfsvariable valueWasOk auf true gesetzt. Normalerweise würde hier der Aufruf der Methode OnTextChanged() erfolgen, da das erfolgreiche Abhandeln der Methode bedeutet, dass der Text erfolgreich geändert wurde. Das Problem dabei ist, dass die wirkliche Textänderung erst nach Verlassen der Methode erfolgt. Wie Sie gleich sehen werden verwenden wir OnTextChanged() aber, um dem Feld ipText den neuen Text zuzuweisen. Da dies geschehen würde, bevor der neue Text in den Textboxen sichtbar ist, würde ipText den falschen (vorherigen) Text enthalten. Aus diesem Grund wird das korrekte Abhandeln zwischengespeichert und erst in Internal_KeyUp() wird dementsprechend OnTextChanged() aufgerufen. Außerdem wird in dieser Methode noch direkt weitergeschaltet, wenn eine Textbox drei Zeichen enthält (in dem Fall ist ohnehin keine weitere Eingabe möglich). Ausgenommen vom Weiterschalten ist in jedem Fall die hinterste unserer Textboxen, denn da ist ja das Ende erreicht. Das Zurückschalten in eine vorherige Textbox wird allerdings ein wenig zu einem Problem. Zwar funktioniert es tadellos (die Tabulatortaste mit (ª) wird automatisch ausgeführt, d.h. wir müssen uns nicht um das Zurückspringen kümmern), beim Loslassen der (ª)-Taste aber wird Internal_KeyUp() erneut aufgerufen – und da eine Textbox durchaus bereits drei Zeichen enthalten kann, führt das dazu, dass automatisch wieder zur nächsten Textbox gesprungen würde. Dieses Verhalten muss abgefangen werden, deshalb auch die umfangreiche Abfrage der KeyCodes. protected void Internal_KeyUp( object sender, KeyEventArgs e ) { TextBox currentTextBox = ( sender as TextBox ); if ( currentTextBox != null ) { // Bei drei Zeichen in der Textbox, weiterschalten int txtIndex = (int)currentTextBox.Tag; int textLength = currentTextBox.Text.Length; if ( ( textLength == 3 ) && ( txtIndex < 3 ) && !e.KeyCode.Equals(Keys.Tab) && !e.KeyCode.Equals(Keys.ShiftKey) ) textBoxes[txtIndex + 1].Focus(); if ( valueWasOk ) { this.valueWasOk = false; this.OnTextChanged( new EventArgs() ); } } }
Als letzte Ereignisbehandlungsroutine nun noch die Methode Internal_KeyDown(). Diese verwenden wir, um uns, wie aus vergleichbaren Steuerelementen anderer Programme bekannt, mithilfe der Pfeiltasten in den Textboxen zu bewegen. Die Pfeiltasten dienen also zum Weiterschalten zwischen den Textboxen.
Sandini Bib
Zusammengesetzte Steuerelemente
633
protected void Internal_KeyDown( object sender, KeyEventArgs e ) { // KeyDown-Ereignis // Eigenschaft Tag ist mit dem Index vorbelegt TextBox currentTextBox = ( sender as TextBox ); if ( currentTextBox != null ) { int txtIndex = (int)currentTextBox.Tag; // Bewegung nach links if ( e.KeyCode == Keys.Left ) { if ( txtIndex > 0 ) textBoxes[txtIndex - 1].Focus(); } else if ( e.KeyCode == Keys.Right ) { if ( txtIndex < 3 ) textBoxes[txtIndex + 1].Focus(); } e.Handled = true; } }
Externe Ereignisse In diesem Beispiel werden nur zwei Ereignisse nach außen getragen, nämlich KeyPress und Resize. Die beiden Methoden, die wir überschreiben müssen, heißen OnKeyPress() und OnResize(). Letztere Methode wird allein aus dem Grund benötigt, dass bei einer Größenänderung ja auch die Größe der Textfelder angepasst werden muss. Das funktioniert übrigens dann auch im Entwurfsmodus. OnKeyPress() wird dazu verwendet, einerseits das Ereignis nach außen hin aufzurufen, andererseits aber auch den Wert von ipText neu festzulegen. In OnResize() wird die benötigte
HINWEIS
Größe der Textboxen errechnet und zugewiesen. Wenn Sie bereits vorhandene Ereignisse verwenden, wie in diesem Fall, sollten Sie immer die entsprechende Methode On...() überschreiben und nie ein eigenes, gleichnamiges Ereignis erzeugen. Um das Ereignis letztendlich nach außen hin auszulösen, genügt es, die gleichnamige Methode der Basisklasse aufzurufen.
protected override void OnResize( System.EventArgs e ) { // Größe ändern // Größe der Textboxen wird angepasst int newWidth = (int)( ( this.ClientSize.Width - ( lblFirstDot.Width * 3 ) ) / 4 ); if ( newWidth > 0 ) { foreach ( TextBox txt in this.textBoxes ) { if ( txt != null ) txt.Width = newWidth;
Sandini Bib
634
19 Eigene Steuerelemente erstellen
} } base.OnResize( e ); } protected override void OnTextChanged( EventArgs e ) { // ipText zuweisen bei Änderung string result = String.Empty; for ( int i = 0; i < 4; i++ ) { result += textBoxes[i].Text; if ( i < 3 ) result += "."; } this.ipText = result; base.OnTextChanged( e ); }
Damit wäre unser eigenes Steuerelement prinzipiell fertig und wir können es testen. Vorher wollen wir jedoch noch ein Standard-Ereignis festlegen (über das Attribut DefaultEvent). Dieses Attribut kann nur auf Klassenebene angewendet werden, gehört also über die Klassendeklaration. Im Beispielcode sieht das dann so aus: [DefaultEvent( "TextChanged" )] public class IPBox : System.Windows.Forms.UserControl { [ ... ] }
Es muss nur der Name des Ereignisses angegeben werden, das als Standardereignis fungieren soll. Wenn Sie zur Entwurfszeit auf das Steuerelement doppelklicken, werden Sie feststellen, dass genau dieses Ereignis eingefügt wird.
19.2.3
Die Bitmap für die Toolbox
Selbstverständlich muss ein selbst definiertes Steuerelement auch eine eigene Bitmap besitzen. Für eine solche Bitmap gilt die Vorgabe, dass sie 16x16 Pixel groß sein muss. Die Anzahl der verwendeten Farben spielt keine Rolle, üblich sind 256 Farben (die reichen absolut aus). Es gibt drei verschiedene Möglichkeiten, eine Bitmap hinzuzufügen. Alle diese Möglichkeiten werden durch das Attribut ToolboxBitmap realisiert. Wirklich sinnvoll sind allerdings nur zwei dieser Möglichkeiten. Bei der dritten Möglichkeit können Sie eine Datei angeben, die dann von der Festplatte geladen wird. Das ist deshalb eine eher unerwünschte Vorgehensweise, weil die Bitmap dann auch an der vorgeschriebenen Stelle auf der Festplatte enthalten sein muss. Um solchen Unwägbarkeiten zu entgehen, betten wir die Bitmap als Ressource in unsere Datei ein und laden sie von dort.
Sandini Bib
Zusammengesetzte Steuerelemente
635
Fügen Sie dem Projekt ein neues Element hinzu (Kontextmenü des Projekts, HINZUFÜGEN | NEUES ELEMENT). Aus dem erscheinenden Dialog wählen Sie Bitmapdatei aus. Der Editor erscheint und Sie können die Bitmap zeichnen (müssen allerdings vorher noch über das Eigenschaftsfenster die Größe auf 16x16 Bildpunkte festlegen. Sollten Sie bereits eine Bitmap gezeichnet haben, die den vorgeschriebenen Kriterien entspricht, können Sie sie auch in das Projektverzeichnis kopieren. Das Visual Studio fragt dann nach, ob die Bitmap neu geladen werden soll. Klicken Sie auf JA, und schon erscheint Ihre Bitmap. Sie muss übrigens im Format bmp vorliegen. Jetzt müssen Sie noch festlegen, wie die Bitmap in das Projekt eingefügt werden soll. Das wird durch die Eigenschaft Buildaktion festgelegt, die standardmäßig auf Inhalt eingestellt ist. Stellen Sie hier Eingebettete Ressource ein, ansonsten funktioniert es nicht. Jetzt haben wir fast alles vorbereitet.
Bitmap einbinden Zwei Möglichkeiten haben Sie, die Bitmap einzubinden. Die einfachste ist, die Bitmap so zu bezeichnen wie das Steuerelement selbst und sie auf oberster Ebene einzubinden (also auf der gleichen Ebene auf der sich auch die Steuerelementklasse befindet). Dann muss im Konstruktor des ToolboxBitmapAttribute nur der Typ des Steuerelements angegeben werden und die Bitmap wird gefunden. Die zweite Möglichkeit beinhaltet die Angabe eines Namens für die einzubindende Bitmap. In diesem Fall muss der komplette Name angegeben werden. Sollte sich die Bitmap in einem Unterverzeichnis befinden, ist dieses als Namespace zu sehen. Der korrekte Name einer Bitmap, die sich (wie in diesem Beispiel) im Unterverzeichnis Bitmap befindet und IpBox.bmp heißt, wäre »Bitmap.IpBox«. Die Endung .bmp ist nicht notwendig. [DefaultEvent( "TextChanged" )] [ToolboxBitmap( typeof( IpTextBox ), "Bitmap.IpBox" )] public partial class IpTextBox : UserControl { [ ... ] }
Eine passende Bitmap finden Sie im Verzeichnis des Projekts auf der CD.
19.2.4
Testen des Steuerelements
Zum Testen des neuen Steuerelements fügen Sie der Projektmappe einfach ein neues Projekt hinzu. Das neu erstellte Steuerelement erscheint sofort in der Toolbox, in einem eigenen Bereich, und kann verwendet werden. Machen Sie sich keine Sorgen, falls nicht die verwendete Bitmap angezeigt wird, die Sie eingefügt haben – in dieser Ansicht wird diese Bitmap nicht verwendet. Das Steuerelement lässt sich nun auf dem Formular platzieren, in der Größe verändern, die Ereignisse können angewählt werden und Sie können sogar schon Text eintragen wenn Sie wollen. Sie werden feststellen, dass das Steuerelement genauso reagiert, wie es programmiert wurde – der eingegebene Text wird auch zur Entwurfszeit bereits kontrol-
Sandini Bib
636
19 Eigene Steuerelemente erstellen
liert und bei einer fehlerhaften Eingabe wird 0.0.0.0 eingetragen. Abbildung 19.2 zeigt ein Formular mit dem Steuerelement im Einsatz.
Abbildung 19.2: Der Test des IP-Steuerelements
19.3
Abgeleitete Steuerelemente
Abgeleitete Steuerelemente sind dann sinnvoll, wenn die Funktionalität eines bestehenden Steuerelements beibehalten und lediglich erweitert werden soll. Das Prinzip ist das gleiche wie bei einem zusammengesetzten Steuerelement, nur dass diesmal nicht von UserControl abgeleitet wird, sondern von einer Steuerelementklasse und natürlich dass Sie das neue Steuerelement nicht aus mehreren zusammensetzen.
19.3.1
Ein erweitertes Panel
CD
Das Beispielprojekt soll ein Panel mit erweiterten Möglichkeiten sein. Es soll die Möglichkeit eines Farbverlaufs beinhalten, der automatisch angezeigt wird und dessen Mittelpunkt festgelegt werden kann, sowie mehrere verschiedene Formen von Umrandungen, die vom Standard-Panel nicht implementiert werden. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_19\ExtendedPanels.
Wir beginnen auch in diesem Fall mit einer Windows-Steuerelementbibliothek, auch wenn wir letztendlich nicht von UserControl, sondern von der Klasse Panel ableiten. Wieder müssen wir darauf achten, dass der verwendete Namespace nicht mit dem Klassennamen übereinstimmt. Bei diesem Projekt ist das besonders wichtig, denn unser Panel soll eine neue Eigenschaft erhalten, die den gleichen Namen trägt wie die Aufzählung, mit der der Wert festgelegt wird. Die Unterscheidung zwischen den beiden ist dann nur noch über den Namespace möglich. Würde der Namespace den gleichen Namen wie die Klasse
Sandini Bib
Abgeleitete Steuerelemente
637
haben, dann würde der Compiler innerhalb der Klasse nach der Aufzählung suchen. Finden würde er allerdings unsere Eigenschaft und somit kommt es dann zu einem Fehler. Abbildung 19.3 zeigt den Dialog mit den Einstellungen, die für das Beispielprojekt verwendet wurden.
Abbildung 19.3: Die Einstellungen für das Projekt
Vorüberlegungen Bevor Sie das Verhalten eines Steuerelements ändern, sollten Sie sich einige Gedanken darüber machen, was Sie eigentlich wollen. Das soll auch hier der Fall sein. Vorweg: Auch wenn Sie eine Steuerelementbibliothek angelegt haben werden Sie das automatisch eingefügte UserControl nicht mehr benötigen. Sie können es einfach löschen. Stattdessen legen Sie eine neue Klasse an, benennen sie GradientPanel und leiten sie von System.Windows. Forms.Panel ab. Die gesamte Funktionalität wird in dieser Klasse erstellt, eine weitere Datei (und damit eine Aufteilung in eine Partial Class) ist nicht notwendig. Kommen wir zur gewünschten Funktionalität. Das Standard-Panel ist, wie Sie wissen, lediglich eine Möglichkeit, Elemente zu gruppieren. Unser Panel soll mehr bieten und ein Design-Objekt darstellen, das vielfältig verwendet werden kann. f Das Panel soll mehrere Arten von Umrandungen darstellen können, z.B. eine geätzte Umrandung oder auch einen gehobenen Rand. Unter .NET 1.1 wurde dazu eine eigene Aufzählung sowie diverse eigene Zeichenmethoden benötigt. In .NET 2.0 enthält die Enumeration System.Windows.Forms.Border3DStyle entsprechende Eintragungen (die Aufzählung wurde erweitert). Damit ist es möglich, die Klasse ControlPaint zum Zeichnen des Rands zu verwenden, was eine enorme Erleichterung darstellt.
Sandini Bib
638
19 Eigene Steuerelemente erstellen
f Das Panel soll (der Name verrät es) auch in der Lage sein, einen Farbverlauf darzustellen. Um es nicht unnötig kompliziert zu machen, soll der Farbverlauf nur aus zwei Farben bestehen. Der Mittelpunkt des Verlaufs soll aber angegeben werden können, und natürlich soll auch der Farbverlauf ausgeschaltet werden können. f Die Eigenschaft BorderStyle muss ausgeblendet werden (entfernen können wir sie nicht), sie wird keine Rolle mehr spielen. Ersetzt wird sie durch die Eigenschaft Border3DStyle vom Typ Border3DStyle. f Das Panel soll auch einen Text anzeigen können (was man natürlich von einem DesignElement auch erwarten kann). Hier kommt uns zugute, dass die Eigenschaft Text bereits vorhanden ist (aber ausgeblendet). Wir müssen sie nur wieder sichtbar machen. Außerdem wird das neue Steuerelement in der Lage sein, den Text an verschiedenen Stellen anzuzeigen, unter anderem zentriert oben, links oben, rechts unten usw. Dazu verwenden wir eine weitere Aufzählung, die in .NET 2.0 auch schon vorhanden ist: System.Drawing.ContentAlignment. Im Beispielprojekt werden für die neu hinzugekommenen Eigenschaften folgende Bezeichnungen verwendet: f Border3DStyle gibt die Art der Umrandung an, gespeichert wird der Wert in einem privaten Feld namens border3DStyle. Dieser Wert ist vom Typ Border3DStyle, d.h. die Namen von Typ und Eigenschaft sollen übereinstimmen, wie das bei Aufzählungen in den meisten Fällen auch im Framework der Fall ist. f Gradient gibt an, ob der Farbverlauf gezeichnet werden soll. Dieser boolesche Wert wird in einem privaten Feld namens gradient gespeichert. f StartColor gibt die linke Farbe an, EndColor die rechte Farbe. Die entsprechenden Felder heißen startColor und endColor. f Die Eigenschaft Text wird einfach mittels override deklariert und sichtbar gemacht. Sie enthält den Text des Steuerelements. Hier müssen wir uns um nichts Weiteres kümmern. f Die Eigenschaft TextAlign ist vom Typ ContentAlignment. Entsprechend der vorgenommenen Einstellung muss hier allerdings manuell gezeichnet werden.
19.3.2
Funktionalität hinzufügen
Benötigte Felder Zunächst deklarieren wir die Felder, die wir für unsere Eigenschaften benötigen, und initialisieren sie auch gleich mit Standardwerten. // Rahmen private System.Windows.Forms.Border3DStyle border3DStyle = Border3DStyle.Raised; // Textausrichtung private ContentAlignment textAlign = ContentAlignment.MiddleCenter;
Sandini Bib
Abgeleitete Steuerelemente
639
// Farben für Farbverlauf private Color startColor = Color.Black; private Color endColor = Color.White; private bool gradient = false; private int gradientPosition = 50;
Der Typ Border3DStyle wird voll qualifiziert, um etwaige spätere Namenskollissionen zu vermeiden.
Die Eigenschaften des Steuerelements Die Implementierung der Eigenschaften ist ebenfalls nicht weiter kompliziert. Alle Eigenschaften können gelesen und geschrieben werden, jede Eigenschaft erhält eine Beschreibung und alle sollen sie in der Kategorie APPEARANCE erscheinen (in der deutschen Version die Kategorie »Darstellung«) [Category( "Appearance" )] [Description( "Linke Farbe für Farbverlauf" )] public Color StartColor { get { return this.startColor; } set { this.startColor = value; this.Invalidate(); } } [Category( "Appearance" )] [Description( "Rechte Farbe für Farbverlauf" )] public Color EndColor { get { return this.endColor; } set { this.endColor = value; this.Invalidate(); } } [Category( "Appearance" )] [Description( "Farbverlauf anzeigen oder nicht" )] public bool Gradient { get { return this.gradient; } set { this.gradient = value; this.Invalidate(); } }
Sandini Bib
640
19 Eigene Steuerelemente erstellen
[Category( "Appearance" )] [Description( "Mittenposition Farbverlauf (1-100)" )] public int GradientPosition { get { return this.gradientPosition; } set { if ( value < 0 || value > 100 ) this.gradientPosition = 50; else this.gradientPosition = value; this.Invalidate(); } } [Category( "Appearance" )] [Description( "Ansicht des Rahmens" )] public System.Windows.Forms.Border3DStyle Border3DStyle { get { return this.border3DStyle; } set { this.border3DStyle = value; this.Invalidate(); } } [Category( "Appearance" )] [Description( "Der angezeigte Text im Steuerelement" )] public override string Text { get { return base.Text; } set { base.Text = value; this.Invalidate(); } } [Category( "Appearance" )] [Description( "Die Ausrichtung des Textes im Steuerelement" )] [Browsable( true )] [EditorBrowsable( EditorBrowsableState.Always )] public ContentAlignment TextAlignment { get { return this.textAlign; } set { this.textAlign = value; this.Invalidate(); } }
Sandini Bib
Abgeleitete Steuerelemente
641
Als letzte Eigenschaft wird nun noch BorderStyle benötigt, denn diese soll ja ausgeblendet werden. Die Zuweisung eines Werts würde nichts bringen, da der Rahmen ja nun durch BevelStyle angegeben wird. Wenn die Eigenschaft aber dennoch im Eigenschaftsfenster erscheint, könnte das Verwirrung stiften. Aus diesem Grund definieren wir sie neu, sorgen dafür, dass zugewiesene Werte nicht weitergeleitet werden, und verstecken sie sowohl vor IntelliSense als auch vor dem Eigenschaftsfenster. Die entsprechenden Attribute haben Sie bereits kennen gelernt. // BorderStyle ausblenden [Browsable( false )] [EditorBrowsable( EditorBrowsableState.Never )] public new System.Windows.Forms.BorderStyle BorderStyle { get { return System.Windows.Forms.BorderStyle.None; } set { base.BorderStyle = System.Windows.Forms.BorderStyle.None; } }
Damit wären die Eigenschaften festgelegt. Zur Entwurfszeit kümmert sich die Entwicklungsumgebung automatisch darum, dass für Eigenschaften, die auf einem Aufzählungstyp basieren, eine entsprechende Liste angezeigt wird, aus der der gewünschte Wert ausgewählt werden kann. Wir müssen also wirklich nicht mehr tun als die Eigenschaften festzulegen.
Zeichnen des Textes Natürlich muss das Steuerelement zur Laufzeit noch gezeichnet werden. Am Anfang soll das Schreiben des Textes stehen, denn dieser Vorgang ist etwas umfangreicher. Während der Rahmen mit einem einzigen Methodenaufruf gezeichnet werden kann (hierfür stellt das .NET Framework bereits eine Methode zur Verfügung), gilt das nicht für den Text. Hier müssen wir zunächst ermitteln, wo der Text gezeichnet werden soll und entsprechende Eigenschaften einstellen. Details über das Arbeiten mit Zeichenmethoden und speziell zum Zeichnen von Text in .NET erhalten Sie noch in Abschnitt 21.3 ab Seite 731. An dieser Stelle nur soviel: .NET kann Text innerhalb eines gegebenen Rechtecks an eine bestimmte Position ausgeben. Das geschieht über Zeichenoperationen, genauer über die Methode DrawString() der Klasse Graphics. Das Verhalten des Frameworks beim Zeichnen wird über eine Instanz der Klasse StringFormat festgelegt. Unter anderem können Sie darin festlegen, an welche Position innerhalb eines gegebenen Rechtecks der Text gezeichnet werden soll. Die horizontale und vertikale Position werden getrennt betrachtet und jeweils durch einen Wert vom Typ StringAlignment festgelegt. Die Eigenschaft Alignment von StringFormat steht für die horizontale Ausrichtung, die Eigenschaft LineAlignment für die vertikale. StringAlignment kennt drei mögliche Zustände: StringAlignment.Near, StringAlignment.Center sowie StringAlignment.Far. Im Falle der horizontalen Ausrichtung bedeutet Near linksbündig, Far rechtsbündig; vertikal gesehen bedeutet Near oben, Far unten. Mit diesen Informationen bestückt müssen wir nun nur noch die Zeichenroutine aufrufen, den zu schreibenden Text sowie das Rechteck, in das gezeichnet werden soll, angeben und
Sandini Bib
642
19 Eigene Steuerelemente erstellen
natürlich die Art des Schreibens über StringFormat. Bei dem Rechteck handelt es sich schlicht um den Client-Bereich unseres Controls. private void DrawText( Graphics g ) { StringFormat stringFormat = new StringFormat( StringFormatFlags.LineLimit ); stringFormat.Trimming = StringTrimming.EllipsisCharacter; switch ( this.textAlignment ) { case TextAlignment.TopLeft: stringFormat.Alignment = StringAlignment.Near; stringFormat.LineAlignment = StringAlignment.Near; break; case TextAlignment.TopCenter: stringFormat.Alignment = StringAlignment.Center; stringFormat.LineAlignment = StringAlignment.Near; break; case TextAlignment.TopRight: stringFormat.Alignment = StringAlignment.Far; stringFormat.LineAlignment = StringAlignment.Near; break; case TextAlignment.MiddleLeft: stringFormat.Alignment = StringAlignment.Near; stringFormat.LineAlignment = StringAlignment.Center; break; case TextAlignment.MiddleCenter: stringFormat.Alignment = StringAlignment.Center; stringFormat.LineAlignment = StringAlignment.Center; break; case TextAlignment.MiddleRight: stringFormat.Alignment = StringAlignment.Far; stringFormat.LineAlignment = StringAlignment.Center; break; case TextAlignment.BottomLeft: stringFormat.Alignment = StringAlignment.Near; stringFormat.LineAlignment = StringAlignment.Far; break;
Sandini Bib
Abgeleitete Steuerelemente
643
case TextAlignment.BottomCenter: stringFormat.Alignment = StringAlignment.Center; stringFormat.LineAlignment = StringAlignment.Far; break; case TextAlignment.BottomRight: stringFormat.Alignment = StringAlignment.Far; stringFormat.LineAlignment = StringAlignment.Far; break; } g.DrawString( Text, this.Font, new SolidBrush( this.ForeColor ), this.ClientRectangle, stringFormat ); }
Die Methode OnPaint Die eigentliche Zeichenroutine ist die Methode OnPaint(), die von der Basisklasse geerbt wird. Darin zeichnen wir sowohl Farbverlauf als auch Rahmen und rufen die Zeichenmethode für den Text auf. OnPaint() muss als protected override deklariert werden. Das Zeichnen des Rahmens ist noch interessant, allerdings auch nicht besonders aufwändig. Der interessante Teil betrifft eher die Tatsache, dass es eine Klasse gibt, die beim Zeichnen eines Steuerelements und seiner enthaltenen Elemente hilft. Diese Klasse heißt ControlPaint, beinhaltet ausschließlich statische Member und ist in System.Windows.Forms deklariert. Unter anderem ist dort eine Methode namens DrawBorder3D() enthalten, die wir zum Zeichnen des Rahmens verwenden. Alles, was diese Methode benötigt, ist vorhanden: Ein Wert des Typs Border3DStyle, der das Aussehen des Rahmens bestimmt, das Rechteck, das umrandet werden soll sowie ein Graphics-Objekt, auf das gezeichnet wird. Angenehm fällt auf, dass DrawBorder3D() den Rest des Steuerelements unangetastet lässt und wirklich nur den Rahmen zeichnet. Dadurch ist es möglich, einen eventuellen Farbverlauf vor dem Rahmen zu zeichnen (andernfalls würde der Farbverlauf den Rahmen verdecken). Die weiteren grafischen Methoden in OnPaint() werden detaillierter in Kapitel 21 ab Seite 691 beschrieben, für den Moment soll der Code mit einer verhältnismäßig knappen (aber ausreichenden) Beschreibung ausreichen. protected override void OnPaint( PaintEventArgs e ) { // Manuelles Zeichnen // BorderStyle wird abgeschaltet base.BorderStyle = BorderStyle.None; // Zeichenbereich festlegen Graphics g = e.Graphics;
Sandini Bib
644
19 Eigene Steuerelemente erstellen
// Zeichnen mit hoher Qualität g.SmoothingMode = SmoothingMode.AntiAlias; // Das Rechteck, in das gezeichnet werden soll Rectangle rect = new Rectangle( 0, 0, this.Width, this.Height ); // Der Farbverlauf - nur wenn eingeschaltet if ( this.Gradient ) { // Farbverlauf zeichnen LinearGradientBrush gradBrush = new LinearGradientBrush( rect, this.startColor, this.endColor, LinearGradientMode.Horizontal ); Blend gradBlend = new Blend( 3 ); gradBlend.Positions[0] = 0.0f; gradBlend.Positions[2] = 1.0f; gradBlend.Factors[0] = 0.0f; gradBlend.Factors[2] = 1.0f; gradBlend.Positions[1] = ( (float)this.GradientPosition ) / 100f; gradBlend.Factors[1] = 0.5f; gradBrush.Blend = gradBlend; g.FillRectangle( gradBrush, rect ); } else { // Lediglich Hintergrundfarbe g.FillRectangle( new SolidBrush( this.BackColor ), rect ); } // Text schreiben DrawText( g ); // Rahmen zeichnen ControlPaint.DrawBorder3D( g, rect, this.border3DStyle ); // OnPaint der Basisklasse aufrufen base.OnPaint( e ); }
Damit wäre die Funktionalität eigentlich komplett implementiert. Wenn Sie ein Testprojekt hinzufügen, werden Sie feststellen, dass Sie ein Panel-Steuerelement besitzen, das mehr Eigenschaften für eine ansprechende Benutzeroberfläche besitzt als das Original – und das mit verhältnismäßig wenig Aufwand. Dennoch sind wir noch nicht ganz fertig. Zum Einen fehlt noch die Bitmap für die Toolbox, zum Anderen werden Sie ein Flackern bemerken, wenn Sie das Steuerelement beispielsweise andocken und die Fenstergröße ändern. Das wollen wir jetzt noch abschalten, und zwar im Konstruktor. Die dazu notwendige Methode heißt SetStyle(), mit ihr lässt sich auf das Verhalten eines Steuerelements einwirken. Die Einstellungen werden mithilfe einer Aufzählung überge-
Sandini Bib
Abgeleitete Steuerelemente
645
ben, der zweite Parameter gibt an, ob diese Einstellungen aktiviert oder deaktiviert werden sollen. In unserem Fall sieht die Anweisung folgendermaßen aus: public GradientPanel() { SetStyle( ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint | ControlStyles.ResizeRedraw | ControlStyles.AllPaintingInWmPaint, true ); }
Detailliertere Informationen über die unterschiedlichen ControlStyles-Elemente erhalten Sie in der Online-Hilfe. Die hier verwendeten haben folgenden Zweck: f ControlStyles.OptimizedDoubleBuffer verhindert das Flackern bei einer Größenänderung, indem die Zeichenoperation im Hintergrund passiert und dann komplett in den Vordergrund kopiert wird f ControlStyles.UserPaint bedeutet, dass das Steuerelement sich selbst zeichnet und nicht das System dies tut. Diese Einstellung muss auf true gesetzt werden, weil ansonsten das Paint-Ereignis nicht ausgelöst wird. f ControlsStyles.AllPaintingInWmPaint bewirkt, dass die Nachricht des Betriebssystems zum Löschen des Hintergrunds ignoriert wird. Auch das dient der Reduktion des Flackerns. f ControlStyles.ResizeRedraw bewirkt, dass das Steuerelement bei einer Größenänderung neu gezeichnet wird. Selbstverständlich wurde auch bei diesem Projekt nicht auf eine Bitmap verzichtet. Sie finden sie ebenfalls im Projektverzeichnis. Wie bei der IpTextBox wurde die Bitmap in das Projekt eingebunden und mittels ToolboxBitmap-Attribut verwendet. Ein Beispielprogramm mit dem erweiterten Panel zeigt Abbildung 19.4.
Abbildung 19.4: Einige Panels mit unterschiedlichen Einstellungen
Sandini Bib
646
19 Eigene Steuerelemente erstellen
HINWEIS
Leider kam es beim Erstellen des Beispielprojekts zu einem Problem, das nur durch eine komplette Neuerstellung der Testapplikation behoben werden konnte. Dieses Problem existiert sowohl in der englischen als auch in der deutschen Version des Visual Studio. Bei fertig eingebundenem Steuerelement funktionierte zunächst alles reibungslos, bis eine Änderung am Quelltext des Steuerelements vorgenommen wurde. Danach ließ sich das Textprogramm nicht mehr aus dem Visual Studio starten. Die Fehlermeldung besagte, dass die DLL mit dem Steuerelement darin nicht gefunden werden können. Diese DLL befand sich aber korrekterweise im Ausführungsverzeichnis. Die Ausführung der Applikation direkt aus dem Explorer funktionierte hingegen reibungslos.
Sandini Bib
20 Benutzeroberfläche In diesem Kapitel geht es um die Gestaltung und Programmierung des Programmteils, das der Anwender letztlich zu Gesicht bekommt, nämlich der Benutzeroberfläche. Das Kapitel beschreibt außerdem den Umgang mit Formularen und wichtige Aspekte der Programmentwicklung: Drag&Drop, Reagieren auf Tastatur- und Mausereignisse, Gestaltung von Menüs und Symbolleisten oder auch die Verwendung der Zwischenablage.
20.1
Bedienelemente
Die wichtigsten Elemente eines Programms sind seine Menüs und Toolbars. Über diese gelangt der Anwender an die verschiedenen Funktionalitäten, die eine Applikation zur Verfügung stellt. Die entsprechenden Steuerelemente der Version 1.1 des .NET Frameworks waren leider noch nicht auf einem aktuellen Stand; ihr Aussehen entsprach in etwa dem, was aus Windows 3.1 bekannt war. Das Menü unterstützte keinerlei Grafiken, die ToolBar und ihre Buttons hatten ebenfalls ein altertümliches Aussehen. Keines der Steuerelemente unterstützte die aus Windows bekannten Themes, an die sich mittlerweile fast jeder gewöhnt hat. Hier waren Programmierer auf Third-Party-Tools angewiesen, was mitunter auch ins Geld gehen konnte. Nichts gegen Third-Party-Tools – aber bitte nicht bei elementaren Steuerelementen, die in absolut jedem Programm benötigt werden. Natürlich gab es Beschwerden, und Microsofts Entwickler haben sich mächtig ins Zeug gelegt, gerade an diesen Stellen Steuerelemente zur Verfügung zu stellen, die ein aktuelles Design besitzen und die Möglichkeiten beinhalten, die ein Anwender von einer modernen Applikation erwartet. Alle Controls die etwas mit der Bedienung eines Programms zu tun haben wurden neu programmiert und besitzen nun eine gemeinsame Basis statt wie vorher auf vollkommen getrennten Konzepten aufzubauen. f Das Steuerelement MenuStrip ersetzt das MainMenu. Bitmaps werden unterstützt, ebenso die Themes von Windows XP. Außerdem ist es fortan möglich, auch andere Steuerelemente als nur Menüeinträge in einem Menü unterzubringen, unter anderem eine TextBox (Sie kennen das möglicherweise aus Office, wo ebenfalls von dieser Möglichkeit Gebrauch gemacht wird). f Das Steuerelement ContextMenuStrip ersetzt wie der Name schon vermuten lässt das ContextMenu, mit den gleichen Vorteilen, die auch das Steuerelement MenuStrip bietet. f Das Steuerelement StatusStrip ersetzt die StatusBar. Auch bei diesem Steuerelement wurde Wert darauf gelegt, dass es mehr Möglichkeiten bietet als beim Vorgänger. Unter anderem können Sie eine ProgressBar in der Statuszeile anzeigen oder auch Buttons mit Dropdown-Funktion, also praktisch ein Menü in der Statusleiste. Ein Label ist ebenfalls möglich, und da dieses eine Bitmap beinhalten kann, sind auch Grafiken kein Problem. f Die ToolBar wird durch das Steuerelement ToolStrip ersetzt. Dieses kommt auch im Windows-XP-Design daher und kann unter anderem auch TextBox- oder ComboBox-
Sandini Bib
648
20 Benutzeroberfläche
Elemente beinhalten (die die gleichen Möglichkeiten bieten wie »herkömmliche« Textboxen oder Comboboxen). Ebenso möglich sind eine ProgressBar, Labels oder Buttons unterschiedlicher Art. f Neu hinzugekommen ist ein Container für ToolStrip-Elemente namens ToolStripContainer. Dieses Steuerelement ermöglicht es, Toolstrips an allen Seiten des Formulars anzudocken, ohne eine Zeile Code programmieren zu müssen. Leider wird das Abreißen eines ToolStrip nicht unterstützt (er wird zwar abgerissen, es wird aber nicht automatisch ein Fenster darum gelegt). Abbildung 20.1 zeigt eine Abbildung der neuen Elemente. Wie Sie sehen können sieht das Ganze schon wesentlich gefälliger aus als unter .NET 1.1.
Abbildung 20.1: Die neuen Bedienelemente in .NET 2.0
20.1.1
Der Menüeditor
Um ein Formular mit einem Menü auszustatten, fügen Sie einfach per Doppelklick in der Toolbox das Steuerelement MenuStrip in Ihr Formular ein. Das noch leere Menü erscheint nun automatisch am oberen Rand des Formulars (siehe Abbildung 20.1). Nun können Sie die einzelnen Menüelemente benennen. Eine Alternative besteht darin, über das Aufgabenmenü der MenuStrip-Komponente häufig benötigte Menuelemente mit einem einzigen Mausklick einzufügen. Die so eingefügten Einträge sind nach wie vor bearbeitbar, Sie können also auch die nicht benötigten einfach löschen. In vielen Fällen ist diese Vorgehensweise sinnvoller als alles selbst zu erstellen – zumal auch die passenden Grafiken sofort mit eingefügt werden. Die Eingabe des Texts für einen Menüeintrag erfolgt direkt im Formular, während allerdings der Name nach wie vor im Eigenschaftsfenster eingegeben werden muss. Sie sollten
Sandini Bib
Bedienelemente
649
davon auch Gebrauch machen und Ihre Menüeinträge mit eindeutigen Namen versehen. Die Möglichkeit, auch die Namen der Menüeinträge im Designer zu bearbeiten existiert nicht mehr; Sie müssen den Umweg über das Eigenschaftenfenster gehen. Ein Minuszeichen dient nach wie vor dazu, einen Trenner einzufügen. Grundsätzlich ist es aber auch möglich, jede Art von Eintrag in einen anderen zu konvertieren. Das funktioniert über das Kontextmenü. Ein Tastenkürzel (der unterstrichene Buchstabe in den Menüs aller gängigen WindowsAnwendungen) können Sie ebenfalls definieren. Dazu müssen Sie lediglich vor den gewünschten Buchstaben ein kaufmännisches und-Zeichen (&) schreiben. Achten Sie hierbei aber darauf, ein Tastenkürzel nicht doppelt zu vergeben, das kann zu Problemen führen. Das gilt aber nur für die gleiche Hierarchieebene; sollten Sie das »D« als Kürzel zum Öffnen des Datei-Menüs verwendet haben, können Sie es innerhalb der darin enthaltenen Einträge wiederverwenden. Menüeinträge können mit der Maus verschoben bzw. kopiert werden (wenn die Taste (Strg) gedrückt gehalten wird). Für komplexere Umbauarbeiten empfiehlt es sich, ganze Menüs mit den Kontextmenükommandos AUSSCHNEIDEN, KOPIEREN und EINFÜGEN zu verschieben. Das Kontextmenü bietet auch die Möglichkeit, zusätzliche Einträge einzufügen. Ein Doppelklick auf einen Menüeintrag fügt das entsprechende Click-Ereignis in den Programmcode ein. Da hierzu standardmäßig die Bezeichnung des Menüpunkts verwendet wird, sollten Sie diesem zunächst einen aussagekräftigen Namen geben. Das empfiehlt sich im Übrigen immer, bei allen Steuerelementen, denen Sie irgendwelche Aktionen zuweisen oder die Sie innerhalb des Programmcodes manipulieren wollen. Es sei denn natürlich, Sie vergeben für Ihre Ereignisse ohnehin eigene Namen und nutzen das Eigenschaften-/Ereignisfenster für die Eingabe. Nachträglich den Namen einer Methode zu ändern stellt aber dank der Refactoring-Features von Visual Studio 2005 auch kein Problem dar.
20.1.2
Menüeigenschaften einstellen
Verhalten und Aussehen von Menüeinträgen Die einzelnen Menüelemente sind vom Typ ToolStripMenuItem. Die Eigenschaften Enabled und Visible geben wie bei Steuerelementen an, ob der Menüeintrag aktiv ist bzw. ob er angezeigt wird. Menüeinträge mit der Einstellung Enabled=false werden in grauer Schrift angezeigt und können nicht verwendet werden. Über die Eigenschaft ShortcutKeys können Sie zusätzlich zum Tastenkürzel, das durch die (Alt)-Taste angewählt wird, den aus nahezu allen Programmen bekannten Shortcut defi-
nieren. Zur Laufzeit kümmert sich das Programm automatisch um die Auswertung dieser Tastenkürzel. Bekannte Tastenkürzel sind z.B. (Strg)+(C) zum Kopieren, (Strg)+(X) zum Ausschneiden oder auch (Strg)+(V) zum Einfügen. Der bekannteste Shortcut aber ist (Alt)+(F4) zum Beenden des Programms, der auch dann funktioniert, wenn Sie ihn nicht explizit eingebaut haben.
Sandini Bib
650
20 Benutzeroberfläche
Falls Sie die Tastenkürzel nicht im Menü anzeigen wollen, können Sie die Eigenschaft ShowShortcutKeys des Menüpunkts auf false stellen. Diese Eigenschaft ist standardmäßig auf true eingestellt. Leider gibt es auch hier keine übergeordnete Eigenschaft, die für das gesamte Menü gelten würde – Sie müssen sie für jeden Menüpunkt getrennt einstellen. Eine Verbesserung jedoch ergibt sich bei der Anzeige dieser Shortcuts. Über die Eigenschaft ShortcutKeyDisplayString können Sie nämlich selbst festlegen, was hinter dem Menüpunkt als Shortcut angezeigt wird. Ein Beispiel, das Sie möglicherweise schon einmal gesehen haben, wäre, den Shortcut (Strg)+(Z) (Rückgängig) durch den String »nicht verfügbar« zu ersetzen. Über die Eigenschaft Checked können Sie vor einem Menüeintrag ein Auswahlhäkchen anzeigen. Checked wird durch eine Menüauswahl nur dann automatisch verändert, wenn Sie auch die Eigenschaft CheckOnClick auf true setzen. Falls nicht müssen Sie die Änderung der Auswahl explizit im Click-Ereignis programmieren. Es gibt leider keine Möglichkeit, die Schriftart zur Darstellung der Menüeinträge über eine Eigenschaft zu verändern. Die Schriftart wird durch die Systemeinstellung vorgegeben und kann mit SystemInformation.MenuFont ermittelt werden.
Grafiken für Menüpunkte Endlich möchte man fast sagen – Menüs können jetzt auch Grafiken darstellen. Microsoft liefert mit dem Visual Studio sogar eine umfangreiche Sammlung aller möglichen Grafiken mit, die Sie im Verzeichnis C:\Programme\Microsoft Visual Studio 8\Common7\VS2005ImageLibrary finden. Diese Grafiken sind wirklich gelungen, mit 24 Bit Farbtiefe, decken auch viele Anwendungsmöglichkeiten ab – allerdings längst noch nicht alle. Sollten Sie bereits im Besitz einer Grafikbibliothek sein, werfen Sie diese nicht weg, Sie werden sie noch brauchen. Einen Wermutstropfen, das vorweg, hat die ganze Geschichte – es ist nicht möglich, für ein Menü eine ImageList zu verwenden. Jeder Menüpunkt bekommt seine eigene Grafik, was allerdings auch bedeutet, dass die Farbe für die Transparenz bei jedem Menüpunkt (über die Eigenschaft ImageTransparentColor) eingestellt werden muss. Hier gibt es schon seit über 10 Jahren eine weit bessere Lösung (nämlich die automatische Transparenz der Farbe, die an einem der Eckpunkte der Grafik zu finden ist). Seis drum – hier ist ein wenig Arbeit angesagt. Das Handling jedoch ist hervorragend. Über die Eigenschaft Image wird die Grafik festgelegt, die in dem Menüpunkt erscheinen soll. Die Eigenschaft ImageAlign von Typ ContentAlignment legt fest, wie diese Grafik (innerhalb ihres Bereichs, nicht des Menüpunkts) positioniert werden soll. Die Standardeinstellung (ContentAlignment.MiddleCenter) ist das, was Sie auch aus anderen Programmen kennen und sollte beibehalten werden. ImageScaling ist eine neue Eigenschaft, die dafür sorgt, dass auch größere Grafiken inner-
halb eines Menüpunkts Platz finden. Sie werden in der Standardeinstellung automatisch so skaliert, dass sie in den Grafikbereich passen. Allerdings sieht das nicht unbedingt vorteilhaft aus, weshalb Sie auf die korrekte Größe Ihrer Grafiken achten sollten. Sie können die Grafiken auch ausblenden, falls Sie das möchten, oder aber bezogen auf den Text anders positionieren. Ersteres erreichen Sie über die Eigenschaft DisplayStyle
Sandini Bib
Bedienelemente
651
vom Typ ToolStripItemDisplayStyle. Sie können damit sowohl Text als auch Grafik oder auch beides ausblenden. TextAlign vom Typ ContentAlignment legt die Ausrichtung des Texts fest. Auch hier ist die Standardeinstellung die sinnvollste. Die Eigenschaft TextDirection bietet Ihnen die Mög-
lichkeit, Text auch vertikal, entweder 90 gedreht oder aber 270 gedreht darzustellen. Diese Möglichkeit wird bei einem Menü wohl weniger in Betracht kommen. Die meisten der vorgestellten Eigenschaften gelten aber auch für alle anderen ToolStrip-Komponenten. TextDirection ist vom Typ ToolStripTextDirection. Über die Eigenschaft TextImageRelation vom Typ TextImageRelation können Sie festlegen, wie die Grafik bezogen auf den Text positioniert wird. Grafiken können über, unter, vor und hinter dem Text angeordnet werden oder aber den Text überlagern. Auch diese Einstellung ist weniger sinnvoll für ein Menü, mehr für eine Toolbar.
Ereignisse Für einen Menüpunkt ist eigentlich nur ein Ereignis wichtig, nämlich das Ereignis Click. Es tritt dann auf, wenn ein Menüpunkt, der keine Unterpunkte enthält, angeklickt bzw. über die Tastatur aufgerufen wird. Falls der Menüpunkt Unterpunkte besitzt, treten andere Ereignisse auf. DropDownOpening tritt während des Öffnens eines Menüpunkts auf, DropDownOpened nach dem Öffnen, DropDownClosed nach dem Schließen. Diese Ereignisse sind insofern interessant, weil es darin möglich ist, Menüpunkte abhängig vom aktuellen Programmstatus zu aktivieren bzw. zu deaktivieren. Wenn ein Menü eine gewisse Größe besitzt, ist es ziemlich aufwändig, bei jeder Änderung des Status (der ja dann immer kontrolliert werden müsste) alle Menüpunkte auf einen Schlag zu aktivieren bzw. zu deaktivieren. Ein Beispiel hierzu wäre das Menü BEARBEITEN. Wenn dieses geöffnet wird, könnten Sie beispielsweise kontrollieren, ob die Zwischenablage Daten im korrekten Format enthält. Ist das der Fall, wird der Menüeintrag EINFÜGEN aktiviert, andernfalls deaktiviert. Der folgende Code enthält die Überprüfung des Clipboard-Inhalts auf das Format Text; weitere Details zum Clipboard erhalten Sie in Abschnitt 20.4.2 ab Seite 677. private void mnuEdit_DropDownOpening( object sender, EventArgs e ) { this.mnuEditPaste.Enabled = Clipboard.ContainsText( TextDataFormats.Text ); }
Jeder Menüpunkt kann seine eigenen Ereignisbehandlungsroutinen erhalten. Normalerweise werden Sie aber wie bei anderen Steuerelementen auch mehrere Ereignisse zu einem zusammenfassen und über den Parameter sender ermitteln, um welchen Menüeintrag es sich handelt. Das funktioniert genauso wie bei anderen Steuerelementen entweder durch Zuweisung eines bereits vorhandenen Ereignisses über das Eigenschaftenfenster oder aber im Programmcode. Wenn Sie eine einzelne Ereignisbehandlungsroutine für alle Menüeinträge verwenden wollen, ist das am einfachsten über eine rekursive Methode erreichbar, die jedem Menü-
Sandini Bib
652
20 Benutzeroberfläche
CD
punkt den gleichen Ereignishandler zuweist. Das folgende kleine Beispielprogramm zeigt, wie das funktioniert. Die einzige Funktion innerhalb der Ereignisbehandlungsroutine ist, dass der Text des gewählten Menüeintrags in einer MessageBox angezeigt wird. In eigenen Programmen würden Sie natürlich die entsprechende Funktionalität hinzufügen. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_20\MenuTest.
public Form1() { InitializeComponent(); AssignMenuClickedEvent( this.menuStrip1.Items ); } private void AssignMenuClickedEvent( ToolStripItemCollection items ) { foreach ( ToolStripItem item in items ) { ToolStripMenuItem currentItem = ( item as ToolStripMenuItem ); if ( currentItem != null ) { if ( currentItem.HasDropDownItems ) AssignMenuClickedEvent( currentItem.DropDownItems ); else currentItem.Click += new EventHandler( Event_MenuItemClicked ); } } } private void Event_MenuItemClicked( object sender, EventArgs e ) { ToolStripMenuItem currentItem = ( sender as ToolStripMenuItem ); if ( currentItem != null ) MessageBox.Show( currentItem.Text, "Menüeintrag geklickt", MessageBoxButtons.OK, MessageBoxIcon.Information ); }
Das kleine Programm zur Laufzeit sehen Sie in Abbildung 20.2.
Sandini Bib
Bedienelemente
653
Abbildung 20.2: Eine Ereignisbehandlungsroutine für alle Menüpunkte
Die Unterscheidung der verschiedenen Menüpunkte muss nicht zwangsläufig durch den Namen geschehen. Die Klasse ToolStripMenuItem stellt auch eine Eigenschaft Tag zur Verfügung, über die die Kontrolle erfolgen kann. Somit können Sie über eine einfache switchAnweisung mehrere Menübefehle in einer einzigen Ereignisbehandlungsroutine zusammenfassen.
20.1.3
Kontextmenüs
HINWEIS
Kontextmenüs kennen Sie sicherlich aus zahlreichen Anwendungen – nahezu jedes Programm besitzt Kontextmenüs. Auch ein eigenes Kontextmenü können Sie leicht zusammenstellen. Sie benötigen dazu das Steuerelement ContextMenuStrip, das Sie einfach durch Doppelklick einfügen. Im Gegensatz zum Hauptmenü kann ein Formular mehrere Kontextmenüs besitzen, die dann jeweils einem oder mehreren Controls zugewiesen werden. Zuständig dafür ist die Eigenschaft ContextMenuStrip des jeweiligen Steuerelements. Manche Steuerelemente haben bereits ein eingebautes Kontextmenü, beispielsweise das Steuerelement TextBox (Ausschneiden, Kopieren und Einfügen ist dort standardmäßig als Funktionalität bereits enthalten). Da dieses Kontextmenü allerdings nicht dem neuen Design entspricht, ist es ratsam, ein eigenes Kontextmenü zu erstellen und zuzuweisen. Das eingebaute Menü wird dabei entfernt.
Die Einträge werden auch beim Steuerelement ContextMenu über den Menüeditor hinzugefügt, also direkt im Formular. Während beim MenuStrip allerdings die Einträge nach der Bearbeitung sichtbar bleiben, wird im Falle eines ContextMenuStrip der Editor für die Menüelemente ausgeblendet. Das Steuerelement selbst befindet sich im Komponentenbereich, durch Anklicken bringen Sie den Editor wieder in den Vordergrund.
Sandini Bib
654
20 Benutzeroberfläche
Ein zugewiesenes Kontextmenü erscheint automatisch, wenn auf dem entsprechenden Steuerelement die rechte Maustaste gedrückt wird. Vor dem Erscheinen können Sie wie auch beim Hauptmenü über die Dropdown-Ereignisse kontrollieren, ob und welche der Menüeinträge deaktiviert werden sollen. Das Erscheinen des Kontextmenüs kann dadurch allerdings nicht verhindert werden. Falls Sie das Erscheinen von Bedingungen abhängig machen wollen, müssen Sie das Menü manuell aufrufen. Dazu müssen Sie zunächst auf die Einstellung für die Eigenschaft ContextMenuStrip des Steuerelements verzichten (bzw., falls es bereits ein voreingestelltes Kontextmenü gibt, einen leeren ContextMenuStrip zuweisen). Stattdessen verwenden Sie im Ereignis MouseDown des Steuerelements die Methode Show() des anzuzeigenden ContextMenuStrip-Objekts. Show() erwartet als Übergabeparameter einmal das Steuerelement, dem es zugeordnet sein soll, und die Koordinaten, an denen das Kontextmenü erscheinen soll. Zusätzlich können Sie falls gewünscht noch angeben, in welche Richtung das Menü aufklappen soll. Dafür ist die Enumeration ToolStripDropDownDirection zuständig. Da Sie aber nie wissen, wohin der Anwender sein Formular gerade verschoben hat, sollten Sie diese Einstellung auf dem Standardwert belassen, sodass Windows selbst sich um die richtige Richtung kümmert. Wird ein Menüpunkt angeklickt, tritt selbstverständlich auch beim ContextMenuStrip das Ereignis Click auf, in dem Sie die Funktionalität spezifizieren können. Wie auch aus dem Hauptmenü bekannt können Sie auch hier eine Ereignisbehandlungsroutine für mehrere Ereignisse verwenden.
Beispielprogramm Das folgende Beispielprogramm zeigt eine besondere Art der Anwendung eines Kontextmenüs für ein TreeView-Steuerelement. Das Kontextmenü wird verwendet, um neue Einträge hinzuzufügen, den aktuellen Eintrag zu ändern oder gar zu löschen. Zu diesem Zweck wird das Kontextmenü wie beschrieben im Ereignis MouseDown aufgerufen. Dort wird dann auch ermittelt, an welcher Position sich der Mauszeiger befindet. Befindet er sich über einem Eintrag, sind verständlicherweise alle Menükommandos verfügbar. Befindet er sich nicht über einem Eintrag, soll nur ermöglicht werden, dass ein neuer Wurzeleintrag hinzugefügt wird. Der aktuelle Knoten wird in einem Feld zwischengespeichert. Dadurch ist der Zugriff auch aus den Klick-Ereignissen des Kontextmenüs heraus möglich. Ist hier kein Knoten ausgewählt, wird das überprüft und ein neuer Wurzelknoten erzeugt. Um dem Anwender das auch deutlich zu machen, wird die Eigenschaft Text des Menüpunkts für einen neuen Eintrag in diesem Fall auch noch abgeändert. Damit das Programm auch bei mehreren TreeView-Objekten weiß, welches gerade verwendet wurde, wird auch die TreeView in einem Feld zwischengespeichert. Da zum Bearbeiten der Einträge die Methode BeginEdit() verwendet wird, muss die Eigenschaft LabelEdit auf true eingestellt werden (da ansonsten das Bearbeiten nicht möglich ist).
Sandini Bib
CD
Bedienelemente
655
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_20\ContextMenuTest.
Die Oberfläche besteht eigentlich nur aus einem TreeView-Steuerelement ohne Einträge und dem Kontextmenü. Die Einträge des Menüs wurden mit mnuNew, mnuEdit und mnuDelete bezeichnet (die Bedeutung der Einträge sollte sich aus dem Namen ergeben). Der Quellcode zeigt wieder nur die relevanten Methoden (alle im Hauptformular). private TreeView currentTreeView = null; private TreeNode currentTreeNode = null; private void TrvItems_MouseDown( object sender, MouseEventArgs e ) { // TreeView ermitteln aus Sender this.currentTreeView = ( sender as TreeView ); if ( this.currentTreeView == null ) return; // Ermitteln des aktuellen Eintrags if ( e.Button == MouseButtons.Right ) { this.currentTreeNode = currentTreeView.GetNodeAt( e.X, e.Y ); // Wenn kein Knoten, kein Edit und kein Delete this.mnuDelete.Enabled = ( this.currentTreeNode != null ); this.mnuEdit.Enabled = ( this.currentTreeNode != null ); if ( this.currentTreeNode != null ) { this.currentTreeView.SelectedNode = this.currentTreeNode; this.mnuInsert.Text = "Neuer Eintrag"; // Wurzelknoten nicht löschen this.mnuDelete.Enabled = ( currentTreeNode.Parent != null ); } else { // Text Kontextmenü ändern this.mnuInsert.Text = "Neuer Wurzeleintrag"; } // Anzeigen this.mnuTreeView.Show( currentTreeView, new Point( e.X, e.Y ) ); } }
Sandini Bib
656
20 Benutzeroberfläche
private void MnuInsert_Click( object sender, EventArgs e ) { // Neuer Eintrag TreeNode newNode = null; if ( currentTreeNode == null ) this.newNode = this.currentTreeView.Nodes.Add( "Neuer Eintrag" ); else this.newNode = this.currentTreeNode.Nodes.Add( "Neuer Eintrag" ); this.newNode.EnsureVisible(); this.newNode.BeginEdit(); } private void MnuEdit_Click( object sender, EventArgs e ) { this.currentTreeNode.BeginEdit(); } private void MnuDelete_Click( object sender, EventArgs e ) { this.currentTreeNode.Remove(); } private void TrvItems_AfterLabelEdit( object sender, NodeLabelEditEventArgs e ) { this.currentTreeView.SelectedNode = e.Node; }
Das letzte Ereignis, AfterLabelEdit, dient nur dazu, den neu hinzugefügten Knoten nach der Bearbeitung zum aktuellen Knoten zu machen. Ansonsten würde nämlich nach dem Hinzufügen der darüber liegende Knoten der aktuell ausgewählte bleiben. Abbildung 20.3 zeigt einen Screenshot des Programms zur Laufzeit.
Abbildung 20.3: Ein TreeView-Steuerelement wird über ein Kontextmenü gefüllt
Sandini Bib
Bedienelemente
20.1.4
657
Symbolleisten (ToolStrip-Steuerelement)
Das Steuerelement ToolStrip ist nicht nur zuständig für die Toolbars eines Programms, es stellt auch die Basisklasse für alle anderen Strip-Elemente dar. Daraus folgt, dass sämtliche Elemente, die in ein Menü eingefügt werden können, auch in einem ToolStrip ihren Platz haben. Allerdings ist das aufgrund der unterschiedlichen Darstellung nicht unbedingt sinnvoll – in einem Menü ist es nicht erforderlich, einen Button zu platzieren (da die Menüeinträge die gleiche Funktionalität zur Verfügung stellen), und in einem ToolStrip hat auch ein Menüeintrag keine wirkliche Daseinsberechtigung. Dennoch können Sie einen ToolStripDropDownButton oder einen ToolStripSplitButton verwenden, um auch in einer Toolbar ähnlich eines Menüs mehrere Auswahlmöglichkeiten zu bieten. Entsprechende Unterelemente können Sie dann (dank der neuen Hierarchie der Bedienelemente) direkt eintragen, Sie müssen also nicht den Umweg über einen ContextStrip gehen. Grafiken müssen allerdings jedem Element getrennt zugewiesen werden, die Verwendung einer ImageList ist nicht vorgesehen. Damit muss natürlich auch wieder die transparente Farbe zugewiesen werden – für jeden ToolStripButton getrennt. Das ist allerdings in Anbetracht der zahlreichen Möglichkeiten, die der ToolStrip bietet, zu verschmerzen. Im Übrigen entsprechen die möglichen Einstellungen denen, die Sie auch bei den ToolStripMenuItem-Elementen vornehmen konnten. Das betrifft beispielsweise Textausrichtung, Grafikausrichtung, Größeneinstellungen der Grafiken auf den Buttons usw. Dies wird hier nicht mehr ausführlich beschrieben – durch die gemeinsame Objekthierarchie all dieser Steuerelemente verhalten sich die bereits beschriebenen Eigenschaften immer auf die gleiche Art und Weise. Wie angesprochen haben Sie durchaus die Möglichkeit, unterschiedliche Steuerelemente in Menüs und Toolbars unterzubringen. Der ToolStrip zeigt die umfangreichste Auswahl; bis auf ein ToolStripMenuItem können Sie hier fast alles einbauen (tatsächlich ist es möglich, wirklich alles einzubauen, also jedes beliebige Steuerelement – dazu aber später mehr). Interessant ist vor allem der SplitButton. Er enthält weitere Unterpunkte, und wird üblicherweise dazu verwendet, aus diesen Unterpunkten eine Auswahl zu treffen, die in der Folge durch einen einfachen Klick auf den Button erneut getroffen werden kann. Der SplitButton ändert dazu üblicherweise sein Aussehen, um zu reflektieren, welche Aktion mit seiner Hilfe durchgeführt wird. Leider tut er das nicht automatisch, dieses Verhalten ist jedoch schnell implementiert.
Beispielprogramm Eine kleine Beispielapplikation demonstriert das Aussehen eines ToolStrip-Elements sowie die Verwendung des ToolStripSplitButton. Die Applikation besteht aus vier Panels, die im Quadrat angeordnet sind, und einem ToolStrip-Element. Die Farbe der Panels soll über einen ToolStripSplitButton geändert werden können. Ein Klick auf ein Panel macht dieses zum aktiven Panel, d.h. seine Farbe wird geändert. Als Farben stehen zur Verfügung Rot, Grün, Blau und Gelb.
Sandini Bib
CD
658
20 Benutzeroberfläche
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_20\ToolStripTest.
Den Aufbau des Beispielprogramms zeigt Abbildung 20.4. Die Panels sind mit pnlColor1, pnlColor2, pnlColor3 und pnlColor4 bezeichnet. Die Grafiken auf den Buttons des ToolStripElements stammen (bis auf die Grafiken für den ToolStripSplitButton und seine Unterelemente) aus der Grafikbibliothek, die mit dem Visual Studio mitgeliefert wird. Die Grafiken für den ToolStripSplitButton befinden sich im Applikationsverzeichnis.
Abbildung 20.4: Der Aufbau des Beispielprogramms für einen ToolStrip
Funktionalität Der Wechsel des gerade aktiven Panels ist recht einfach zu bewerkstelligen. Das Formular enthält ein Feld namens activePanel vom Typ Panel. Das gerade angeklickte Panel wird dieser Variablen zugewiesen. Das erledigt eine einfache Routine, die dem Click-Ereignis jedes Panels zugewiesen wird. Da es sich um eine gemeinsame Routine handelt, wurde ihr ein Name gegeben, der nicht auf ein bestimmtes Panel hinweist. private void Event_ActivePanelChanged( object sender, EventArgs e ) { // Kommt immer von einem Panel, dennoch testen Panel currentPanel = ( sender as Panel ); if ( currentPanel != null ) this.activePanel = currentPanel; }
Sandini Bib
Bedienelemente
659
Die Farbe muss nun nur noch dem durch activePanel referenzierten Panel zugewiesen werden. Im Constructor wird, damit es hier keine Probleme gibt, gleich das Panel pnlColor1 zugewiesen. Die einzelnen Einträge unterhalb des ToolStripSplitButton sowie dieser selbst erhalten einen Eintrag für die Eigenschaft Tag. Dieser Eintrag reflektiert der Lesbarkeit halber gleich die Farbe, mit der ein Panel belegt werden soll. Als erste Standardfarbe wird Gelb festgelegt, das ist dann auch der Tag-Wert für den ToolStripSplitButton. Die Unterelemente erhalten die Tag-Werte »gelb«, »rot«, »grün« und »blau«. Alle Tag-Werte werden kleingeschrieben. Entsprechend werden natürlich auch die Grafiken vergeben. Ein einziger Eventhandler reicht nun für die gesamte Funktionalität aus. Dieser muss bei den Untereinträgen dem Ereignis Click zugewiesen werden, beim Button aber dem Ereignis ButtonClick. Der Grund dafür ist, dass auch beim Öffnen der Unterelemente für den ToolStripSplitButton das Click-Ereignis ausgelöst wird, d.h. es käme sofort zu einer Farbänderung. Das ist allerdings nicht erwünscht. Das Ereignis ButtonClick tritt explizit auf, wenn nur der Button angeklickt wurde. Sowohl die Einträge unterhalb des ToolStripSplitButton (sie sind übrigens vom Typ ToolStripMenuItem – also dem gleichen Typ wie beim MenuStrip oder beim ContextMenuStrip) sind von der Basisklasse ToolStripItem abgeleitet. Auch die Eigenschaften Tag und Image stammen von dieser Basisklasse. Innerhalb der Ereignisbehandlungsroutine wird daher zunächst auf ToolStripItem gecastet. Schlägt die Umwandlung fehl, erfolgt keine Aktion. Andernfalls wird die Grafik des ToolStripSplitButton auf die Grafik des Senders eingestellt, ebenso verfahren wir mit der Eigenschaft Tag. Damit ändert der Button sein Aussehen, sobald eines der Elemente angeklickt wurde. Über eine switch-Anweisung ermitteln wir nun den Wert aus der Eigenschaft Tag und ändern entsprechend die Farbe des gerade aktiven Panels, das ja in activePanel referenziert ist. Da auch dieser EventHandler unabhängig von einem bestimmten Control ist, wird er ebenfalls neutral benannt. private void Event_ChangeColor( object sender, EventArgs e ) { // Aktuelles ToolStripItem holen ToolStripItem currentItem = ( sender as ToolStripItem ); if ( currentItem != null ) { string currentTag = (string)currentItem.Tag; // Tag ermitteln this.btnColors.Tag = currentTag; // Tag für Button festlegen this.btnColors.Image = currentItem.Image; // Image Für Button festlegen // Farbe zuweisen switch ( currentTag ) { case "red": this.activePanel.BackColor = Color.Red; break; case "blue": this.activePanel.BackColor = Color.Blue; break; case "yellow": this.activePanel.BackColor = Color.Yellow; break; case "green": this.activePanel.BackColor = Color.Green; break; } } }
Sandini Bib
660
20 Benutzeroberfläche
Das Ergebnis (oder besser eines der möglichen Ergebnisse zahlreicher Farbzuweisungen) sehen Sie in Abbildung 20.5.
Abbildung 20.5: Viele, viele bunte Panels … im Buch natürlich in Form verschiedener Graustufen
20.1.5
Die Statusleiste (StatusStrip-Steuerelement)
Die Statusleiste hat ihre Rolle im Laufe der Zeit verändert. Aus einem einfachen Element zur Anzeige von Kurzhilfetexten für die diversen Steuerelemente auf einem Formular (was natürlich immer noch möglich ist und auch verwendet wird) ist ein multifunktionales Steuerelement geworden, das dynamisch Fortschrittsanzeigen anzeigt, in mehrere Abschnitte getrennt werden kann, Menüs beinhalten kann (in Form eines ToolStripDropDownButton oder eines ToolStripSplitButton) – kurz, es ist ein Element mit vielen Möglichkeiten und deshalb auch in der Hierarchie der ToolStrip-Elemente enthalten. Das StatusStrip-Steuerelement erweitert die doch bescheidenen Möglichkeiten seines Vorgängers. Wie bereits von den Menüelementen und vom ToolStrip-Element gewohnt ist es auch hier möglich, zahlreiche Elemente hinzuzufügen. Das Verhalten entspricht wieder dem der Elemente aus MenuStrip oder ToolStrip – kein Wunder, handelt es sich doch bei den eingefügten Elementen um die gleichen Controls.
Gestaltung Gestaltungstechnisch müssen Sie sich im Vergleich zum Vorgänger umstellen, denn die StatusBarPanel-Objekte gibt es nicht mehr. Stattdessen können Sie die einzelnen Elemente (beispielsweise ein ToolStripStatusLabel für die Anzeige von Text) mit einem Rahmen belegen, um so die unterschiedlichen Bereiche zu trennen. Mehr als die Anzeige von
Sandini Bib
Bedienelemente
661
ToolStripStatusLabel, ProgressBar bzw. Button-Elementen (lediglich ToolStripDropDownButton sowie ToolStripSplitButton) macht allerdings in einer Statusleiste keinen Sinn.
Bei den Rahmenarten für das ToolStripStatusLabel allerdings sind die Designmöglichkeiten wirklich ausgeschöpft worden. Was eigentlich auch einem herkömmlichen Panel gut zu Gesicht stehen würde, ist hier implementiert: Der BorderStyle vom Typ Border3DStyle zeigt zahlreiche Varianten, die auch in der selbst erstellten Panel-Komponente aus dem vorangegangenen Kapitel zu finden sind. Der Rahmen kann erhöht, versunken oder geätzt dargestellt werden, was zahlreiche Möglichkeiten eröffnet. Außerdem kann das Label auch noch mit Links umgehen sowie Grafiken anzeigen. Eine Allzweckwaffe sozusagen.
20.1.6
Eigene Elemente für den ToolStrip
Es wurde bereits mehrfach angesprochen, dass sämtliche Elemente, die in einer der StripKomponenten angezeigt werden können, von ToolStripItem abgeleitet sind. Es ist in der Tat möglich, jedes beliebige von ToolStripItem abgeleitete Element in jedem StripSteuerelement einzubetten (es macht lediglich bei manchen Kombinationen keinen Sinn).
CD
Interessanter ist jedoch die Tatsache, dass Sie alle visuellen Steuerelemente (auch selbst definierte) in ein ToolStripItem verwandeln können. Genauer gesagt, sie müssen sie in einem ToolStripControlHost einbetten. Diese Klasse ist speziell dafür vorgesehen, andere Steuerelemente in einem ToolStrip anzuzeigen. Die Vorgehensweise wird an einem Beispiel dargestellt. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_20\ToolStripControls.
Im Falle eigener Steuerelemente müssen Sie immer auf die korrekte Benennung sowohl des Projekts als auch der Projektmappe achten. Zwar ist es möglich, die Projektmappe so zu benennen wie auch das Projekt, aber Überschneidungen führen leicht zu Verwirrung. In diesem Fall wurde das Projekt mit ToolStripCustomControls bezeichnet, die Projektmappe mit ToolStripControls. Das eigentliche Control, das eingefügt werden soll, ist ein MonthCalendar, der in einem Menü erscheinen kann. Dementsprechend wurde eine neue Klasse namens ToolStripMonthCalendar angelegt. Die Klasse ToolStripControlHost dient als Wrapper um das eigentlich anzuzeigende Steuerelement. Intern werkelt dann das eigentliche Steuerelement, nach außen hin sieht der ToolStrip die Host-Klasse, die problemlos eingebettet werden kann. Unsere neue Klasse wird also nicht von MonthCalendar abgeleitet, sondern vielmehr von ToolStripControlHost. Die MonthCalendar-Instanz, mit der das Wrapper-Element arbeitet, wird nur eingebettet. Dazu besitzt ToolStripControlHost eine Eigenschaft Control, die das eingebettete Steuerelement beherbergt. Die benötigte Instanz wird im Konstruktor übergeben, durch eine Verkettung des Konstruktors mit dem Konstruktor der Basisklasse. In der Regel wird es jedoch so sein, dass Sie noch einige Einstellungen vornehmen möchten, bevor die Instanz übergeben wird. Deshalb erstellen wir in unserer Klasse eine Eigenschaft CreateControl, die statisch ist und einen Wert vom Typ Control zurückliefert. Auch
Sandini Bib
662
20 Benutzeroberfläche
MonthCalendar ist letztlich von der Klasse Control abgeleitet, und der Konstruktor der Basis-
klasse erwartet einen Parameter dieses Typs. Die Umwandlung kann also auch hier geschehen. Natürlich funktioniert das Ganze auch mit einer Methode. Die Klasse mit Konstruktor und der statischen Eigenschaft CreateControl zeigt das folgende Listing. public class ToolStripMonthCalendar : ToolStripControlHost { // private Eigenschaft zum Erstellen einer neuen Instanz private static MonthCalendar CreateControl { get { return new MonthCalendar(); } } public ToolStripMonthCalendar() : base( CreateControl ) { } }
Über die Eigenschaft Control kann jederzeit auf die enthaltene Steuerelement-Instanz zugegriffen werden. Da derartige Steuerelemente aber in der Regel über die Grenzen einer Programmiersprache hinaus verfügbar sein sollen, muss auf etwaige generische Konstrukte verzichtet werden. Stattdessen wird gutes altes Casting benötigt. Um das Ganze nicht ausarten zu lassen, definieren wir eine Eigenschaft namens MonthCalendarControl, über die wir an das enthaltene Steuerelement herankommen, denn das müssen wir ja auf jeden Fall. Von außen besteht kein Bedarf für diese Eigenschaft, daher empfiehlt sich eine Deklaration als private. // Eigenschaft zum Ermitteln des eigentlichen MonthCalendar-Controls private MonthCalendar MonthCalendarControl { get { return Control as MonthCalendar; } }
Eigenschaften und Methoden Das Steuerelement ist nun gekapselt. Damit dringen seine Eigenschaften, Methoden und Ereignisse natürlich nicht nach außen durch. Zwar besitzt auch ToolStripControlHost einige Standardeigenschaften (die ggf. ausgeblendet werden müssen), einige speziell bei MonthCalendar wichtige Dinge müssen aber manuell für die Öffentlichkeit zugänglich gemacht werden. Zunächst einige der Eigenschaften: public bool ShowToday { get { return MonthCalendarControl.ShowToday; } set { MonthCalendarControl.ShowToday = value; } } public bool ShowTodayCircle { get { return MonthCalendarControl.ShowTodayCircle; } set { MonthCalendarControl.ShowTodayCircle = value; } }
Sandini Bib
Bedienelemente
663
public bool ShowWeekNumbers { get { return MonthCalendarControl.ShowWeekNumbers; } set { MonthCalendarControl.ShowWeekNumbers = value; } } public Day FirstDayOfWeek { get { return MonthCalendarControl.FirstDayOfWeek; } } public DateTime[] BoldedDates { get { return MonthCalendarControl.BoldedDates; } set { MonthCalendarControl.BoldedDates = value; } } public DateTime[] AnnuallyBoldedDates { get { return MonthCalendarControl.AnnuallyBoldedDates; } set { MonthCalendarControl.AnnuallyBoldedDates = value; } } public DateTime[] MonthlyBoldedDates { get { return MonthCalendarControl.MonthlyBoldedDates; } set { MonthCalendarControl.MonthlyBoldedDates = value; } }
Der Zugriff geschieht grundsätzlich über die Eigenschaft MonthCalendarControl, die ja den eigentlichen MonthCalendar darstellt. Das Festlegen der Werte stellt natürlich kein Problem dar. Für die hervorgehobenen Datumsangaben (BoldedDates, AnnuallyBoldedDates, MonthlyBoldedDates) stellt MonthCalendar Methoden zur Verfügung, um hier neue Datumsangaben hinzuzufügen. Diese sind natürlich auch nicht nach außen hin sichtbar und müssen daher programmiert werden. public void AddBoldedDates( DateTime date ) { MonthCalendarControl.AddBoldedDate( date ); } public void AddAnnuallyBoldedDate( DateTime date ) { MonthCalendarControl.AddAnnuallyBoldedDate( date ); } public void AddMonthlyBoldedDate( DateTime date ) { MonthCalendarControl.AddMonthlyBoldedDate( date ); }
Sie sehen an dieser Stelle bereits, dass es relativ einfach ist, bestimmte Elemente eines so gekapselten Steuerelements zu veröffentlichen. Diese Technik funktioniert nicht nur an
Sandini Bib
664
20 Benutzeroberfläche
dieser Stelle – Sie können auch selbst eine Wrapper-Klasse bauen, die Objekte beliebigen Typs aufnimmt und eine gemeinsame Schnittstelle zur Verfügung stellt. Normalerweise geschieht das über Interfaces, die Vorgehensweise mit einem Wrapper ist dann sinnvoll, wenn alle diese Steuerelemente zwingend Bestandteil einer bestimmten Objekthierarchie sein müssen (wie in diesem Fall).
Ereignisse Auch Ereignisse müssen veröffentlicht werden. Hier tritt ein besonderer Fall auf. Einerseits haben wir ein eigenes Control gebaut, das seine eigenen Ereignisse veröffentlicht. Dieses Control ist allerdings ein Wrapper, d.h. nicht von dem Control abgeleitet, dass die veröffentlichten Ereignisse in Wirklichkeit auslöst. Aus diesem Grund werden Ereignisse lediglich weitergeleitet, d.h. das vom inneren MonthCalendar-Control ausgelöste Ereignis ruft in der Wrapper-Klasse eine Ereignisbehandlungsroutine auf. Die Wrapper-Klasse ihrerseits definiert ein Ereignis desselben Typs und leitet den Aufruf an dieses Ereignis weiter. In diesem Fall sollen nur zwei Ereignisse ausgelöst werden, die speziell zum MonthCalendarControl gehören: DateSelected und DateChanged. Beide sind vom Typ DateRangeEventHandler. public event DateRangeEventHandler DateChanged; public event DateRangeEventHandler DateSelected;
Die entsprechenden dazugehörigen Methoden heißen (wie gehabt) OnDateChanged() bzw. OnDateSelected(). In diesem Fall aber handelt es sich um Ereignisbehandlungsroutinen für die wirklichen Ereignisse, die aus dem inneren Control kommen. Deshalb genügt es nicht, nur die passenden Argumente vom Typ DateRangeEventArgs zu übergeben. Das Ereignis muss komplett abonniert werden. private void OnDateSelected( object sender, DateRangeEventArgs e ) { if ( DateSelected != null ) DateSelected( this, e ); } private void OnDateChanged( object sender, DateRangeEventArgs e ) { if ( DateChanged != null ) DateChanged( this, e ); }
Innerhalb der Ereignisbehandlungsroutine wird lediglich der Parameter sender ausgetauscht; statt des inneren MonthCalendar-Steuerelements ist der Sender nun unser ToolStripMonthCalendar-Control. Der letzte Schritt besteht noch darin, die Ereignisse auch zu abonnieren, d.h. die Ereignisbehandlungsroutinen zu verknüpfen. Hierzu bietet ToolStripControlHost zwei Methoden. OnSubscribeControlEvents() zum Abonnieren der Ereignisse, OnUnSubscribeControlEvents() um das »Abo« aufzuheben. In beiden Routinen werden lediglich die EventHandler anbzw. abgehängt. Die Methoden sind in der Basisklasse als protected virtual deklariert, in unserer Klasse verwenden wir daher protected override.
Sandini Bib
Bedienelemente
665
protected override void OnSubscribeControlEvents( Control control ) { base.OnSubscribeControlEvents( control ); // Control casten MonthCalendar calendar = control as MonthCalendar; // wenn nicht null, weitermachen if ( calendar != null ) { calendar.DateChanged += new DateRangeEventHandler(OnDateChanged); calendar.DateSelected += new DateRangeEventHandler( OnDateSelected ); } } protected override void OnUnsubscribeControlEvents( Control control ) { base.OnUnsubscribeControlEvents( control ); // Control casten MonthCalendar calendar = control as MonthCalendar; // wenn nicht null, weitermachen if ( calendar != null ) { calendar.DateChanged -= new DateRangeEventHandler( OnDateChanged ); calendar.DateSelected -= new DateRangeEventHandler( OnDateSelected ); } }
Anzeige im Designer Eigentlich wäre das Steuerelement damit bereits fertiggestellt. Eines fehlt allerdings noch: Die Sichtbarkeit im Designer. Bisher ist es so, dass das Steuerelement zwar manuell einem Menü hinzugefügt werden kann, allerdings nicht über den Designer. Wie sehr viele Dinge im .NET Framework erfolgt die Steuerung dieses Verhaltens über ein Attribut. Es handelt sich um die Klasse ToolStripItemDesignerAvailabilityAttribute. Sie erwartet im Konstruktor einen Wert des Typs ToolStripItemDesignerAvailability, bei dem es sich um ein Bitfeld handelt. Die folgenden Werte sind möglich (um die Zeile nicht ganz zu lang werden zu lassen, wird der Name des Enums weggelassen): f MenuStrip: Das Element kann als Bestandteil eines MenuStrip verwendet werden. Das betrifft nicht die Unterpunkte eines Menüs, hierzu müssen Sie die Einstellung ContextMenuStrip verwenden. MenuStrip bezieht sich nur auf die oberste Hierarchieebene eines Menüs. f ContextMenuStrip: Das Element kann in einem Menü, einem KontextMenü oder einem DropDownButton als untergeordnetes Element verwendet werden. f StatusStrip: Das Element kann als Bestandteil einer Statusleiste eingesetzt werden.
Sandini Bib
666
20 Benutzeroberfläche
f ToolStrip: Das Element kann als Bestandteil einer Toolbar eingesetzt werden. f None: Das Element ist nirgends sichtbar. Allerdings ist der Einsatz nach wie vor möglich, jedoch nicht mehr über den Designer. f All: Der Einsatz des Steuerelements ist in allen Strip-Komponenten über den Designer möglich. In unserem Fall genügt es, das Element für ein Kontextmenü verfügbar zu machen. An einer anderen Stelle macht es nicht viel Sinn. Das Attribut wird auf die Klasse angewendet. [ToolStripItemDesignerAvailability( ToolStripItemDesignerAvailability.ContextMenuStrip )]
Damit ist das Steuerelement fertiggestellt. Abbildung 20.6 zeigt den Designer, mit dem eigenen Steuerelement in der Liste der verfügbaren Elemente. Abbildung 20.7 zeigt die Applikation zur Laufzeit.
Abbildung 20.6: Das MonthCalendar-Control im Designer
Abbildung 20.7: Das MonthCalendar-Control zur Laufzeit in einem Menü
Sandini Bib
Standarddialoge
20.2
667
Standarddialoge
Das .NET Framework liefert die in einem Programm üblichen Standarddialoge mit. Dazu gehören unter anderem Dialoge zum Öffnen und Speichern von Dateien, Dialoge zur Auswahl von Farben und Schriftarten, Dialoge zum Drucken und zur Druckvorschau sowie ein Dialog zur Verzeichnisauswahl. Die Dialoge, die für das Drucken zuständig sind, werden detaillierter in Kapitel 22.2.2 ab Seite 836 behandelt, wo es dann auch um das Drucken selbst geht. Die verbleibenden Dialoge werden hier beschrieben. Die Standarddialoge dienen lediglich dazu, Informationen zu ermitteln, beispielsweise den Speicherort und den Namen einer Datei. Das Laden und Speichern selbst müssen Sie dann selbst implementieren, was auch sinnvoll ist, da es auf mehrere Arten geschehen kann. Alle Standarddialoge werden über die Methode ShowDialog() angezeigt. Der von dieser Methode zurückgelieferte Wert ist vom Typ DialogResult. Mit seiner Hilfe können Sie auswerten, mit welchem Button der Anwender den Dialog beendet hat. Am häufigsten werden hier vermutlich die Werte DialogResult.OK oder DialogResult.Cancel verwendet werden.
20.2.1
Dateien öffnen und speichern
Die Dialoge OpenFileDialog und SaveFileDialog zum Öffnen und Speichern einer Datei ähneln sich wie ein Ei dem anderen, beide Klassen sind von der ihnen übergeordneten Klasse FileDialog abgeleitet. Da sie eigentlich aus dem Betriebssystem stammen, ermöglichen diese Dialoge auch die Anlage eines neuen Verzeichnisses, einfach über einen Button des Dialogs. Die Eigenschaft Filter legt fest, welche Dateien angezeigt werden sollen. Dabei handelt es sich um einen String mit festgelegtem Aufbau. Bezeichnung und eigentlicher Filter werden durch ein Pipe-Symbol (|) getrennt, ebenso mehrere Filtervarianten. Die Auswahl der gewünschten Filtervariante erfolgt dann innerhalb des Dialogs über die Auswahlliste im Dialog. Über die Eigenschaft FilterIndex können Sie festlegen, welcher der eingegebenen Filter beim Erscheinen des Dialogs verwendet werden soll. Das folgende kleine Beispielprogramm zeigt einen Dialog mit drei Filtern, wobei der zweite ausgewählt ist. private void button1_Click(object sender, System.EventArgs e) { dlgOpen.Filter = "C#-Dateien|*.cs|VB-Dateien|*.vb|Alle Dateien|*.*"; dlgOpen.FilterIndex = 2; if (dlgOpen.ShowDialog()==DialogResult.OK) MessageBox.Show(dlgOpen.FileName); }
Sandini Bib
ACHTUNG
668
20 Benutzeroberfläche
Anders als ansonsten in .NET gewohnt beginnt der Wert für FilterIndex nicht mit dem Wert 0, sondern mit 1. Ein Wert von 2 zeigt also in der Tat den zweiten Filtereintrag an.
Der Dateiname wird in der Eigenschaft FileName zurückgeliefert. Der Wert DialogResult.OK entspricht natürlich einem Klick auf den Button ÖFFNEN bzw. beim SaveFileDialog auf den Button SPEICHERN. Zusätzlich können Sie für diesen Fall noch das Ereignis FileOk auswerten, das beim Klick auf einen dieser Buttons ausgelöst wird. Da Sie einen solchen Dialog in der Regel nicht zur Laufzeit erstellen, sondern ihn auf dem Formular platzieren (wodurch Sie ihn in jeder Methode verwenden können), gelangen Sie einfach über das Eigenschaftenfenster zu diesem Ereignis. Selbstverständlich funktioniert das Ganze auch mit einer dynamischen Erzeugung des Dialogs zur Laufzeit. Der folgende kurze Codeausschnitt zeigt, wie Sie das FileOk-Ereignis programmatisch mit dem Dialog verbinden. private void OkClicked( object sender, CancelEventArgs e ) { // Verwenden des Events } private void button1_Click( object sender, System.EventArgs e ) { OpenFileDialog dlgOpen = new OpenFileDialog(); dlgOpen.FileOk += new CancelEventHandler( OkClicked ); dlgOpen.Filter = "C#-Dateien|*.cs|VB.NET-Dateien|*.vb|Alle Dateien|*.*"; dlgOpen.FilterIndex = 2; if ( dlgOpen.ShowDialog() == DialogResult.OK ) MessageBox.Show( dlgOpen.FileName ); }
Die Klasse CancelEventArgs ist im Namespace System.ComponentModel deklariert. Der Parameter e bietet eine Eigenschaft Cancel, mit der Sie das Schließen des Dialogs beim Klick auf OK (respektive ÖFFNEN bzw. SPEICHERN) verhindern können. Der Dialog bleibt geöffnet, solange Sie e.Cancel auf true setzen. Über die Eigenschaft DefaultExt können Sie festlegen, welche Dateiendung an den Dateinamen angehängt werden soll, falls der Anwender diesen manuell und ohne Endung eingibt. Die Eigenschaft Title enthält den Text in der Titelleiste des Dialogs. Über InitialDirectory können Sie das Verzeichnis angeben, in das der Dialog springt, wenn er angezeigt wird. Die Eigenschaft ValidateNames, deren Standardwert true ist (und der auch nicht geändert werden sollte) legt fest, ob der eingegebene Dateiname automatisch auf nicht erlaubte Zeichen kontrolliert werden soll. Die Eigenschaft MultiSelect erlaubt es, festzulegen, ob der Anwender mehrere Dateien auswählen kann. Das geschieht wie auch von anderen derartigen Dialogen gewohnt über die Verwendung der Tasten (ª) und (Strg) und gleichzeitigem Anklicken der Dateinamen.
Sandini Bib
Standarddialoge
669
Im Falle der Einstellung true für MultiSelect werden alle ausgewählten Dateinamen in der Eigenschaft FileNames zurückgeliefert, einem Array aus Strings.
20.2.2
Farbauswahl
Der Dialog für die Farbauswahl ist ebenfalls aus zahlreichen Windows-Programmen bekannt. In .NET wird er gekapselt durch die Komponente ColorDialog. Das Erscheinungsbild des Dialogs ist durch verschiedene Eigenschaften steuerbar. Mit der Eigenschaft AllowFullOpen legen Sie fest, ob die Schaltfläche BENUTZERDEFINIERTE FARBEN angezeigt werden soll (bei true) oder nicht. Ist die Eigenschaft FullOpen true, bewirkt sie, dass der Dialog schon gleich im voll geöffneten Zustand angezeigt wird. Die Eigenschaft Color schließlich liefert die im Dialog eingestellte Farbe zurück bzw. dient dazu, diese vor dem Öffnen des Dialogs auf die aktuell verwendete Farbe einzustellen. Die Eigenschaften AnyColor und SolidColorOnly scheinen keine besondere Bedeutung zu haben, zumindest ändert sich nichts an der Verhaltensweise des Dialogs, wenn sie auf true gestellt werden.
20.2.3
Schriftart auswählen
Über die Komponente FontDialog besitzen Sie eine komfortable Möglichkeit, eine Schriftart auszuwählen bzw. auch sie zuzuweisen, bevor der Dialog geschlossen wird (z.B. für eine Vorschau). Auch dieser Dialog wird durch Eigenschaften gesteuert, wobei es hier ein paar Sachen mehr einzustellen gibt als beim doch recht einfachen ColorDialog. Die Eigenschaft Font legt fest, welche Schriftart im Dialog angezeigt werden soll. Vor dem Öffnen sollten Sie diese auf die aktuell verwendete Schriftart festlegen, damit die Anzeige auch stimmt. Die Eigenschaften ShowColor und ShowEffects legen fest, ob die Schrifteffekte und Farben einstellbar sind. Unter Schrifteffekten versteht man hier unterstreichen oder durchstreichen des Textes. Die Farbeinstellung kennen Sie aus anderen Programmen ebenfalls, sie ist aber nicht immer notwendig und kann bei Bedarf ausgeblendet werden. Wenn Sie die Eigenschaft FontMustExist auf true setzen, wird ein Fehler angezeigt, wenn der Benutzer eine Schriftart eingibt, die nicht existiert. Über FixedPitchOnly können Sie steuern, ob nur Zeichensätze mit einer festen Zeichenbreite angezeigt werden (solche Zeichensätze sind z.B. Courier New oder das in diesem Buch für die Quelltexte verwendete Letter Gothic). Über die Eigenschaft ScriptsOnly können Sie zudem OEM-Zeichensätze und Zeichensätze, die Symbole enthalten (wie z.B. WingDings), ausblenden. Über die Eigenschaft ShowApply können Sie dem Anwender auch eine ÜBERNEHMEN-Schaltfläche anbieten. Wird diese Schaltfläche angeklickt, löst der Dialog ein Apply-Ereignis aus, in dem Sie eine Schriftart zuweisen können, ohne den Dialog zu schließen. Die Funktionsweise ist die gleiche wie beim Ereignis FileOk der Dialoge OpenFileDialog und SaveFileDialog.
Sandini Bib
670
20.3
20 Benutzeroberfläche
MDI-Anwendungen
Man unterscheidet zwischen zwei Arten von Anwendungen, SDI-Anwendungen (Single Document Interface) und MDI-Anwendungen (Multiple Document Interface). Auch das Visual Studio hat einen MDI-Modus. Bei dieser Art Anwendung stellt das Hauptfenster einen Arbeitsbereich zur Verfügung, in dem alle weiteren untergeordneten Formulare dargestellt werden. Beispiele dafür sind z.B. Corel PhotoPaint oder auch frühere Versionen von Word. Sogar das Visual Studio können Sie von einer Ansicht mit Tabs auf eine MDIAnsicht umstellen. Allerdings macht das nicht sehr viel Sinn, da ein Projekt durch die vielen MDI-Fenster sehr unübersichtlich werden kann.
20.3.1
Grundlagen
Auch das .NET Framework unterstützt MDI-Anwendungen. Der große Unterschied zu einer SDI-Anwendung ist, dass das Hauptfenster mit einem zentralen Menü ausgestattet ist, das auch für die untergeordneten Fenster (die so genannten MDI-Child-Fenster) gilt. Besitzt ein MDI-Child ebenfalls ein Menü, so wird dies an das Hauptmenü angefügt. Die Art und Weise, wie das geschieht bzw. wo die Menüpunkte angefügt werden, können Sie selbst entscheiden. Um eine MDI-Anwendung zu erstellen müssen Sie eigentlich nur die Eigenschaft IsMdiContainer des Hauptformulars auf true setzen. Schon zeigt sich der innere Bereich in der Farbe, die in den Systemfarben für den Arbeitsbereich steht (SystemColors.AppWorkspace). Diese Farbe lässt sich im Programm nicht ändern, lediglich in den Systemeinstellungen. Selbstverständlich haben Sie auch die Möglichkeit, vorher noch Steuerelemente auf dem Formular zu platzieren, der Arbeitsbereich wird dann entsprechend verkleinert. Sie müssen diese Steuerelemente nicht andocken, sollten es aber tun, denn der gesamte Bereich des Hauptfensters, der kein angedocktes Steuerelement enthält, wird als Arbeitsbereich deklariert. Die angeordneten Steuerelemente würden sich demnach zur Laufzeit möglicherweise über den Child-Fenstern befinden und diese verdecken oder eben durch die Child-Fenster verdeckt werden.
Child-Fenster erzeugen Ein Child-Fenster erstellen Sie ebenso wie ein »normales« Fenster, indem Sie ein neues Formular erzeugen. Sie sind frei in der Gestaltung dieses Fensters (in der Regel dient es aber zur Anzeige eines Textes o.Ä., d.h. es beinhaltet im Wesentlichen ein Steuerelement, das die gesamte Fläche des Formulars einnimmt). Die Deklaration als Child-Fenster geschieht über die Eigenschaft MdiParent, der das übergeordnete Fenster zugewiesen wird. Für das folgende Beispiel wird davon ausgegangen, dass ein Hauptformular besteht (FrmMain), das auch ein Menü beinhaltet. Im Menüpunkt mnuNew wird ein neues Child-Fenster erzeugt. Das dazu notwendige Formular heißt FrmChild und muss natürlich vorher erstellt worden sein.
Sandini Bib
MDI-Anwendungen
671
private void mnuNew_Click( object sender, EventArgs e ) { // Neues Fenster erzeugen FrmChild frm = new FrmChild(); // MdiParent zuweisen frm.MdiParent = this; frm.Show(); }
Programm beenden Wie gehabt wird auch ein MDI-Programm beendet, indem die Methode Close() des Hauptformulars aufgerufen wird. Allerdings kann das Beenden noch abgefangen werden, denn zunächst werden alle Child-Fenster geschlossen und deren Closing-Ereignisse ausgelöst. Es bleibt also die Möglichkeit, ggf. zu überprüfen, ob eine Datei noch gespeichert werden muss, und das Beenden noch aufzuhalten. Setzen Sie dazu den Wert der Eigenschaft e.Cancel auf true.
20.3.2
MDI-Fenster verwalten
Die Klasse Form kennt einige Eigenschaften und Methoden, die zur Verwaltung von MDIFenstern nützlich sind. Die folgende Tabelle listet diese Eigenschaften und Methoden auf. Eigenschaften, Methoden und Ereignisse für die MDI-Verwaltung ActiveMdiChild
verweist auf das aktive MDI-Child-Fenster. Die Eigenschaft enthält null, wenn noch kein MDI-Fenster geöffnet wurde.
MdiChildren
verweist auf ein Array mit den Form-Objekten aller MDI-Fenster.
ActivateMdiChild()
aktiviert das als Parameter angegebene Child-Fenster.
LayoutMdi()
ermöglicht das Anordnen der MDI-Fenster nach verschiedenen Kriterien (nebeneinander, untereinander usw.). Übergeben wird ein Wert des Typs MdiLayout.
MdiChildActivate
Ein Ereignis, das dann auftritt, wenn sich das aktive MDI-Fenster ändert. Das Ereignis tritt auch dann auf, wenn das letzte Subfenster geschlossen wird. Die Eigenschaft ActiveMdiChild enthält dann null.
Die Eigenschaft ActiveMdiChild, bzw. die Elemente des durch MdiChildren zurückgelieferten Arrays sind vom Typ Form. Um auf die Elemente Ihres eigenen Kindfensters zugreifen zu können, müssen Sie hier also erst in den korrekten Fenstertyp casten. Das ist dann notwendig, wenn Sie Funktionalität des Kindfensters aus dem Hauptfenster heraus aufrufen möchten. Häufig können Sie die benötigte Funktionalität aber auch im Kindfenster selbst unterbringen.
Sandini Bib
672
20 Benutzeroberfläche
Menüverwaltung Jedes MDI-Fenster kann ein eigenes Menü beinhalten, das automatisch mit dem Menü des Hauptfensters verbunden wird. Hierbei sind mehrere Kombinationen möglich: f Die Menüpunkte des MDI-Menüs oder eines Hauptpunkts des MDI-Menüs werden mit den bestehenden Unterpunkten eines Hauptpunkts des Hauptmenüs verbunden und erscheinen dann in einer bestimmten Reihenfolge. f Die MDI-Hauptmenüpunkte erscheinen an einer bestimmten Stelle im Hauptmenü. f Beide Möglichkeiten werden miteinander verbunden. Das Verhalten eines Menüs wird durch drei Eigenschaften gesteuert, deren Verhalten sich leider nur teilweise auf den ersten Blick erschließt. Die Eigenschaften des MenuStrip unterscheiden sich dabei von denen des MainMenu aus .NET 1.1, sowohl im Namen als auch in der Funktionalität. Vermutlich wollten Microsofts Leute es einfacher machen, letztendlich kostete es aber wieder einige Zeit, herauszufinden, was wirklich passiert. Die Funktionalität der einzelnen Eigenschaften hätte wirklich einfacher beschrieben werden können. f Mit der Eigenschaft AllowMerge legen Sie fest, ob ein Menü sich mit einem anderen verbinden lässt. Diese Eigenschaft muss sowohl beim Hauptmenü als auch beim Menü des Kindfensters auf true eingestellt sein. f Die Eigenschaft MergeAction gibt die Art der Verbindung an. MergeAction muss lediglich für das ToolStripMenuItem festgelegt werden, das gemerged wird, also nicht für das Menü, das als Basis dienen soll. f Die Eigenschaft MergeIndex legt fest, an welcher Stelle ein Menüpunkt oder auch das gesamte Menü eingefügt wird. Dieser Index wird für das gesamte Menü, also das Basismenü plus das zusammenzuführende, festgelegt.
Mergen von ToolStrip-Komponenten Während Menüs automatisch zusammengeführt werden, müssen Sie bei einem Toolstrip selbst Hand anlegen. Hierzu gibt es den ToolStripManager, eine Klasse, die ToolStripAktionen verwaltet. Diese Klasse bietet mehrere Methoden, für das Zusammenführen sind allerdings nur zwei relevant: Merge() sowie RevertMerge(). Wie die Namen schon erkennen lassen dient eine der Methoden dem Zusammenführen, die andere dem Rückgängigmachen dieser Aktion. Auch die Elemente eines ToolStrip haben die Eigenschaft MergeIndex, die sich ebenso verhält wie die gleichnamige Eigenschaft bei Menüs. Es gilt, dass beim Zusammenführen von Elementen die Eigenschaft MergeIndex den Index (und damit die Position) eines Elements im zusammengeführten MenuStrip oder ToolStrip angibt. Die Verwendung des ToolStripManager ist notwendig, weil das System bei mehreren ToolStrip-Komponenten nicht automatisch erkennen kann, welcher ToolStrip des Kindfensters mit welchem ToolStrip des Hauptfensters zusammengeführt werden soll. Daher müssen Sie hier selbst Hand anlegen.
Sandini Bib
MDI-Anwendungen
20.3.3
673
Beispiel: Mergen von Menüs und ToolStrip-Komponenten
CD
In diesem Beispiel dreht sich alles allein um das Zusammenführen von Menü und ToolStrip. Das Beispiel hat ansonsten keine besondere Funktion. Sie finden den Quellcode des Beispielprogramms auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_20\MenuMerge. Auf der CD finden Sie auch einen TextEditor, komplett mit .NET geschrieben, der ebenfalls ein MDIInterface aufweist. Auch in diesem können Sie sich den Vorgang des Zusammenführens ansehen. Das Abdrucken des Codes würde allerdings den Rahmen dieses Buchs sprengen.
Benötigt werden ein Hauptformular und ein Kindformular. Das Hauptformular muss als MDI-Container angelegt werden (Eigenschaft IsMdiContainer auf true). Es erhält einen MenuStrip und einen ToolStrip, die allerdings noch nicht komplett gefüllt sind. Ein vollständiger ToolStrip wird erst angezeigt, wenn auch ein Kindfenster angezeigt wird. Am Anfang steht wie bei allen Windows-Applikationen das Menü DATEI. Danach soll das Menü BEARBEITEN eingefügt werden – dieses kommt aus dem Kindfenster, denn bei einer MDI-Applikation wird dort auch die entsprechende Funktionalität eingebaut.
Die Menüpunkte des Hauptfensters Legen Sie zunächst das DATEI-Menü an. Dieses beinhaltet die folgenden Menüpunkte. Hinter den Menüpunkten ist in der MergeIndex für den Menüpunkt angegeben. Der Hauptmenüpunkt DATEI trägt den MergeIndex 0. f NEU [0] f ÖFFNEN [1] f (Trenner) [4] f EINSTELLUNGEN [6] f (Trenner) [10] f BEENDEN [11] Der dritte Menüpunkt ist der Menüpunkt FENSTER. Dieses Menü beinhaltet die üblichen Einträge eines Fenster-Menüs und besitzt den MergeIndex-Wert 1000 – was im Umkehrschluss bedeutet, dass es immer an der rechten Stelle angezeigt wird, ganz gleich, wie viele Menüpunkte zwischen DATEI- und FENSTER-Menüpunkt auch eingefügt werden.
Die Menüpunkte des Kindfensters Auch das Kindfenster muss noch einige Menüpunkte erhalten. Zunächst ist auch hier wieder das DATEI-Menü angegeben. Der Menüpunkt DATEI selbst besitzt den MergeIndex 0 und als MergeAction ist MergeAction.MatchOnly angegeben. Damit sucht das System beim Zusam-
Sandini Bib
674
20 Benutzeroberfläche
menführen den gleichen Menüpunkt im Menü des Hauptfensters, tut aber ansonsten nichts damit. Die Unterpunkte werden entsprechend dann in diesem Menü eingefügt. Für die Unterpunkte ist MergeAction.Insert eingestellt. f SPEICHERN [2] f SPEICHERN UNTER … [3] f SEITE EINRICHTEN … [5] f (Trenner) [7] f DRUCKEREINRICHTUNG … {8] f DRUCKEN … [9] Das BEARBEITEN-Menü wird komplett eingefügt, weshalb die Einstellungen für die Unterpunkte irrelevant sind. Lediglich der Menüpunkt BEARBEITEN selbst benötigt einen MergeIndex (in diesem Fall 1) und MergeAction muss auf MergeAction.Insert eingestellt werden.
Zusammenführen der ToolStrip-Elemente Während es bei Menüs noch einfach ist (weil es nur ein Hauptmenü geben kann), scheint es beim ToolStrip etwas komplizierter zu werden. Grundsätzlich ist die Vorgehensweise wie schon angedeutet die gleiche. In diesem Fall werden sämtliche ToolStrip-Elemente des Kindfensters sogar nach dem ToolStrip des Hauptfensters eingefügt, womit ein Zählen der Elemente erspart bleibt – sie werden ohnehin nur angehängt, daher sind hier keine Einstellungen vorzunehmen. Nur das Zusammenführen muss noch geschehen. Das geschieht am besten im Ereignis Activate. Passend dazu soll die Zusammenführung im Ereignis Deactivate rückgängig gemacht werden. Im Kindfenster wird zunächst der ToolStrip aus dem Hauptfenster benötigt, denn ansonsten ist eine Zusammenführung nicht möglich. Die einfachste Möglichkeit ist, ein entsprechendes Feld im Kindfenster anzulegen und den ToolStrip dann im Konstruktor dieses Kindfensters zu übergeben. private ToolStrip _tlsMain; public FrmChild( ToolStrip baseStrip ) { InitializeComponent(); this._tlsMain = baseStrip; }
Für die eigentliche Aktion des Zusammenführens werden am besten eigene Methoden zur Verfügung gestellt – möglicherweise soll ja das Zusammenführen auch noch von anderer Stelle aus erfolgen. In den Ereignisbehandlungsroutinen für Activated bzw. Deactivated werden diese Methoden dann aufgerufen. In diesem Beispiel entspricht _tlsMain dem ToolStrip des Hauptformulars, tlsChild dem ToolStrip des Kindformulars.
Sandini Bib
MDI-Anwendungen private void MergeToolStrips() { // Erst mergen rückgängig machen - es könnte noch gemerged sein UnmergeToolStrips(); // Jetzt zusammenführen ToolStripManager.Merge( this.tlsChild, this._tlsMain ); } private void UnmergeToolStrips() { // Mergen rückgängig machen ToolStripManager.RevertMerge(this._tlsMain ); } private void FrmChild_Activated( object sender, EventArgs e ) { MergeToolStrips(); } private void FrmChild_Deactivate( object sender, EventArgs e ) { UnmergeToolStrips(); }
Das Ergebnis dieser kleinen Prozedur zeigt Abbildung 20.8.
Abbildung 20.8: Ein MDI-Fenster mit zusammengeführtem Menü und ToolStrip
675
Sandini Bib
676
20 Benutzeroberfläche
20.4
Programmiertechniken
Bezüglich einer Benutzeroberfläche gibt es zahlreiche Programmiertechniken, die insgesamt auch ein eigenes Buch füllen könnten. Einige der am häufigsten verwendeten Techniken werden hier vorgestellt.
20.4.1
Anzeige eines Splashscreens
Splashscreens sind die Fenster, die beim Programmstart während der Initialisierung des Programms angezeigt werden. Diese Vorgehensweise ist natürlich auch unter .NET möglich. Die Anzeige eines Splashscreens kann auf mehrere Arten erfolgen: f Der Splashscreen kann im Load-Ereignis des Hauptformulars angezeigt werden. Direkt nach der Anzeige erfolgen die Initialisierungen, danach wird der Dialog wieder entfernt. Diese Möglichkeit ist aber nicht wirklich ideal. f Besser ist es, den Splashscreen in der Main()-Methode anzuzeigen, vor dem Aufruf von Application.Run(). Wichtig ist hierbei, dass Windows weiterhin auf Messages lauschen kann, d.h. vergessen Sie nicht, Application.DoEvents() aufzurufen.
CD
Der folgende kleine Codeausschnitt zeigt, wie die Implementierung eines solchen Splashscreens aussehen kann. Das Formular, das für den Splashscreen verwendet wird, wurde vorab erstellt und ist Bestandteil des Projekts. Damit kann es auch wiederverwendet werden, z.B. als About-Dialog. Unverständlich ist, warum die Microsoft-Leute den Visual-Basic-Programmierern eine entsprechende SplashForm als Template mitgeben und den Visual C#-Programmierern nicht. Sehe ich da eine Vorliebe der Microsoftler für Visual Basic …? Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_20\SplashScreen. Es tut allerdings nicht sehr viel, außer einen SplashScreen anzuzeigen. Dazu wurde das Template aus Visual Basic nachgebaut. Ein weiteres Beispiel mit einem Splashscreen, der eine Fortschrittsanzeige anzeigt finden Sie in Abschnitt 25.2.5 ab Seite 903 über Reflection.
static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault( false ); FrmSplash frm = new FrmSplash(); // Slpashscreen erzeugen frm.Show(); // Anzeigen frm.Update(); // Korrektes Zeichnen erzwingen Application.DoEvents();
// Das System arbeiten lassen
Sandini Bib
Programmiertechniken
677
// Hier sollte das Laden etwaiger Optionen stehen System.Threading.Thread.Sleep( 3000 ); frm.Close(); frm.Dispose();
// Splashscreen schließen // Splashscreen aus Speicher entfernen
Application.Run( new Form1() );
// Eigentlicher Start
}
Damit der Splashscreen letztendlich wirklich gut aussieht, sollten Sie ein Fenster ohne Rand verwenden (FormBorderStyle=FormBorderStyle.None). Falls Sie eine Umrandung möchten, können Sie ein Panel auf das Formular legen und diesem eine Umrandung zuweisen (Ebenfalls über BorderStyle, z.B. BorderStyle.FixedSingle). Außerdem sollte das Formular in der Mitte des Bildschirms angezeigt werden. Das erreichen Sie durch die Einstellung StartPosition=FormStartPosition.CenterScreen.
20.4.2
Arbeiten mit der Zwischenablage
Zum Ansprechen der Zwischenablage existiert im .NET Framework die Klasse Clipboard aus dem Namespace System.Windows.Forms. In .NET 1.1 kannte diese Klasse lediglich zwei (statische) Methoden, nämlich einmal zum Entnehmen eines Objekts und einmal zum Einlegen eines Objekts in die Zwischenablage. Um es vor allem bei häufig benötigten Formaten einfacher zu machen, auf die Zwischenablage zuzugreifen, wurden einige Methoden hinzugefügt. Der ursprüngliche Ansatz über GetDataObject() bzw. SetDataObject() funktioniert aber nach wie vor (allein schon aus Gründen der Abwärtskompatibilität). Die folgende Liste liefert die Bedeutung der enthaltenen Methoden. f Methoden, die mit Contains beginnen, liefern Informationen darüber, ob ein bestimmtes Format enthalten ist. ContainsText() beispielsweise liefert true, wenn das Format Text enthalten ist. ContainsAudio() liefert true, wenn eine Audiodatei enthalten ist. f Die diversen Get-Methoden liefern die Daten des entsprechenden Formats. GetText() liefert also die im Textformat enthaltenen Daten, GetImage() eine evtl. enthaltene Grafik. GetData() steht für beliebige Daten und ersetzt damit GetDataObject().GetData(), das aber aus Kompatibilitätsgründen immer noch vorhanden ist. f Set-Methoden legen Daten in der Zwischenablage ab. Sie ersetzen damit SetObjectData(), das aber ebenfalls noch vorhanden ist. SetText() legt einen Text in der Zwischenablage ab, SetData() beliebige Daten. In diesem Fall kann auch ein eigenes Datenformat angegeben werden (dabei handelt es sich lediglich um einen Namen, also einen String), der beispielsweise nur von Ihrem Programm verwendet wird. f Über die Methode GetDataObject() kommen Sie auch an eine Liste der enthaltenen Datenformate heran. Diese liefert GetDataObject().GetFormats(). Es handelt sich um ein Array aus Strings, da die Formate der Zwischenablage als Zeichenketten angegeben werden.
Sandini Bib
HINWEIS
20 Benutzeroberfläche
Eine ganze Reihe von .NET-Steuerelementen kommuniziert selbstständig mit der Zwischenablage. Beispielsweise funktionieren in Textfeldern die Tastenkürzel (Strg)+(C), (Strg)+(X) und (Strg)+(V) automatisch, ohne dass Sie eine Zeile Code schreiben müssen.
TIPP
678
Wenn Sie Programme zur Manipulation der Zwischenablage schreiben, ist das Programm c:\windows\system32\clipbrd.exe ein nützliches Hilfsmittel. Es zeigt die gerade in der Zwischenablage enthaltenen Daten in beliebigen Formaten an.
Die Verwendung der Zwischenablage gestaltet sich vor allem dank der neuen Methoden intuitiv und bedarf daher keiner weiteren Erklärung.
20.4.3
Drag&Drop
Drag&Drop ist mittlerweile eine Standardtechnik geworden, um Dateien zu verschieben (z.B. im Windows Explorer) oder auch um Objekte innerhalb eines Programms zu verschieben (z.B. Toolbars). Drag&Drop wird auch vom .NET Framework unterstützt, allerdings auf einer recht niedrigen Ebene. Das bedeutet einfach, dass man sich um viele Dinge noch selbst kümmern muss. Initiiert wird eine Drag&Drop-Operation durch die Methode DoDragDrop() des StartElements, von dem das Objekt stammt. Für das Zielelement gilt, dass die Eigenschaft AllowDrop auf true eingestellt sein muss, damit dieses Steuerelement auch ein Objekt empfangen kann. Die eigentliche Operation erfolgt dann in den Ereignissen DragEnter und DragDrop. DragEnter dient in der Regel dazu, das Datenformat des gezogenen Objekts zu kontrollieren und dann festzulegen, ob das betreffende Steuerelement wirklich als Ziel fungieren kann. Im Ereignis DragDrop wird das Element dann abgelegt. Da Sie frei darin sind, was Sie in diesen Ereignissen programmieren, sind Sie sehr flexibel. Das gezogenene Objekt selbst kann im Ereignis übergeben werden. Wenn es sich dabei um Text handelt, könnten Sie diesen entweder direkt in Ihrem Programm verwenden oder Sie könnten entscheiden, dass es sich hierbei um einen Dateinamen handelt und die entsprechende Datei laden. Sie sind also sehr frei in Ihren Entscheidungen.
Drag&Drop-Operation initiieren Am Anfang einer Drag&Drop-Operation steht immer die Methode DoDragDrop(). Sie wird üblicherweise im Ereignis MouseDown gestartet, da das Drücken eines Mausbuttons den Start einer solchen Operation bedeutet. Die Methode erwartet zwei Parameter, einmal das Objekt, das verschoben oder kopiert werden soll, und dann wiederum einen Parameter vom Typ DragDropEffects, der angibt, welche Operationen für dieses Objekt erlaubt sind. Das Auslösen einer Drag&Drop-Operation gestaltet sich oft schwieriger als das Empfangen und Verarbeiten derselben. Der Grund dafür ist, dass viele Steuerelemente bereits
Sandini Bib
Programmiertechniken
679
standardmäßig auf den Klick mit der Maus reagieren, und eine Drag&Drop-Operation wird ja mit eben dieser ausgelöst. Dadurch stellt sich für den Programmierer das Problem, wie man die Verschiebeoperation vom normalen Klick auf das Steuerelement unterscheidet. Viele Steuerelemente bieten dazu leider keine ausreichenden Möglichkeiten, so dass alle möglichen Umwege begangen werden müssen. Zu allem Überfluss kommen sich bei manchen Steuerelementen die interne Logik zur automatischen Verarbeitung von Mausklicks und Ihr eigener Code zum Start einer Drag&Drop-Aktion in die Quere. Eine positive Ausnahme stellen die beiden Steuerelemente TreeView und ListView dar. Dort tritt nämlich das DragItem-Ereignis auf, wenn der Anwender versucht, zuvor markierte Einträge zu verschieben. In der Ereignisprozedur können Sie dann mühelos DoDragDrop() ausführen.
Drag&Drop-Ereignisse zulassen Die Eigenschaft AllowDrop eines Steuerelements steuert, ob dieses ein gezogenes Objekt empfangen kann. Standardmäßig ist diese Eigenschaft auf false eingestellt. In diesem Zusammenhang soll nochmals erwähnt werden, dass auch ein Formular grundsätzlich als Steuerelement gilt und demnach ebenfalls Objekte empfangen kann. Bei Container-Objekten (z.B. Form oder Panel) vererbt sich die AllowDrop-Eigenschaft automatisch auf alle enthaltenen Steuerelemente, auch dann, wenn deren AllowDrop-Eigenschaft auf false eingestellt ist. In diesem Fall gilt nämlich nicht das Steuerelement als Empfänger, sondern vielmehr der Container. Dieses Verhalten kann praktisch sein, aber auch unerwünscht. Praktisch ist es, wenn jeder Bereich des Formulars als Ziel einer Drag&DropOperation dienen soll, denn dann ist es nur notwendig, AllowDrop für das Formular auf true zu setzen (die Einstellung der restlichen Steuerelemente ist nicht mehr relevant). Wollen Sie allerdings ein Steuerelement, das auf einem solchen Formular (oder anderen Containerelement) liegt, explizit von einer Drag&Drop-Operation ausschließen, müssen Sie den umgekehrten Weg gehen. Setzen Sie in diesem Fall AllowDrop für das Steuerelement explizit auf true (auch wenn das zunächst unlogisch klingt) und verweigern Sie die Annahme des Objekts in den Ereignissen DragEnter und DragDrop des Steuerelements explizit. Sobald die Maus über das Steuerelement gezogen wird, gelten nämlich dann dessen DragDrop-Ereignisse statt derer des Containers.
Drag&Drop-Ereignisse Die folgenden Ereignisse treten bei einer Drag&Drop-Operation üblicherweise auf. f Das Ereignis DragEnter tritt auf, wenn die Maus mit einem gezogenen Objekt über ein Steuerelement bewegt wird. Mit der Eigenschaft Data des Parameters e der Ereignisbehandlungsroutine können die Daten ermittelt werden, die beim Loslassen übergeben werden. In diesem Ereignis erfolgt üblicherweise eine Kontrolle der Daten. Hier entscheiden Sie, ob das Steuerelement die gezogenen Daten annimmt oder nicht. Das kann beispiels-
Sandini Bib
680
20 Benutzeroberfläche
weise dadurch geschehen, dass Sie den Typ des übergebenen Objekts kontrollieren. Ob das Objekt angenommen wird oder nicht, wird durch die Einstellung der Eigenschaft e.Effect gesteuert. Wenn Sie diesen auf DragDropEffects.None einstellen, wird das Objekt nicht angenommen (der Mauszeiger zeigt sich dann als ein Verbotsschild). Auch wenn diese Eigenschaft im ersten Moment nur der Einstellung des Mauscursors dient, so geht ihre Bedeutung doch etwas weiter. Wenn der Mauscursor nämlich als Verbotsschild dargestellt wird, tritt auch kein DragDrop-Ereignis auf, wenn die Maustaste losgelassen wird. f Das Ereignis DragOver tritt kontinuierlich während der Bewegung der Maus auf. (Das Ereignis entspricht in etwa dem Ereignis MouseMove.) Bei manchen Drag&DropAnwendungen können Sie hier eine Art Vorschau realisieren, die anzeigt, was passieren würde, wenn die Maustaste jetzt losgelassen würde. Eine andere Funktion der DragOver-Ereignisprozedur könnte darin bestehen, im TreeView-Steuerelement die hierarchische Liste auseinander zu klappen (wie dies auch im Windows-Explorer der Fall ist, wenn Sie Dateien in ein anderes Verzeichnis verschieben oder kopieren möchten). Sie können die Funktion auch von den Sondertasten ((ª), (Strg)) abhängig machen. Diese lassen Sie über die Eigenschaft KeyState des Parameters e überprüfen. So können Sie auch den Mauscursor (über die Einstellung von e.Effect) verändern, wenn eine solche Taste gedrückt ist, um zwischen den verschiedenen Modi des Verschiebens zu unterscheiden. f Das Ereignis DragLeave tritt analog zu DragEnter auf, wenn die Maus das Steuerelement wieder verlässt. Im Regelfall ist es nicht notwendig eine Prozedur zu diesem Ereignis zu schreiben. Insbesondere brauchen Sie sich nicht um das Aussehen der Maus zu kümmern (Sie können auch gar nicht, weil keine entsprechenden Parameter übergeben werden) – dafür ist nun das Steuerelement verantwortlich, über dem sich die Maus nach dem Verlassen befindet. f Das Ereignis DragDrop tritt auf, wenn der Anwender die Maustaste loslässt, die Daten also fallen gelassen werden. Was Sie in diesem Ereignis letztendlich mit den Daten tun, bleibt Ihnen überlassen. Dadurch werden Sie in Bezug auf Drag&Drop sehr flexibel, denn es gibt keine fest vorgeschriebene Operation, die in diesem Ereignis ausgeführt werden müsste. Auch für das Steuerelement, das die Drag&Drop-Operation initiiert hat, tritt ein Ereignis auf, nämlich QueryContinueDrag. In diesem Ereignis haben Sie die Möglichkeit, die Operation jederzeit abzubrechen. Dieses Ereignis tritt ständig während des Drag&DropVorgangs auf. Über die Eigenschaft e.Action vom Typ DragAction können Sie das weitere Verhalten festlegen. Die Einstellung DragAction.Cancel bricht den Vorgang ab, die Einstellung DragAction.Drop ermöglicht es, den Vorgang vorzeitig zu beenden.
DragEventArgs An die Ereignisprozeduren zu DragEnter, DragOver und DragLeave wird mit e ein Objekt der Klasse DragEventArgs übergeben. Die folgende Tabelle fasst die Eigenschaften dieser Klasse zusammen.
Sandini Bib
Programmiertechniken
681
Eigenschaften der Klasse DragEventArgs (aus System.Windows.Forms) AllowedEffect
gibt an, welche Drag&Drop-Operationen zulässig sind. Diese Eigenschaft enthält die DragDropEffects-Kombination, die bei DoDragDrop im zweiten Parameter angegeben wurde.
Data
verweist auf ein IDataObject-Objekt mit den Daten, die durch die Drag&DropOperation übertragen werden. Die Auswertung der Daten ist im vorigen Abschnitt beschrieben. (Die Zwischenablage überträgt Daten ebenfalls in dieser Form.)
Effect
gibt das Aussehen der Maus an. e.Effect muss insbesondere in der DragEnterProzedur eingestellt werden, andernfalls wird der Mauscursor als Eintritt verboten dargestellt und es kann kein DragDrop-Ereignis auftreten. Bei der Einstellung von e.Effect sind nur die Werte zulässig, die mit e.AllowedEffect angegeben wurden. Falls je nach den Zustandstasten (Strg), (ª) oder (Alt)unterschiedliche Drag&Drop-Operationen möglich sind, sollte in der DragOver-Prozedur e.KeyState ausgewertet und e.Effect entsprechend eingestellt werden.
KeyState
enthält den Zustand der Tasten (Strg), (ª) oder (Alt) sowie der Maustasten. Aus unerfindlichen Gründen gilt KeyState als Integer-Wert, d.h. es gibt keine Aufzählung mit den zulässigen KeyState-Zuständen. Stattdessen enthält die Online-Hilfe die folgende Tabelle: 1 2 4 8 16 32
linke Maustaste rechte Maustaste (ª)-Taste (Strg)-Taste mittlere Maustaste (Alt)-Taste
Da beliebige Kombinationen dieser Werte zulässig sind, müssen Sie zur Auswertung & verwenden. Die folgende Zeile stellt fest, ob die mittlere Maustaste gedrückt ist: if ( ( e.KeyState & 16 ) == 16 ) ... X und Y
enthält die absoluten Mauskoordinaten. Diese müssen Sie erst mit PointToClient() in die Koordinaten bezogen auf das Steuerelement umrechnen.
Beispiel – Drag&Drop aus dem Windows Explorer Das Beispielprogramm ist in der Lage, Dateien aus dem Windows Explorer aufzunehmen und in einer Liste anzuzeigen. Dazu befindet sich auf dem Formular eine ListBox mit Namen lstList. Zur Anzeige von Textdateien wird eine Textbox (txtView) verwendet, deren Eigenschaft Multiline auf true eingestellt ist. Weitere Steuerelemente sind nicht vorhanden (sieht man von zwei Labels für die Beschriftung ab). Ein Doppelklick auf eine Datei in der Liste bewirkt, dass versucht wird, diese zu öffnen. Das funktioniert allerdings nur mit Textdateien (Endung .txt). Andernfalls wird eine Warnung ausgegeben. Falls Sie möchten, können Sie auch eine Textdatei direkt auf die Textbox ziehen, falls es sich dabei nur um eine Datei handelt und diese Datei die Endung .txt besitzt, wird sie automatisch geöffnet.
Sandini Bib
CD
682
20 Benutzeroberfläche
Den gesamten Quellcode finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_20\DragDropExplorer.
Die Daten sind innerhalb der Ereignisbehandlungsroutinen über die Eigenschaft e.Data erreichbar. Dabei handelt es sich um ein allgemeingültiges Datenobjekt, das mehrere Daten in unterschiedlichen Formaten zur Verfügung stellen kann. Grundsätzlich funktioniert dies wie bei der Zwischenablage, dort wurden allerdings spezielle Methoden hinzugefügt, um die Daten leichter ermitteln zu können. GetDataPresent() ermittelt, ob Daten eines bestimmten Formats enthalten sind. Über e.Data.GetData() ermitteln Sie die eigentlichen Daten, das gewünschte Format muss dabei übergeben werden. Das Datenformat bei Drag&Drop-Vorgängen mit Dateien ist DataFormats.FileDrop. Zurückgeliefert wird ein Array aus Strings, allerdings verpackt in den Datentyp object. Sie müssen also casten. Dieses Array enthält die Dateinamen, die aus dem
Explorer gezogen wurden. Der Quelltext sollte Sie vor keine großen Probleme stellen. Alle Methoden befinden sich im Hauptformular der Anwendung. private void LstList_DragEnter( object sender, System.Windows.Forms.DragEventArgs e ) { // Format überprüfen if ( e.Data.GetDataPresent( DataFormats.FileDrop ) ) e.Effect = DragDropEffects.Link; else e.Effect = DragDropEffects.None; } private void LstList_DragDrop( object sender, System.Windows.Forms.DragEventArgs e ) { // Dateien zur Liste hinzufügen string[] files = (string[])e.Data.GetData( DataFormats.FileDrop ); this.lstList.Items.AddRange( files ); } private void LstList_DoubleClick( object sender, System.EventArgs e ) { string file = lstList.SelectedItem.ToString(); if ( Path.GetExtension( file ).Equals( ".txt" ) ) { this.txtView.Text = File.ReadAllText( file ); } else { MessageBox.Show( "Dieses Dateiformat wird nicht unterstützt" ); } }
Sandini Bib
Programmiertechniken
683
private void TxtView_DragEnter( object sender, System.Windows.Forms.DragEventArgs e ) { // Kontrollieren, ob es sich nur um eine Datei handelt // und ob diese Datei eine Textdatei ist e.Effect = DragDropEffects.None; // Dateiformat checken if ( e.Data.GetDataPresent( DataFormats.FileDrop ) ) { string[] files = (string[])e.Data.GetData( DataFormats.FileDrop ); if ( files.Length == 1 ) { if ( Path.GetExtension( files[0] ).Equals( ".txt" ) ) { e.Effect = DragDropEffects.Copy; } } } } private void TxtView_DragDrop( object sender, System.Windows.Forms.DragEventArgs e ) { // Kontrolle, ob es sich um nur eine Datei handelt wurde bereits // im Ereignis DragEnter erledigt. Datei hier einfach laden string[] files = (string[])e.Data.GetData( DataFormats.FileDrop ); this.txtView.Text = File.ReadAllText( files[0] ); }
Abbildung 20.9 zeigt das Programm zur Laufzeit. Die linke ListBox enthält die Dateinamen, die TextBox auf der rechten Seite dient zum Anzeigen des Inhalts von Textdateien.
Abbildung 20.9: Drag&Drop mit dem Explorer. Die linke Seite zeigt die Dateien, rechts können Textdateien auch geöffnet angezeigt werden.
Sandini Bib
684
20 Benutzeroberfläche
Drag&Drop zwischen Listenfeldern Häufig wird, wo es die Situation erlaubt, auch innerhalb einer Applikation ein Drag&Drop-Vorgang ermöglicht. Das einfache Ziehen eines Elements bewirkt (je nach Programmsituation) entweder ein Kopieren oder Verschieben. Der andere Modus kann dann mittels einer der Tasten (Strg) (macht aus einem Verschiebevorgang einen Kopiervorgang) oder (ª) (macht aus einem Kopiervorgang einen Verschiebevorgang) eingeschaltet werden. Das folgende Beispielprogramm ermöglicht das Verschieben von einer Listbox in eine andere. Die Standardaktion ist dabei, dass die ursprünglichen Daten gelöscht werden. Wird die (Strg)-Taste gedrückt, werden die Daten lediglich kopiert, d.h. aus der Quell-Listbox wird nichts gelöscht. Der Drag&Drop-Vorgang wird durch die rechte Maustaste eingeleitet. Es können natürlich auch mehrere Elemente markiert werden, die dann gemeinsam kopiert oder verschoben werden. Die Verwendung der rechten Maustaste stellt auch sicher, dass in jedem Fall selektierte Elemente auch in der SelectedItems-Liste der Listbox enthalten sind, denn sie mussten ja zwangsläufig vor dem Verschieben selektiert werden. Diese Liste wird als Basis für das Kopieren/Verschieben genommen. Die Programmoberfläche besteht lediglich aus zwei Listboxen und einem Button zum Beenden. Das Verschieben ist nur von der linken Listbox (lstSource) zur rechten (lstDestination) möglich, nicht auf dem anderen Weg. Aber das können Sie auch selbst noch implementieren.
Vorgehensweise Für die Listbox-Daten wird ein eigenes Datenformat verwendet. Ein solches Datenformat hat eigentlich nur einen Namen, der irgendwo angegeben werden muss. Die Methode DoDragDrop() ermöglicht nur die Angabe der erlaubten Effekte, nicht aber die Angabe eines Datenformats. Dieses muss also auf andere Weise festgelegt werden. Das eigentliche Datenobjekt ist vom Typ IDataObject. Alle Daten, die für den Drag&DropVorgang verwendet werden, werden demnach in ein solches Objekt gekapselt, das die Schnittstelle IDataObject implementiert. In .NET gibt es hierfür bereits eine vorgefertigte Klasse, nämlich DataObject. Sie erwartet im Konstruktor sowohl das Objekt, das verschoben werden soll, als auch ein Datenformat in Form eines String. Alles was wir tun müssen ist also, unsere Daten in ein Objekt des Typs DataObject kapseln und dieses Objekt dann als Inhalt der Drag&Drop-Operation übergeben. Dann ist es uns auch möglich, ein eigenes Datenformat anzugeben. Die zu ziehenden Daten ermitteln wir aus SelectedItems der Quell-Listbox, kopieren sie aber in ein Array des Typs object. Das hat vor allem den Grund, dass wir dadurch später die Elemente leichter löschen und auch mittels AddRange() dem Ziel hinzufügen können. Die Eigenschaft AllowDrop der Ziel-Listbox muss auf true eingestellt sein. Die Standardeinstellung ist false. Weiterhin ermöglichen wir über SelectionMode der Quell-Listbox eine Mehrfachauswahl.
Sandini Bib
Programmiertechniken
685
Programmcode An erster Stelle steht das Starten des Ziehens. Das geschieht im Ereignis MouseDown der Quell-Listbox. Dort kontrollieren wir, ob die rechte Maustaste gedrückt wurde, wenn ja, wird der Ziehvorgang gestartet. Die Daten werden in ein object-Array kopiert und in ein DataObject-Objekt gekapselt. Dieses wird dann der Methode DoDragDrop() übergeben. Das Datenformat ist dragdrop. private void LstSource_MouseDown( object sender, MouseEventArgs e ) { // Drag&Drop-Vorgang starten if ( lstSource.SelectedItems.Count > 0 ) { if ( e.Button.Equals( MouseButtons.Right ) ) { object[] dragArray = new object[this.lstSource.SelectedItems.Count]; this.lstSource.SelectedItems.CopyTo( dragArray, 0 ); DataObject obj = new DataObject( "dragdrop", dragArray ); this.lstSource.DoDragDrop( obj, DragDropEffects.Copy | DragDropEffects.Move ); } } }
Natürlich kann ein Drag&Drop-Vorgang nur dann geschehen, wenn auch Elemente selektiert sind. Der nächste Schritt ist der Eintritt in die Ziel-Listbox. Hier gibt es eine Besonderheit, denn das Ereignis DragEnter besitzt den gleichen Code, der auch in DragOver verwendet werden müsste. Aus diesem Grund lassen sich beide Ereignisse auf die gleiche Methode umleiten, weshalb diese umbenannt wird. Die folgende Methode gilt also für die Ereignisse DragEnter und DragOver der Ziel-ListBox. private void LstDestination_DragEnterAndOver( object sender, DragEventArgs e ) { if ( e.Data.GetDataPresent( "dragdrop" ) ) { if ( ( e.KeyState & 8 ) == 8 ) e.Effect = DragDropEffects.Copy; // Kopieren else e.Effect = DragDropEffects.Move; // Verschieben } }
Nun bleibt nur noch das Ablegen übrig. Über e.Effect können wir kontrollieren, ob verschoben oder kopiert werden soll. Im Falle des Verschiebens werden die Elemente aus der Quelle gelöscht. private void LstDestination_DragDrop( object sender, DragEventArgs e ) { object[] dragArray = (object[])e.Data.GetData( "dragdrop" ); this.lstDestination.Items.AddRange( dragArray );
Sandini Bib
686
20 Benutzeroberfläche
if ( e.Effect == DragDropEffects.Move ) { foreach ( object o in dragArray ) { this.lstSource.Items.Remove( o ); } } }
Das war auch schon die ganze Funktionalität. Abbildung 20.10 zeigt einen Screenshot des fertigen Programms mit einigen verschobenen Elementen.
Abbildung 20.10: Drag&Drop mit zwei Listboxen
20.4.4
Konfigurationsdateien
Die Registry war seit Windows 9x ein beliebter Ort, um Einstellungen für alle Applikationen zu speichern. Dabei war sie dafür eigentlich gar nicht vorgesehen. Die Folge davon ist, dass die Registry auf heutigen Systemen meist derart vollgestopft ist, dass das System sehr langsam wird. Vor allem auf den Systemstart wirkt sich das aus. .NET geht nun (zumindest dem Anschein nach) einen Schritt zurück und führt wieder Konfigurationsdateien ein. An die Stelle des Textformats der .ini-Dateien (aus Windows 3.1; der eine oder andere geschätzte Leser wird sich noch erinnern) tritt aber nun XML. Prinzipiell können sie alle Daten in diesen Dateien speichern und darauf zugreifen. Das interessanteste Feature ist jedoch, dass .NET die Zuweisung von Eigenschaften an Applikationseinstellungen auch selbst erledigen kann. Der Schlüssel dazu ist ein neues Feature namens ApplicationSettings. In jeder Windows.Forms-Applikation finden Sie im Projektmappenexplorer einen automatisch angelegten Ordner namens Properties. In diesem finden Sie sowohl die Datei AssemblyInfo.cs (die
Sandini Bib
Programmiertechniken
687
sich unter .NET 1.1 noch im Hauptverzeichnis der Anwendung befand), eine Datei Resources.resx für Resourcen der Anwendung (auf die der Zugriff ebenfalls sehr einfach möglich ist) sowie die Datei Settings.cs. In dieser befindet sich die Logik für sämtliche Einstellungen, und jede Windows.Forms-Applikation hat automatisch Zugriff auf eine Instanz dieser Settings-Klasse.
ACHTUNG
Gespeichert werden die Daten in der Datei app.config. Diese ist standardmäßig nicht vorhanden; Sie können sie entweder von Hand hinzufügen oder aber Sie legen eine Eigenschaft des Formulars zum automatischen Speichern fest (dieser Vorgang wird im folgenden Abschnitt beschrieben). In diesem Fall wird ebenfalls eine Datei app.config erstellt. Benennen Sie die Datei app.config auf keinen Fall um! Dieser Name ist nur ein Platzhalter. Sobald die Applikation kompiliert wird, wird die Datei app.config umbenannt in <Applikationsname>.config. Es gibt für jede Applikation nur eine einzige Konfigurationsdatei.
Automatisches Speichern von Einstellungen Eigenschaften können direkt im Eigenschaftsfenster zum Speichern festgelegt werden. Um die Zuweisung zwischen Eigenschaft und gespeichertem Wert kümmert sich .NET automatisch, lediglich das Speichern und laden müssen Sie noch selbst übernehmen – das ist aber kaum der Rede wert. Unter der Kategorie ApplicationSettings finden Sie (im Falle einer Form) bereits zwei vordefinierte Einträge. Diese werden allerdings noch nicht gespeichert, da kein Eintrag in der Konfigurationsdatei festgelegt ist, unter dem diese Einstellungen abgelegt werden. Diese Festlegung können Sie frei vornehmen. Unter PropertyBinding können Sie weitere Eigenschaften zum Speichern festlegen. Das funktioniert natürlich nicht nur mit der Form, sondern auch mit den Eigenschaften anderer Controls.
CD
Beispielprogramm Den vollständigen Quelltext finden Sie auf der beiliegenden CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_20\AppConfiguration.
Das Beispielprogramm speichert verschiedene Einstellungen in der Konfigurationsdatei. Einmal die Position des Hauptfensters (Eigenschaft Location), zum Zweiten die Schriftart der Textbox textBox1. Um die Schriftart zu speichern müssen Sie lediglich die Textbox markieren, wie angesprochen die Kategorie ApplicationSettings in den Eigenschaften öffnen und unter PropertyBinding eine neue Verknüpfung anlegen. Ein Klick auf den Ellipsen-Button bringt Sie in einen Dialog, in dem Sie die zu speichernde Eigenschaft festlegen können. Unter welchem Namen diese dann in den Applikationseinstellungen gespeichert wird, können Sie selbst angeben. Öffnen Sie die ComboBox neben der Eigenschaft und wählen Sie NEU. Geben Sie dann den gewünschten Namen an. Abbildung
Sandini Bib
688
20 Benutzeroberfläche
20.11 zeigt Ihnen eine bestehende Verbindung zwischen Eigenschaft und gespeichertem Wert.
Abbildung 20.11: Die Eigenschaft Font wird komplett in den Applikationseinstellungen gespeichert. Der Name der gespeicherten Eigenschaft lautet TextBox1Font.
Speichern der Einstellungen Sinnvollerweise werden die Einstellungen dann gespeichert, wenn das Programm geschlossen wird. Dabei bieten sich zwei Möglichkeiten an: Entweder das Ereignis FormClosed, womit erst gespeichert wird, wenn das Programm wirklich geschlossen wird, oder das Ereignis FormClosing, was bedeutet, dass gespeichert wird, wenn auch nur versucht wird ein Programm zu schließen. Eine Sicherheitsabfrage, die ebenfalls in FormClosing programmiert wird, könnte das ja dann noch verhindern. Im Beispiel wird die zweite Variante gewählt. Das Kommando zum Laden bzw. Speichern der Einstellungen benötigt jeweils nur eine Zeile Code. Hier die beiden Ereignisbehandlungsroutinen. private void FrmMain_FormClosing( object sender, FormClosingEventArgs e ) { Properties.Settings.Default.Save(); } private void FrmMain_Load( object sender, EventArgs e ) { Properties.Settings.Default.Reload(); }
Fortan speichert Ihre Applikation die gewünschten Daten automatisch. Die benutzerspezifischen Daten werden übrigens nicht in der Konfigurationsdatei abgelegt, sondern an einem anderen Ort im Benutzerprofil. Damit ist gewährleistet, dass ein anderer Benutzer keine Kenntnis der Einstellungen seines Kollegen erhalten kann – es könnte sich ja um sicherheitsrelevante Daten handeln, wie z.B. Passwörter. Das setzt natürlich voraus, dass das System korrekt konfiguriert ist – der Administrator bzw. ein Account, der Administra-
Sandini Bib
Programmiertechniken
689
torrechte besitzt, kann selbstverständlich auf alle Applikationseinstellungen aller Benutzer zugreifen.
Weitere Daten automatisch speichern Die Eigenschaften von Steuerelementen sind natürlich nicht die einzigen Daten, die Sie speichern können. Sie können auch Daten, die aus dem Programm kommen bzw. die der Benutzer eingegeben hat, auf diese Art und Weise speichern. Dann müssen Sie sich allerdings selbst um die Zuweisung kümmern. Achten Sie auch darauf, dass nicht alle Datentypen für die Speicherung zur Verfügung stehen. Zum Speichern der Werte klicken Sie doppelt auf den Eintrag Settings.settings im Projektmappenexplorer. Ähnlich wie bei Ressourcen können Sie nun eigene Einstellungen festlegen und einen Namen vergeben, unter dem sie gespeichert werden. Der Zugriff erfolgt wieder über das Settings-Objekt, die eingestellten Werte werden dort in Form von Eigenschaften zur Verfügung gestellt.
Sandini Bib
Sandini Bib
21 Grafikprogrammierung (GDI+) Mit .NET kommt auch eine neue Bibliothek für die Grafikprogrammierung, GDI+. GDI steht für Graphics Device Interface, das + steht für die erweiterte Funktionalität im Vergleich zum früher verwendeten GDI. In der Tat hat sich gerade im Bereich der Grafik recht viel geändert. Dieses Kapitel führt Sie in die Arbeit mit GDI+ ein.
21.1
Einführung
Grundsätzlich würde GDI+ mehr als genug hergeben, um ein eigenes Buch darüber zu schreiben. Die Klassen, die in .NET hierfür zuständig sind, sind enorm umfangreich und bieten ein großes Spektrum an Möglichkeiten. Daher kann dieses Kapitel lediglich in die Thematik einführen, jedoch nicht bis ins letzte Detail vorgehen. In jedem Fall aber wird ein Fundament geschaffen, auf dem Sie aufbauen können.
Überblick über die Namespaces Die Assembly, in der die GDI+-Klassen und Namespaces deklariert sind, heißt System.Drawing.dll. Standardmäßig wird ein Verweis auf diese Datei beim Erstellen eines neuen Windows.Forms-Projekts eingefügt. Der Basisnamespace System.Drawing wird bei Formularen automatisch eingebunden. Er steht in diesem Kapitel auch im Mittelpunkt. Falls Sie eine eigene Klasse erstellen, in der Sie Grafikfunktionalität unterbringen wollen, müssen Sie die benötigten Namespaces selbst einbinden. Die folgende Tabelle zeigt einen Überblick.
Namespaces für GDI+ System.Drawing
Grundfunktionen zur Grafikprogrammierung
System.Drawing.Design
Elemente für Benutzeroberflächen von Grafikprogrammen (FontEditor, Bitmap-Editor etc.)
System.Drawing.Drawing2D
Funktionen zum Zusammensetzen von Grafikobjekten, für Matrixtransformationen etc.
System.Drawing.Imaging
Spezialfunktionen zum Lesen und Schreiben von Metafile-Dateien und zur Bearbeitung von Bitmaps
System.Drawing.Printing
Ausdruck von Grafik und Text
System.Drawing.Text
Funktionen zur Textausgabe und zur Verwaltung von Schriftarten
Die Menge der enthaltenen Klassen ist zu umfangreich, als dass sie alle an dieser Stelle beschrieben werden könnten (das ist nicht einmal ansatzweise möglich). Aus diesem Grund beschränkt sich das Kapitel in der Hauptsache auf den Namespace System.Drawing. Wann immer ein weiterer Namespace benötigt wird, werden wir Sie darauf hinweisen.
Sandini Bib
692
21 Grafikprogrammierung (GDI+)
Auf den Namespace System.Drawing.Printing kommen wir in Kapitel 22 ab Seite 831 nochmals zurück. Darin enthalten sind Klassen, die der Ausgabe von Grafik und Text auf dem angeschlossenen Drucker dienen. Sie werden feststellen, dass sich die Vorgänge bzgl. des Zeichnens auf einer Zeichenfläche am Bildschirm und der Ausgabe auf dem Drucker stark ähneln, da sie auf dem gleichen Prinzip basieren. In allen Quellcodes des Kapitels wird davon ausgegangen, dass zumindest der Namespace System.Drawing mittels using eingebunden ist. Da dies der Standardeinstellung entspricht, müssen Sie sich normalerweise nicht speziell darum kümmern.
21.1.1
Ein erstes Beispiel
Das folgende Programm zeichnet einige einfache geometrische Formen direkt in ein Formular. Das eigentliche Zeichnen wird dabei in der Ereignisbehandlungsroutine zum PaintEreignis ausgeführt. Dieses Ereignis wird immer dann ausgelöst, wenn der Fensterinhalt aus irgendeinem Grund neu gezeichnet werden muss. Das kann sehr häufig der Fall sein.
HINWEIS
Da wir Zufallszahlen verwenden wollen, um die zu zeichnenden Formen zu erstellen, können wir das Paint-Ereignis nicht für die Initialisierung der benötigten Variablen verwenden. Das würde nämlich bedeuten, dass sich die Formen immer wieder ändern, sobald das Fenster neu gezeichnet wird. Stattdessen wollen wir nur dann neue Formen erstellen, wenn das Programm neu gestartet wird. Wir verwenden daher für die Initialisierung das Load-Ereignis und speichern die Werte in Instanzvariablen des Formulars. Grafiken, die mithilfe der GDI+-Methoden gezeichnet werden, werden nicht »gespeichert«, oder anders ausgedrückt – wenn das Fenster bzw. das Steuerelement, auf dem gezeichnet wurde, verdeckt war, ist die Grafik wieder verschwunden. Um dies zu verhindern wird grundsätzlich das Ereignis Paint verwendet, um Grafiken zu zeichnen – sinnvollerweise das Paint-Ereignis des Steuerelements, auf dem gezeichnet werden soll. Dieses Ereignis wird immer dann aufgerufen, wenn das Fenster neu gezeichnet werden muss (also z.B. auch, wenn es verdeckt war).
Um den eigentlichen Zeichenvorgang durchzuführen, wird noch eine Zeichenoberfläche benötigt. In .NET handelt es sich dabei um eine Instanz der Klasse Graphics, die jedes Steuerelement, auf dem Zeichnen grundsätzlich möglich ist, zur Verfügung stellen kann. Da wir direkt auf das Formular zeichnen, wird es für uns noch einfacher, denn der Parameter e vom Typ PaintEventArgs liefert in der Eigenschaft Graphics bereits das fertige GraphicsObjekt des Formulars. In einer weiteren Eigenschaft, ClipRectangle, wird ein Rechteck in Form einer RectangleStruktur übergeben, das angibt, welche Bereiche des Fensters neu gezeichnet werden können. Damit sind Sie in der Lage, die Zeichenoperationen nur auf die Bereiche anzuwenden, die wirklich Neuzeichnen erfordern. In diesem Beispielprogramm wird diese Möglichkeit allerdings nicht verwendet, stattdessen wird alles neu gezeichnet. Die Struktur Rectangle ist ebenfalls in System.Drawing deklariert.
Sandini Bib
Einführung
693
Die eigentlichen Zeichenmethoden sind Methoden der Klasse Graphics. Damit aber gezeichnet werden kann, werden weitere Objekte benötigt. Wie im richtigen Leben auch benötigt man einen Zeichenstift, der in .NET durch die Klasse Pen repräsentiert wird. Pen-Objekte dienen zum Zeichnen der Umrandung eines Objekts. Zum Füllen von Flächen mit einer Farbe wird ein Brush-Objekt verwendet, das, wie der Name schon sagt, einen Farbpinsel (oder Füllpinsel) darstellt. Für das Beispiel wird je eines dieser Objekte erstellt. Im Falle des Brush-Objekts greifen wir auf einen vordefinierten einfarbigen Pinsel zurück, der von einer statischen Methode der Klasse Brushes geliefert wird. Zum Zeichnen verwenden wir die Methoden DrawRectangle(), DrawEllipse() und FillEllipse(). Diese Methoden besitzen zahlreiche Varianten, die mit unterschiedlichen Übergabeparametern zurecht kommen. In jedem Fall aber wird ein Pen- bzw. Brush-Objekt benötigt und natürlich die Ausmaße des Objekts, das gezeichnet werden soll. DrawEllipse() dient dabei sowohl dem Zeichnen einer Ellipse (wie der Name auch schon sagt) als auch dem Zeichnen eines Kreises. Eine Angabe von Mittelpunkt und Radius ist daher nicht möglich (eine Ellipse hat ja keinen Radius). Stattdessen wird ein Rechteck (bzw. die Koordinaten eines Rechtecks) übergeben, das die Ellipse/den Kreis umschließt.
Das Koordinatensystem beginnt mit (0,0) in der linken oberen Ecke des inneren Formularbereichs. Weitere Koordinatensysteme, die sich davon unterscheiden, können ebenfalls festgelegt werden. CD
Den gesamten Quellcode finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Grafik_Intro.
using using using using using using
System; System.Drawing; System.Collections; System.ComponentModel; System.Windows.Forms; System.Data;
namespace Grafik_Intro { partial class MainForm : System.Windows.Forms.Form { public MainForm() { InitializeComponent(); } // Eigene Felder für die Grafiken int x1 = 10, y1 = 10; int w1, h1; int x2, y2; int w2, h2;
Sandini Bib
694
21 Grafikprogrammierung (GDI+) private void MainForm_Load( object sender, System.EventArgs e ) { // Werte ermitteln und zuweisen int maxWidth = this.ClientRectangle.Width - 20; int maxHeight = this.ClientRectangle.Height - 20; Random rnd = new Random( DateTime.Now.Millisecond ); // Nur ein paar Zufallszahlen ... this.x2 = rnd.Next( maxWidth / 2 ); this.y2 = rnd.Next( maxHeight / 2 ); this.w2 = rnd.Next( maxWidth - ( x2 + 10 ) ); this.h2 = rnd.Next( maxHeight - ( y2 + 10 ) ); this.w1 = rnd.Next( maxWidth ); this.h1 = rnd.Next( maxHeight ); } private void MainForm_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { // Grafikobjekt Graphics g = e.Graphics; // Zeichenstift Pen p = new Pen( Color.Blue, 2f ); // Vordefinierten Pinsel holen Brush b = Brushes.Red; // Zeichnen g.DrawRectangle( p, this.x1, this.y1, this.w1, this.h1 ); g.FillEllipse( b, this.x2, this.y2, this.w2, this.h2 ); g.DrawEllipse( p, this.x2, this.y2, this.w2, this.h2 ); }
} }
Das Programm zeichnet immer wieder ein leeres Rechteck und eine gefüllte Ellipse, mit jeweils unterschiedlichen Werten. Die Werte werden bei Start des Programms festgelegt. Wenn ein anderes Fenster über das Programmfenster gezogen und wieder entfernt wird, sehen Sie, dass die Zeichnung immer noch da ist. In Wirklichkeit wird in einem solchen Fall natürlich neu gezeichnet, was aber nicht auffällt, da es sich nicht um eine umfangreiche Grafik handelt. Bei großen Grafiken mit zahlreichen Zeichenvorgängen kann es allerdings zu einem gewissen Flimmern kommen. Abbildung 21.1 zeigt das Programm zur Laufzeit.
Sandini Bib
Einführung
695
TIPP
Abbildung 21.1: Das erste Grafikprogramm
Die Fehlersuche in Paint-Ereignisbehandlungsroutinen ist ziemlich schwierig. Wenn Sie beispielsweise einen Haltepunkt innerhalb der Paint-Prozedur setzen, dann lösen Sie durch das Erscheinen der Entwicklungsumgebung ein neues Paint-Ereignis aus. Ein relativ einfaches Hilfsmittel, um dies zu vermeiden, ist die Eigenschaft TopMost des Formulars. Wenn Sie diese Eigenschaft auf true setzen, bleibt das Formular gegenüber der Entwicklungsumgebung immer im Vordergrund. (Ein Paint-Ereignis können Sie dennoch leicht auslösen, z.B. indem Sie das Programmfenster minimieren und dann wieder herstellen.)
21.1.2
Grafik-Container (Form, PictureBox)
Im obigen Beispielprogramm erfolgten alle Grafikausgaben direkt in ein (ansonsten leeres) Formular. Diese Vorgehensweise ist in der Praxis eher unüblich. Dort werden nur bestimmte Bereiche für die Grafikausgabe genutzt. In .NET sind nahezu alle visuellen Steuerelemente auch als Grafik-Container geeignet. Häufig kommt hier jedoch das Steuerelement PictureBox zum Einsatz, das auch weitere Methoden z.B. zum Laden oder Speichern einer Grafik zur Verfügung stellt. Der Quellcode bei der Verwendung eines Steuerelements als Grafik-Container kann übrigens gleich bleiben, es ändert sich lediglich das Ereignis. Statt des Paint-Ereignisses des Formulars wird nun das Paint-Ereignis des Steuerelements verwendet, das ebenfalls einen Parameter e zur Verfügung stellt. Dessen Graphics-Eigenschaft stellt das Graphics-Objekt des jeweiligen Steuerelements zur Verfügung. Natürlich kann diese Vorgehensweise auch Nachteile haben. So müsste für jedes Steuerelement eine eigene Paint-Routine geschrieben werden. Möchten Sie das Ganze zentralisieren, beispielsweise mit der Paint-Routine des Formulars alle Zeichenoperationen durch-
Sandini Bib
696
21 Grafikprogrammierung (GDI+)
führen, können Sie auch über die Methode CreateGraphics() des jeweiligen Steuerelements ein Graphics-Objekt erhalten. Das folgende Beispiel verwendet diese Technik, um unterschiedliche Grafiken auf verschiedenen Panels anzuzeigen. Gezeichnet werden auch hier Rechtecke und Kreise (Ellipsen). Um die Darstellung ein wenig zu verbessern wurde die Hintergrundfarbe der Panels auf Weiß eingestellt. Der Einfachheit halber werden jetzt auch voreingestellte Pen-Objekte verwendet, die die Klasse Pens zur Verfügung stellt.
CD
Das Programm enthält vier Panel-Steuerelemente, denen die gleiche Größe zugewiesen wurde. Alle sind 150 Pixel breit und 140 Pixel hoch. Das Listing zeigt der Einfachheit halber lediglich die Paint-Ereignisbehandlungsroutine (ansonsten wurde nichts am Code verändert) Den gesamten Quellcode finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\GrafikContainer.
private void MainForm_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { //Panel1: Graphics g = panel1.CreateGraphics(); g.DrawRectangle( Pens.Blue, 5, 5, 140, 130 ); g.Dispose(); //Panel2: g = panel2.CreateGraphics(); g.DrawEllipse( Pens.Red, 5, 5, 140, 130 ); g.Dispose(); //Panel3: g = panel3.CreateGraphics(); g.FillRectangle( Brushes.Yellow, 5, 5, 140, 130 ); g.DrawRectangle( Pens.Black, 5, 5, 140, 130 ); g.Dispose(); //Panel4: g = panel4.CreateGraphics(); g.FillEllipse( Brushes.Aqua, 5, 5, 140, 130 ); g.DrawEllipse( Pens.Black, 5, 5, 140, 130 ); g.Dispose(); }
Das Ergebnis dieser Routine zeigt Abbildung 21.2.
Sandini Bib
Einführung
697
Abbildung 21.2: Ein Beispiel für das Zeichnen in Container
21.1.3
Dispose für Grafikobjekte
Sicherlich haben Sie vor allem in der letzten Routine zahlreiche Aufrufe der Methode Dispose() für das Graphics-Objekt bemerkt. Damit werden die Ressourcen freigegeben, die das Objekt belegt, und können neu zugewiesen werden. Die Regel für die Anwendung der Methode Dispose() ist einfach. Rufen Sie sie immer dann auf, wenn f ein entsprechendes Objekt diese Methode bereitstellt und f Sie das Objekt selbst erzeugt haben. Nahezu alle Klassen, die etwas mit der Grafikprogrammierung zu tun haben (und natürlich auch viele andere), stellen eine Methode Dispose() zur Verfügung. Sie werden jedoch feststellen, dass sich weder dieses Buch noch die meisten anderen C#-Bücher noch die offizielle Online-Hilfe des Visual Studio an diese Regel halten. Ist also Dispose() doch nicht so wichtig? Oder sind alle Buchautoren (einschließlich des Autors dieses Buchs) nur schlampig? Tatsächlich ist es so, dass ohne die Verwendung von Dispose() das Programm zwar auch funktioniert, der Speicherbedarf aber enorm ansteigen kann. Wird mit Dispose() gearbeitet, bleibt der Speicherbedarf annähernd gleich, über die gesamte Laufzeit des Programms hinweg. Außerdem wird das Programm (meistens) schneller ausgeführt, was auf die niedrigere Speicherbelastung zurückzuführen ist.
Sandini Bib
698
21 Grafikprogrammierung (GDI+)
Wann Dispose() nicht verwendet werden sollte ... Bevor Sie nun blindlings am Ende jeder Ihrer Grafikprozeduren für alle Objektvariablen Dispose() ausführen, noch eine Warnung: Dispose() darf nur dann ausgeführt werden, wenn Sie das Grafikobjekt selbst erzeugt haben. Wenn das Grafikobjekt dagegen bereits existiert und Sie darauf in einer Variablen nur verweisen, ist Dispose() nicht erlaubt! GDI+ kennt beispielsweise eine ganze Reihe fertiger Objekte, die Sie direkt verwenden können – beispielsweise die vorgefertigten Pen- und Brush-Objekte, die vorher schon einmal benutzt worden waren. Wenn Sie diese Objekte zur Initialisierung einer Objektvariablen verwenden, dürfen Sie Dispose() nicht ausführen! Wenn Sie es doch tun, geben Sie den Speicher für ein Objekt frei, das von Haus aus zur Verfügung steht. Danach steht es eben nicht mehr zur Verfügung, bei der nächsten Verwendung erwartet Sie eine Fehlermeldung. Im Projekt Grafik_Intro haben Sie gesehen, dass das Graphics-Objekt von der Eigenschaft Graphics des Parameters e der Ereignisbehandlungsroutine stammt. In den meisten PaintEreignisroutinen wird so vorgegangen, dass eine eigene Objektvariable erzeugt wird, um dieses Graphics-Objekt aufzunehmen. Es ist allerdings kein selbst erzeugtes. Nun stellt sich die Frage, ob dieses Graphics-Objekt nur für das Paint-Ereignis erzeugt wird oder ob es intern ebenfalls noch gebraucht wird. Dementsprechend ist die Frage, ob man Dispose() anwenden darf oder nicht. Dokumentiert ist das nicht. In solchen Fällen kann man auf zwei Arten vorgehen. Entweder man testet es aus (womit man zum Ergebnis kommt, dass der Dispose()-Aufruf in der Paint-Methode des Formulars nicht schadet, in der einer PictureBox allerdings schon) oder man sorgt dafür, dass man das Graphics-Objekt explizit erzeugt und darf danach ohne Gewissensbisse Dispose() darauf anwenden. Jedes Steuerelement, das zum Zeichnen geeignet ist, liefert ein Graphics-Objekt über die Methode CreateGraphics().
21.1.4
Fazit
f Bei Grafikobjekten, die selbst nur relativ wenig Speicher beanspruchen – z.B. bei Pen- oder gewöhnlichen Brush-Objekten lohnt sich Dispose() nur, wenn Sie wirklich sehr viele derartige Objekte erzeugen. (Wenn Sie Grafikobjekte in einer Schleife erzeugen, sollten Sie Dispose() immer anwenden!) f Bei Objekten, die sehr viel Speicher beanspruchen (insbesondere bei großen Image-, Bitmap- und Metafile-Objekten, bei TextureBrush-Objekten auf der Basis einer großen Bitmap etc.), ist die Dispose()-Methode ein Muss! Bei den restlichen Objekten liegt die Entscheidung für oder wider Dispose() in Ihrem Ermessen. Bei den meisten Anwendungen ist der Unterschied weder im Speicherverbrauch noch in der Geschwindigkeit messbar. f Dispose() darf auf keinen Fall bei Objekten verwendet werden, die Sie nicht selbst erzeugt haben bzw. die im Programm (eventuell auch intern) noch benötigt werden. Das gilt auch für die Parameter von Ereignisbehandlungsroutinen.
Sandini Bib
Elementare Grafikoperationen
699
Generell brauchen Sie keine Angst zu haben, dass Ihr Programm unkontrolliert immer mehr Speicher oder Ressourcen verbraucht, ohne diesen wieder freizugeben. (Das war bei früheren Windows-Versionen bzw. bei früheren Grafikbibliotheken ein akutes Problem – aber diese Zeiten sind vorbei.) Gefährlich ist nur der variable Speicherverbrauch, bis die Garbage Collection wieder Platz schafft. Wenn dieser Speicherbedarf auch nur vorübergehend so groß wird, dass Arbeitsspeicher auf die Festplatte ausgelagert werden muss, wird das Programm über die Maßen langsam. Wenn das System dann auch noch eine »durchwachsene« Performance mitbringt, kann die Geschwindigkeit sogar untragbar werden.
21.2
Elementare Grafikoperationen
Dieser Abschnitt gibt einen Überblick über die wichtigsten Methoden der Klasse Graphics aus dem Namespace System.Drawing, mit denen Sie Linien, Rechtecke, Vielecke etc. zeichnen können. Außerdem werden die Klassen Color, Pen[s] und XxxBrush eingehend behandelt. Objekte dieser Klassen benötigen Sie, um Farben, Linienformen und Füllmuster für diverse Grafikmethoden einzustellen.
21.2.1
Linien, Rechtecke, Vielecke, Ellipsen, Kurven (Graphics-Klasse)
Alle in diesem Abschnitt beschriebenen Methoden werden auf ein Graphics-Objekt angewendet. Wie der vorige Abschnitt bereits gezeigt hat, erhalten Sie ein derartiges Objekt auf zwei Arten: durch den Parameter e vom Typ PaintEventArgs der Paint-Ereignisbehandlungsroutine oder über den Aufruf der Methode CreateGraphics() des Objekts, von dem Sie den Zeichenbereich erhalten möchten. Um den Code kompakt zu halten, ist es zumeist sinnvoll, am Beginn der Ereignisbehandlungsroutine eine Referenz auf das Graphics-Objekt in einer Variable mit kurzem Namen zu speichern (in den folgenden Beispielen g). Ein gemeinsames Merkmal fast aller Grafikmethoden besteht darin, dass sie unzählige Male überladen sind. Zum Zeichnen eines Rechtecks gibt es beispielsweise die folgenden Möglichkeiten: public void DrawRectangle( Pen pen, int x, int y, int width, int height ) public void DrawRectangle( Pen pen, float x, float y, float width float height ) public void DrawRectangle( Pen pen, Rectangle rect )
Im ersten Fall wird die Größe des Rechtecks durch vier int-Werte ausgedrückt. Stattdessen können Sie die Koordinaten aber auch durch Fließkommazahlen angeben. Schließlich können Sie das Rechteck auch auf der Basis einer Rectangle-Struktur zeichnen.
Sandini Bib
HINWEIS / CD
700
21 Grafikprogrammierung (GDI+)
Alle Beispiele zu diesem Abschnitt sind in einem einzigen Programm vereint. Sie finden es auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\ Kapitel_21\Grafikmethoden. Das Programm verwendet ein TabControl-Steuerelement, dessen Dialogblätter verschiedene Effekte demonstrieren. Gezeichnet wird jeweils direkt in ein Dialogblatt dieses Steuerelements (Paint-Ereignis). Im Folgenden wird aus Platzgründen immer nur diese eine Ereignisbehandlungsroutine abgedruckt.
Elementare Datentypen im Namespace System.Drawing Bevor Sie sich mit den Methoden DrawRectangle(), DrawLine() etc. näher auseinander setzen, sollten Sie einige allgemeine Datentypen des System.Drawing-Namespaces kennen lernen. Diese Datentypen werden häufig bei Parametern diverser Grafikmethoden eingesetzt. Die folgende Tabelle zählt die wichtigsten dieser Datentypen auf. Dabei handelt es sich ausnahmslos um Wertetypen (structs). Datentyp
Bedeutung
Color
bezeichnet eine Farbe. Die Eigenschaften R, G und B geben den Rot-, Grün- und Blauanteil der Farbe an.
Point
bezeichnet einen Koordinatenpunkt. Die Eigenschaften X und Y liefern entsprechend ihrem Namen die X- bzw. Y-Koordinate als int-Wert.
PointF
wie Point, aber mit float-Werten für X und Y
Rectangle
bezeichnet ein Rechteck. X und Y geben die Koordinaten des linken oberen Eckpunkts an, Width und Height die Breite und Höhe. Alle Werte sind int-Werte.
RectangleF
wie Rectangle, aber alle Werte sind float-Werte
Size
gibt die Größe eines Bereichs an (Höhe und Breite). Die Werte werden als int angegeben.
SizeF
wie Size, die Werte werden als float angegeben
Die folgenden Zeilen zeichnen ein Rechteck auf der Basis einer Rectangle-Struktur: // g ist ein Graphics-Objekt Rectangle aRect = new Rectangle(10, 120, 100, 20); Pen aPen = new Pen(Color.Blue, 2); g.DrawRectangle(aPen, aRect);
Zur Rectangle-Struktur gehören einige recht praktische Methoden: So können Sie mit IntersectsWith() testen, ob sich zwei Rechtecke überlappen. Contains() überprüft, ob sich ein Koordinatenpunkt innerhalb des Rechtecks befindet. Leider ist GDI+ ziemlich inkonsequent, was den Umgang mit den int- und float-Varianten von Rectangle[F], Point[F] und Size[F] betrifft: Manche Eigenschaften bzw. Methoden liefern nur die int-, andere nur die float-Variante als Ergebnis. Andererseits erwarten man-
Sandini Bib
Elementare Grafikoperationen
701
che Methoden genau eine der beiden Varianten als Parameter und oft (sehr oft) ist es natürlich gerade die Variante, die momentan nicht zur Verfügung steht. Zur Umwandlung zwischen den beiden Typen gibt es unterschiedliche Methoden, die hier am Beispiel von Rectangle[F]-Objekten demonstriert werden. (Analoge Methoden stehen auch für Point[F] und Size[F] zur Verfügung.) aRect = Rectangle.Ceiling(rectF) // float --> int: aufrunden aRect = Rectangle.Round(rectF) // float --> int: runden aRect = Rectangle.Truncate(rectF) // float --> int: abrunden
Konvertierungen zwischen den verschiedenen Datentypen sind natürlich auch hier möglich, sowohl implizite als auch explizite. Sehr interessant ist in diesem Zusammenhang, dass zwischen Point und Size gecastet werden kann: Size sz = new Size(10,10); Point pt = (Point)sz;
Das erwartet man nicht unbedingt, ist aber verständlich, wenn man sich den Konstruktor von Size anschaut. Auch hier kann eine Point-Struktur übergeben werden, um die SizeStruktur zu initialisieren.
Rechtecke zeichnen
HINWEIS
Die Syntaxvarianten der Methode DrawRectangle() wurde bereits in der Einführung dieses Abschnitts beschrieben. Wenn Sie mehrere Rechtecke mit derselben Farbe auf einmal zeichnen möchten, können Sie stattdessen DrawRectangles() verwenden. An diese Methode muss ein Array von Rectangle-Strukturen übergeben werden, wie das folgende Beispielprogramm demonstriert. DrawRectangle() ist nicht in der Lage, Rechtecke mit negativer Breite oder Höhe zu zeichnen. DrawRectangle(aPen, 50,50,-10,-10) zeichnet also nicht ein Rechteck von
(40,40) nach (50,50), wie man vielleicht hätte erwarten können.
Der Quelltext sollte leicht verständlich sein. Es werden einige Rechtecke mit unterschiedlichen Parametern gezeichnet. // Rechtecke zeichnen private void TabRectangle_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { Graphics g = ( (TabPage)sender ).CreateGraphics(); g.SmoothingMode = SmoothingMode.AntiAlias; // Ein rotes Rechteck Rectangle aRect = new Rectangle( 10, 10, 100, 100 ); Pen aPen = new Pen( Color.Red, 2 ); g.DrawRectangle( aPen, aRect );
Sandini Bib
702
21 Grafikprogrammierung (GDI+)
// Ein grünes Rechteck aPen = new Pen( Color.Green, 2 ); RectangleF aRectF = new RectangleF( 120, 10, 100f, 100f ); g.DrawRectangle( aPen, Rectangle.Ceiling( aRectF ) ); // Blaue Rechtecke aPen = new Pen( Color.Blue, 2 ); Rectangle[] rects = new Rectangle[10]; for ( int i = 0; i < 10; i++ ) rects[i] = new Rectangle( 10 + ( 12 * i ), 120 + ( 5 * i ), 100, 30 ); g.DrawRectangles( aPen, rects ); g.Dispose(); }
Das Ergebnis der Bemühungen zeigt Abbildung 21.3.
Abbildung 21.3: Rechtecke
Linien zeichnen Zum Zeichnen von Linien verwenden Sie die Methode DrawLine(). Die Koordinatenpunkte des Start- und Endpunkts können als int-Werte, als float-Werte oder durch Point/PointFStrukturen angegeben werden. Wenn Sie einen ganzen Linienzug zeichnen möchten, setzen Sie stattdessen DrawLines() ein. Die Methode erwartet als Parameter ein Array aus Point-/PointF-Strukturen. Es zeichnet dann eine Linie, die die Punkte der Reihe nach miteinander verbindet. Der Linienzug wird nicht geschlossen.
Sandini Bib
Elementare Grafikoperationen
703
Das Beispielprogramm zeichnet zuerst ein Muster aus verschiedenfarbigen Linien und dann einen Linienzug. Hintergrundinformationen zum Umgang mit Farben folgen in Abschnitt 21.2.2 ab Seite 712. // Linien zeichnen private void TabLines_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { TabPage currentTab = sender as TabPage; Graphics g = currentTab.CreateGraphics(); int aWidth = currentTab.ClientSize.Width; int aHeight = currentTab.ClientSize.Height; g.SmoothingMode = SmoothingMode.AntiAlias; // Linienstruktur zeichnen Pen aPen; for ( double i = 0d; i < 1; i += 0.03333d ) { aPen = new Pen( Color.FromArgb( (int)( i * 255 ), 0, (int)( i * 255 ) ) ); g.DrawLine( aPen, 0, (float)( aHeight * i ), (float)( aWidth - aWidth * i ), 0 ); aPen.Dispose(); } PointF[] points = new PointF[10]; for ( int i = 0; i < 10; i++ ) { points[i].X = 100 + ( 20 * i ); points[i].Y = (float)( 120 + Math.Sin( i ) * 50 ); } aPen = new Pen( Color.Blue, 2 ); g.DrawLines( aPen, points ); g.Dispose(); aPen.Dispose(); }
Das Ergebnis zeigt Abbildung 21.4.
Sandini Bib
704
21 Grafikprogrammierung (GDI+)
Abbildung 21.4: Einige Linien. Die seltsam anmutende Konstruktion in der Mitte stellt eine Sinuskurve dar.
Punkte zeichnen Erstaunlicherweise fehlt der Graphics-Klasse eine Methode, um einen einzelnen Punkt zu zeichnen. Wenn Sie eine Grafik aus einzelnen Punkten zusammensetzen möchten, müssen Sie im Regelfall die Bitmap-Klasse verwenden, die mit der Methode SetPixel() ausgestattet ist (siehe Abschnitt 21.4 ab Seite 764). Wenn Sie das nicht möchten, behelfen Sie sich mit FillRectangle() und geben als Breite und Höhe jeweils den Wert 1 an. Beachten Sie aber, dass diese Vorgehensweise verhältnismäßig langsam ist und nicht zum Zeichnen großflächiger Pixelmuster verwendet werden sollte:
VERWEIS
g.FillRectangle(Brushes.Black, 150, 100, 1, 1);
Informationen zu den FillXxx()-Methoden finden Sie etwas weiter unten (Überschrift Gefüllte Objekte). Das Beispiel setzt voraus, dass Sie ein Koordinatensystem mit der Längeneinheit Pixel verwenden, wie dies standardmäßig der Fall ist. Wenn Sie ein anderes Koordinatensystem verwenden (siehe Abschnitt 21.2.5), ändert sich auch die Größe eines Rechtecks mit der Breite und Länge eins.
Der folgende Beispielcode verwendet diese Methode zum Zeichnen einzelner Punkte. Dass diese Vorgehensweise in der Tat langsam ist, sehen Sie bei der Programmausführung am gemächlichen Aufbau des Punktmusters. Das Programm selbst sollte leicht verständlich sein. private void TabDots_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { TabPage currentTab = sender as TabPage; Graphics g = currentTab.CreateGraphics(); int aWidth = currentTab.ClientSize.Width; int aHeight = currentTab.ClientSize.Height;
Sandini Bib
Elementare Grafikoperationen
705
g.SmoothingMode = SmoothingMode.AntiAlias; SolidBrush aBrush = new SolidBrush( Color.Black ); for ( int i = 0; i < aWidth; i++ ) for ( int u = 0; u < aHeight; u++ ) if ( Math.Sin( ( i * u ) / 30 ) > 0 ) g.FillRectangle( aBrush, i, u, 1, 1 ); g.Dispose(); aBrush.Dispose(); }
Das Ergebnis zeigt Abbildung 21.5.
Abbildung 21.5: Ein Punktmuster aus schwarzen und weißen Punkten
Ellipsen, Ellipsenbögen und Kreise zeichnen Zum Zeichnen von Kreisen und Ellipsen existiert nur eine einzige Methode, DrawEllipse(). Die Ausmaße der zu zeichnenden Ellipse werden durch ein Rechteck angegeben, das die Ellipse (ggf. den Kreis) umschließt. Sie können entweder die Koordinaten des Eckpunktes und Breite sowie Höhe angeben als auch mit Rectangle/RectangleF-Instanzen arbeiten. In anderen Programmiersprachen ist das anders gelöst, z.B. durch Angabe eines Mittelpunkts und zweier Radien. Gerade beim Zeichnen von Kreisen wäre es schön, wenn eine solche Möglichkeit zusätzlich zur Verfügung stehen würde. Ebenso ist es leider nicht möglich, gedrehte Ellipsen zu zeichnen, deren Achsen nicht senkrecht bzw. waagerecht stehen. Kreisbögen bzw. Ellipsenbögen zeichnen Sie mit der Methode DrawArc(). Die Parameter sind die gleichen wie bei der Methode DrawEllipse(), allerdings werden zusätzlich noch zwei Winkelangaben benötigt: Der Startwinkel und der Winkelbereich, der gezeichnet
Sandini Bib
706
21 Grafikprogrammierung (GDI+)
werden soll. Die Winkel werden in Grad angegeben. Anders als möglicherweise vermutet befindet sich der Winkel 0 nicht etwa bei 12 Uhr, sondern bei 3 Uhr, also auf der X-Achse. DrawArc() zeichnet nur den Kreisbogen. Um ein »Tortenstück« zu zeichnen, wie man es von Statistiken, Umfragen und Wahlhochrechnungen kennt, können Sie die Methode DrawPie() verwenden. Diese Methode funktioniert genauso wie DrawArc(), der Unterschied liegt allein darin, dass zusätzlich zum Kreisbogen noch eine Verbindung zum Mittelpunkt gezeichnet wird.
Auch hierzu gibt es ein kurzes Codebeispiel, das die Verwendung verdeutlicht. // Ellipsen/Kreise private void TabEllipse_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { Graphics g = ( (TabPage)sender ).CreateGraphics(); Pen aPen = new Pen( Color.Blue, 3 ); g.SmoothingMode = SmoothingMode.AntiAlias; // Eine Ellipse g.DrawEllipse( aPen, 10, 10, 200, 50 ); // Dünner Kreis, roter Viertelkreis aPen = new Pen( Color.Black, 1 ); g.DrawEllipse( aPen, 10, 80, 80, 80 ); aPen = new Pen( Color.Red, 5 ); g.DrawArc( aPen, 10, 80, 80, 80, 45, 90 ); // drei Ellipsensegmente g.DrawPie( aPen, 100, 100, 200, 100, -45, 135 ); g.DrawPie( aPen, 100, 100, 200, 100, 100, 30 ); g.DrawPie( aPen, 100, 100, 200, 100, 180, 90 ); g.Dispose(); aPen.Dispose(); }
Das Ergebnis zeigt Abbildung 21.6.
Sandini Bib
Elementare Grafikoperationen
707
Abbildung 21.6: Ellipsen, Kreise und Kreissegmente
Kurvenzüge zeichnen Die Methoden DrawCurve() bzw. DrawClosedCurve() zeichnen eine geschwungene Kurve, die exakt durch die in einem Point/PointF-Array angegebenen Koordinatenpunkte führt. Bei DrawClosedCurve() wird der Linienzug geschlossen (d.h. es wird eine Verbindung zwischen dem ersten und dem letzten Punkt hergestellt). Intern werden so genannte Splines zur Konstruktion der Kurve verwendet. Durch einen optionalen Parameter (tension) können Sie angeben, wie nah die Kurve den Punkten folgen soll. Wenn dieser Wert 0 beträgt, werden die Punkte durch gerade Linien verbunden. Bei 1 schwingt die Kurve relativ stark über die Koordinatenpunkte hinaus. Werte größer als 1 sind zwar zulässig, führen aber selten zu brauchbaren Ergebnissen. Der Standardwert beträgt 0,5. Abbildung 21.7 zeigt einige zufällige Punkte, die mit zwei verschiedenen Linienzügen verbunden sind. Die breite Linie wurde dabei mit dem Standardwert für tension gezeichnet, für die dünnere grüne Linie wurde ein Wert von 1 verwendet. Um eine möglichst hohe Bildqualität zu erzielen, wurde Antialiasing aktiviert (siehe Abschnitt 21.5.1 ab Seite 792). Da bei diesem Beispiel die zufälligen Punkte im Paint-Ereignis initialisiert werden, wird bei jeder Änderung, die ein Neuzeichnen zur Folge hat, ein neuer Kurvenzug gezeichnet. // Kurvenzug private void TabCurves_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { TabPage currentTab = sender as TabPage; Graphics g = currentTab.CreateGraphics(); int aWidth = currentTab.ClientSize.Width; int aHeight = currentTab.ClientSize.Height;
Sandini Bib
708
21 Grafikprogrammierung (GDI+)
// Zeichenstift und Pinsel deklarieren Pen aPen; SolidBrush aBrush; // Koordinatenpunkte initialisieren Point[] pt = new Point[10]; Random rnd = new Random( DateTime.Now.Millisecond ); g.SmoothingMode = SmoothingMode.AntiAlias; for ( int i = 0; i < 10; i++ ) { pt[i].X = i * ( ( aWidth - 30 ) / 10 ) + rnd.Next( 0, 30 ); pt[i].Y = rnd.Next( 0, aHeight ); } // Koordinatenpunkte zeichnen aBrush = new SolidBrush( Color.Red ); for ( int i = 0; i < 10; i++ ) g.FillEllipse( aBrush, pt[i].X - 6, pt[i].Y - 6, 12, 12 ); // Linienzug zeichnen aPen = new Pen( Color.Black, 3 ); g.DrawCurve( aPen, pt ); aPen = new Pen( Color.Green, 1 ); g.DrawCurve( aPen, pt, 1 ); g.Dispose(); aPen.Dispose(); aBrush.Dispose(); }
Abbildung 21.7 zeigt das Ergebnis.
Sandini Bib
Elementare Grafikoperationen
709
Abbildung 21.7: Ein Kurvenzug
Bezierkurven zeichnen Während Sie bei DrawCurve() relativ wenig Einfluss auf die genaue Form der Kurve haben (nämlich eigentlich nur über den Parameter tension), können Sie mit DrawBezier() Kurven zeichnen, deren Ausprägung in jedem Segment durch zwei Stützpunkte gesteuert wird. Die Methode erwartet als Parameter vier Koordinatenpunkte. Die Kurve führt vom ersten zum vierten Punkt. Die beiden anderen Punkte dienen als Stützpunkte, die die Lage von Tangenten angeben. Diese Tangenten bestimmen, in welchem Winkel die Kurve vom Start- bzw. zum Endpunkt führt. Je weiter die Stützpunkte vom Start- bzw. Endpunkt entfernt sind, desto stärker nähert sich die Bezierkurve an die Tangenten an. In Abbildung 21.7 ist eine Bezierkurve samt der Stützpunkte und den daraus resultierenden Tangenten zu sehen. Wenn Sie einen ganzen Kurvenzug zeichnen möchten, bietet sich DrawBeziers() an: Diese Methode erwartet ein Point/PointF-Array mit 3*n+1 Elementen. Die Kurve führt vom ersten Koordinatenpunkt durch den vierten Punkt, dann durch den siebten Punkt etc. Die dazwischen liegenden Punkte sind die Stützpunkte. // Bezier-Kurvenzug private void TabBezier_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { Graphics g = ( (TabPage)sender ).CreateGraphics(); Point[] pts = new Point[4]; Pen aPen; SolidBrush aBrush; pts[0] pts[1] pts[2] pts[3]
= = = =
new new new new
Point( Point( Point( Point(
10, 10 ); 30, 30 ); 140, 80 ); 150, 10 );
Sandini Bib
710
21 Grafikprogrammierung (GDI+)
g.SmoothingMode = SmoothingMode.AntiAlias; // Koordinatenpunkte und Tangenten zeichnen aBrush = new SolidBrush( Color.Red ); foreach ( Point pt in pts ) g.FillEllipse( aBrush, pt.X - 6, pt.Y - 6, 12, 12 ); aPen = new Pen( Color.Black, 1 ); g.DrawLine( aPen, pts[0], pts[1] ); g.DrawLine( aPen, pts[2], pts[3] ); // Bezierkurve zeichnen aPen = new Pen( Color.Black, 3 ); g.DrawBezier( aPen, pts[0], pts[1], pts[2], pts[3] ); g.Dispose(); aBrush.Dispose(); aPen.Dispose(); }
Abbildung Abbildung 21.8 zeigt das Ergebnis.
Abbildung 21.8: Eine Bezierkurve mit zwei Stützpunkten
Gefüllte Objekte zeichnen Bisher wurden lediglich Methoden zum Zeichnen verschiedener Formen besprochen. Alle DrawXxx()-Methoden erwarten dazu ein Objekt vom Typ Pen als Zeichenstift. Die meisten dieser Methoden liegen auch als FillXxx()-Methoden vor. Der Unterschied zwischen beiden ist, dass nicht der Rand gezeichnet, sondern die gezeichnete Form ausgefüllt wird. Entsprechend erwarten diese Methoden statt eines Pen-Objekts ein Brush-Objekt.
Sandini Bib
Elementare Grafikoperationen
711
Das folgende Codebeispiel zeigt einige FillXxx()-Methoden im Einsatz. Möchten Sie ein Objekt zeichnen, das sowohl gefüllt ist als auch einen gezeichneten Rand hat, wenden Sie einfach nach der FillXxx()-Methode die entsprechende DrawXxx()-Methode an. Auch das wird im Listing gezeigt. Das Ergebnis im Bild zeigt Abbildung 21.9. // Gefüllte Objekte private void TabFilled_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { Graphics g = ( (TabPage)sender ).CreateGraphics(); Pen aPen = new Pen( Color.Black, 1 ); Brush aBrush = new SolidBrush( Color.Red ); g.SmoothingMode = SmoothingMode.AntiAlias; g.FillRectangle( aBrush, 10, 10, 200, 60 ); aBrush = Brushes.Yellow; g.FillEllipse( aBrush, 10, 70, 150, 50 ); g.DrawEllipse( aPen, 10, 70, 150, 50 ); aBrush = new HatchBrush( HatchStyle.Cross, Color.Red ); g.FillPie( aBrush, 10, 120, 100, 100, -10, 340 ); g.Dispose(); aBrush.Dispose(); aPen.Dispose(); }
Abbildung 21.9: Einige gefüllte Objekte
Sandini Bib
712
21 Grafikprogrammierung (GDI+)
21.2.2
Farben (Color-Struktur)
Bei der Erzeugung von Pen- oder Brush-Objekten müssen Sie eine Color-Instanz angeben. In den meisten Beispielen dieses Kapitels wurden dazu einfach die vordefinierten Farben verwendet, von denen Color eine ganze Menge zur Verfügung stellt. Im Falle der Farbe Schwarz für ein Pen-Objekt beispielsweise: Pen aPen = new Pen( Color.Black, 2 );
Wenn Sie eine neue Farbe aus bekannten Rot-, Grün- und Blauanteilen zusammensetzen möchten, verwenden Sie am besten die statische Methode FromArgb(). An diese Methode können Sie die Farbanteile mit int-Werten zwischen 0 und 255 übergeben. Die Farbe Weiß erhalten Sie also mit Color.FromArgb(255, 255, 255).
TIPP
Color col = Color.FromArgb( 255, 0, 0 );
Die Aufzählung Color.Farbname enthält gewöhnliche Farbnamen. Wenn Sie dagegen Windows-Systemfarben benötigen, müssen Sie auf die SystemColors-Klasse zurückgreifen. Beispielsweise gibt SystemColors.Control die Standardfarbe für den Hintergrund von Steuerelementen an, SystemColors.ControlText die Farbe für den Text von Steuerelementen und SystemColors.Window die Hintergrundfarbe für Fenster.
Alphakanal Bei der Verwendung der Methode FromArgb() sind Sie nicht auf die Angabe der Farbanteile beschränkt. Als ersten Parameter können Sie auch einen Wert für den so genannten Alphakanal angeben. Dieser steuert die Transparenz der Farbe. Je höher der Wert, desto undurchsichtiger die Farbe. Für eine vollständige Transparenz müssen Sie also den Wert 0 angeben (womit aber die Farbe dann irrelevant ist ... man sieht sie ohnehin nicht).
VERWEIS
FromArgb() funktioniert auch, wenn Sie nur einen einzigen Integer-Wert übergeben. In diesem Fall interpretiert das erste Byte den Blauanteil, das zweite Byte den Grünanteil, das dritte Byte den Rotanteil und das vierte Byte den Alphawert. Color.FromArgb(&HFFFF0000) liefert somit die deckende Farbe Rot.
Weitere Informationen sowie ein Beispiel für die Anwendung des Alphakanals finden Sie in Abschnitt 21.4.4 ab Seite 778, in dem es um transparente Bitmaps geht.
Eigenschaften und Methoden von Farben Die drei wichtigsten Eigenschaften eines Color-Objekts lauten R, G und B und liefern den Rot-, Grün- und Blauanteil der Farbe als Byte-Wert; die Eigenschaft A liefert den dazugehörigen Alphawert (255 bei deckenden Farben). Wenn Sie die Farbanteile entsprechend des HSB-Farbmodells ermitteln möchten, müssen Sie dazu die Methoden GetHue(), GetSatura-
Sandini Bib
Elementare Grafikoperationen
713
tion() und GetBrightness() verwenden. Die Methode ToArgb() liefert die Farbe in dem oben schon beschriebenen Integer-Format.
Beachten Sie bitte, dass Farben nicht geändert werden können. Sie können also nicht bei einem bereits vorhandenen Color-Objekt die Eigenschaft R verändern. Stattdessen müssen Sie – in der Regel mit Color.FromArgb() – eine neue Color-Instanz erzeugen.
Der Dialog zur Farbauswahl Wenn Sie in Ihrem Programm eine interaktive Farbauswahl ermöglichen möchten, können Sie dazu auf die Komponente ColorDialog zurückgreifen. Die Anwendung entspricht der aller anderen Dialoge. Die Methode ShowDialog() liefert einen Wert vom Typ DialogResult zurück, mit dem Sie auswerten können, welchen Button der Benutzer angeklickt hat. Anschließend können Sie über die Eigenschaft Color auf die ausgewählte Farbe zugreifen.
CD
Beim folgenden Beispielprogramm wird vor der Anzeige die Eigenschaft FullOpen des Dialogs auf true gesetzt. Damit erreichen Sie, dass gleich der gesamte Dialog (und nicht nur der linke Teil) angezeigt wird. Anschließend wird die Hintergrundfarbe eines Panels entsprechend der ausgewählten Farbe verändert. (Wenn Sie ein Graphics-Objekt mit einer Hintergrundfarbe füllen möchten, verwenden Sie dazu die Methode Clear(). Beachten Sie aber, dass diese Einstellung nicht bleibend gespeichert wird. Clear() funktioniert im Prinzip wie FillRectangle().) Den gesamten Quellcode finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Farbauswahl.
private void BtnChooseColor_Click( object sender, System.EventArgs e ) { dlgColor.FullOpen = true; if ( dlgColor.ShowDialog() == DialogResult.OK ) pnlColor.BackColor = dlgColor.Color; }
Sandini Bib
714
21 Grafikprogrammierung (GDI+)
Abbildung 21.10: Der Dialog zur Farbauswahl
21.2.3
Linienformen (Pen-Klasse)
Einfarbige Zeichenstifte Objekte vom Typ Pen wurden bereits häufig in den bisherigen Beispielen eingesetzt. Ein Pen-Objekt gibt den Zeichenstift an, mit der die aufgerufene Methode des Graphics-Objekts letztendlich arbeitet. Pen-Objekte können auf mehrere Arten erzeugt werden, der Konstruktor der Klasse ist mehrfach überladen. Am häufigsten verwendet wird die Möglichkeit, dem Konstruktor einfach eine Farbe und die gewünschte Breite des Zeichenstifts zu übergeben. Wird letztere nicht angegeben, hat der Zeichenstift eine Breite von einem Pixel. Pen aPen = new Pen( Color.Black, 2 );
Da es der Zeichenmethode egal ist, wann der Zeichenstift erzeugt wurde, kann dies auch in der Methode selbst geschehen: g.DrawLine( new Pen( Color.Red, 2 ), 0, 0, 100, 100 );
Es gibt eine weitere Möglichkeit, einen Zeichenstift zu erzeugen. Die Klasse Pens liefert mit ihren statischen Eigenschaften mehrere vordefinierte Pen-Objekte verschiedener Farben, die allerdings immer eine Breite von 1 haben. Auch in diesem Fall dürfen Sie Dispose() nicht anwenden (siehe Abschnitt 0 ab Seite 698): g.DrawLine( Pens.Red, 0, 0, 100, 100 );
Sandini Bib
HINWEIS
Elementare Grafikoperationen
715
Beachten Sie, dass es bei dieser Syntaxvariante nicht erlaubt (und auch nicht möglich) ist, das Pen-Objekt anschließend wieder durch Dispose() freizugeben. Sie haben nämlich keinen Zugriff auf das Objekt, weil es zwar der Zeichenmethode übergeben, aber nicht einer Variablen zugewiesen wurde. Genauer gesagt, es wurde direkt einer lokalen Variable der Zeichenmethode übergeben. Normalerweise ist das kein Problem, weil der Speicherbedarf eines solchen Objekts verhältnismäßig gering ist, die Garbage Collection gibt es irgendwann wieder frei. Innerhalb von Schleifen oder beim Einsatz mehrerer Pen-Objekte sollten Sie allerdings eine eigene Objektvariable verwenden.
Zeichenstifte auf der Basis eines Brush-Objekts Ein Zeichenstift muss nicht zwangsläufig einfarbig sein, er kann auch ein Muster beinhalten. Zu diesem Zweck können Sie dem Konstruktor statt einer Farbe auch ein beliebiges Brush-Objekt übergeben. Beispielsweise ist es so möglich, einen Zeichenstift mit einem Farbverlauf oder einem Füllmuster zu erzeugen. Sinn macht das allerdings nur ab einer gewissen Breite, weil ansonsten das Muster (bzw. der Farbverlauf) nicht sichtbar ist. Pen aPen = new Pen( aBrush, 2 );
Linienmuster Bestimmte Linienmuster können Sie auch ohne Übergabe eines Brush-Objekts verwenden. Vordefinierte Muster sind in der Aufzählung DashStyle aus dem Namespace System.Drawing.Drawing2D festgelegt. Zur Auswahl stehen punktierte Linien, strichpunktierte Linien, gestrichelte Linien usw. Den gewünschten Stil müssen Sie an die Eigenschaft DashStyle des Pen-Objekts übergeben. Sollten Sie ein eigenes Muster definieren wollen, können Sie die Eigenschaft DashPattern verwenden. Diese Eigenschaft erwartet ein Array aus float-Werten, die die Längen der Linien und Zwischenräume angeben. Der erste Wert gibt dabei die Länge des ersten Linienstücks an, der zweite Wert die Länge des ersten Zwischenraums, der dritte Wert die Länge des zweiten Linienstücks usw. Dabei ist allerdings darauf zu achten, dass die Längen der Linienstücke und Zwischenräume proportional zur Dicke der Linie sind. Je dicker diese ist, desto größer auch Linien und Abstände.
CD
Das folgende Beispiel demonstriert die verschiedenen Linienarten. Gezeichnet wird in diesem Fall auf ein Panel (drawPanel), das zur besseren Sichtbarkeit mit der Hintergrundfarbe Weiß belegt wurde. Gezeigt wird aus Platzgründen nur die eigentliche Zeichenmethode. Die Namespaces System.Drawing und System.Drawing.Drawing2D müssen per using eingebunden sein. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\PenStyles.
Sandini Bib
716
21 Grafikprogrammierung (GDI+)
private void BtnDraw_Click( object sender, System.EventArgs e ) { // Deklarationen Graphics g = drawPanel.CreateGraphics(); Font fnt = new Font( "Arial", 10 ); Pen p = new Pen( Color.Black, 5 ); int i = 1; float[] pattern = { 3f, 1f, 5f, 1f }; g.Clear( Color.White ); foreach ( DashStyle d in Enum.GetValues( typeof( DashStyle ) ) ) { // DrawString schreibt auf die Zeichenfläche ... g.DrawString( d.ToString(), fnt, Brushes.Black, 10, i * 20 ); // Dashstyle festlegen p.DashStyle = d; // Wenn DashStyle == Custom, Pattern zuweisen if ( d == DashStyle.Custom ) p.DashPattern = pattern; g.DrawLine( p, 110, 10 + ( i * 20 ), 250, 10 + ( i * 20 ) ); i++; } }
Das Ergebnis zeigt Abbildung 21.11.
Abbildung 21.11: Ein Beispiel für die verschiedenen DashStyle-Einstellungen
Sandini Bib
Elementare Grafikoperationen
717
Linienanfang und -ende Die Gestaltung von Linienanfang und Linienende ist durch die Eigenschaften StartCap und EndCap von Pen möglich. Auch hier gibt es vordefinierte Muster, die Sie zuweisen können. Sie sind in der Aufzählung LineCap aus System.Drawing.Drawing2D definiert. Auch hier gibt es eine Custom-Einstellung, in der Sie die Linienenden selbst definieren können. Das geschieht in diesem Fall aber über eine der Klassen CustomLineCap oder AdjustableArrowCap. Diese können den Eigenschaften CustomStartCap bzw. CustomEndCap des Pen-Objekts zugewiesen werden, wenn LineCap die Einstellung LineCap.Custom enthält. Die Erstellung eines selbst definierten Linienendes ist recht komplex, hier ist wirklich jede denkbare Form möglich. Gearbeitet wird in diesem Fall mit GraphicsPath-Objekten, die dazu dienen, eine grafische Figur festzulegen. Diese Festlegung erfolgt durch ein Array aus Point/PointF-Werten. Zusätzlich kann jedem Punkt noch eine Bedeutung zugewiesen werden.
CD
Diese Möglichkeit wird allerdings nicht besonders häufig genutzt, üblicherweise reichen die vordefinierten Linientypen aus. Das folgende Beispiel zeigt, wie Linienenden gezeichnet werden. Auch hier müssen die Namespaces System.Drawing und System.Drawing. Drawing2D eingebunden sein. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Linecap.
private void BtnDraw_Click( object sender, System.EventArgs e ) { // Deklarationen Graphics g = drawPanel.CreateGraphics(); Font fnt = new Font( "Arial", 10 ); Pen p = new Pen( Color.Black, 9 ); int i = 1; g.Clear( Color.White ); g.SmoothingMode = SmoothingMode.AntiAlias; // Hohe Darstellungsqualität foreach ( LineCap l in Enum.GetValues( typeof( LineCap ) ) ) { // DrawString schreibt auf die Zeichenfläche ... g.DrawString( l.ToString(), fnt, Brushes.Black, 10, i * 25 ); // Linienenden festlegen p.StartCap = l; p.EndCap = l; g.DrawLine( p, 130, 10 + ( i * 25 ), 240, 10 + ( i * 25 ) ); i++; } g.Dispose(); }
Sandini Bib
718
21 Grafikprogrammierung (GDI+)
Abbildung 21.12 zeigt das Ergebnis.
Abbildung 21.12: Verschiedene Linienenden
Diese Einstellungen sind natürlich nicht alles. Wenn mehrere Linien verbunden werden, ist es auch mit den hier vorgestellten Möglichkeiten nicht machbar, saubere Verbindungen an den Ecken herzustellen. Solche Verbindungen können Sie über die Eigenschaft LineJoin erreichen. Dieser Eigenschaft muss einer der Werte der Aufzählung LineJoin aus System.Drawing.Drawing2D zugewiesen werden. Die Möglichkeiten sind vielfältig, Sie können beispielsweise spitze Ecken oder auch abgerundete Ecken erzeugen.
21.2.4
Füllmuster (Brush-Klassen)
Nahezu alle Methoden der Graphics-Klasse, die mit Draw beginnen, sind auch in einer Variante vorhanden, die mit Fill beginnt. Diese Methoden dienen zum Zeichnen eines ausgefüllten Objekts. Sie erwarten als ersten Parameter statt eines Zeichenstifts ein BrushObjekt, das das Füllmuster (oder den Füllpinsel) angibt. Die Klasse Brush selbst ist abstrakt und kann daher nicht direkt instanziert werden. Zum Einsatz kommen daher Instanzen beliebiger von Brush abgeleiteter Klassen. Die folgende Tabelle liefert eine Übersicht:
Sandini Bib
Elementare Grafikoperationen
719
Klasse
Funktion
SolidBrush
SolidBrush
ist ein einfarbiger Pinsel zum Erzeugen einer einfarbigen
Fläche. TextureBrush
TextureBrush verwendet eine Bitmap als Füllmuster. Diese kann entweder geladen oder im Code erzeugt werden.
HatchBrush
HatchBrush
LinearGradientBrush
LinearGradientBrush
PathGradientBrush
verwendet ebenfalls einen Farbverlauf. Der Bereich, in dem dieser Farbverlauf definiert ist, wird allerdings nicht durch ein Rechteck, sondern durch eine Instanz der Klasse GraphicsPath festgelegt. Einerseits ist damit vieles möglich, andererseits würde eine allzu detaillierte Betrachtung den Rahmen dieses Buchs sprengen. Diese Klasse ist in System.Drawing.Drawing2D deklariert.
dient zum Füllen eines Objekts mit einem vordefinierten Muster. Diese Klasse ist in System.Drawing.Drawing2D deklariert. verwendet einen Farbverlauf als Füllmuster. Der Farbverlauf wird innerhalb eines rechteckigen Bereichs definiert. Diese Klasse ist in System.Drawing.Drawing2D deklariert.
PathGradientBrush
CD
Das folgende Beispiel demonstriert die Verwendung der verschiedenen Pinselarten. Zum Einsatz kommen aber nur die ersten vier in der obigen Tabelle angegebenen Pinsel, PathGradientBrush bleibt aufgrund der Komplexität (und der Tatsache, dass GraphicsPath auch noch nicht behandelt wurde) außen vor. Details zu den einzelnen Brush-Klassen folgen im weiteren Verlauf des Kapitels. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Brushes.
private void PnlDraw_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { Graphics g = pnlDraw.CreateGraphics(); // Festlegen der Dimensionen int x1 = 10; int w1 = ( pnlDraw.ClientSize.Width / 2 ) - 20; int x2 = ( pnlDraw.ClientSize.Width / 2 ) + 10; int h1 = ( pnlDraw.ClientSize.Height / 2 ) - 20; int y1 = 10; int y2 = ( pnlDraw.ClientSize.Height / 2 ) + 10; g.Clear( Color.White ); // SolidBrush Brush b = new SolidBrush( Color.Blue );
Sandini Bib
720
21 Grafikprogrammierung (GDI+)
g.FillEllipse( b, x1, y1, w1, h1 ); g.DrawEllipse( Pens.Black, x1, y1, w1, h1 ); // HatchBrush b = new HatchBrush( HatchStyle.BackwardDiagonal, Color.Red, Color.White ); g.FillEllipse( b, x2, y1, w1, h1 ); g.DrawEllipse( Pens.Black, x2, y1, w1, h1 ); // TextureBrush b = new TextureBrush( new Bitmap( "Texture2.bmp" ) ); g.FillEllipse( b, x1, y2, w1, h1 ); g.DrawEllipse( Pens.Black, x1, y2, w1, h1 ); // LinearGradientBrush b = new LinearGradientBrush( new Rectangle( x2, y2, w1, h1 ), Color.Yellow, Color.Gray, LinearGradientMode.Horizontal ); g.FillEllipse( b, x2, y2, w1, h1 ); g.DrawEllipse( Pens.Black, x2, y2, w1, h1 ); }
Abbildung 21.13 zeigt das Ergebnis.
Abbildung 21.13: Die verschiedenen Pinselmuster im Vergleich
Einfarbige Muster (SolidBrush) Einfarbige Muster werden durch die Klasse SolidBrush repräsentiert. Obwohl Sie diese über den Konstruktor unter Angabe einer Farbe leicht erzeugen können, erübrigt sich das in den meisten Fällen. Die Klasse Brushes stellt eine Vielzahl unterschiedlicher SolidBrushObjekte zur Verfügung, die Sie direkt verwenden können. Der Konstruktor wird also nur
Sandini Bib
Elementare Grafikoperationen
721
dann benötigt, wenn Sie eine nicht vordefinierte Farbe verwenden wollen (die Sie möglicherweise über einen RGB-Wert erzeugen wollen). g.FillEllipse( Brushes.Blue, 10, 10, 100, 100 );
bzw. SolidBrush aBrush = new SolidBrush( Color.FromArgb( 255, 127, 127 ); g.FillEllipse( aBrush, 10, 10, 100, 100 );
Bitmap-Muster (TextureBrush) TextureBrush liefert ein Füllmuster, das aus einer Bitmap besteht. Wenn Sie eine FillXxx()Methode mit einem solchen Muster ausführen, wird das Grafikobjekt mit der Bitmap gefüllt. Standardmäßig wird der Inhalt der Bitmap automatisch wiederholt, so dass ein endloses Muster entsteht. Damit dies überzeugend aussieht, sollten speziell präparierte Bitmaps eingesetzt werden, die für eine Endloswiederholung ausgelegt sind. Im Internet, wo dieser Effekt für Hintergrundmuster verwendet wird, finden sich Unmengen derartiger Bitmaps.
Zum Erzeugen eines TextureBrush-Objekts müssen Sie im Konstruktor ein Bitmap-Objekt angeben. Im Regelfall werden Sie die erforderliche Bitmap einfach aus einer Datei laden. Die folgenden Zeilen gehen davon aus, dass sich die Bitmap-Datei im Programmverzeichnis befindet. (Weitere Informationen zum Umgang mit Bitmaps finden Sie in Abschnitt 21.4.) TextureBrush b = new TextureBrush( new Bitmap( "Texture2.bmp" ) ); g.FillEllipse( b, x1, y2, w1, h1 );
Noch einige Interna: Das Füllmuster beginnt normalerweise beim Koordinatenpunkt (0, 0) der Zeichenfläche (also beispielsweise des Formulars oder des PictureBox-Steuerelements) und setzt sich dann in beide Koordinatenrichtungen periodisch fort. Wenn Sie also eine Bitmap-Datei mit einer Größe von 100x100 Pixeln verwenden und ein Rechteck zwischen den Punkten (50, 50) und (150, 150) zeichnen, beginnt das Muster innerhalb des Rechtecks in der Mitte der Bitmap (was üblicherweise für Verwirrung sorgt). Abhilfe schafft die Eigenschaft RenderingOrigin der Klasse Graphics, mit der Sie den Koordinatennullpunkt für das Muster verschieben können. RenderingOrigin = new Point(50, 50) bewirkt beispielsweise, dass das Muster an der Position (50, 50) beginnt und sich von dieser Position aus periodisch wiederholt. Falls Sie möchten, dass das Muster am linken oberen Rand des Rechtecks beginnt (unabhängig von den Koordinaten dieses Eckpunkts), müssen Sie die Eigenschaft WrapMode des TextureBrush-Objekts auf WrapMode.Clamp stellen. Damit beginnt die Textur am linken oberen Eck des zu zeichnenden Objekts. Allerdings wird die Bitmap nun nicht mehr wiederholt. Teile des zu zeichnenden Objekts, die über die Bitmap hinausreichen, bleiben einfach unsichtbar. Die WrapMode-Aufzählung ist im Namespace System.Drawing.Drawing2D deklariert. Falls Sie die Textur noch besser an das Grafikobjekt anpassen möchten (z.B. wenn Sie die Textur zusammen mit dem Grafikobjekt verdrehen möchten), müssen Sie die TransformMethoden des Brush-Objekts anwenden. Das setzt allerdings ein gutes Verständnis der
Sandini Bib
722
21 Grafikprogrammierung (GDI+)
TIPP
mathematischen Vorgänge voraus, die bei einer Texturtransformation vor sich gehen. (Ausführliche Informationen finden Sie in jedem guten Buch zur Grafikprogrammierung.) Bei TextureBrush-Objekten sollten Sie daran denken, diese nach ihrer Verwendung durch Dispose() wieder freizugeben. Insbesondere bei großen Bitmaps beanspruchen derartige Objekte relativ viel Speicher. Je früher dieser wieder freigegeben werden kann, desto besser.
Vordefinierte Füllmuster (HatchBrush) GDI+ kennt eine große Anzahl vordefinierter Füllmuster, die über die HatchBrush-Klasse zugänglich sind. Anders als die zuvor besprochenen Brush-Klassen, die in System.Drawing deklariert sind, ist HatchBrush im Namespace System.Drawing.Drawing2D zu finden. Der Konstruktor erwartet zumindest das gewünschte Muster in Form eines Elements der HatchStyle-Aufzählung (die ebenfalls in System.Drawing.Drawing2D definiert ist) und eine Vordergrundfarbe. Die Angabe der Hintergrundfarbe ist ebenfalls möglich, ist aber optional. Standard für den Hintergrund ist schwarz. Mithilfe eines kleinen Beispielprogramms werden die zur Verfügung stehenden HatchBrush-Stile angezeigt.
CD
Abbildung 21.14: Alle HatchBrushes auf einen Schlag
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\HatchBrushes.
Sandini Bib
Elementare Grafikoperationen
723
Farbverlaufmuster (LinearGradientBrush) Die Klasse LinearGradientBrush ist ebenfalls im Namespace System.Drawing2D definiert. Im einfachsten Fall kann diese Klasse dazu verwendet werden, den linearen Verlauf zwischen zwei Farben als Füllmuster zu verwenden. Das Füllmuster wird dazu durch zwei Koordinatenpunkte und zwei Farben definiert. Ähnlich wie bei Bitmap-Mustern wird das so definierte Muster in beide Richtungen fortgesetzt.
VERWEIS
LinearGradientBrush b = new LinearGradientBrush( pt1, pt2, color1, color2 ); g.FillEllipse( b, 10, 10, 100, 100 );
Sie können LinearGradientBrush-Objekte auch dazu verwenden, um nichtlineare Verläufe zwischen mehreren Farben durchführen – aber dieses Thema übersteigt den Rahmen dieses Buchs. Zu dieser Form der Anwendung finden Sie in den Hilfeseiten weitere Informationen.
21.2.5
Koordinatensysteme und -transformationen
Standardmäßig beginnt das Koordinatensystem von Formularen und Steuerelementen mit dem Punkt (0,0) im linken oberen Eck. Die x-Achse zeigt nach links, die y-Achse nach unten. Als Einheit werden Bildschirmpixel verwendet. Wie nicht anders zu erwarten, sind diese Grundannahmen keineswegs unveränderlich. Im Folgenden lernen Sie einige Möglichkeiten kennen, um auf das Koordinatensystem Einfluss zu nehmen. Beachten Sie aber, dass Sie durch eine Änderung des Koordinatensystems nicht nur die Positionen eines Koordinatenpunkts (x,y) ändern, sondern wegen der geänderten Skalierung auch die Einheit für die Linienstärke von Linien oder Kurven. HatchBrush-Muster allerdings werden generell nie skaliert.
HINWEIS
Ein Sonderfall ist die Größe von Schriftarten: Ob diese sich mit dem Koordinatensystem ändert oder nicht, hängt von zwei Faktoren ab. Einerseits, in welcher Einheit die Schriftgröße angegeben wird (Punkt, Pixel etc.), und andererseits, auf welche Weise die Skalierung des Koordinatensystems bewirkt wird. Wenn Sie beispielsweise die Schriftart in der Einheit Punkt angeben, ändert sich deren Größe weder durch eine geänderte PageUnitEinstellung noch durch PageScale, wohl aber durch ScaleTransform() (Genaueres hierzu erfahren Sie weiter unten). Falls Sie vorhaben, nicht das Standardkoordinatensystem zu verwenden (oder das Koordinatensystem gar variabel sein soll), sollten Sie Ihr Programm von Anfang an mit diesen Einstellungen testen. Eine spätere Umstellung ist meistens sehr mühsam.
Maßeinheit des Koordinatensystems (Eigenschaft PageUnit) Die Eigenschaft PageUnit des Graphics-Objekts gibt an, welche Einheit im Koordinatensystem verwendet wird. Zur Auswahl stehen die Konstanten der Aufzählung GraphicsUnit:
Sandini Bib
724
21 Grafikprogrammierung (GDI+)
Konstante
Einheit
Pixel
Bildschirmpixel
Point
Punkt (ein Punkt entspricht 1/72 Zoll)
Display
Diese Einheit ist abhängig von der Art der Ausgabe. Bei Bildschirmausgaben entspricht sie der Einstellung Pixel, beim Drucken 1/100 Zoll. Die Hilfe behauptet hier übrigens, es seien 1/75 Zoll, Versuche haben aber ergeben, dass es sich dabei nicht um den korrekten Wert handelt.
Document
1/300 Zoll
World
Weltkoordinaten. Diese Einstellung kann nicht für PageUnit verwendet werden.
Sie können die Einheit durch eine einfache Zuweisung innerhalb der Paint-Ereignisbehandlungsroutine verändern:
VERWEIS
private void Form1_Paint( object sender, PaintEventArgs e ) { Graphics g = e.Graphics; g.PageUnit = GraphicsUnit.Millimeter; }
Neben der Einheit des Koordinatensystems wird beim Zeichnen auch die DPIEinstellung berücksichtigt (also das Maß, wie groß ein Pixel am Bildschirm ist). Das ist insbesondere bei Schriften wichtig, deren Größe normalerweise in Punkt (1/72 Zoll) angegeben wird. Hintergrundinformationen zu diesem Thema finden Sie im Abschnitt 21.3.4.
Skalierung des Koordinatensystems Mit der Eigenschaft PageScale können Sie das gesamte Koordinatensystem um einen bestimmten Faktor skalieren. Der Faktor wird als float-Wert angegeben. g.PageScale=2f bewirkt beispielsweise, dass Ausgaben im Graphics-Objekt doppelt so groß werden wie in der Standardeinstellung, PageScale=0.5f bewirkt entsprechend, dass sie halb so groß werden. PageScale und PageUnit können unabhängig voneinander eingestellt werden, ihre Wirkung multipliziert sich.
Transformationen Noch viel weiter gehende Möglichkeiten zur Beeinflussung des Koordinatensystems bieten die verschiedenen Transformationsmethoden des Graphics-Objekts: f TranslateTransform(dx, dy) verschiebt den Koordinatennullpunkt um dx Einheiten auf der x-Achse und um dy Einheiten auf der y-Achse. Bei positiven Werten für dx bzw. dy wird nach rechts unten verschoben.
Sandini Bib
Elementare Grafikoperationen
725
f ScaleTransform(fx, fy) skaliert die Koordinatenachsen um die Faktoren fx und fy. ScaleTransform(2, 2) hat somit fast dieselbe Wirkung wie PageScale=2. Die einzige Ausnahme sind Schriften, deren Größe sich durch die Einstellung von PageScale nicht beeinflussen lässt, wohl aber durch ScaleTransform(). ScaleTransform() und PageScale können gleichzeitig verwendet werden, in diesem Fall multipliziert sich die Wirkung. Wenn Sie möchten, dass die y-Achse wie in der Mathematik üblich nach oben zeigt, können Sie das einfach mit ScaleTransform(1,-1) erreichen. f RotateTransform(winkel) verdreht das Koordinatensystem um den angegebenen Winkel im Uhrzeigersinn. Als Rotationspunkt gilt immer der Koordinatenursprung. Wenn Sie um einen anderen Punkt drehen möchten, müssen Sie vorher TranslateTransform() ausführen. f Mathematisch versierte Programmierer können mit Transform() die Transformationsmatrix (ein Objekt des Typs Matrix, diese Klasse ist in System.Drawing.Drawing2D deklariert) direkt lesen bzw. verändern. f ResetTransform() setzt alle Transformationen zurück. Beachten Sie, dass bei der Durchführung von Transformationen die Reihenfolge nicht gleichgültig ist. Das folgende Beispielprogramm demonstriert die Wirkung einfacher Transformationen. Es wird ein Testrechteck ausgegeben, wobei vor der Ausgabe unterschiedliche Transformationen durchgeführt werden. Der eigentliche Zeichenvorgang geschieht in der Methode DoDrawing().
CD
Gezeichnet wird auf einem Panel namens pnlDraw, das der besseren Sichtbarkeit wegen einen weißen Hintergrund hat. Ausgangspunkt für alle Zeichenmethoden ist das PaintEreignis des Formulars FrmMain, des Hauptformulars dieser Anwendung. Für den Code wird vorausgesetzt, dass System.Drawing und System.Drawing.Drawing2D mittels using eingebunden sind. Gezeigt werden wie üblich nur die relevanten Methoden. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Transformation.
private void DoDrawing( Graphics g, string s, Color c ) { // Deklarationen Pen aPen = new Pen( c, 3 ); SolidBrush aBrush = new SolidBrush( c ); Font aFont = new Font( "Arial", 10 ); HatchBrush hBrush = new HatchBrush( HatchStyle.DarkDownwardDiagonal, c, Color.White ); // Zeichnen g.DrawRectangle( aPen, 5, 5, 190, 70 ); g.DrawEllipse( aPen, 20, 30, 30, 30 );
Sandini Bib
726
21 Grafikprogrammierung (GDI+)
g.FillEllipse( hBrush, 50, 30, 40, 40 ); g.DrawString( s, aFont, aBrush, 10, 10 ); aFont.Dispose(); } private void FrmMain_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { Graphics g = pnlDraw.CreateGraphics(); g.SmoothingMode = SmoothingMode.AntiAlias; // Standard DoDrawing( g, "Standard", Color.Black ); // Translation g.TranslateTransform( 10, 100 ); DoDrawing( g, "Nur Translation", Color.DarkRed ); // Translation + Skalierung g.ResetTransform(); g.TranslateTransform( 10, 200 ); g.ScaleTransform( 2f, 0.7f ); DoDrawing( g, "Translation+Skalierung", Color.DarkBlue ); // Translation + Rotation g.ResetTransform(); g.TranslateTransform( 450, 30 ); g.RotateTransform( 45 ); DoDrawing( g, "Translation+Rotation", Color.DarkGreen ); g.Dispose(); }
Abbildung 21.15 zeigt das Ergebnis des Beispiels.
Abbildung 21.15: Transformationen
Sandini Bib
Elementare Grafikoperationen
21.2.6
727
Syntaxzusammenfassung
Methoden der Klasse Graphics (aus System.Drawing) Clear( Color color )
Füllt die gesamte Zeichenfläche mit einer (Hintergrund-)Farbe. Falls das Graphics-Objekt von einem Panel oder einer PictureBox stammt, ist zu beachten, dass die Eigenschaft BackColor durch diese Operation nicht gesetzt wird.
DrawArc( Pen pen, Rectangle rect, float startAngle, float sweepAngle)
Zeichnet einen Kreis- bzw. Ellipsenbogen. Diese Methode ist mehrfach überladen.
DrawBezier( Pen pen, Point p1, Point p2, Point p3, Point p4 )
Zeichnet eine bzw. mehrere Bezierkurven durch die angegebenen Punkte. Diese Methoden sind mehrfach überladen.
DrawBeziers( Pen pen, Point[] p) DrawCurve( Pen Pen, Point[] p) DrawClosedCurve( Pen pen Point[] p)
Verbindet mehrere Punkte durch eine (im Falle von DrawClosedCurve() geschlossene) Kurve. Auch diese Methoden sind mehrfach überladen.
DrawEllipse( Pen pen, Rectangle rect)
Zeichnet eine Ellipse bzw. einen Kreis mit dem Umfang des angegebenen Rechtecks, d.h. die Ellipse bzw. der Kreis werden in das Rechteck eingepasst.
DrawIcon( Icon ico, Rectangle rect)
Zeichnet ein Icon in die Grafik. Diese Methoden sind überladen.
DrawImage( Image img, Point p)
Zeichnet eine Bitmap in die Grafik (siehe Abschnitt 21.4.3). Diese Methoden sind mehrfach überladen.
DrawImageUnscaled( Image img, Point p) DrawLine( Pen pen, Point p1, Point p2 )
Zeichnet eine Linie bzw. einen Linienzug. Diese Methoden sind überladen.
DrawLines( Pen pen, Point[] p ) DrawPath( Pen pen, GraphicsPath path )
Zeichnet ein zusammengesetztes Objekt (ein GraphicsPathObjekt, siehe Abschnitt 21.5.2 ab Seite 792)
DrawPie( Pen pen, Rectangle rect, float startAngle, float sweepAngle )
Zeichnet ein Ellipsensegment. Diese Methode ist wieder mehrfach überladen.
DrawRectangle( Pen p, Rectangle rect ) DrawRectangles( Pen p, Rectangle[] rect )
Zeichnet eines oder mehrere Rechtecke. Auch diese Methoden sind mehrfach überladen.
Sandini Bib
728
21 Grafikprogrammierung (GDI+)
Methoden der Klasse Graphics (aus System.Drawing) FillXxx()
Entspricht den oben aufgezählten Methoden, das Grafikobjekt wird aber mit einer Farbe oder einem Füllmuster gefüllt. Alle DrawXxx()-Methoden arbeiten mit Zeichenstiften (Pen-Objekten), die entsprechenden FillXxx()-Methoden mit Pinseln (BrushObjekten).
Farben Erzeugen einer Color-Instanz (aus System.Drawing) Color.Black, Color.White ...
Die statischen Eigenschaften der Color-Struktur liefern vordefinierte Color-Instanzen, die sofort verwendet werden können.
SystemColors.Control ...
Die statischen Eigenschaften der Klasse SystemColors liefern jeweils eine Instanz der Color-Struktur, die der jeweiligen Systemfarbe entspricht.
Color.FromArgb( int r, int g, int b )
liefert eine Farbe entsprechend dem angegebenen Rot-, Grün- und Blauanteil.
Color.FromArgb( int a, int r, int g, int b )
liefert eine Farbe entsprechend dem angegebenen Rot-, Grün- und Blauanteil. Der Parameter a gibt zusätzlich den Alphakanal an (0 = durchsichtig, 255 = deckend).
Color.FromArgb( int c )
liefert eine Farbe entsprechend dem Wert des Parameters c. Dieser enthält einen int-Wert, der die Byte-Werte für r, g, b und a zusammenfasst.
Color.FromName( string name )
liefert eine Farbe entsprechend dem angegebenen englischen Namen (z.B. "Black").
Color.FromKnownColor( KnownColor c )
liefert eine Farbe entsprechend des Werts der KnownColorAufzählung, der der Methode übergeben wird.
Methoden und Eigenschaften der Struktur Color (aus System.Drawing) A
liefert den Wert des Alphakanals als Byte-Wert.
R, G, B
liefern den roten, grünen und blauen Farbanteil (Byte).
GetHue(), GetSaturation(), GetBrightness()
liefern den Farbton, die Sättigung und die Helligkeit (nach dem HSB-Farbmodell).
ToArgb()
liefert den int-Wert der Farbe. Dieser Wert enthält die drei ByteWerte für den Rot-, Grün- und Blau-Anteil sowie ein Byte für den Alphakanal.
Sandini Bib
Elementare Grafikoperationen
729
Zeichenobjekte Erzeugen einer Pen-Instanz (aus System.Drawing) Pens.Red, Pens.Blue,
Die statischen Eigenschaften der Klasse Pens liefern vordefinierte mit einer Linienbreite von 1.
...
Pen-Objekte
new Pen( col [, n] )
liefert ein Pen-Objekt für die angegebene Farbe. Optional kann die Linienstärke angegeben werden. Fehlt diese Angabe, wird eine Linienstärke von 1 eingestellt.
new Pen( br [, n] )
liefert ein Pen-Objekt auf der Basis des angegebenen Brush-Objekts. Auch hier kann optional die Linienstärke angegeben werden, wobei eine Linienstärke von 1 wieder Standard ist.
Eigenschaften der Klasse Pen (aus System.Drawing) DashStyle
legt das Linienmuster in Form eines Werts der Aufzählung System.Drawing.Drawing2D.DashStyle fest.
DashPattern
definiert ein eigenes Linienmuster. Zugewiesen wird ein Array aus float-Werten, das die Länge der Linienstücke und Leeräume angibt.
StartCap
verändert das Aussehen des Startpunkts der Linie. Zugewiesen wird ein Wert der Aufzählung System.Drawing.Drawing2D.LineCap.
EndCap
wie oben, aber für den Endpunkt der Linie
LineJoin
verändert das Aussehen der Verbindungspunkte bei Linienzügen. Übergeben wird ein Wert der Aufzählung System.Drawing.Drawing2D.LineJoin.
Verschiedene Brush-Objekte erzeugen (aus System.Drawing) Brushes.Red, Brushes.Blue
...
liefert einen der vordefinierten einfarbigen Pinsel, also ein SolidBrush-Objekt
new SolidBrush( Color c )
Liefert ein einfarbiges Muster für die angegebene Farbe
new TextureBrush( Bitmap )
liefert ein Bitmap-Muster mit der angegebenen Bitmap als Füllung. Der Konstruktor von TextureBrush hat recht viele Überladungen, für weitere Möglichkeiten konsultieren Sie bitte auch die OnlineHilfe.
new HatchBrush( HatchStyle hs, Color foreColor, Color backColor )
liefert ein vordefiniertes Füllmuster. HatchBrush und die Aufzählung HatchStyle sind in System.Drawing.Drawing2D deklariert.
new LinearGradientBrush( Point p1, Point p2, Color c1, Color c2 )
liefert ein Farbverlaufmuster. p1 und p2 geben zwei Koordinatenpunkte an. An diesen Punkten hat das Muster die Farben c1 und c2. Dazwischen wird linear interpoliert. LinearGradientBrush ist im Namespace System.Drawing.Drawing2D deklariert.
Sandini Bib
730
21 Grafikprogrammierung (GDI+)
Weitere Strukturen (structs) Point/PointF-Struktur X, Y
enthalten die Koordinaten eines Punkts, entweder als int-Werte (Point) oder als float-Werte (PointF).
Size/SizeF-Struktur Width, Height
geben die Größe eines Objekts in X- und Y-Richtung an.
Rectangle/RectangleF-Struktur X, Left
enthalten die x-Koordinate des linken oberen Ecks.
Y, Top
enthalten die y-Koordinate des linken oberen Ecks.
Width, Height
geben die Größe des Rechtecks in x- und y-Richtung an.
Right
enthält die x-Koordinate des rechten unteren Ecks.
Bottom
enthält die y-Koordinate des rechten unteren Ecks.
Location
liefert ein Point/PointF-Objekt mit dem linken oberen Eckpunkt des Rechtecks.
Size
liefert ein Size/SizeF-Objekt mit der Größe des Rechtecks.
Inflate( int width, int height )
liefert ein neues Rechteck, das an den Rändern um width bzw. height vergrößert ist.
Intersect( Rectangle rect )
liefert ein neues Rechteck, das sich aus der gemeinsamen Fläche des Rechtecks rect und des aktuellen Rechtecks ergibt. Diese Methode existiert auch in einer statischen Version.
Union( Rectangle rect1, Rectangle rect2 )
liefert ein neues Rechteck, das beide Rechtecke rect1 und rect2 umschließt. Diese Methode ist statisch.
IntersectsWith( Rectangle rect )
testet, ob das Rechteck rect2 das aktuelle Rechteck überlappt.
Contains( Point p )
testet, ob das Rechteck einen angegebenen Koordinatenpunkt oder ein angegebenes Rechteck enthält.
Gemeinsame Methoden von Point[F], Size[F], Rectangle[F] Offset( int dx,int dy )
liefert ein um (dx,dy) verschobenes Objekt (nicht für Size[F]).
Ceiling()
konvertiert von PointF nach Point, SizeF nach Size bzw. RectangleF nach Rectangle, wobei alle Werte der übergebenen Instanz aufgerundet werden. Diese Methode ist statisch.
Sandini Bib
Text ausgeben (Font-Klassen)
731
Gemeinsame Methoden von Point[F], Size[F], Rectangle[F] Truncate()
Wie Ceiling(), aber Nachkommastellen werden abgeschnitten. Diese Methode ist statisch.
Round()
Wie Ceiling(), wobei aber nicht nur aufgerundet, sondern nach mathematischen Grundsätzen gerundet wird. Diese Methode ist statisch.
Manipulation des Koordinatensystems Koordinatentransformation (Graphics-Klasse) PageUnit
bestimmt die Koordinateneinheit. Zugewiesen wird ein Element der GraphicsUnit-Aufzählung (Standard ist GraphicsUnit.Pixel).
PageScale
bestimmt den Skalierungsfaktor für alle Grafikausgaben (Standard ist 1). Der Wert wird als float angegeben.
TranslateTransform( float dx, float dy )
verschiebt das Koordinatensystem um (dx, dy).
ScaleTransform( float fx, float fy )
skaliert das Koordinatensystem um die Faktoren fx und fy.
RotateTransform( float angle )
rotiert das Koordinatensystem um den Winkel angle. Die Angabe erfolgt in Grad.
ResetTransform()
setzt das Koordinatensystem in den Standardzustand zurück.
Transform()
liest bzw. verändert die Transformationsmatrix (ein System.Drawing.Drawing2D.Matrix-Objekt).
21.3
Text ausgeben (Font-Klassen)
In diesem Abschnitt geht es um die verschiedenen Klassen, die Schriftarten repräsentieren. Diese Klassen sind in den Namensräumen System.Drawing bzw. System.Drawing.Text deklariert.
21.3.1
Einführung
Grundsätzlich erfolgt die Textausgabe wie das Zeichnen einer Linie oder eines anderen grafischen Objekts. Die Graphics-Klasse sieht dazu die Methode DrawString() vor. In der einfachsten Syntaxvariante werden fünf Parameter an die Methode übergeben: g.DrawString( "Text", aFont, aBrush, x, y );
Sandini Bib
732
21 Grafikprogrammierung (GDI+)
Damit wird die Zeichenkette "Text" an der Koordinatenposition x,y ausgegeben. x und y bezeichnen genau genommen den linken oberen Eckpunkt des Rechtecks, in dem der Text ausgegeben wird. aBrush ist ein beliebiges Brush-Objekt und gibt an, welches Muster bzw. welche Farbe bei der Ausgabe verwendet werden sollen. Selbstverständlich können Sie dazu alle Brush-Objekte verwenden, die das .NET Framework zur Verfügung stellt. Beispielsweise können Sie mit einem LinearGradientBrush-Objekt die Textfarbe kontinuierlich verändern. aFont gibt die Schriftart an, in der Sie den Text ausgeben möchten. Zum Erzeugen eines neuen Font-Objekts gibt es wiederum zahlreiche Möglichkeiten, da der Konstruktor dieser Klasse vielfach überladen ist. Im einfachsten Fall geben Sie nur den Namen der Schriftart und die gewünschte Größe an: Font aFont = new Font( "Arial", 12 );
Während die Qualität von Zeichnungen unter anderem durch die Eigenschaft SmoothingMode verändert werden kann, kann die Qualität von Textausgaben durch die Eigenschaft TextRenderingHint eingestellt werden. Diese ist vom Typ TextRenderingHint, der in System.Drawing.Text definiert ist. Mehr über die Einstellung der Qualität einer grafischen Ausgabe erfahren Sie in Abschnitt 21.5.1 ab Seite 787. Eine einfache Prozedur, mit der ein Text auf einem Formular ausgegeben werden kann, sieht folgendermaßen aus: private void FrmMain_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { // Deklarationen Graphics g = e.Graphics; Font aFont = new Font( "Arial", 36 ); // Textqualität einstellen g.TextRenderingHint = TextRenderingHint.AntiAlias; // Zeichnen g.DrawString( "Hallo GDI+", aFont, Brushes.Black, 10, 10 ); aFont.Dispose(); }
Das Ergebnis dieser kleinen Methode (bei der es sich selbstverständlich um das PaintEreignis des Formulars handelt) sehen Sie in Abbildung 21.16.
Sandini Bib
Text ausgeben (Font-Klassen)
733
CD
Abbildung 21.16: Eine einfache Textausgabe
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\BasicText.
TIPP
Auch wenn dieses erste Beispiel vollkommen problemlos erscheint, ist der Umgang mit Schriften doch ziemlich komplex. Die folgenden Abschnitte beschreiben detailliert die zahlreichen Möglichkeiten von GDI+ zur Textausgabe und -manipulation. Unter anderem wird gezeigt, wie Sie Schriftgrößen exakt einstellen, Texte drehen usw. Nach der Verwendung von Schriftarten sollten Sie die Font-Objekte immer mit Dispose() wieder freigeben. Das gilt insbesondere, wenn Sie (z.B. in einer Schleife) sehr viele Font-Objekte erzeugen.
21.3.2
TrueType-, OpenType- und Type-1-Schriftformate
Es gibt verschiedene Möglichkeiten, Schriften zu beschreiben. Die folgende Aufzählung nennt die drei wichtigsten Formate: f TrueType ist ein Schriftformat, das vor Jahren aus einer gemeinsamen Initiative von Microsoft und Apple entstanden ist. Die meisten Standardschriften, die mit Windows mitgeliefert werden, sind TrueType-Schriften. Bekannte TrueType-Schriften sind Arial, Courier New und Times New Roman. Die Dateikennung lautet *.ttf. (Werfen Sie einen Blick in das Unterverzeichnis Fonts Ihres Windows-Verzeichnisses.) f Type-1 ist das (historisch gesehen viel ältere) Schriftformat zur Beschreibung von PostScript-Schriften. Bekannte Schriften sind Helvetica, Courier und Times. Die übliche Dateikennung ist *.pfm. f OpenType ist ein Überformat, das gleichermaßen zur internen Beschreibung von TrueType- und Type-1-Schriften geeignet ist. Aktuelle Windows-Versionen unterstützen OpenType per Default. Teilweise stehen die traditionellen TrueType-Schriften dort bereits als OpenType-Schriften zur Verfügung. Beispielsweise ist auf dem Entwicklungsrechner für dieses Buch, der unter Windows XP läuft, die Schriftart Arial als OpenType-Schrift installiert. Intern basiert die Schrift aber weiterhin auf der ursprünglichen TrueType-Schrift und sieht vollkommen unverändert aus.
Sandini Bib
VORSICHT
734
21 Grafikprogrammierung (GDI+)
GDI+ unterstützt leider ausschließlich TrueType- und OpenType-Schriftfamilien. PostScript-Schriftarten, die im Type-1-Format und nicht als OpenType-Schriften installiert sind, können dagegen nicht verwendet werden! Diese Einschränkung gilt beispielsweise, wenn Sie ein Adobe-Schriftpaket installiert haben. Wenn Sie mit new Font( "AvantGarde", 15 ) eine Schriftart angeben, die entweder gar nicht oder in einem anderen Format als TrueType oder OpenType installiert ist, wird ohne Fehlermeldung ein Font-Objekt mit der Schrift Microsoft Sans Serif erzeugt. Das passiert auch, wenn Ihnen bei der Angabe des Schriftnamens ein Tippfehler unterläuft.
21.3.3
Schriftarten und -familien
GDI+ unterscheidet zwischen konkreten Schriftarten (Font-Klasse) und Schriftfamilien (FontFamily-Klasse). Am einfachsten ist der Unterschied anhand eines Beispiels zu erkennen: Helvetica ist eine Schriftfamilie. Innerhalb dieser Familie gibt es vier Schriftarten (Fonts), nämlich Helvetica regular, Helvetica italic, Helvetica bold sowie Helvetica bold italic. (Diese vier Schriftarten entsprechen dem normalen Aussehen, fetter Schrift, kursiver Schrift und fettkursiver Schrift.) Beachten Sie, dass Spezialattribute wie narrow oder black in GDI+ eigene Familien gründen. Daher ist Arial Narrow eine eigene Familie (wobei es wiederum die vier Schriftarten gibt, normal, kursiv, fett und fettkursiv). An den Konstruktor der Font-Klasse kann auch ein FontFamily-Objekt übergeben werden. Im folgenden Beispiel wird zuerst ein Objekt für die FontFamily erzeugt und daraus dann ein spezifisches Font-Objekt abgeleitet. FontFamily aFamily = new FontFamily( "Arial" ); Font aFont = new Font( aFamily, 14, FontStyle.Italic );
Es ist aber auch der umgekehrte Weg möglich: Sie erzeugen ein Font-Objekt und ermitteln mit der FontFamily-Eigenschaft die dazugehörende Schriftfamilie. Font aFont = new Font( "Arial", 20 ); FontFamily aFamily = aFont.FontFamily;
Liste aller Schriftfamilien ermitteln Die statische Eigenschaft Families der Klasse FontFamily liefert ein Array aus FontFamilyObjekten, das alle am Rechner installierten TrueType- bzw. OpenType-Schriftfamilien enthält. Andere Schriftfamilien – z.B. PostScript-Typ-1-Schriften – werden ignoriert. Die folgenden Zeilen zeigen, wie die Schriftfamilien mithilfe einer foreach-Schleife ermittelt und in eine ComboBox eingetragen werden können. foreach ( FontFamily aFamily in FontFamily.Families ) comboBox1.Items.Add( aFamily.Name );
Sandini Bib
Text ausgeben (Font-Klassen)
735
Als Alternative zur Eigenschaft Families der Klasse FontFamily können Sie auch die gleichnamige Eigenschaft der Klassen InstalledFontCollection bzw. PrivateFontCollection auswerten. Beide Klassen sind im Namespace System.Drawing.Text definiert. Das Ergebnis bei InstalledFontCollection sollte das gleiche sein wie bei obiger Schleife.
VERWEIS
PrivateFontCollection ermöglicht es, eine eigene Liste von Schriftarten zusammen-
zustellen, wobei dabei sowohl systemweit installierte Schriften als auch Schriftartdateien an einem beliebigen Ort genutzt werden können. Eine Anleitung zur Anwendung von PrivateFontCollection finden Sie in der Dokumentation zu GDI+ im Artikel Creating a private Font Collection: ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.WIN32COM.v10.en/gdicpp/GDIPlus/usingGDIPlus/ usingtextandfonts/creatingaprivatefontcollection.htm
Wenn Sie die Klassen PrivateFontCollection bzw. InstalledFontCollection zur Ermittlung der Schriftfamilien benutzen, müssen Sie darauf achten, dass Sie eine Instanz der jeweiligen Klasse erzeugen, bevor Sie auf die Eigenschaft Families zugreifen. Es handelt sich bei dieser Eigenschaft um einen Instanzmember, nicht um einen statischen Member. InstalledFontCollection ifc = new InstalledFontCollection(); foreach ( FontFamily ff in ifc.Families ) comboBox1.Items.Add( ff.Name );
Beispielprogramm Das folgende Beispielprogramm zeigt alle am Rechner verfügbaren Schriftarten in zehn Spalten an. Gezeichnet wird wieder auf einem Panel namens pnlDraw, dessen Hintergrundfarbe (wie könnte es anders sein) auf Weiß eingestellt wurde. Das Zeichnen geschieht im Click-Ereignis eines Buttons. Da alle Schriftarten ohne zusätzliche Auszeichnung dargestellt werden sollen, wird vor der Ausgabe kontrolliert, ob der Stil FontStyle.Regular für die betreffende Schriftart verfügbar ist, nur dann wird sie auch ausgegeben. Die Kontrolle erfolgt über die Methode IsStyleAvailable() des Font-Objekts.
CD
Auf dem Entwicklungsrechner sind wesentlich mehr Schriften installiert, als hier darstellbar wären. Gegebenenfalls müsste deshalb noch ein Bildlauf implementiert werden. Hier geht es jedoch um die Darstellung der Schriften, weshalb darauf verzichtet wurde. Auf Ihrem Rechner wird der Platz für die Darstellung vermutlich ausreichen. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Font-Families.
Sandini Bib
736
21 Grafikprogrammierung (GDI+)
private void BtnShowFonts_Click( object sender, System.EventArgs e ) { // Zeigt alle installierten Schriftfamilien an Graphics g = pnlDraw.CreateGraphics(); int i = 0; int x = 0; int y = 0; foreach ( FontFamily ff in FontFamily.Families ) { if ( ff.IsStyleAvailable( FontStyle.Regular ) ) { x = ( i % 10 ) * 80; y = (int)( ( i / 10 ) * 50 ); Rectangle rect = new Rectangle( x, y, 70, 45 ); Font aFont = new Font( ff, 14, FontStyle.Regular, GraphicsUnit.Pixel ); g.DrawString( ff.Name, aFont, Brushes.Black, rect ); i++; aFont.Dispose(); } } }
Abbildung 21.17 zeigt die Ausgabe des Programms. Sie sehen, dass die Anzahl der am Beispielrechner installierten Schriftarten nicht gerade unerheblich ist.
Abbildung 21.17: Die installierten Schriftarten auf dem Rechner des Autors
Sandini Bib
Text ausgeben (Font-Klassen)
21.3.4
737
Schriftgröße
Wenn es um die detaillierteren Eigenschaften und Einstellungen für Schriften geht, kommen wir unweigerlich in den Bereich des Desktop-Publishing, in diesem Fall des Schriftsatzes. Die Größe einer Schrift wird durch ein Grundmaß festgelegt, das man EM-Box nennt. In Zeiten des Bleisatzes entsprach die Größe dieser Box der maximalen Größe, die eine Schriftart einnehmen konnte. Wenn Sie unter GDI+ ein neues Font-Objekt instanzieren, geben Sie die Größe üblicherweise in Punkt an. Die angegebene Größe entspricht dann der Größe der EM-Box für diese Schriftart. Ein Punkt ist die traditionelle Maßeinheit für Schriftarten und entspricht 1/72 Zoll oder 0,35mm. 12 Punkt entsprechen damit 4,2 mm. Eine Schriftart mit dieser Größe können Sie auf diese Weise auf mehrere Arten angeben, da es der Konstruktor des Font-Objekts erlaubt, die gewünschte Einheit in Form eines Elements der Aufzählung GraphicsUnit mit anzugeben. Die folgenden zwei Anweisungen erzeugen damit die gleiche Schrift: Font font1 = new Font( "Arial", 12 ); Font font2 = new Font( "Arial", 4.2f, FontStyle.Regular, GraphicsUnit.Millimeter );
Schriftgröße und Bildschirmauflösung (dpi) Die Bildschirmauflösung in Windows wird in dpi angegeben (Dots per Inch, oder Pixel pro Zoll). Eine höhere dpi-Einstellung verbessert somit bei hochauflösenden Monitoren die Lesbarkeit. Standard sind 96 DPI. Im Zusammenhang mit Schriftarten ist die dpi-Einstellung deshalb wichtig, weil die Größe eben in Punkt angegeben wird. Wie groß die Schriftart letztendlich erscheint, bzw. wie viele Pixel benötigt werden, um die Schriftart in der angegebenen Größe darzustellen, hängt von eben dieser dpi-Einstellung ab. Windows berücksichtigt die dpi-Einstellung automatisch. Wenn Sie die Schriftgröße unabhängig von der dpi-Einstellung einstellen möchten, müssen Sie beim Instanzieren des Font-Objekts explizit GraphicsUnit.Pixel angeben: Font aFont = new Font( "Arial", 20, FontStyle.Regular, GraphicsUnit.Pixel );
Beschreibung der Schriftgröße Die Klassen Font und FontFamily enthalten eine Menge Eigenschaften und Methoden, die die Größe einer Schriftart beschreiben. Wie groß ein bestimmter Buchstabe einer Schrift genau ist, lässt sich ja nie mit Bestimmtheit sagen, denn es ist nicht bekannt, welches der Zeichen einer Schriftart als Maßstab genommen wird. Die Eigenschaften und Methoden der Klassen Font/FontFamily helfen hier nur bedingt. Die Beschreibung ist leider eher dürftig, und die Werte werden in den unterschiedlichsten Maßen zurückgeliefert. Die Eigenschaft Size eines Font-Objekts beispielsweise liefert laut Dokumentation die Geviertgröße einer Schriftart in Designeinheiten zurück, SizeInPoints die Fontgröße in Punkt.
Sandini Bib
738
21 Grafikprogrammierung (GDI+)
Wer sich noch nicht ein wenig detaillierter mit DTP oder Schriftsatz beschäftigt hat, wird vermutlich bereits bei dem Begriff Geviertgröße Schwierigkeiten bekommen, denn die Dokumentation liefert keinen direkten Hinweis darauf, worum es sich dabei handelt. Aus diesem Grund wurde dieser Abschnitt in das Buch aufgenommen.
Schriftdesign Ausgangspunkt beim Design einer neuen Schrift ist traditionellerweise eine so genannte EM-Box. Als es noch Bleisatz gab, musste jeder Buchstabe in eine solche Box passen. Mittlerweile gibt es diese Einschränkung zwar nicht mehr, die Höhe der EM-Box ist aber immer noch eine Art Basisgröße für Schriften. Etwas größer als die Höhe der EM-Box ist der so genannte Linespacing-Abstand: Dabei handelt es sich um den kleinstmöglichen Abstand zwischen zwei Textzeilen, ohne dass es zu Überschneidungen durch Über- oder Unterlängen kommt. Bei vielen Schriftarten ergibt sich der Linespacing-Abstand einfach aus der Summe der Ascent- und Descent-Werte. Der Ascent-Wert gibt die maximale Buchstabenhöhe oberhalb der so genannten Basislinie an. (Die Basislinie ist die Linie, an der die Buchstaben ausgerichtet werden.) Der Ascent-Wert berücksichtigt auch Überhöhen. Der Descent-Wert gibt die maximale Buchstabenhöhe unterhalb der Basislinie an (für Unterlängen). Abbildung 21.18 zeigt deutlich diesen Zusammenhang.
Abbildung 21.18: Ascent- und Descent-Höhe einer Schriftart
Wie hängen die vier Werte nun zusammen? Bei den meisten Schriftarten ergibt sich der Linespacing-Abstand einfach aus der Summe von Ascent und Descent. Bei manchen Schriftarten wird dazu noch ein kleiner Wert hinzugefügt. Der EM-Wert, den Sie beim Erzeugen einer Schriftart angeben, ist im Regelfall kleiner als die Summe aus Ascent und Descent. Der Grund dafür ist, dass es in der Regel keine Buchstaben gibt, die gleichzeitig Unter- und Überhöhe beanspruchen. Aus diesem Wert lässt sich daher kein Rückschluss auf die tatsächliche Buchstabengröße (bzw. -höhe) ziehen. Um zu berechnen, wie hoch Buchstaben wirklich werden können, ist ausschließlich die Summe aus Ascent- und Descent-Wert relevant. Der Linespacing-Wert ist bei manchen Schriftarten deutlich größer als Ascent + Descent, daher also ebenfalls kein gutes Maß. Alle vier erwähnten Werte können Sie mit den Methoden GetCellAscent(), GetCellDescent(), GetEmHeight() und GetLineSpacing() der FontFamily-Klasse ermitteln. Als Parameter müssen Sie dabei die gewünschten Schriftattribute (Elemente der FontStyle-Aufzählung) angeben,
Sandini Bib
Text ausgeben (Font-Klassen)
739
aber überraschenderweise keine Schriftgröße. Der Grund besteht darin, dass die vier Methoden ihre Ergebnisse in so genannten Design-Einheiten liefern (je nach Dokumentation auch FUnits oder Font units). Diese Einheit wurde beim Design der Schriftart verwendet.
VERWEIS
Das Design der Schrift ist größenunabhängig. Verschiedene Schriftgrößen ergeben sich einfach daraus, wie stark die Buchstaben vergrößert bzw. verkleinert werden. Bei den meisten Schriftarten ist die EM-Höhe 2048 oder eine andere Zweierpotenz, weil dann eine Verkleinerung bzw. Vergrößerung der Schriften besonders effizient durchgeführt werden kann. Eine gute Einführung in die Nomenklatur, die zur Beschreibung von Zeichensätzen (speziell von TrueType-Fonts) verwendet wird, gibt die folgende Seite: http://www.microsoft.com/typography/otspec/TTCH01.htm
Umrechnung der Schriftgröße in Pixel (Font-Klasse) Auch die Font-Klasse bietet eine ganze Reihe von Eigenschaften und Methoden, die in unterschiedlichen Formen die Schriftgröße ausdrücken. Anders als bei der FontFamily-Klasse gelten die Resultate nun für eine ganz bestimmte Schriftart, d.h. die Schriftgröße, die Attribute etc. werden automatisch berücksichtigt. f Die Eigenschaft SizeInPoints gibt die Schriftgröße in Punkten an. (Die Größe bezieht sich genau genommen auf die EM-Box der Schrift.) f Size liefert ebenfalls die Größe der EM-Box, diesmal allerdings in der Basiseinheit des Font-Objekts, die durch die Eigenschaft Unit ausgedrückt wird. Welche Basiseinheit das Font-Objekt hat hängt davon ab, welche Konstante der Aufzählung GraphicsUnit beim Erzeugen des Font-Objekts angegeben wurde. Wurde beispielsweise GraphicsUnit.Millimeter angegeben, dann enthält Unit eben diesen Wert, Size liefert die Größe der EM-Box in Millimetern. Damit wäre auch der Begriff Geviertgröße geklärt: Es handelt sich dabei um die Größe der EM-Box. Und wenn von Designeinheiten die Rede ist, ist die Einheit gemeint, die in der Eigenschaft Unit des Font-Objekts abgelegt ist. f Die Eigenschaft Height liefert die Schriftgröße offensichtlich in Pixeln. (Die Dokumentation behauptet, die Angabe erfolgt in current graphics units, aber es ist schleierhaft, wodurch diese current graphics units definiert sind.) Anders als bei den Size-Eigenschaften bezieht sich das Ergebnis von GetHeight() auf den Linespacing-Wert der Schrift. f Die Methode GetHeight() liefert wie Height den Linespacing-Wert der Schrift. Die Methode erwartet ein Graphics-Objekt als Parameter, dessen Maßeinheit verwendet wird. Wenn es sich um das Graphics-Objekt eines Formulars oder eines Steuerelements in der Standardeinstellung handelt, gelten ebenfalls Pixel als Einheit.
Sandini Bib
740
21 Grafikprogrammierung (GDI+)
An GetHeight() kann statt eines Graphics-Objekts auch ein dpi-Wert übergeben werden. In diesem Fall wird der Linespacing-Wert in Pixeln entsprechend der dpi-Auflösung ermittelt. Die Information, nach der wir eigentlich suchen, nämlich die maximale Buchstabengröße (Ascent + Descent) in Pixeln, liefert keine der Font-Eigenschaften und -Methoden. Wir können uns diese Information aber selbst ausrechnen. Dazu müssen wir die Ergebnisse von GetCellAscent() und GetCellDescent() in Pixel umrechnen. Der erforderliche Umrechnungsfaktor ergibt sich aus GetHeight() (Linespacing in der Graphics-Einheit) und GetLineSpacing() (Linespacing in Design-Einheiten).
CD
Die folgenden Codezeilen ermitteln die vier Maße ascent, decent, linespacing und emheight in der Einheit eines Graphics-Objekts. Das Graphics-Objekt wie auch die gewünschte Schriftart in Form eines Font-Objekts müssen der Methode übergeben werden. Die Ausgabe erfolgt der Einfachheit halber in eine Listbox namens lbxResult, die sich auf dem Formular befinden sollte. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\FontSizes.
private void CalcFontSizes( Graphics g, Font f ) { // Kalkulation der Werte float lineSpacing = f.GetHeight( g ); float factor = lineSpacing / f.FontFamily.GetLineSpacing( f.Style ); float ascent = f.FontFamily.GetCellAscent( f.Style ) * factor; float descent = f.FontFamily.GetCellDescent( f.Style ) * factor; float emHeight = f.FontFamily.GetEmHeight( f.Style ) * factor; float charHeight = ascent + descent; // Ausgabe lbxResult.Items.Clear(); lbxResult.Items.Add( lbxResult.Items.Add( lbxResult.Items.Add( lbxResult.Items.Add( lbxResult.Items.Add( lbxResult.Items.Add( lbxResult.Items.Add(
String.Format( "" ); String.Format( String.Format( String.Format( String.Format( String.Format(
"Schriftart: {0}, {1} Punkt", f.Name, f.Size ) ); "Linespacing:\t{0} Pixel", lineSpacing ) ); "EM-Höhe:\t\t{0} Pixel", emHeight ) ); "Ascent:\t\t{0} Pixel", ascent ) ); "Descent:\t\t{0} Pixel", descent ) ); "Zeichenhöhe:\t{0} Pixel", charHeight ) );
}
Das Ergebnis dieser Methode zeigt Abbildung 21.19.
Sandini Bib
Text ausgeben (Font-Klassen)
741
Abbildung 21.19: Die Pixelmaße der Schriftart Arial in der Größe 12 Punkt bei 96 dpi
Platzbedarf der Textausgabe im Voraus ermitteln (MeasureString()) Im letzten Beispiel haben wir die Gesamthöhe einer Schriftart ermittelt. Ein weiterer Wert, der für die Ausgabe wichtig ist, ist der horizontale Platzbedarf einer Zeichenkette. Dieser Wert kann mithilfe der Methode MeasureString() der Klasse Graphics ermittelt werden. Die Methode erwartet als Parameter einmal die Zeichenkette und die Schriftart, wieder in Form eines Font-Objekts. Das zurückgelieferte Ergebnis ist eine Instanz der SizeF-Struktur. Die Eigenschaften Width und Height geben den benötigten Platzbedarf an.
CD
Im folgenden Beispiel wird wiederum ein beliebiges Steuerelement zur Ausgabe einer Zeichenkette verwendet. Das Steuerelement muss selbstverständlich eine solche Ausgabe ermöglichen. Die Ausgabe soll unabhängig von der Größe des Steuerelements rechts unten erfolgen. Die Koordinaten für die Methode DrawString() ergeben sich aus der Breite bzw. Höhe des Steuerelements abzüglich des Platzbedarfs für die Zeichenkette. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\TextAlignRight.
private void WriteText( Control c, string msg, Font f ) { Graphics g = c.CreateGraphics(); g.Clear( Color.White ); SizeF aSize = g.MeasureString( msg, f ); g.DrawString( msg, f, Brushes.Black, c.Width - aSize.Width, c.Height - aSize.Height ); g.Dispose(); }
Sandini Bib
742
21 Grafikprogrammierung (GDI+)
Ein Screenshot zeigt wieder, wie eine solche Ausgabe aussehen kann. Das Programm auf der CD ermöglicht es, einen beliebigen Text einzugeben, der dann in der rechten unteren Ecke des Panels angezeigt wird.
Abbildung 21.20: Rechtsbündige Ausgabe in einem Panel
Das von MeasureString() gelieferte SizeF-Objekt ist etwas größer als der tatsächliche Platzbedarf. Width ist etwas breiter als erforderlich: Das soll sicherstellen, dass der Platz auch bei kursiven Buchstaben in jedem Fall ausreichend ist. In Einzelfällen kann es allerdings dennoch passieren, dass der Platz für kursive Buchstaben zu klein ist. Der Zusatzabstand kann auch als Wortabstand genutzt werden. Auch Height ist ein wenig zu groß und berücksichtigt einen Abstand, der mindestens zwischen zwei Textzeilen eingehalten werden sollte. Wenn Sie diese Zusatzabstände vermeiden möchten, müssen Sie an MeasureString() einen zusätzlichen Parameter für die Fomatinformation übergeben. Dabei handelt es sich um eine Instanz der Klasse StringFormat, nämlich StringFormat.GenericTypographic. Damit erreichen Sie, dass Width dem tatsächlichen Platzbedarf entspricht (ohne Reserve für Kursivierung) und dass Height exakt der maximalen Buchstabenhöhe (Ascent+Descent) entspricht. SizeF aSize = g.MeasureString( msg, aFont, 0, StringFormat.GenericTypographic );
Selbstverständlich sollte StringFormat.GenericTypographic dann auch als zusätzliche Formatinformation bei DrawString() angegeben werden, sonst passen der tatsächliche und der von MeasureString() ermittelte Platzbedarf nicht zusammen. In Abbildung 21.21 ist zweimal der Buchstabe »f« in der Schriftart Georgia mit einer Größe von 100 Punkt zu sehen. Die weiße Fläche symbolisiert jeweils den durch MeasureString() berechneten Platzbedarf, der kleine graue Kreis die Koordinatenposition für die Ausgabe des Buchstabens durch DrawString(). Links erfolgt die Ausgabe und der MeasureString()Aufruf normal, rechts unter Angabe der Formatoption GenericTypographic.
Sandini Bib
Text ausgeben (Font-Klassen)
743
VERWEIS
Abbildung 21.21: MeasureString-Resultat mit und ohne die Formatoption GenericTypographic
So wie DrawString() Text auch auf mehrere Zeilen verteilen kann, so kann MeasureString() auch den Platzbedarf für mehrzeilige Ausgaben berechnen. Details zur mehrzeiligen Textausgabe sowie zur Angabe von Formaten (wie GenericTypographic) folgen in Abschnitt 21.3.5 ab Seite 748.
Platzbedarf eines Leerzeichens ermitteln Die Ermittlung des Platzbedarfs eines Leerzeichens ist nicht so trivial, wie es im ersten Moment scheint. Die Übergabe einer Zeichenkette an MeasureString(), die lediglich ein Leerzeichen enthält, führt zu einem falschen Ergebnis. Sowohl MeasureString() als auch DrawString() ignorieren nämlich Leerzeichen am Ende einer Zeichenkette, und wenn diese nur aus einem Leerzeichen besteht, wird eben dieses ignoriert. Dennoch wird ein Wert größer als 0 herauskommen, da MeasureString() ja standardmäßig einen Wert hinzufügt (für kursive Zeichen). Üblicherweise wird jeder annehmen, das sei der richtige Wert. Den korrekten Wert können Sie auf zwei Arten ermitteln. Einmal können Sie zwei Berechnungen anstellen, einmal mit der Zeichenkette »xx« und einmal mit »x x« (mit Leerzeichen zwischen den »x«), und die Werte voneinander abziehen. Der andere Weg ist der sinnvollere und macht wieder Gebrauch von der Klasse StringFormat. Von dieser wird eine Instanz der Form StringFormat.GenericTypographic erzeugt, deren Eigenschaft FormatFlags auf StringFormatFlags.MeasureTrailingSpaces eingestellt wird. Font aFont = new Font( "Arial",12 ); StringFormat sf = StringFormat.GenericTypoGraphic; sf.FormatFlags = StringFormatFlags.MeasureTrailingSpaces; SizeF aSize = gr.MeasureString( " ", aFont, 0, sf );
Sandini Bib
744
21 Grafikprogrammierung (GDI+)
Visualisierung von Descent, Ascent und MeasureString
CD
Um die oben beschriebenen Schriftgrößen zu erforschen (die Online-Dokumentation war nicht immer besonders aussagekräftig), wurde ein kleines Programm entwickelt, das die verschiedenen Größen berechnet und visualisiert. Auch Abbildung 21.21 wurde mit diesem Programm erzeugt. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\FontInformation. Der Quelltext ist in diesem Fall aus Platzgründen nicht abgedruckt, auf der CD aber gut kommentiert vorhanden.
In den Eingabeelementen können Sie den anzuzeigenden Text eingeben, eine Schriftart auswählen, die Schriftattribute fett und/oder kursiv aktivieren und die Formatoption StringFormat.GenericTypographic (für DrawString() und MeasureString()) festlegen, falls gewünscht. Der Testtext wird bei jeder Änderung in einer Größe von 60 Punkt angezeigt. Der Koordinatenpunkt (x,y) für die Textausgabe wird durch einen kleinen Kreis markiert. Der durch MeasureString() berechnete Platzbedarf wird durch ein hellgelbes Rechteck dargestellt. Darüber hinaus werden der Ascent-Wert, die Basislinie, der Descent-Wert und der Linespacing-Wert durch vier horizontale Linien markiert. (Bei vielen Schriftarten ergibt die Summe von Ascent und Descent exakt den Linespacing-Wert – dann sind u.U. nur drei Linien zu sehen.) Schließlich werden in einem Textfeld alle Zahlenwerte samt Einheit angezeigt. Abbildung 21.22 zeigt das Programm zur Laufzeit.
Abbildung 21.22: Berechnung und Visualisierung verschiedener Parameter der Schriftgröße
Sandini Bib
Text ausgeben (Font-Klassen)
745
Beispiel – Textausgabe relativ zu einer Grundlinie Vielleicht haben Sie sich schon gefragt, warum so ausführlich auf die Berechnung verschiedenster Schriftgrößenparameter eingegangen wird. Die Notwendigkeit dafür erkennen Sie, wenn Sie versuchen, mit DrawString() einige Wörter nebeneinander zu stellen, die in unterschiedlichen Schriften oder Schriftgrößen dargestellt werden sollen. Solange Sie zur Positionierung nur das Ergebnis von MeasureString() verwenden, ist es unmöglich, die Wörter entlang einer Grundlinie auszurichten. In Abbildung 21.23 sehen Sie, wie ein kurzer Satz auf drei verschiedene Arten dargestellt wird. (Für jedes Wort des Satzes wird eine andere Schriftart oder -größe verwendet.) f Ganz oben werden die Wörter entlang einer Oberkante ausgerichtet. (Dazu wird bei DrawString() einfach immer dieselbe y-Koordinate angegeben.) f In der Mitte werden die Wörter entlang der Unterkante der MeasureString()-Rechtecke ausgerichtet. Das sieht zwar schon etwas besser aus, aber die Y-Position der einzelnen Wörter variiert immer noch spürbar. f Unten wird zur Ausrichtung der Ascent-Wert jedes Wortes berücksichtigt. Die Wörter stehen nun wirklich auf einer Linie.
CD
Die Grundidee des Programmcodes ist leicht zu verstehen: msg enthält ein Feld mit jedem einzelnen Wort des Satzes. In drei foreach-Schleifen werden die Wörter jeweils einzeln unter Anwendung unterschiedlicher Font-Objekte ausgegeben. Um einen der Schriftgröße entsprechenden Abstand zwischen den Wörtern zu erzielen, wird x jeweils um die exakte Wortlänge (MeasureString() mit StringFormat.GenericTypographic) plus ein Drittel der Buchstabenhöhe in Pixel vergrößert. Zur Ausgabe dient wie immer ein Panel mit Namen pnlDraw und ebenfalls wie immer ist dessen Hintergrund weiß eingefärbt. Die Orientierung wird erleichtert durch das Zeichnen der jeweiligen Linie, an der sich die Ausgabe orientiert. Gestartet wird die Ausgabe im Click-Ereignis eines Buttons. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Font-BaseLine.
Sandini Bib
746
21 Grafikprogrammierung (GDI+)
Abbildung 21.23: Beispielprogramm zur Ausrichtung von Text private void BtnWrite_Click( object sender, System.EventArgs e ) { // Deklarationen Graphics g = pnlDraw.CreateGraphics(); string[] fontNames = { "Times New Roman", "Comic Sans MS", "Arial" }; string baseMsg = "Der Umgang mit verschieden großen Schriftarten ist schwierig"; string[] msg = baseMsg.Split( ' ' ); int i = 0; float x = 0f; float y = 0f; // Variante 1: Ausrichten an Oberkante i = 0; x = 0f; y = 10f; // y-Koordinate der Oberkante g.DrawLine( Pens.Gray, 0, y, pnlDraw.Width, y ); foreach ( string s in msg ) { Font f = new Font( fontNames[i % 3], 22 - 2 * i ); g.DrawString( s, f, Brushes.Black, x, y ); x += g.MeasureString( s, f, 0, StringFormat.GenericTypographic ).Width; x += f.SizeInPoints / 72 * g.DpiX / 3; i++; f.Dispose(); }
Sandini Bib
Text ausgeben (Font-Klassen)
747
// Variante 2: Ausrichten an der Unterkante i = 0; x = 0f; y = 150f; // y-Koordinate der Unterkante g.DrawLine( Pens.Gray, 0, y, pnlDraw.Width, y ); foreach ( string s in msg ) { Font f = new Font( fontNames[i % 3], 22 - 2 * i ); SizeF aSize = g.MeasureString( s, f, 0, StringFormat.GenericTypographic ); g.DrawString( msg[i], f, Brushes.Black, x, y - aSize.Height, StringFormat.GenericTypographic ); x += g.MeasureString( s, f, 0, StringFormat.GenericTypographic ).Width; x += f.SizeInPoints / 72 * g.DpiX / 3; i++; f.Dispose(); } // Variante 3: Korrektes Ausrichten an der BaseLine über DrawBaseLine() i = 0; x = 0f; y = 250f; // y-Koordinate der Grundlinie g.DrawLine( Pens.Gray, 0, y, pnlDraw.Width, y ); foreach ( string s in msg ) { Font f = new Font( fontNames[i % 3], 22 - 2 * i ); DrawBaseString( s, g, f, Brushes.Black, x, y ); x += g.MeasureString( s, f, 0, StringFormat.GenericTypographic ).Width; x += f.SizeInPoints / 72 * g.DpiX / 3; i++; f.Dispose(); } }
Bei der dritten Variante wird zur Textausgabe die selbst definierte Prozedur DrawBaseString() verwendet. Im Unterschied zu DrawString() bezeichnet die y-Koordinate hier den Ort der Grundlinie. Innerhalb der Prozedur wird die tatsächliche y-Koordinate berechnet. Dazu wird von der y-Basiskoordinate der Ascent-Wert in Pixeln abgezogen. private void DrawBaseString( string msg, Graphics g, Font f, Brush b, float x, float y ) { // Die übergebene y-Koordinate bestimmt die Grundlinie SizeF aSize = g.MeasureString( msg, f ); y = y - f.FontFamily.GetCellAscent( f.Style ) * ( f.GetHeight( g ) / f.FontFamily.GetLineSpacing( f.Style ) ); g.DrawString( msg, f, b, x, y ); }
Sandini Bib
748
21.3.5
21 Grafikprogrammierung (GDI+)
Schriftauszeichnung und Textformatierung
Schriftstile / -auszeichnungen Um Text mit Auszeichnungen zu versehen (kursiv, fett etc.) übergeben Sie dem Konstruktor des Font-Objekts einen Parameter des Typs FontStyle. Dabei handelt es sich um ein Bitfeld, d.h. mehrere Einstellungen können mithilfe des Operators | kombiniert werden. Die folgende Codezeile erzeugt ein Font-Objekt mit den Auszeichnungen fett und kursiv: Font f = new Font( "Arial", 12, FontStyle.Italic | FontStyle.Bold );
Leider lassen sich diese Eigenschaften nachträglich nicht mehr ändern, es muss immer wieder ein neues Font-Objekt erzeugt werden. Um die Auszeichnungen zu ändern müssen Sie daher ein neues Font-Objekt basierend auf dem bestehenden zuweisen. Dazu übergeben Sie dem Konstruktor den bestehenden Font und weisen die neuen Stile zu. Damit können Sie einen Stil auch ein- und ausschalten. Statt der |-Verknüpfung verwenden Sie einfach den ^-Operator für die exklusiv-oder-Verknüpfung mit dem bestehenden Stil. Die folgende Codezeile schaltet Fett für den Font einer Textbox ein oder aus: textBox1.Font = new Font( textBox1.Font, textBox1.Font.FontStyle ^ FontStyle.Bold );
Analog funktioniert das auch für die übrigen Stile.
Layouteffekte mit der StringFormat-Klasse Bei den Methoden DrawString() und MeasureString() kann als optionaler Parameter ein StringFormat-Objekt angegeben werden. Dieses Objekt steuert diverse Spezialeffekte, die das Aussehen (Layout) des Textes beeinflussen. Es gibt verschiedene Möglichkeiten, ein StringFormat-Objekt zu erzeugen: StringFormat sf1 = StringFormat.GenericDefault; StringFormat sf2 = StringFormat.GenericTypographic; StringFormat sf3 = new StringFormat( formatFlags );
// Standardlayout // Für typographische Anwendungen // Standardlayout plus Optionen
Bei der dritten Variante muss formatFlags ein Element der Aufzählung StringFormatFlags sein. Dabei handelt es sich um ein Bitfeld, d.h. die verschiedenen Elemente lassen sich durch | miteinander verknüpfen und gemeinsam zuweisen. Die folgende Aufzählung beschreibt ganz kurz einige der Elemente von StringFormatFlags. (Eine vollständige und ausführlichere Beschreibung finden Sie in der Online-Hilfe.) f DirectionVertical: Der Text wird vertikal von oben nach unten ausgegeben. (Eine Ausgabe vertikal von unten nach oben oder andere Textdrehungen können nicht durch ein StringFormat-Objekt erzielt werden. Tipps, wie Text in beliebigen Winkeln verdreht werden kann, folgen aber etwas weiter unten.)
Sandini Bib
Text ausgeben (Font-Klassen)
749
f LineLimit: Textzeilen werden entweder vertikal vollständig oder gar nicht ausgegeben. Die Standardeinstellung für DrawString() ist, dass auch dann ausgegeben wird, wenn der Platz nicht ausreicht. In diesem Fall werden die auszugebenden Buchstaben unten abgeschnitten. Mit der Einstellung StringFormatFlags.LineLimit vermeiden Sie also abgeschnittene Buchstaben. f MeasureTrailingSpaces: Dieses Element bewirkt, dass MeasureString() Leerzeichen am Ende einer Zeichenkette berücksichtigt. Standardmäßig ist das nicht der Fall, Leerzeichen am Anfang einer Zeichenkette werden jedoch berücksichtigt.
ANMERKUNG
f NoWrap: Dieses Element bewirkt, dass zu lange Zeilen nicht automatisch umbrochen werden, wie dies per Default geschieht (siehe die Überschrift Mehrzeilige Textausgaben). Wenn man sich ein StringFormat.GenericTypographics-Objekt genauer ansieht, so scheint es, als würde es sich dabei um ein einfaches StringFormat-Objekt mit den Formatflags FitBlackBox, LineLimit und NoClip handeln. Wie aus Abbildung 21.21 einige Seiten weiter oben hervorgeht, bewirken diese Formatierungsoptionen, dass DrawString() den Text so knapp wie möglich positioniert und dass MeasureString() die kleinstmöglichen Größenangaben liefert (ohne Platzreserven für Überstände durch Kursivierung). Bei mehrzeiligem Text wird der Abstand zwischen den Zeilen nicht reduziert, MeasureString() berücksichtigt unterhalb der letzten Zeile aber keinen zusätzlichen Zeilenabstand mehr. Merkwürdigerweise ist jeder Versuch gescheitert, mit new StringFormat(flags) ein Objekt zu erzeugen, das denselben Effekt wie ein StringFormat.GenericTypographicsObjekt hat. Bei diesen Experimenten ergab sich überhaupt der Eindruck, als würden manche StringFormatFlags-Elemente, die bei einem StringFormat.GenericTypographicObjekt offensichtlich funktionieren, bei einem normalen StringFormat-Objekt einfach ignoriert werden. Dieser Umstand und die an dieser Stelle nicht ausreichende Dokumentation in der Online-Hilfe haben es leider unmöglich gemacht, StringFormat und insbesondere StringFormatFlags-Elemente detaillierter zu beschreiben.
Die meisten durch StringFormat erzielbaren Layouteffekte erreichen Sie allerdings nicht durch StringFormatFlags-Elemente, sondern indem Sie einzelne Eigenschaften des StringFormat-Objekts verändern. Die folgenden Absätze fassen die wichtigsten Möglichkeiten zusammen: f Rechtsbündiger und zentrierter Text: Die Eigenschaft Alignment bestimmt die horizontale Ausrichtung von Text. Normalerweise wird Text linksbündig ausgerichtet (linksbündig ab der durch DrawString() angegebenen x-Koordinate). sf.Alignment=StringAlignment.Far bewirkt im deutschen Sprachraum eine rechtsbündige Formatierung. Die x-Koordinate gibt nun den rechten Begrenzungspunkt für die Ausgabe an. (Bei einigen asiatischen Sprachen, bei denen die Schrift üblicherweise von rechts nach links ausgegeben wird, ist die Wirkung von Far gerade umgekehrt.) StringAlignment.Center bewirkt eine Zentrierung des Texts.
Sandini Bib
750
21 Grafikprogrammierung (GDI+)
f Tastenkürzel anzeigen: Steuerelemente und Menüeinträge können üblicherweise auch durch [Alt]-Tastenkürzel aktiviert bzw. ausgewählt werden. Das Tastenkürzel wird im Beschriftungstext durch ein vorangestelltes &-Zeichen angegeben. Im Steuerelement wird dieses Zeichen aber nicht ausgegeben, vielmehr wird der nachfolgende Buchstabe unterstrichen. Genau diesen Effekt erzielen Sie durch die Einstellung sf.HotkeyPrefix = HotkeyPrefix.Show, wobei sf ein Stringformat-Objekt ist. f Text vertikal ausrichten: Die Eigenschaft LineAlignment funktioniert wie die Eigenschaft Alignment, steuert aber die vertikale Ausrichtung. Durch die Einstellung StringAlignment.Far erreichen Sie, dass der Text an der durch die y-Koordinate vorgegebenen Unterkante ausgerichtet wird. StringAlignment.Center bewirkt eine vertikale Zentrierung der Ausgabe. f Text kürzen: Oft stehen Sie vor dem Problem, dass der Platz für die Textausgabe beschränkt ist. Die Lösung besteht dann meist darin, den auszugebenden Text zu kürzen. Diese Arbeit kann auch DrawString() übernehmen, wenn Sie beim StringFormat-Objekt die Eigenschaft Trimming richtig einstellen. Trimming wird oft dann eingesetzt, wenn die Textausgabe in nur einer Zeile erfolgen soll. Daher muss das StringFormat-Objekt mit der Einstellung StringFormatFlags.NoWrap erzeugt werden. Besonders praktisch ist die Kürzung von Datei- und Verzeichnisnamen (sf.Trimming = StringTrimming.EllipsisPath): Dabei wird der Dateiname in der Mitte gekürzt, so dass Anfang und Ende lesbar bleiben. Andere Elemente der StringTrimming-Aufzählung ermöglichen andere Formen der Textkürzung: Beispielsweise entfernt EllipsisWord das Ende eines Satzes an einer Wortgrenze und fügt stattdessen drei Punkte ein. f Tabulatoren: Dank StringFormat können Sie bei der Textausgabe sogar einfache Tabulatoren verwenden. Dazu müssen Sie zuerst mit der Methode SetTabStops(n, m()) die Position der Tabulatoren bestimmen. n gibt den Abstand vor dem ersten Tabulator an und ist meistens 0. m ist ein float-Array und gibt die Abstände zwischen den Tabulatoren an. Als Maßeinheit wird diejenige des Grafikobjekts verwendet, auf das DrawString() angewendet wird (und nicht etwa die Zeichengröße, wie die Hilfe fälschlich behauptet). Bei einer Ausgabe am Bildschirm sind das im Regelfall Pixel. Die folgenden Zeilen definieren zwei Tabulatoren an den Positionen 130 und 260. float tabs[] = { 130, 130 }; StringFormat sf = new StringFormat( StringFormatFlags.NoWrap ); sf.SetTabStops( 0, tabs );
Bei der Textausgabe müssen Sie nun in den Text Tabulatorzeichen einfügen (\t). StringFormat kennt leider nur linksbündige Tabulatoren.
StringFormat-Anwendungen Das folgende Beispielprogramm demonstriert einige Effekte, die Sie mit verschiedenen StringFormat-Eigenschaften erzielen können. Wenn Sie das Programm ausführen, sollten Sie die Fenstergröße variieren – einige Texte ändern dann dynamisch ihre Position. Wie immer wird auch hier wieder zur Ausgabe ein Panel verwendet. Damit die Elemente ihre Position automatisch ändern, wird das Paint-Ereignis des Panels zum Zeichnen verwendet.
Sandini Bib
Text ausgeben (Font-Klassen)
751
CD
Abbildung 21.24: Demonstration verschiedener StringFormat-Effekte
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Font-StringFormat.
private void PnlDraw_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { Graphics g = e.Graphics; Font f = new Font( "Arial", 25, FontStyle.Regular, GraphicsUnit.Pixel ); g.Clear( Color.White ); // Vertikaler Text StringFormat sf = new StringFormat( StringFormatFlags.DirectionVertical ); g.DrawString( "Vertikaler Text", f, Brushes.Black, 0, 0, sf ); sf.Dispose(); // Rechtsbündiger Text sf = new StringFormat(); sf.Alignment = StringAlignment.Far; g.DrawString( "Rechtsbündiger Text", f, Brushes.Black, pnlDraw.Width, 0, sf ); sf.Dispose(); // Text an Unterkante ausgerichtet sf = new StringFormat(); sf.LineAlignment = StringAlignment.Far; g.DrawString( "Unterkante", f, Brushes.Black, 50, pnlDraw.Height, sf ); sf.Dispose();
Sandini Bib
752
21 Grafikprogrammierung (GDI+)
// Gekürzter Dateiname sf = new StringFormat( StringFormatFlags.NoWrap ); sf.Trimming = StringTrimming.EllipsisPath; RectangleF rectf = new RectangleF( 50, 70, pnlDraw.ClientSize.Width - 60, 0 ); string aName = @"C:\Eigene Dateien\Unterverzeichnis\Dateiname.txt"; g.DrawString( aName, f, Brushes.Black, rectf, sf ); sf.Dispose(); // Mehrspaltiger Text float[] tabs = { 130, 130 }; sf = new StringFormat( StringFormatFlags.NoWrap ); sf.SetTabStops( 0, tabs ); string aText = "SP1 \t SP2 \t SP3 \r\na\tb\tc\r\n1\t2\t3"; g.DrawString( aText, f, Brushes.Black, 50, 160, sf ); sf.Dispose(); f.Dispose(); }
Damit bei einer Größenänderung auch wirklich neu gezeichnet wird, zwingen wir das Panel dazu, sobald die Größe geändert wird. Das geschieht durch eine einfache Anweisung im Ereignis Resize: private void PnlDraw_Resize( object sender, System.EventArgs e ) { pnlDraw.Invalidate(); Application.DoEvents(); }
Mehrzeiliger Text Wie Abbildung 21.24 oben bereits beweist, kommt DrawString() auch mit mehrzeiligem Text zurecht. Wenn Sie den Zeilenumbruch selbst vorgeben möchten, müssen Sie die Position des Zeilenumbruchs explizit festlegen. Das geschieht entweder wie im obigen Beispiel durch Einfügen der Escape-Sequenzen \r\n oder indem Environment.NewLine an der betreffenden Stelle hinzugefügt wird. g.DrawString( "Abc" + Environment.NewLine + "efg", f, Brushes.Black, 10, 10 ); DrawString() kann sich allerdings auch selbst um einen Zeilenumbruch kümmern (siehe Abbildung 21.25). Dazu übergeben Sie anstatt einer Koordinatenposition ein RectangleF-
Objekt, das das Rechteck angibt, in dem die Ausgabe erfolgen soll. Wenn Sie vermeiden möchten, dass bei Platzproblemen die letzte sichtbare Zeile abgeschnitten wird (siehe Abbildung 21.25 links), müssen Sie zur Textausgabe ein StringFormatObjekt mit dem Parameter StringFormatFlags.LineLimit erzeugen. Im Beispielprogramm wird derselbe Text zweimal angezeigt, einmal linksbündig und einmal zentriert mit der LineLimit-Option. Wie gehabt erfolgt die Ausgabe in ein Panel, gestartet wird die Ausgabe im Click-Ereignis eines Buttons.
Sandini Bib
CD
Text ausgeben (Font-Klassen)
753
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Font-Wrapping.
private void BtnWrite_Click( object sender, System.EventArgs e ) { Graphics g = pnlDraw.CreateGraphics(); g.Clear( Color.White ); Font aFont = new Font( "Arial", 18, FontStyle.Regular, GraphicsUnit.Pixel ); string aString = "DrawString() kümmert sich bei Bedarf selbstständig um " + "einen eventuell notwendigen Zeilenwechsel."; // Linksbündiges Rechteck RectangleF rectF = new RectangleF( 10, 10, pnlDraw.Width / 2 - 20, pnlDraw.Height / 2 - 20 ); g.DrawString( aString, aFont, Brushes.Black, rectF ); g.DrawRectangle( Pens.Blue, Rectangle.Ceiling( rectF ) ); // Zentriertes Rechteck, mit LineLimit StringFormat sf = new StringFormat( StringFormatFlags.LineLimit ); sf.Alignment = StringAlignment.Center; rectF.Offset( pnlDraw.Width / 2 - 5, 0 ); g.DrawString( aString, aFont, Brushes.Black, rectF, sf ); g.DrawRectangle( Pens.Blue, Rectangle.Ceiling( rectF ) ); aFont.Dispose(); sf.Dispose(); }
Das Ergebnis zeigt Abbildung 21.25.
Abbildung 21.25: Automatischer Zeilenumbruch mit DrawString()
Sandini Bib
754
21 Grafikprogrammierung (GDI+)
Der automatische Zeilenumbruch unterliegt gewissen Einschränkungen: Es ist nicht möglich, mehrere unterschiedliche Schriftarten zu mischen. Ebenso wird kein Blocksatz unterstützt, es gibt keine Silbentrennung etc. Wenn Sie also vorhaben, Ihr eigenes Textverarbeitungsprogramm zu schreiben, müssen Sie sich notgedrungen selbst um den Zeilenumbruch kümmern. MeasureString() kann auch dazu verwendet werden, den Platzbedarf für mehrzeiligen Text zu ermitteln. Die folgende Anweisung ermittelt den Platzbedarf für die erste Textausgabe im obigen Beispielprogramm. aSize.Width enthält die tatsächlich beanspruchte Breite, ist also etwas schmäler als das zugrunde liegende Rechteck. aSize.Height ist in diesem Fall gleich groß wie die Höhe des Rechtecks. Wenn der Text in aString kürzer wäre und das vorgegebene Rechteck nicht vollständig füllen würde, würde aSize.Height ebenfalls nur den tatsächlich beanspruchten Raum angeben. SizeF aSize = gr.MeasureString( aString, aFont, rectF.Size );
Rotierter Text GDI+ sieht keine eigene Methode vor, um Zeichenketten unter Angabe eines bestimmten Winkels auszugeben. Selbstverständlich ist es aber dennoch möglich, rotierten Text in allen erdenklichen Winkeln darzustellen. Sie müssen dazu lediglich das Koordinatensystem des Graphics-Objekts transformieren (siehe auch Abschnitt 21.2.5). Natürlich ist das ein wenig unbequem, denn eine Transformation des Koordinatensystems betrifft ja alle Grafikausgaben. Sie müssen daher nach Ausgabe der Zeichenkette die Transformation wieder rückgängig machen. Die folgenden Zeilen demonstrieren die prinzipielle Vorgehensweise. Der Methode RotateText() werden ein Graphics-Objekt, ein Font-Objekt, die Koordinaten x und y sowie ein Winkel übergeben. Der Winkel wird in Grad angegeben. Zunächst wird die aktuelle Transformationsmatrix in oldMatrix gespeichert. Da wir nicht wissen können, ob eine Transformation vorhanden war, wird jede vorhandene Transformation gelöscht. Dann wird der Mittelpunkt der Ausgabe mittels TranslateTransform() an den durch x und y angegebenen Punkt verschoben. RotateTransform() schließlich dreht das Koordinatensystem im Uhrzeigersinn. private void RotateText( Graphics g, Font f, int x, int y, float angle ) { Matrix oldMatrix = g.Transform; // System.Drawing.Drawing2D einbinden (Matrix-Klasse) g.ResetTransform(); g.TranslateTransform( x, y ); g.RotateTransform( angle ); g.DrawString( s, fnt, Brushes.Black, 0, 0 ); g.Transform = oldMatrix; }
Sandini Bib
HINWEIS
Text ausgeben (Font-Klassen)
755
Anders als in der Mathematik, wo der Winkel 0 üblicherweise bei »12 Uhr« angesiedelt ist, befindet er sich hier auf der X-Achse des Koordinatensystems (bei »3 Uhr«. Das ist durchaus logisch, da wir bei einem Winkel von 0 eine gerade, lesbare Textausgabe erwarten.
Textausgabe unter verschiedenen Winkeln
CD
Das folgende Beispielprogramm gibt eine kurze Zeichenkette unter verschiedenen Winkeln aus (siehe Abbildung 21.26). Der Startpunkt für die Ausgabe jeden Texts sowie das Ausgaberechteck werden durch einen kleinen Kreis bzw. durch ein Rechteck hervorgehoben. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Font-Rotation.
Abbildung 21.26: Rotierter Text private void PnlDraw_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { // Deklarationen Graphics g = e.Graphics; string aText = "Rotierter Text"; Font f = new Font( "Arial", 18, FontStyle.Regular, GraphicsUnit.Pixel );
Sandini Bib
756
21 Grafikprogrammierung (GDI+)
StringFormat sf = StringFormat.GenericTypographic; sf.LineAlignment = StringAlignment.Center; // Mittelpunkt-Koordinaten festlegen float x0 = (float)( pnlDraw.Width / 2 ); float y0 = (float)( pnlDraw.Height / 2 ); // Radius Innenkreis float radius = 70; for ( int i = 0; i < 360; i += 30 ) { float angle = (float)i; float x = x0 + radius * (float)( Math.Cos( angle / 180 * Math.PI ) ); float y = y0 + radius * (float)( Math.Sin( angle / 180 * Math.PI ) ); // Transformation g.ResetTransform(); g.TranslateTransform( x, y ); g.RotateTransform( angle ); // Textausgabe g.DrawString( aText, f, Brushes.Black, 0, 0, sf ); // Startpunkt und Hilfsrechteck g.FillEllipse( Brushes.Gray, -5, -5, 10, 10 ); SizeF aSize = g.MeasureString( aText, f, 0, sf ); g.DrawRectangle( Pens.Gray, 0, -aSize.Height / 2, aSize.Width, aSize.Height ); } f.Dispose(); sf.Dispose(); }
Über das Ereignis Resize des Panels wird das Neuzeichnen bei einer Größenänderung erzwungen. private void PnlDraw_Resize( object sender, System.EventArgs e ) { this.pnlDraw.Invalidate(); }
Rotierter Text mit Rotationspunkt Im obigen Beispiel wurden die erforderlichen Koordinatentransformationen direkt im Code ausgeführt. Wenn Sie sich damit nicht plagen möchten, hilft Ihnen vielleicht die Prozedur DrawRotatedString(), die im folgenden Beispielprogramm vorgestellt wird. An diese Prozedur übergeben Sie ähnlich wie an die Methode DrawString() die Zeichenkette, ein
Sandini Bib
Text ausgeben (Font-Klassen)
757
Graphics-Objekt, ein Font-Objekt, ein Brush-Objekt sowie x- und y-Koordinaten. Außerdem geben Sie den gewünschten Drehwinkel an.
CD
Besonders praktisch ist der letzte Parameter, der die Position des Rotationspunkts relativ zur Zeichenkette angibt. Dabei handelt es sich um eine selbst definierte Aufzählung namens CenterPoint. Die zur Auswahl stehenden Einstellungen können Sie dem Code entnehmen. Der Rotationspunkt wird in diesem Beispiel wieder durch einen grauen Punkt angezeigt. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Font-DrawRotatedString.
Zunächst die Aufzählung für die unterschiedlichen Positionen des Rotationspunkts: public enum CenterPoint { LowerLeft, LowerMiddle, LowerRight, CenterLeft, CenterMiddle, CenterRight, UpperLeft, UpperMiddle, UpperRight }
Jetzt der Code für das Zeichnen. Beachten Sie die zwei switch-Blöcke. Diese sind notwendig, weil für die Eigenschaften Alignment und LineAlignment von StringFormat unterschiedliche Einstellungen abghängig vom Rotationspunkt notwendig sind. Man hätte das Ganze auch in einen einzigen switch-Block packen können, der dann aber wesentlich länger geworden wäre. Wieder wird die bestehende Transformationsmatrix zwischengespeichert und nach dem Zeichenvorgang wieder zugewiesen. Anschließend wird ein StringFormat-Objekt initialisiert, entsprechend des Parameters cp vom Typ CenterPoint. Die übrigen Anweisungen zur Ausgabe sind bereits aus dem vorhergehenden Beispiel bekannt. private void DrawRotatedString( string aText, Graphics g, Font f, Brush b, float x, float y, float angle, CenterPoint cp ) { StringFormat sf = StringFormat.GenericTypographic; sf.Alignment = StringAlignment.Near; sf.LineAlignment = StringAlignment.Near; Matrix oldMatrix = g.Transform;
Sandini Bib
758 // LineAlignment switch ( cp ) { case CenterPoint.LowerLeft: case CenterPoint.LowerMiddle: case CenterPoint.LowerRight: sf.LineAlignment = StringAlignment.Far; break; case CenterPoint.CenterLeft: case CenterPoint.CenterMiddle: case CenterPoint.CenterRight: sf.LineAlignment = StringAlignment.Center; break; } // Alignment switch ( cp ) { case CenterPoint.CenterRight: case CenterPoint.LowerRight: case CenterPoint.UpperRight: sf.Alignment = StringAlignment.Far; break; case CenterPoint.CenterMiddle: case CenterPoint.LowerMiddle: case CenterPoint.UpperMiddle: sf.Alignment = StringAlignment.Center; break; } // Transformation g.ResetTransform(); g.TranslateTransform( x, y ); g.RotateTransform( angle ); // Testausgabe g.DrawString( aText, f, b, 0, 0, sf ); // Rotationspunkt g.FillEllipse( Brushes.Gray, -5, -5, 10, 10 ); // Zurücksetzen g.Transform = oldMatrix; sf.Dispose(); }
21 Grafikprogrammierung (GDI+)
Sandini Bib
Text ausgeben (Font-Klassen)
759
Aufgerufen wird die Zeichenmethode über den Button btnDraw. Darin werden die Einstellungen des Programms erfasst und DrawRotatedString() aufgerufen. private void BtnDraw_Click( object sender, System.EventArgs e ) { Graphics g = this.pnlDraw.CreateGraphics(); // Löschen if ( cbxDelete.Checked ) g.Clear( Color.White ); // Werte ermitteln // Mittelpunkt CenterPoint cp = (CenterPoint)Enum.Parse( typeof( CenterPoint ), cbxCenterPoint.Text, true ); // Koordinaten float x = (float)( Int32.Parse( tbxX.Text ) ); float y = (float)( Int32.Parse( tbxY.Text ) ); // Winkel float angle = (float)( Int32.Parse( tbxAngle.Text ) ); // Fontgröße float fSize = (float)( Int32.Parse( tbxFontSize.Text ) ); // Font ermitteln Font f = new Font( cbxFont.Text, fSize ); // Farbe ermitteln KnownColor kc = (KnownColor)( Enum.Parse( typeof( KnownColor ), cbxColor.Text, true ) ); Brush b = new SolidBrush( Color.FromKnownColor( kc ) ); DrawRotatedString( tbxText.Text, g, f, b, x, y, angle, cp ); }
Das Ergebnis der Bemühungen zeigt Abbildung 21.27. Mit dem Programm auf CD können Sie die verschiedenen Einstellungen leicht testen. Dass die Ausgabe im Beispiel exakt in der Mitte des Panels erfolgt, wurde allerdings über eine kleine Berechnung im Quellcode erzwungen (die im Code auf der CD auch enthalten, aber auskommentiert ist).
Sandini Bib
760
21 Grafikprogrammierung (GDI+)
Abbildung 21.27: Frei rotierbarer Text mit unterschiedlichen Einstellungen
21.3.6
Font-Auswahldialog
Zur interaktiven Auswahl einer Schriftart können Sie auf das FontDialog-Steuerelement zurückgreifen, das Sie aus der Toolbox in Ihr Formular einfügen können. Der Dialog wird wie auch die anderen Dialoge verwendet. ShowDialog() liefert einen DialogResult-Wert zurück, den Sie auswerten und entsprechend verfahren können. Die ausgewählte Schriftart können Sie über die Eigenschaft Font des Dialogs ermitteln und zuweisen. Das Aussehen des Dialogs kann durch einige Eigenschaften beeinflusst werden: f ShowColor gibt an, ob auch die Farbe der Schrift ausgewählt werden darf. ShowEffects gibt an, ob die Zusatzattribute unterstrichen und durchgestrichen zur Auswahl stehen. f ShowApply gibt an, ob der Button ÜBERNEHMEN angezeigt wird. Damit kann die Schriftauswahl schon vor dem Dialogende ausprobiert werden. Ein Klick auf diesen Button bewirkt, dass das Ereignis Apply ausgelöst wird, das Sie dann verwenden können, um die Schriftart zuzuweisen ohne den Dialog zu schließen. f Wenn Sie (z.B. bei einem Texteditor) nur die Auswahl von solchen Schriften zulassen möchten, bei denen die Buchstabenbreite konstant ist (z.B. Courier New), müssen Sie die Eigenschaft FixedPitchOnly auf true setzen. Im folgenden Programm wird der Font-Dialog dazu verwendet, Schriftart und Schriftfarbe eines Labels zu verändern. Der Code ist recht trivial. Beachten Sie die Ereignisbehandlungsroutine DlgFont_Apply(), die das Ereignis zum Übernehmen der Einstellungen beim Klick auf den Button ÜBERNEHMEN darstellt.
Sandini Bib
CD
Text ausgeben (Font-Klassen)
761
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Font-Selection.
private void BtnFont_Click(object sender, System.EventArgs e) { // Dialogeinstellungen dlgFont.Font = lblResult.Font; dlgFont.Color = lblResult.ForeColor; // Änderungen übernehmen if (dlgFont.ShowDialog() == DialogResult.OK) { lblResult.ForeColor = dlgFont.Color; lblResult.Font = dlgFont.Font; lblResult.Text = dlgFont.Font.Name; } } private void DlgFont_Apply(object sender, System.EventArgs e) { // Änderungen übernehmen - Dialog bleibt offen lblResult.ForeColor = dlgFont.Color; lblResult.Font = dlgFont.Font; lblResult.Text = dlgFont.Font.Name; }
Das Resultat der Bemühungen zeigt Abbildung 21.28.
Abbildung 21.28: Der Fontdialog im Einsatz
Sandini Bib
762
21.3.7
21 Grafikprogrammierung (GDI+)
Syntaxzusammenfassung
Die Klasse Font Die erste Tabelle gibt die wichtigsten Konstruktoren der Font-Klasse an. In der zweiten Tabelle finden Sie wichtige Eigenschaften und Methoden der Klasse Font. Konstruktoren der Klasse Font (aus System.Drawing) new Font( Font f, FontStyle fs )
erzeugt ein neues Font-Objekt aus einem bestehenden Font-Objekt und mit den angegebenen Einstellungen für FontStyle.
new Font( FontFamily ff, float size, FontStyle fs )
erzeugt ein neues Font-Objekt mit der Größe size und den in fs angegebenen FontStyle-Eigenschaften.
new Font( string fontName, float size )
erzeugt ein neues Font-Objekt mit der in size angegebenen Größe und aus der durch fontName angegebenen Font-Familie.
new Font( string fontname, float size, FontStyle fs )
erzeugt ein neues Font-Objekt mit der Größe size und den in fs angegebenen FontStyle-Eigenschaften. fontName gibt den Namen der Font-Familie an.
Eigenschaften und Methoden der Klasse Font (aus System.Drawing) Height
liefert die Höhe des Zeichensatzes. Die Einheit entspricht der in der Eigenschaft Unit angegebenen Einheit (Standard: Punkt).
FontFamily
liefert die Font-Familie der Schriftart.
Size
liefert die EM-Größe des Zeichensatzes. Die Einheit entspricht der in der Eigenschaft Unit angegebenen Einheit (Standard: Punkt).
SizeInPoints
liefert die EM-Größe des Zeichensatzes in der Einheit Punkt.
Unit
liefert oder setzt die Maßeinheit für dieses Font-Objekt in Form einer Konstanten der GraphicsUnit-Aufzählung.
GetHeight( Graphics g )
liefert die Höhe der aktuellen Schriftart in Pixeln, was dem Minimalabstand zwischen zwei Textzeilen entspricht, wenn die Ausgabe im angegebenen Graphics-Objekt erfolgt. Die Einheit entspricht der in der Eigenschaft PageUnit angegebenen Einheit (Standard: Pixel).
GetHeight( float dpi )
wie oben, aber für die Ausgabe in einem Zeichenobjekt, das die durch dpi angegebene Auflösung hat
Sandini Bib
Text ausgeben (Font-Klassen)
763
Basisoperationen mit Fonts g.DrawString( string s, Font f, Brush b, float x, float y )
gibt die Zeichenkette s in der Schriftart f und der Farbe (bzw. dem Muster) b an den Koordinaten x und y aus. g ist ein GraphicsObjekt.
gr.DrawString( string s, Font f, Brush b, RectangleF rectf )
wie oben, aber mit automatischem Zeilenumbruch innerhalb des angegebenen Rechtecks
g.MeasureString( string s, font f )
ermittelt den Platzbedarf für die Textausgabe. Zurückgeliefert wird der Wert in Form einer SizeF-Struktur.
Die Klasse FontFamily Die Klasse FontFamily repräsentiert eine Font-Familie. Eigenschaften und Methoden der Klasse FontFamily (aus System.Drawing) Families
liefert ein Array aller installierten Schriftfamilien. Diese Eigenschaft ist statisch.
GetCellAscent( FontStyle fs )
liefert die maximale Buchstabenhöhe oberhalb der Grundlinie für das angegebene Schriftattribut in Entwurfseinheiten.
GetCellDescent( FontStyle fs )
liefert die maximale Buchstabenhöhe unterhalb der Grundlinie für das angegebene Schriftattribut in Entwurfseinheiten.
GetEmHeight( FontStyle fs )
liefert die Höhe einer EM-Box. Die EM-Box ist ein Rastermaß beim Design von Zeichensätzen. Einheit: Entwurfseinheiten.
GetLineSpacing( FontStyle fs )
liefert den minimalen Vertikalabstand zweier Textzeilen. Einheit: Entwurfseinheiten.
Die Klasse StringFormat Die Klasse StringFormat steht für Format- und Layouteinstellungen. Wichtige Member der Klasse StringFormat (aus System.Drawing) new StringFormat( StringFormatFlags flags )
erzeugt ein neues StringFormat-Objekt. flags sind Elemente der StringFormatFlags-Aufzählung (z.B. StringFormatFlags.NoWrap, um einen automatischen Zeilenumbruch zu verhindern oder StringFormatFlags.DirectionVertical um vertikalen Text anzuzeigen).
Sandini Bib
764
21 Grafikprogrammierung (GDI+)
Wichtige Member der Klasse StringFormat (aus System.Drawing) GenericTypographic
liefert ein StringFormat-Objekt mit Einstellungen zurück, die bewirken, dass die Methoden DrawString() und MeasureString() der Klasse Graphics keine Zusatzabstände für Zeilenabstand, Kursivierung etc. berücksichtigen. Diese Eigenschaft ist statisch.
Alignment
steuert die horizontale Ausrichtung. Der Wert ist vom Typ StringAlignment, mögliche Einstellungen sind StringAlignment.Near, StringAlignment.Far und StringAlignment.Center.
LineAlignment
steuert die vertikale Ausrichtung von Text, ebenfalls in Form eines Werts vom Typ StringAlignment.
Trimming
steuert die automatische Verkürzung von zu langem Text. Der Wert ist vom Typ StringTrimming. Diese Aufzählung enthält zahlreiche Einstellmöglichkeiten (z.B. StringTrimming.EllipsisPath), mit denen unter anderem eine Pfadangabe wie unter Windows gewohnt gekürzt werden kann. Schade, dass eine solche Möglichkeit bei der Klasse Path fehlt.
SetTabStops( float n, float[] m )
setzt linksbündige Tabulatoren an den Positionen n+m[0], n+m[0]+m[1], n+m[0]+m[1]+m[2] usw. Als Maßeinheit wird die Einheit des Graphics-Objekts verwendet.
21.4
Bitmaps, Icons und Metafiles
Dieser Abschnitt beschreibt Möglichkeiten, Bitmaps, Icons und Metafiles innerhalb von Formularen oder Steuerelementen anzuzeigen und zu manipulieren. In vielen Fällen reichen dazu die Funktionen, die GDI+ bietet, aus – manchmal aber ist es auch in diesem Fall ganz sinnvoll, per P/Invoke auf die gute alte GDI-Schnittstelle zurückzugreifen. Das .NET Framework unterstützt eine nicht zu verachtende Zahl von Formaten von Haus aus. Dazu gehören selbstverständlich das Windows-eigene BMP-Format, weiterhin Pixelgrafiken vom Typ JPEG und PNG, Icons (*.ICO) und auch Vektorgrafiken in den Formaten WMF und EPS. Das Hauptanwendungsgebiet werden aber vermutlich die Pixelgrafiken darstellen.
21.4.1
Die Klassen Graphics, Image und Bitmap
Dass Graphics für Zeichnoperationen zuständig ist, wissen wir bereits aus den zahlreichen vorangegangenen Beispielen. Graphics stellt sozusagen eine Art »Leinwand« dar, auf der die Zeichenoperationen durchgeführt werden. Jedes Steuerelement, das zur Anzeige grafischer Elemente in der Lage ist, stellt auch ein Graphics-Objekt zur Verfügung. Wir haben das bereits des Öfteren ausgenutzt, indem wir das Graphics-Objekt mittels CreateGraphics() erzeugt haben.
Sandini Bib
Bitmaps, Icons und Metafiles
765
Während bisher eine »leere« Leinwand mit selbst gezeichneten Objekten gefüllt wurde, geht es in den folgenden Abschnitten mehr um das Zeichnen und Manipulieren kompletter Grafiken. Die Grundlage hierzu ist das Verständnis der Zusammenhänge zwischen den beteiligten Klassen.
Grafikklassen
HINWEIS
Ein Graphics-Objekt ist ausschließlich ein Hilfsmittel, um Grafikoperationen durchzuführen, es kümmert sich aber nicht um die Speicherung der Daten. Aus diesem Grund muss der Inhalt eines Fensters bzw. Steuerelements immer wieder neu gezeichnet werden, wenn das Fenster zwischenzeitlich verdeckt war. Für GDI-Experten: Das Graphics-Objekt erfüllt in etwa die gleiche Funktion wie der Device-Context aus GDI. Delphi-Programmierer kennen ebenfalls etwas Vergleichbares, in Delphi wird die Zeichenfläche als Canvas bezeichnet.
Die Klasse Image dient als Basisklasse für Grafiken. Von ihr sind die Klassen Bitmap (Pixelgrafik) und Metafile (Vektorgrafik) abgeleitet. Image stellt also lediglich gemeinsame Methoden und Eigenschaften zur Verfügung, ist aber eine abstrakte Klasse, kann also nicht instanziert werden. Dennoch werden Sie häufig erleben, dass als Parameter ein Image-Objekt erwartet wird. Dabei kann es sich dann entweder um ein Objekt des Typs Bitmap als auch um ein Objekt des Typs MetaFile handeln. Bitmap-Objekte dienen dazu, Pixelgrafiken zu verwalten (also aus Dateien zu laden, im Arbeitsspeicher zu halten, wieder in Dateien zu speichern usw.). Im Gegensatz zum GraphicsObjekt gibt es aber nur relativ wenig Methoden, um den Bildinhalt zu verändern. Die wichtigste Methode heißt SetPixel() und ermöglicht es, die Farbe eines einzelnen Bildpunkts zu verändern. Für wirkliche Manipulationen muss allerdings in vielen Fällen auf GDI-Funktionen zurückgegriffen werden.
Sowohl das Graphics- als auch das Bitmap-Objekt sind an sich unsichtbar. Sichtbar werden derartige Objekte erst, wenn sie in einem Formular oder Steuerelement dargestellt werden. Dazu gibt es natürlich wieder verschiedene Wege wie auch in den anderen Bereichen von .NET. Icon-Objekte dienen dazu, Icons zu verwalten. Zur Bearbeitung von Icons stehen zwar nur
HINWEIS
ziemlich wenig Methoden zur Auswahl, gegebenenfalls können Sie aber ein Icon auch in eine Bitmap umwandeln und bearbeiten. Metafile-Objekte helfen laut Dokumentation dabei, Metafile-Dateien zu lesen,
anzuzeigen und zu speichern. In der Praxis ist allerdings nur die Ausführung der beiden ersten Aufgaben gelungen. Alle Versuche, selbst eine neue Metafile-Datei zu erstellen, sind gescheitert.
Sandini Bib
HINWEIS
766
21 Grafikprogrammierung (GDI+)
Falls Ihnen der Begriff Bitmap Probleme bereitet, hier der Versuch einer Definition: Eine Bitmap ist eine Datenmenge, um die Farbe jedes Punktes (jedes Pixels) eines rechteckigen Bereichs zu speichern. Sie können sich eine Bitmap auch als zweidimensionales Array vorstellen, das für jeden Punkt die dazugehörigen Farbinformationen enthält. In der Praxis haben Sie ständig mit Bitmaps zu tun: Jedes gescannte Bild, jedes Foto, das mit einer Digitalkamera aufgenommen wurde, jedes mit Photoshop erzeugte Bild ist eine Bitmap. Es gibt zahllose Formate, um Bitmaps in Dateien zu speichern. So ist unter Windows vor allem das BMP-Format verbreitet, während Sie im Internet überwiegend GIF- und JPEG-Dateien vorfinden. Diese Formate unterscheiden sich vor allem durch ihren Speicherbedarf und die Darstellungsqualität. (Bei einigen Formaten werden die Bildinformationen komprimiert, um so kleinere Dateien zu erreichen. Bei den meisten Kompressionsverfahren gehen allerdings – für das menschliche Auge oft fast unmerklich – Bildinformationen verloren.)
Zusammenhang zwischen Bitmap- und Graphics-Objekten Bitmap-Objekte können nicht in Graphics-Objekte umgewandelt werden. Sie haben daher zwar keine Möglichkeit der direkten Umwandlung, können aber die Bitmap auf das Graphics-Objekt kopieren bzw. das Graphics-Objekt anweisen, die angegebene Bitmap zu zeichnen.
f Die Methode DrawImage() der Klasse Graphics zeichnet eine angegebene Bitmap auf dem entsprechenden Graphics-Objekt.
VERWEIS
f Mit der Methode Graphics.FromImage() können Sie ein neues Graphics-Objekt erzeugen, das die Bitmap enthält. Die Methoden, die die Klasse Graphics zur Verfügung stellt, können Sie auf diese Weise direkt auf die Bitmap anwenden. Das Graphics-Objekt muss nach seiner Verwendung mit Dispose() freigegeben werden. Während FromImage() die Möglichkeit bietet, ein Graphics-Objekt für eine Bitmap zu erzeugen, ist die umgekehrte Richtung nicht vorgesehen: Sie können also nicht ohne weiteres ein Bitmap-Objekt erzeugen, das den aktuellen Inhalt eines GraphicsObjekts enthält.
Wenn Sie einen Zusammenhang zwischen dem Inhalt eines Formulars bzw. eines Steuerelements und einem Bitmap- oder Graphics-Objekt herstellen möchten, helfen die folgenden Methoden: f Die statische Methode FromHWnd() der Klasse Graphics liefert Ihnen ein Graphics-Objekt, das sich auf ein bestimmtes Steuerelement bezieht. Die Methode erwartet als Parameter das so genannte Handle dieses Steuerelements, das durch dessen gleichnamige Eigenschaft geliefert wird. Ein auf diese Art erzeugtes Graphics-Objekt sollte mittels Dispose() wieder gelöscht werden.
Sandini Bib
Bitmaps, Icons und Metafiles
767
Beachten Sie, dass die Methode FromHwnd() aus Sicherheitsgründen nur dann funktioniert, wenn das Programm von einer lokalen Festplatte (und nicht von einem Netzwerklaufwerk) ausgeführt wird. Andernfalls kommt es zu einem SecurityExceptionFehler. f Bei zahlreichen Steuerelementen können Sie über die Eigenschaft BackgroundImage ein Hintergrundbild festlegen, das dann im Steuerelement angezeigt wird. Änderungen, die an der zugewiesenen Bitmap vorgenommen werden, werden allerdings nur sichtbar, wenn das Steuerelement neu gezeichnet wird. Ggf. können Sie dies explizit auslösen, indem Sie die Methode Invalidate() des Steuerelements ausführen. Die angegebene Bitmap verhält sich wie das Hintergrundbild in einem HTML-Dokument, d.h. sie wird über die gesamte Größe des Steuerelements gekachelt. f Das PictureBox-Steuerelement bietet sowohl eine Eigenschaft Image als auch eine Eigenschaft BackgroundImage. Beide dienen dazu, Grafiken aufzunehmen. BackgroundImage verhält sich dabei so, wie oben beschrieben, während Sie die Position und das Verhalten des in Image festgelegten Bilds durch die Eigenschaft SizeMode festlegen können. Beachten Sie, dass Sie trotz zweier angezeigter Bilder nach wie vor das Graphics-Objekt der PictureBox zum Zeichnen verwenden können, wobei die geladenen Bitmaps nicht geändert werden. f Die Methode CreateGraphics() haben wir bereits in den vorhergehenden Beispielen verwendet, um ein Graphics-Objekt zu erzeugen, das den Zeichenbereich des entsprechenden Steuerelements repräsentiert. Diese Methode funktioniert natürlich auch. Natürlich kann diese Aufzählung nicht mehr als eine kleine Übersicht darstellen. Details über die einzelnen Methoden und die unterschiedlichen Vorgehensweisen erhalten Sie in den folgenden Abschnitten.
21.4.2
Bitmaps in Formularen darstellen
Wenn Sie in einem Formular ein Bild in die Eigenschaft BackgroundImage geladen haben und auf dem Graphics-Objekt des Formulars zeichnen, wird die geladene Bitmap nicht verändert. Das hat zur Folge, dass diese Grafikausgaben wiederholt werden müssen, wenn das Fenster verdeckt war. Eine Hintergrundgrafik wird hingegen automatisch wieder angezeigt. Sie haben übrigens keine Möglichkeit, das Verhalten der Hintergrundbitmap zu ändern, d.h. sie beispielsweise gestreckt darzustellen. Falls Sie das möchten, müssen Sie anders vorgehen. Eine Möglichkeit besteht darin, ein PictureBox-Steuerelement auf dem Formular zu platzieren, dessen Eigenschaft Dock auf DockStyle.Fill einzustellen und die Bitmap in die Image-Eigenschaft der PictureBox zu laden. In diesem Fall würde die Bitmap das gesamte Formular ausfüllen, würde aber gestreckt, statt an den Rändern wiederholt. Eine zentrierte Darstellung können Sie erreichen, indem Sie ebenfalls ein PictureBoxSteuerelement verwenden, dieses aber über das Resize-Ereignis ständig korrekt ausrichten (die Verwendung der Eigenschaft Anchor ist nicht möglich, hier würde das Bild wieder gestreckt). Sie können der PictureBox zwar auch über die Eigenschaft SizeMode mitteilen, dass
Sandini Bib
768
21 Grafikprogrammierung (GDI+)
sie das enthaltene Bild zentriert anzeigen soll, die erste Möglichkeit hat aber den Vorteil, dass eine eventuell vorhandene Hintergrundgrafik des Formulars nicht verdeckt wird. Die dritte Möglichkeit ist, die Bitmap über das Paint-Ereignis ständig neu an die gewünschte Position zu zeichnen. Natürlich funktioniert das alles auch, wenn ein Hintergrundmuster enthalten ist – in diesem Fall bildet das Muster den Hintergrund, und das Bild wird einfach darauf gezeichnet. In allen Fällen werden die Bitmap-Dateien in die zum Formular gehörende Ressourcendatei eingefügt. Damit wird die Bitmap ein integraler Bestandteil der Assembly, so dass deren Platzbedarf bei großen Bitmaps stark ansteigt.
Beispielprogramm – Bitmaps dynamisch laden Wenn Sie eine übermäßige Codegröße vermeiden möchten, können Sie die Bitmap-Dateien beim Programmstart auch dynamisch laden. Allerdings funktioniert Ihr Programm in diesem Fall nur dann korrekt, wenn die Bitmap-Dateien auch gefunden werden. Sichern Sie in diesem Fall den Code entweder durch einen try-catch-Block ab oder kontrollieren Sie vor dem Laden die Existenz der Dateien.
CD
Für das folgende Beispiel muss sich die Datei river.bmp im gleichen Verzeichnis wie die *.exe-Datei befinden. Vor dem Laden wird kontrolliert, ob die Bitmap existiert, wenn nicht, wird eine Fehlermeldung angezeigt. Das Hintergrundbild des Formulars wurde bereits zur Entwurfszeit festgelegt, das PictureBox-Steuerelement wird grundsätzlich zentriert angezeigt und passt sich der Größe des enthaltenen Bildes an. Der Quellcode zeigt wieder nur die relevanten Methoden des Hauptformulars. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\LoadPicture.
private void FrmMain_Resize( object sender, System.EventArgs e ) { int left = ( this.ClientSize.Width - picMain.Width ) / 2; int top = ( this.ClientSize.Height - picMain.Height ) / 2; this.picMain.Left = left; this.picMain.Top = top; } private void FrmMain_Load( object sender, System.EventArgs e ) { if ( File.Exists( "river.bmp" ) ) this.picMain.Image = new Bitmap( "river.bmp" ); else MessageBox.Show( "Die Bilddatei ist nicht vorhanden", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Hand ); }
Sandini Bib
Bitmaps, Icons und Metafiles
769
Die Eigenschaft SizeMode des PictureBox-Steuerelements wurde auf SizeMode.AutoSize eingestellt, sodass das Bild immer komplett dargestellt wird. Abbildung 21.29 zeigt das Programm zur Laufzeit.
Abbildung 21.29: Ein geladenes Bild vor einem gekachelten Hintergrund. Möglicherweise ist das im Buch nicht so deutlich zu sehen wie auf dem Bildschirm.
Weitergabe von Bitmaps (Bitmap-Container) Wenn Sie zusammen mit einem Programm eine Reihe von Bitmaps mitliefern möchten, bestehen drei Möglichkeiten: f Sie liefern die Bitmaps als Dateien mit, die sich im gleichen Verzeichnis wie das Programm oder in einem Unterverzeichnis davon befinden. Diese Vorgehensweise ist einfach durchzuführen; die Codegröße bleibt, die Wartung ist unkompliziert (etwa der Austausch der Bitmaps durch neue Dateien). Der Hauptnachteil besteht darin, dass der Konfigurationsaufwand für den Setup-Vorgang etwas größer ist und dass generell das Risiko groß ist, dass die Bitmap-Dateien bei der Weitergabe des Programms verschwinden und das Programm in der Folge nicht mehr so funktioniert, wie es ursprünglich gedacht war. f Das ImageList-Steuerelement ermöglicht es, während der Programmerstellung beliebig viele Bitmaps zu laden. Diese Bitmaps können dann im laufenden Programm über die Eigenschaft Images der ImageList angesprochen werden. Leider ist das Steuerelement nicht als universeller Bitmap-Container geeignet: Beim Laden werden nämlich alle Bitmaps auf eine einheitliche Größe und eine einheitliche Farbtiefe eingestellt, die durch die Eigenschaften ImageSize und ColorDepth festgelegt werden. Standardmäßig
Sandini Bib
770
21 Grafikprogrammierung (GDI+)
beträgt die Größe nur 16*16 Pixel und die Farbtiefe 8 Bit (also 256 Farben). Angesichts dieser Einschränkungen ist das Steuerelement also nur dann brauchbar, wenn Sie eine Menge Bitmaps mit einheitlichem Format verwalten möchten (beispielsweise die Bitmaps für eine Symbolleiste). Selbstverständlich steht es Ihnen frei, für unterschiedliche Größen mehrere ImageList-Steuerelemente zu verwenden. f Sie können auch auf Ressourcendateien zurückgreifen. Dabei handelt es sich um XMLDateien, die bei der Kompilierung direkt in das Programm integriert werden. Die Entwicklungsumgebung verwendet ebenfalls Ressourcen-Dateien, um in Formularen oder Steuerelementen enthaltene Bitmaps zu speichern. Jedes Windows.Forms-Projekt beinhaltet unter dem Eintrag Properties bereits eine Standard-Ressourcendatei, die Sie verwenden können. Wenn Sie dieser Ressourcendatei Grafiken hinzufügen, sind diese später über die Klasse Resources als Eigenschaften erreichbar. In .NET 1.1 müssen Sie auf die Klassen des Namensraums System.Resources zurückgreifen. Im Falle von Steuerelementen wie beispielsweise dem MenuStrip oder dem ToolStrip, die ja auch mit Bitmaps umgehen, können Sie im Auswahldialog für die Bitmaps wählen, ob diese aus einer Ressource kommen sollen oder direkt angegeben werden. An dieser Stelle können Sie auch auf einfache Weise Grafiken zur Standard-Ressourcendatei hinzufügen.
21.4.3
Bitmaps manipulieren
Im vorigen Abschnitt stand die Nutzung fertiger Bitmap-Dateien im Vordergrund. Dieser Abschnitt beschäftigt sich damit, wie Sie Bitmaps selbst per Programmcode erzeugen, bearbeiten, speichern und später wieder laden können.
Neue Bitmap erzeugen Ein neues Bitmap-Objekt können Sie einfach erstellen, indem Sie beispielsweise im Konstruktor die gewünschte Größe in Pixeln angeben. Standardmäßig sind zur Speicherung eines jeden Pixels 32 Bit vorgesehen. Davon stehen je acht Bit für die drei Farbtöne Rot, Grün und Blau und weiterhin acht Bit für den Alphakanal, der die Transparenz steuert. Durch einen optionalen dritten Parameter können Sie ein anderes Pixelformat angeben (ein Element der Aufzählung PixelFormat, die in System.Drawing.Imaging definiert ist). Bitmap bm = new Bitmap( x, y );
Die Standardfarbe dieser neuen Bitmap ist nicht etwa Weiß, wie man vermuten könnte, sondern durchsichtig. Genau genommen werden die Werte der Bitmap mit dem Wert 0 initialisiert, d.h. außer den Farb-Bytes wird auch der Alphawert mit 0 initialisiert. Dadurch wird die Bitmap undurchsichtig. Sichtbar werden erst die Punkte, die Sie auf der Bitmap zeichnen (z.B. durch SetPixel()). Interessant wird das Erzeugen einer neuen Bitmap auf die beschriebene Weise, wenn Sie beispielsweise eine vorhandene Grafik verkleinern wollen. Der Konstruktor der BitmapKlasse ist überladen und ermöglicht auch die Angabe einer Ursprungsbitmap. Dennoch
Sandini Bib
Bitmaps, Icons und Metafiles
771
werden die angegebenen Maße verwendet. So können Sie also mit relativ wenig Aufwand eine Bitmap skalieren. Beachten Sie dabei aber, dass das Seitenverhältnis beibehalten werden muss, um Verzerrungen zu vermeiden.
Einzelne Pixel zeichnen Mit der Methode SetPixel() können Sie nun die Farbe jedes einzelnen Pixel der Bitmap verändern. Die Pixel werden durch Koordinaten angegeben, wobei die Koordinate (0,0) das erste Pixel im linken oberen Eck der Bitmap bezeichnet. Die folgende Anweisung zeichnet einen roten Punkt an die Koordinatenposition (10,10). Bitmap bm = new Bitmap( 50,50 ); bm.SetPixel(10, 10, Color.FromArgb( 255, 0, 0) )
Umgekehrt können Sie mit der Methode GetPixel() die Farbe eines beliebigen Pixels ermitteln. GetPixel() liefert als Ergebnis ein Color-Objekt.
Zeichnen auf einer Bitmap Grundsätzlich ist es nicht möglich, mithilfe der Methoden eines Bitmap-Objekts Zeichenoperationen durchzuführen. Sie können jedoch den Umweg über ein Graphics-Objekt gehen und dessen Zeichenmethoden anwenden. Ermöglicht wird das durch die Methode FromImage(), die bereits kurz angesprochen wurde. Die folgenden Codezeilen zeigen exemplarisch, wie der Zugriff funktioniert. Bitmap bm = new Bitmap( ... ); Graphics g = Graphics.FromImage( bm ); g.DrawEllipse( 10, 10, bm.Width-10, bm.Height-10 );
Die Zeichnung erfolgt dabei direkt auf der Bitmap, d.h. wenn Sie diese abspeichern, sind die gezeichneten Elemente auch auf der gespeicherten Grafik vorhanden.
Bitmap mit DrawImage() in ein Graphics-Objekt kopieren Da eine Bitmap unsichtbar ist, muss sie erst angezeigt werden. Natürlich kann dazu auch das PictureBox-Steuerelement verwendet werden, dessen Eigenschaft Image die Bitmap zugewiesen werden kann. Es geht aber auch direkt, wieder über das Graphics-Objekt des Formulars. Die Klasse Graphics bietet eine Methode namens DrawImage(), mit deren Hilfe Sie eine Grafik ausgeben können – und zwar auf jedem Graphics-Objekt, auf das Sie Zugriff erhalten. In der einfachsten Form der Methode DrawImage() wird die gesamte Bitmap ohne Veränderung einfach an eine bestimmte Koordinatenposition in das Graphics-Objekt kopiert. Zusätzliche Parameter ermöglichen es auch, die Bitmap zu strecken oder zu stauchen bzw. komplett unter Beibehaltung des Seitenverhältnisses zu skalieren. Die folgenden Zeilen zeichnen eine Bitmap auf ein Formular. Dabei wird zunächst die Position berechnet, an der die Bitmap mittig erscheint. Ist die Bitmap größer als der Clientbereich des Formulars, wird sie an der Position (0,0) gezeichnet und gestaucht bzw.
Sandini Bib
772
21 Grafikprogrammierung (GDI+)
gestreckt. Sie nimmt dann den gesamten Formularbereich ein. Andernfalls wird die Grafik in der Originalgröße zentriert auf das Formular gezeichnet.
CD
Verwendet wird für dieses Beispiel das Ereignis Paint des Formulars. Benötigt wird ein Objekt des Typs Image, das in diesem Fall als Feld der Formularklasse deklariert und über einen Button mit einer Grafik gefüllt wird. Im Paint-Ereignis selbst erfolgt die Kontrolle, ob die Grafik vorhanden ist oder nicht. Im letzteren Fall findet natürlich kein Zeichenvorgang statt. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\DrawImage.
private void FrmMain_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { if ( this.currentImage != null ) { Graphics g = e.Graphics; if ( ( currentImage.Width > this.ClientSize.Width ) || ( currentImage.Height > this.ClientSize.Height ) ) { g.DrawImage( currentImage, this.ClientRectangle ); // zeichnen und skalieren } else { int x = (int)( ( this.ClientSize.Width - currentImage.Width ) / 2 ); int y = (int)( ( this.ClientSize.Height - currentImage.Height ) / 2 ); g.DrawImage( currentImage, x, y ); } } }
Farbänderungen Die Methode DrawImage() ist 30-fach (!!!) überladen. Einige dieser überladenen Versionen ermöglichen es auch, Farbänderungen durchzuführen. Verantwortlich dafür zeichnet ein Objekt des Typs ImageAttributes. Die Klasse ImageAttributes ist im Namespace System. Drawing.Imaging deklariert und besitzt zahlreiche Methoden zur Farbmanipulation. Unter anderem ist es mit dieser Klasse möglich, Farben aus einer Bitmap auszublenden (transparent darzustellen), den Gamma-Wert eines Bildes zu ändern oder sogar festzulegen, wie ein Bild auf einem Formular gekachelt wird. Leider sind die verschiedenen Eigenschaften und Methoden dieser Klasse recht unzulänglich dokumentiert, etwas Experimentierfreudigkeit ist daher angebracht. Ein kleines Beispielprogramm zeigt, wie der Gamma-Wert eines Bilds korrigiert werden kann. Änderungen des Gamma-Werts eines Bilds führen dazu, dass die Mitteltöne der Grafik verstärkt oder geschwächt werden. Vereinfacht gesagt, das Bild wird subjektiv aufgehellt oder verdunkelt. Bei vielen Grafikprogrammen ist es so, dass das Bild bei höheren Gamma-Werten aufgehellt wird, bei niedrigen abgedunkelt. Im .NET Framework ist das umgekehrt, ein Wert unterhalb von 1 führt zur Aufhellung, ein Wert größer 1 zur Abdunklung.
Sandini Bib
Bitmaps, Icons und Metafiles
773
CD
Die zentrale Methode heißt AdjustGamma(). Ihr wird der Gamma-Wert in Form einer Variablen von Typ float übergeben. Über die Methode SetGamma() des ImageAttributes-Objekts wird der Gamma-Wert dann zugewiesen und beim Zeichnen des Bilds direkt auf dem Formular verwendet. Beachten Sie, dass die Variable currentImage ein Feld des Formulars ist. Die Methode AdjustGamma() führt keine Überprüfung durch, ob img tatsächlich eine Grafik enthält. Diese Kontrolle wurde bereits vorher durchgeführt. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\GammaCorrection.
private void AdjustGamma( float gammaValue ) { // Gamma-Wert zuweisen ImageAttributes imgAttributes = new ImageAttributes(); imgAttributes.SetGamma( gammaValue ); Graphics g = this.CreateGraphics(); Rectangle destRect = new Rectangle( 0, 0, currentImage.Width, currentImage.Height ); g.DrawImage( currentImage, destRect, 0, 0, currentImage.Width, currentImage.Height, GraphicsUnit.Pixel, imgAttributes ); g.Dispose(); } DrawImage() führt übrigens automatisch eine Interpolation der Bilddaten durch, um auch bei einer Größenveränderung eine möglichst hohe Qualität zu erzielen. Wenn Sie sicher sind, dass das angegebene Bild ohne jegliche Skalierung in den Zeichenbereich passt, können Sie auch die Methode DrawImageUnscaled() der Klasse Graphics verwenden, die etwas schneller arbeitet.
Bitmap in einem Steuerelement anzeigen (Image-Eigenschaft) Wenn Sie die selbst erzeugte Bitmap vollständig und ohne weitere Manipulationen in einem Steuerelement anzeigen möchten, weisen Sie das Bitmap-Objekt einfach der ImageEigenschaft des Steuerelements zu. pictureBox1.Image = bitmap;
Der Vorteil eines solchen Steuerelements wie der PictureBox ist, dass die Bilddaten immer neu eingelesen werden, wenn die PictureBox neu gezeichnet werden muss. Das Zeichnen geschieht also automatisch und Sie müssen sich nicht darum kümmern. Allerdings nur, wenn das Fenster z.B. verdeckt war, also ein Neuzeichnen notwendig wurde. Sollten Sie infolge eines Menükommandos die enthaltene Bitmap verändern, so werden diese Änderungen nicht sofort angezeigt, sondern erst beim nächsten »Auffrischen« des Bildes. Falls Sie die Anzeige sofort aktualisieren möchten, können Sie die Methode Refresh() des Steuerelements verwenden. Sinnvollerweise allerdings nicht nach jedem ge-
Sandini Bib
774
21 Grafikprogrammierung (GDI+)
änderten Pixel – Refresh() kann recht viel Zeit beanspruchen und sollte daher mit Bedacht eingesetzt werden.
Bitmaps laden Über das richtige Laden und Speichern einer Bitmap gibt es endlose Diskussionen. An dieser Stelle soll zumindest eine Variante vorgestellt werden, die zufrieden stellend funktioniert. Das Problem beim Laden einer Bitmap über die schnell erreichbaren Methoden Image.FromFile() bzw. über den Konstruktor der Klasse Bitmap ist, dass das Bild danach auf der Festplatte gelocked ist. Oder anders ausgedrückt: In Wirklichkeit wird das Bild nicht als eigenständige Grafik geladen, sondern es bleibt eine Verbindung zu der Datei offen, aus der die Bitmap stammt. Diese oft unerwünschte Tatsache führt beim Verschieben der Grafik zu der Fehlermeldung, dass die Datei noch verwendet wird. In manchen Programmen kann dieses Verhalten durchaus wünschenswert sein, in anderen wiederum nicht. Wie Sie eine Grafik so laden, dass die Grafikdatei danach unabhängig ist (was man daran merkt, dass sie auf der Festplatte an einen anderen Ort verschoben werden kann), zeigt das folgende Beispielprogramm. Der Ladevorgang erfolgt dabei über ein Stream-Objekt, nach dessen Schließen die Datei auf der Festplatte wieder frei verschiebbar ist. Um eine Datei in eine PictureBox zu laden, können Sie folgendermaßen vorgehen: private void BtnLoad2_Click(object sender, System.EventArgs e) { if (dlgOpen.ShowDialog()==DialogResult.OK) { // Laden und Datei in Ruhe lassen FileStream fs = new FileStream(dlgOpen.FileName, FileMode.Open); pictureBox1.Image = Image.FromStream(fs); fs.Close(); } }
Das Gleiche funktioniert natürlich äquivalent auch mit Bitmaps. Wenn Sie eine neue Bitmap aus einem Stream erzeugen wollen, können Sie so vorgehen: private void BtnLoad1_Click(object sender, System.EventArgs e) { if (dlgOpen.ShowDialog()==DialogResult.OK) { // Laden und Datei in Ruhe lassen FileStream fs = new FileStream(dlgOpen.FileName, FileMode.Open); Bitmap bmp = new Bitmap(Image.FromStream(fs)); fs.Close(); pictureBox1.Image = bmp; } }
Sandini Bib
CD
Bitmaps, Icons und Metafiles
775
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\BitmapLoader.
Grafiken speichern Wie bekannt, unterstützt das .NET Framework zahlreiche Arten von Grafiken. Beim Speichern einer Grafik, die als Objekt des Typs Bitmap vorhanden ist, kann daher auf mehrere Arten vorgegangen werden. Das Speichern selbst erledigt die Methode Save(). Diese Methode ist wieder mehrfach überladen und ermöglicht so genauere Angaben über die Art des Speicherns. Zur Angabe des gewünschten Datentyps wird die Aufzählung ImageFormat aus dem Namespace System.Drawing.Imaging verwendet, mit der die Formate BMP, GIF, PNG, TIF und JPEG unterstützt werden. bmp.Save( "meineBitmap.gif", ImageFormat.Gif );
Je nach Format wird das in der Bitmap enthaltene Bild dabei verändert. Beispielsweise wird beim GIF-Format die Anzahl der Farben auf 256 reduziert, wozu eine eigene Palette erzeugt wird. Bei den Formaten JPEG und PNG wird das Bild komprimiert. Mit der oben angegebenen Save()-Methode haben Sie allerdings keinen Einfluss darauf, wie diese Veränderungen durchgeführt werden, z.B. welche Qualitätsstufe bei der Komprimierung verwendet wird. Dazu müssen wir ein wenig tiefer in die Trickkiste greifen, wie der nächste Abschnitt zeigt. Beim Speichern eines Bilds unter Angabe eines ImageFormat-Werts ist es übrigens unerheblich, in welcher Form die ursprüngliche Datei vorlag. Auf diese Weise können Dateien leicht zwischen den unterstützten Formaten konvertiert werden. Das folgende Beispiel zeigt beides, sowohl die Zuweisung einer Kompression als auch die Konvertierung der Bitmap in eine JPEG-Datei.
Beispielprogramm: JPEG-Dateien komprimieren Das Komprimieren einer JPEG-Datei wird von jedem Grafikprogramm beherrscht, und auch GDI+ bietet diese Möglichkeit, wenn auch auf einem leider relativ umständlichen Weg. Benötigt werden dazu zwei Objekte, einmal ein Objekt des Typs ImageCodecInfo und dann ein Objekt des Typs EncoderParameters. Beide Klassen sind im Namespace System. Drawing.Imaging deklariert. Das ImageCodecInfo-Objekt steht für den Codec des zu speichernden Bilds. In einem solchen Codec ist unter anderem das Dateiformat für ein entsprechendes Bild beschrieben. Enthalten ist auch der Mime-Type, mit dessen Hilfe beispielsweise bei einem E-Mail-Programm das Dateiformat angegeben wird. Wir müssen der Save()-Methode also die Informationen über das Dateiformat liefern. Das Objekt des Typs EncoderParameters liefert die zum Speichern benötigten Parameter. Dazu gehören z.B. die Art der Komprimierung (wenn Sie eine TIF-Datei mit LZWKomprimierung speichern wollen) oder, wie in diesem Fall verwendet, die Bildqualität.
Sandini Bib
776
21 Grafikprogrammierung (GDI+)
Allerdings ist die Anwendung auch hier nicht ganz einfach, denn EncoderParameters ist eigentlich eine Liste mit Objekten des Typs EncoderParameter, damit auch mehrere Speicherparameter kombiniert werden können. Die enthaltenen EncoderParameter-Objekte enthalten wiederum in der Eigenschaft Encoder ein Objekt gleichen Typs, das die Detailinformationen des Parameters beinhaltet. Am Beispiel ist das allerdings ein wenig besser zu sehen als in einer theoretischen Erklärung.
CD
Der Aufbau des Beispielprogramms besteht aus zwei Textboxen für die Dateinamen, zwei Buttons zum Ermitteln der Dateinamen über Dialoge, einem NumericUpDown-Steuerelement zur Angabe des Kompressionsfaktors, einem Button zum Starten der Umwandlung in JPEG und natürlich auch einem Button zum Beenden des Programms. Da das Format der Quelldatei beliebig ist (es darf jedem unterstützten Format entsprechen), wird hier auch gleich noch eine Konvertierung nach JPEG durchgeführt. Abbildung 21.30 zeigt das Hauptformular in der Entwurfsansicht. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\JPegCompress.
Abbildung 21.30: Das Hauptformular der Anwendung in der Entwurfsansicht
Zunächst muss ermittelt werden, welcher Codec verwendet werden soll. Dazu gibt es leider keine einfache Methode, alle enthaltenen Codecs müssen durchlaufen und das passende entweder über den Mime-Typ oder auch den Namen ermittelt werden. Der Einfachheit halber wird dazu eine eigene Methode geschrieben: private ImageCodecInfo GetImageCodec(string mimeType) { // Codec zurückliefern ImageCodecInfo[] iCodecs = ImageCodecInfo.GetImageEncoders();
Sandini Bib
Bitmaps, Icons und Metafiles
777
foreach (ImageCodecInfo ici in iCodecs) { if (ici.MimeType==mimeType) { return ici; } } return null; } GetImageEncoders() liefert alle bekannten Codecs zurück, der richtige wird durch den Mime-Type ermittelt. Die eigentliche Konvertierungsmethode nutzt GetImageCodec(), legt
den richtigen Parameter zum Speichern fest und konvertiert die Bitmap. private void ConvertFile(string src, string dest, int pictureQuality) { // src = Quelldatei // dest = Zieldatei // pictureQuality = Qualitätsfaktor (Prozent) // Laden Bitmap bm = new Bitmap(src); // Richtigen Codec ermitteln, leider umständlich ImageCodecInfo ici = GetImageCodec("image/jpeg"); // Encoderparameter festlegen (und damit Kompression) EncoderParameters ep = new EncoderParameters(1); ep.Param[0] = new EncoderParameter(Encoder.Quality, pictureQuality); // Bitmap speichern unter neuem Namen bm.Save(dest, ici, ep); //Bitmap aus Speicher entfernen bm.Dispose(); }
Beachten Sie, dass der Namespace System.Drawing.Imaging eingebunden werden muss, da die verwendeten Klassen ImageCodecInfo, EncoderParameters usw. dort deklariert sind. Der einfache Aufbau des Programms macht auch eine Batch-Verarbeitung möglich, beispielsweise das Konvertieren aller *.bmp-Dateien eines Verzeichnisses in das entsprechende JPEG-Äquivalent. Dazu wird lediglich eine einfache Routine benötigt, die alle Dateien eines Verzeichnisses einliest und die Konvertierungsroutine für jede Datei aufruft. Auf Wunsch kann auch noch eine Fortschrittsanzeige eingebaut werden. private void BatchConvert( DirectoryInfo di ) { // Konvertiert alle BMP-Dateien des Verzeichnisses // di in JPEG-Dateien
Sandini Bib
778
21 Grafikprogrammierung (GDI+)
foreach ( FileInfo fi in di.GetFiles( "*.bmp" ) ) { string src = fi.Name; string dest = Path.ChangeExtension( src, ".jpg" ); ConvertFile( src, dest, 75 ); } }
21.4.4
Transparente Bitmaps
Wenn Sie eine Bitmap über Angabe der Größeninformationen neu erstellen, ist diese zunächst durchsichtig. Alle Werte innerhalb der Bitmap, also die Farbinformationen für die Pixel, werden bei der Initialisierung auf den Wert 0 gesetzt. Zu diesen Werten gehört auch der Alphakanal, und wenn dieser 0 ist, ist die Bitmap durchsichtig. Der Alphakanal ist allerdings nicht Bitmap-abhängig, sondern kann für einzelne Farben festgelegt werden und ist somit für sämtliche Zeichenfunktionen gültig. Er kann in 255 Stufen eingestellt werden, womit Sie einige interessante Effekte erzielen können, beispielsweise ein Bild mit einem Schleier belegen. Um eine Farbe innerhalb einer Bitmap komplett transparent zu machen, müssen Sie nicht auf jedes einzelne Bit zugreifen. Die Klasse Bitmap besitzt eine Methode MakeTransparent(), der Sie einen Farbwert übergeben können. Dieser Farbwert wird dann in der Bitmap transparent dargestellt. Intern wird die Bitmap lediglich nach allen Pixeln durchsucht, die die angegebene Farbe aufweisen, und deren Aplhakanal wird dann auf den Wert 0 eingestellt.
HINWEIS
Beachten Sie bitte, dass MakeTransparent() neu hinzugefügte Pixel nicht berücksichtigt. Wenn Sie also in einer Bitmap alle roten Pixel transparent gemacht haben und dann mit einer der Zeichenmethoden rot darauf zeichnen, erscheint diese neue Zeichnung. Wenn Sie auch diese Linien dann transparent darstellen möchten, müssen Sie MakeTransparent() erneut anwenden. An dieser Stelle eine kleine Information über das Verhalten der Bitmaps in einer ImageList. Da die transparente Farbe dort global angegeben werden muss, müssen alle enthaltenen Bitmaps die gleiche Hintergrundfarbe aufweisen – bei Bitmaps mit 24 Bit Farben keine leichte Angelegenheit. Wesentlich einfacher wäre es gewesen, bestehende Standards zu übernehmen und bei der Darstellung die Farbe des linken oberen Pixels automatisch als transparente Farbe zu verwenden.
Beispielprogramm Das folgende Beispielprogramm zeigt die Anwendung sowohl der Methode MakeTransparent() als auch des Alphakanals. Eine einfache Grafik wird geladen. Mittels Buttonklick können Sie eine Hintergrundfarbe transparent machen. Ein weiterer Button ermöglicht es, eine halbtransparente farbige Fläche über das Bild zu legen.
Sandini Bib
Bitmaps, Icons und Metafiles
779
Die Darstellung der Grafik erfolgt in einem Panel-Steuerelement. Um die Auswahl der transparenten Farbe so einfach wie möglich zu machen, soll diese per Maus ausgewählt werden können. Allerdings ist es nicht ganz so einfach, die Hintergrundfarbe innerhalb des Panels zu ermitteln. Deshalb bedienen wir uns eines kleinen Tricks, der auch in der Realität gerne angewandt wird, nämlich einer Shadow-Bitmap. Die Shadow-Bitmap ist eine exakte Repräsentation des sichtbaren Bilds. In diesem Fall sogar des gesamten Panel-Bereichs. Gezeichnet wird zunächst nur auf der Shadow-Bitmap, die nachher einfach in den Vordergrund gestellt wird. Das ist deshalb schneller, da die Bitmap nicht sichtbar ist und so keine visuelle Reaktion auf das Zeichnen stattfinden muss. Insgesamt soll das Programm also Folgendes können: f Auswahl einer Farbe aus dem Bild mittels Mauszeiger f Alle Pixel mit der ausgewählten Farbe durchsichtig machen f Festlegen einer halbtransparenten Farbe, die über das Bild gelegt wird f Festlegen der Transparenz dieser Farbe f Angeben einer Abweichung für die Transparenz Abbildung 21.31 zeigt den Aufbau des Beispielprogramms zur Entwurfszeit.
Abbildung 21.31: Der Entwurf des Beispielprogramms
Der letzte Punkt der Aufzählung hat seinen Urspung im Test des Programms. Bei einem herkömmlichen Foto ist es sehr selten, dass mehrere exakt gleichfarbige Pixel nebeneinander liegen (auch wenn die Unterschiede marginal sind). Daher werden zur besseren Sichtbarkeit mehrere Farben transparent gemacht, die Anzahl kann angegeben werden. Von der Ursprungsfarbe ausgehend werden die jeweils helleren und dunkleren Farben zusätzlich transparent gemacht, wodurch eine größere Fläche auf einen Schlag transparent dargestellt wird.
Sandini Bib
CD
780
21 Grafikprogrammierung (GDI+)
Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\AlphaKanal.
Laden und Zeichnen der Grafik Für das Programm werden drei Felder benötigt. Eines für die Shadow-Bitmap, eines für die transparente Farbe und eines für die verschleiernde Farbe. Alle Felder werden mit Nullwerten initialisiert. Bitmap shadowBitmap = null; Color transparentColor = Color.Empty; Color alphaColor = Color.Empty;
Der erste Schritt ist das Laden einer Grafik und die Zuweisung derselben an die ShadowBitmap bzw. das Zeichnen der Grafik auf dem Panel. Um die Arbeit zu vereinfachen wird die Shadow-Bitmap in der Größe des Panels erstellt. Die Grafik wird dann darauf gezeichnet, mittig, wenn sie hineinpasst, oder ab den Koordinaten (0,0), wenn sie entweder höher oder breiter als das Panel ist. Skaliert wird an dieser Stelle nicht. private void BtnLoad_Click( object sender, System.EventArgs e ) { // Datei laden und anzeigen // Unter Verwendung der Shadow-Bitmap if ( dlgOpen.ShowDialog() == DialogResult.OK ) { FileStream fs = new FileStream( dlgOpen.FileName, FileMode.Open ); Bitmap bmp = (Bitmap)Image.FromStream( fs ); fs.Close(); // Shadow-Bitmap vorbereiten shadowBitmap = new Bitmap( pnlDraw.Width, pnlDraw.Height ); // Shadow-Bitmap zeichnen Graphics g = Graphics.FromImage( shadowBitmap ); g.Clear( Color.White ); if ( ( bmp.Width > pnlDraw.Width ) || ( bmp.Height > pnlDraw.Height ) ) { g.DrawImage( bmp, 0, 0 ); } else { int x = (int)( ( pnlDraw.Width - bmp.Width ) / 2 ); int y = (int)( ( pnlDraw.Height - bmp.Height ) / 2 ); g.DrawImageUnscaled( bmp, x, y ); } g.Dispose(); // Bitmap auf Panel zeichnen DrawBitmap(); } }
Sandini Bib
Bitmaps, Icons und Metafiles
781
Die Methode DrawBitmap() zeichnet den Vordergrund. Sie ist dafür zuständig, dass der Inhalt der Shadow-Bitmap auf dem Panel erscheint. In dieser Methode wird auch der Farbschleier gezeichnet. private void DrawBitmap() { // Zeichnen der Bitmap auf das Panel Graphics g = pnlDraw.CreateGraphics(); g.Clear( Color.White ); g.DrawImage( shadowBitmap, 0, 0 ); // Zeichnen des Farbschleiers if ( alphaColor != Color.Empty ) g.FillRectangle( new SolidBrush( alphaColor ), 0, 0, pnlDraw.Width, pnlDraw.Height ); }
Die Anweisung Clear() für das Graphics-Objekt des Panels ist wichtig, da ansonsten die transparenten Bestandteile nicht sichtbar wären. Das enthaltene Bild würde nämlich ansonsten nur überzeichnet, d.h. an den transparenten Stellen würde sich immer noch der korrekte Bildausschnitt befinden, der vorher gezeichnet worden war. Deshalb wird das Panel bei jedem Zeichenvorgang gelöscht. Damit die Grafik auch dann gezeichnet wird, wenn das Fenster vorher verdeckt war, müssen wir auch noch das Paint-Ereignis mit Leben füllen. Dieses Leben beschränkt sich aber sinnvollerweise auf den Aufruf von DrawBitmap(), allerdings nur, wenn die Shadow-Bitmap nicht leer ist. private void PnlDraw_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { if ( shadowBitmap != null ) DrawBitmap(); }
Damit wären die Zeichenfunktionen schon vollständig. Als Nächstes kümmern wir uns um die Farbauswahl. Hier wird dann auch deutlich, warum die Shadow-Bitmap die Größe des Panels hat, denn auf diese Weise entsprechen die Koordinaten des Panels auch denen auf der Bitmap.
Transparenz zuweisen Um die gewünschte Farbe zu ermitteln, müssen wir nur das MouseMove-Ereignis des Panels auswerten und die Farbe aus den gleichen Koordinaten der Shadow-Bitmap entnehmen. Um diese Farbe auch dem Anwender darzustellen, legen wir sie als Hintergrundfarbe für ein kleines Panel neben dem Button zum Zuweisen der Transparenz fest. private void PnlDraw_MouseDown( object sender, System.Windows.Forms.MouseEventArgs e ) { // Farbauswahl if ( shadowBitmap != null ) { transparentColor = shadowBitmap.GetPixel( e.X, e.Y ); pnlTransColor.BackColor = transparentColor; } }
Sandini Bib
782
21 Grafikprogrammierung (GDI+)
Die eigentliche Zuweisung der Transparenz geschieht über einen Button. Zunächst wird die aktuelle Farbe transparent gezeichnet. Danach werden weitere Farben ermittelt, wobei deren Anzahl in einer Textbox angegeben werden kann. Eine Angabe von 10 bedeutet, dass die jeweils 10 helleren und dunkleren Farben ebenfalls transparent gezeichnet werden. Die Methode ist etwas umfangreich, vor allem wegen der Kontrollen und Farbzuweisungen, aber dennoch leicht zu verstehen. private void BtnTransparent_Click( object sender, System.EventArgs e ) { if ( !transparentColor.IsEmpty ) { int transparencyKey = Int32.Parse( txtTransparent.Text ); shadowBitmap.MakeTransparent( transparentColor ); // Ähnliche Farben auch transparent machen int[] rgb = new int[6]; for ( int i = 0; i < transparencyKey; i++ ) { rgb[0] = transparentColor.R - i; rgb[1] = transparentColor.G - i; rgb[2] = transparentColor.B - i; rgb[3] = transparentColor.R + i; rgb[4] = transparentColor.G + i; rgb[5] = transparentColor.B + i; for ( int u = 0; u < 6; u++ ) { if ( rgb[u] < 0 ) rgb[u] = 0; if ( rgb[u] > 255 ) rgb[u] = 255; } Color tc1 = Color.FromArgb( rgb[0], rgb[1], rgb[2] ); Color tc2 = Color.FromArgb( rgb[3], rgb[4], rgb[5] ); shadowBitmap.MakeTransparent( tc1 ); shadowBitmap.MakeTransparent( tc2 ); } DrawBitmap(); } }
Damit wäre das Kapitel Transparenz erledigt. Jetzt fehlt nur noch die Festlegung der Farbe für den Farbschleier. Der zu verwendende Alphawert wird über eine Textbox angegeben, sinnvoll ist hier ein Wert von 128 oder ein bisschen weniger. Damit wird der Effekt deutlich sichtbar, ohne dass das Bild im Hintergrund ganz verschwindet.
Sandini Bib
Bitmaps, Icons und Metafiles
783
private void BtnDrawColor_Click( object sender, System.EventArgs e ) { // Alphawert zuweisen if ( dlgColor.ShowDialog() == DialogResult.OK ) { Byte alphaValue = Byte.Parse( txtAlpha.Text ); alphaColor = Color.FromArgb( alphaValue, dlgColor.Color.R, dlgColor.Color.G, dlgColor.Color.B ); DrawBitmap(); } }
Ein Aufruf von DrawBitmap() am Ende der Methode genügt, da dort ja auch der Farbschleier gezeichnet wird. Das Ergebnis unserer Bemühungen sehen Sie in Abbildung 21.32.
Abbildung 21.32: Ein Bild mit Transparenz und Farbschleier. Zu beachten ist, dass die Transparenz nur auf das ursprüngliche Bild wirkt und der Farbschleier wirklich komplett darübergelegt wird.
Wichtig in diesem Moment ist auch folgende Überlegung: Dadurch, dass Farbschleier und Grafik getrennt sind, wirkt die Transparenz auch wirklich nur auf die Grafik. Auch die Auswahl einer Farbe wirkt nur auf die Grafik, d.h. der Farbanteil des Schleiers ist bei der Farberfassung nicht berücksichtigt. Dadurch ist sichergestellt, dass der Schleier immer komplett über das Bild gelegt wird und keine transparenten Elemente enthält.
21.4.5
Metafile-Dateien
Metafiles sind Dateien, in denen eine Abfolge von Grafikkommandos gespeichert sind. (In der Grafiksprache werden derartige Dateiformate als vektororientiert bezeichnet – im Gegensatz zu pixel- oder rasterorientierten Bitmap-Formaten.) Metafile-Dateien können von vielen Programmen wie andere Grafikdateien geladen und angezeigt werden. Der große Unterschied zu Bitmap-Dateien besteht darin, dass nicht das
Sandini Bib
784
21 Grafikprogrammierung (GDI+)
Endergebnis (also die Farben von einer Menge Pixeln) gespeichert werden, sondern die Kommandos zur Erzeugung der Grafik. Das hat vor allem zwei Vorteile: f Metafile-Dateien sind meistens viel kleiner (platzsparender) als Bitmap-Dateien. f Metafile-Dateien können beliebig skaliert werden. Anders als bei Bitmaps wird dadurch die Qualität nicht beeinträchtigt, da lediglich die Grafikkommandos auf die neue Größe umgerechnet werden.
Metafile-Formate Es gibt im .NET Framework nicht nur ein Metafile-Format, sondern gleich vier Formate bzw. Varianten: f Windows Metafile Format (WMF, seit Windows 3.1), Dateikennung *.wmf. f Enhanced Metafile Format (EMF, seit Windows 95), Dateikennung *.emf. f EMF+ Only: Eine neue, GDI+-konforme Metafile-Variante, die aber inkompatibel zum herkömmlichen GDI ist. (Herkömmliche Windows-Programme können EMF+OnlyDateien daher nicht lesen. Nur .NET-konforme Programme auf der Basis von GDI+ können damit umgehen.) Als Dateikennung wird weiterhin zumeist *.emf verwendet. f EMF+Dual: Um das EMF-Kompatibilitätsproblem zwischen GDI und GDI+ zu lösen, gibt es schließlich ein Dual-Format, das die Grafikinformationen gemäß dem alten und dem neuen EMF-Format enthält. Der Hauptnachteil besteht darin, dass derartige Dateien ca. doppelt so groß sind als eigentlich notwendig wäre. Laut Dokumentation können Sie mit der Klasse System.Drawing.Imaging.Metafile MetafileDateien lesen bzw. erzeugen. GDI+ kann Metafile-Dateien aller Formate anzeigen, selbst aber nur Dateien in den Formaten EMF, EMF+Only und EMF+Dual erzeugen (nicht WMF).
Metafile-Dateien laden und anzeigen
CD
Das Laden einer Metafile-Datei funktioniert analog zur Bitmap, nämlich durch Angabe eines Dateinamens oder eines Stream-Objekts im Konstruktor der Klasse Metafile. Über die Methode DrawImage() des Graphics-Objekts können Sie auch eine Vektorgrafik auf einem Formular (bzw. auf jedem Steuerelement, das ein Graphics-Objekt zur Verfügung stellt) anzeigen, sogar als Hintergrundbild. Der folgende Programmausschnitt zeigt, wie es funktioniert. Sowohl das Feld metaFile als auch die Methoden sind Bedtandteil des Hauptformulars. Das gesamte Beispielprogramm (inklusive der Grafiken) finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\ShowMetaFile.
Sandini Bib
Bitmaps, Icons und Metafiles private Metafile metaFile; private void FrmMain_Load( object sender, System.EventArgs e ) { // Metafile laden über FileStream FileStream stream = new FileStream( "sample.emf", FileMode.Open ); this.metaFile = new Metafile( stream ); stream.Close(); } private void FrmMain_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { // Zeichnen des Metafiles auf dem Panel Graphics g = pnlMain.CreateGraphics(); int x = 0; int y = 0; if ( ( metaFile.Width < pnlMain.Width ) && ( metaFile.Height < pnlMain.Height ) ) { x = (int)( ( pnlMain.Width - metaFile.Width ) / 2 ); y = (int)( ( pnlMain.Height - metaFile.Height ) / 2 ); } g.Clear( Color.White ); g.DrawImage( this.metaFile, x, y, g.VisibleClipBounds, GraphicsUnit.Pixel ); g.Dispose(); }
Auf dem Bildschrim sieht das Ganze dann aus wie in Abbildung 21.33.
Abbildung 21.33: Eine Vektorgrafik gezeichnet auf einem Panel.
785
Sandini Bib
786
21.4.6
21 Grafikprogrammierung (GDI+)
Syntaxzusammenfassung
Konstruktoren der Klasse Bitmap (aus System.Drawing) new Bitmap( string fileName )
lädt eine Bitmap aus einer Datei.
new Bitmap( Stream stream )
lädt eine Bitmap aus einem Stream.
new Bitmap( int w, int h )
erzeugt eine neue Bitmap mit der Breite w und der Höhe h.
new Bitmap( int w, int h, PixelFormat format )
wie oben, aber unter Anwendung des gewünschten Pixelformats. Die Aufzählung PixelFormat ist in System.Drawing.Imaging deklariert.
new Bitmap( Image img )
erzeugt eine Kopie der übergebenen Grafik.
new Bitmap( Image img, int w, int h )
erzeugt eine Kopie der übergebenen Grafik und ändert die Größe auf die durch w und h angegebenen Werte.
new Bitmap( Image img, Size size )
erzeugt eine neue Bitmap aus der übergebenen Grafik und legt die Größe auf den Wert fest, der durch size angegeben wird
bm.SetPixel( x, y, color )
zeichnet ein Pixel in der Bitmap.
bm.GetPixel( x, y )
ermittelt die Farbe eines Pixels der Bitmap und liefert das Ergebnis als Color-Objekt.
bm.SetTransparent(color)
macht alle Pixel, die exakt die Farbe color aufweisen, unsichtbar.
bm.Save( "filename", format )
speichert die Bitmap in einer Datei unter Verwendung des gewünschten Formats (Aufzählung Imaging.ImageFormat).
bm.Save( "filename", ici, encps )
speichert die Bitmap in einer Datei unter Verwendung des angegebenen Formats (ImageCodecInfo-Objekt) und unter Berücksichtigung zusätzlicher Parameter (EncoderParameters-Objekt).
bm.Save( stream, ... )
wie oben, aber speichert die Bitmap unter Zuhilfenahme des angegebenen Stream-Objekts.
ctr.Image = bm
zeigt die Bitmap in einem Steuerelement an.
frm.BackGroundImage = bm
zeigt die Bitmap als sich periodisch wiederholenden Hintergrund eines Fensters an.
gr = Graphics.FromImage( bm )
liefert ein Graphics-Objekt, um in einer Bitmap zu zeichnen.
gr.DrawImage( bm, ... )
zeichnet eine Bitmap in ein Graphics-Objekt.
Methoden der Klasse Bitmap (aus System.Drawing) SetPixel( int x, int y, Color color )
zeichnet ein Pixel in der Bitmap.
GetPixel( int x, int y )
ermittelt die Farbe eines Pixels der Bitmap. Zurückgeliefert wird ein Wert des Typs Color.
Sandini Bib
Fortgeschrittene Programmiertechniken
787
Methoden der Klasse Bitmap (aus System.Drawing) MakeTransparent( Color color )
macht alle Pixel, die die Farbe color aufweisen, transparent. Intern wird der Alpha-Wert der entsprechenden Pixel auf 0 gesetzt (wodurch sie transparent erscheinen).
Save( string fileName )
speichert die Bitmap in die Datei mit dem Namen fileName.
Save( string fileName, ImageFormat format )
speichert die Bitmap in einer Datei unter Verwendung des gewünschten Formats. Die Aufzählung ImageFormat ist in System.Drawing.Imaging definiert.
Save( Stream stream, ImageFormat format )
wie oben, aber speichert die Bitmap unter Zuhilfenahme des angegebenen Stream-Objekts.
Save( string fileName, ImageCodecInfo ici, EncoderParameters enc )
speichert die Bitmap in einer Datei unter Verwendung des angegebenen Formats und zusätzlicher Informationen. Das Format wird hier in Form eines ImageCodecInfo-Objekts angegeben, der Parameter enc dient zur Festlegung beispielsweise der Bildqualität oder des Kompressionsverfahrens.
Konstruktor und Methoden der Klasse Icon (aus System.Drawing) new Icon( string fileName )
lädt eine Icon-Datei und liefert ein Icon-Objekt.
ToBitmap()
liefert ein Bitmap-Objekt, das dem Icon entspricht.
Konstruktoren der Klasse Metafile (aus System.Drawing.Imaging) new MetaFile( string fileName )
erzeugt ein neues Objekt vom Typ Metafile aus der Datei mit dem übergebenen Dateinamen.
new MetaFile( Stream stream )
erzeugt ein neues Metafile-Objekt aus dem übergebenen Stream.
21.5
Fortgeschrittene Programmiertechniken
In diesem Abschnitt des Kapitels zeigen wir Ihnen einige Programmiertechniken, die mit GDI+ möglich sind. Nebenbei werden auch einige Interna des neuen Grafiksystems erläutert.
21.5.1
Zeichen- und Textqualität
Die Graphics-Klasse stellt einige Eigenschaften zur Verfügung, die die Qualität von Grafikund Textausgaben beeinflussen. Dabei gilt die (logische) Regel: Je höher die Qualität, desto langsamer die Grafikausgaben.
Sandini Bib
788
21 Grafikprogrammierung (GDI+)
Prinzipiell funktioniert die Verbesserung der Ausgabe folgendermaßen: Wenn eine Linie, eine Kurve oder ein Buchstabe gezeichnet wird, liegt ein einzelner Bildschirmpunkt unter Umständen nicht immer exakt innerhalb oder außerhalb des zu zeichnenden Objekts, sondern beispielsweise genau auf der Kante. In solchen Grenzfällen ergibt sich die neue Farbe des betroffenen Pixels aus einer Mischung von Hintergrundfarbe und Zeichenfarbe, wobei die Mischung umso stärker zur Zeichenfarbe tendiert, je mehr das Pixel dem Zeichenobjekt zuzurechnen ist. Diese Technik wird bei Text Antialiasing und bei anderen Zeichenoperationen Smoothing genannt. Bei der Textausgabe kommt noch hinzu, dass der Abstand zwischen Buchstaben in Bruchteilen von Pixeln variiert werden kann, um eine noch bessere Darstellungsqualität (vor allem bei sehr kleiner Schrift) zu erzielen. GDI+ sieht unzählige Eigenschaften vor, um diese Mechanismen zu steuern. In der Praxis reicht allerdings eine Variation der Eigenschaften CompositingQuality, SmoothingMode und TextRenderingHint der Klasse Graphics vollkommen aus, um einen optimalen Kompromiss zwischen Geschwindigkeit und Darstellungsqualität zu erzielen. Die entsprechenden Aufzählungen für die Werte dieser Eigenschaften sind im Namespace System.Drawing.Drawing2D definiert. f Die Eigenschaft CompositingMode gibt an, ob bei Grafikausgaben der Hintergrund berücksichtigt werden soll oder nicht. Die möglichen Einstellungen lauten CompositingMode.SourceOver und CompositingMode.CopyOver. CompositingMode.SourceOver (die Standardeinstellung) bewirkt, dass der Hintergrund
beim Zeichnen berücksichtigt wird. Alle im Weiteren beschriebenen Maßnahmen zur Erzielung einer hohen Ausgabequalität funktionieren ausschließlich in Kombination mit dieser Einstellung. CompositingMode.SourceCopy bewirkt, dass Ausgaben einfach in die vorhandene Grafik
kopiert werden. Bei einer vorhandenen Hintergrundgrafik ist das Ergebnis wenig berauschend. f Die Eigenschaft CompositingQuality gibt an, wie die Grafikausgabe auf dem schon vorhandenen Hintergrund erfolgen soll. Die höchste Qualität wird erzielt, wenn die Farbwerte der Pixel aus der Umgebung mit berücksichtigt werden. Mögliche Einstellungen sind unter anderem CompositingQuality.Default, CompositingQuality.HighSpeed und CompositingQuality.HighQuality. Die Einstellung betrifft sowohl Grafik- als auch Textausgaben. Inwieweit sich die Einstellung bemerkbar macht, hängt stark vom Hintergrund ab. f Die Eigenschaft InterpolationMode beeinflusst ebenfalls, wie die Grafikausgabe und die bereits vorhandene Hintergrundfarbe miteinander kombiniert werden. Es existieren mehrere Einstellungen für diese Eigenschaft, unter anderem InterpolationMode.Bicubic, InterpolationMode.High oder InterpolationMode.Default (die Standardeinstellung).
Sandini Bib
Fortgeschrittene Programmiertechniken
789
f Die Eigenschaft PixelOffsetMode gibt an, ob Koordinatenangaben in Bildschirmpixeln generell um einen halben Pixel versetzt interpretiert werden sollen. Mögliche Einstellungen sind unter anderem PixelOffsetMode.Default, PixelOffsetMode.Half oder PixelOffsetMode.None. Eine Veränderung der Darstellungsqualität im unten angegebenen Testprogramm durch die Variation dieses Parameters konnte allerdings nicht festgestellt werden. f Die Eigenschaft SmoothingMode gibt an, ob schräge Linien bzw. Kurven bei der Ausgabe geglättet werden. Mögliche Einstellungen sind unter anderem SmoothingMode.Default, SmoothingMode.HighSpeed oder SmoothingMode.HighQuality. Die Einstellung betrifft nur Grafikausgaben. Sie ist besonders deutlich bei leicht schrägen Linien bzw. flachen Kurven bemerkbar. f Die Eigenschaft TextRenderingHint gibt an, wie Text geglättet werden soll. Der Aufzählungstyp TextRenderingHint ist in System.Drawing.Text definiert. Diese Einstellung wirkt sich lediglich auf Textausgaben aus, nicht auf das Zeichnen von Grafiken. Mögliche Einstellungen sind unter anderem TextRenderingHint.SystemDefault (die Einstellung des Betriebssystems), TextRenderingHint.AntiAliasGridFit (optimale Darstellung auf gewöhnlichen Monitoren), TextRenderingHint.ClearTypeGridFit (optimale Darstellung auf LC-Displays), TextRenderingHint.SingleBitPerPixelGridFit (ein Kompromiss zwischen Geschwindigkeit und Qualität) bzw. TextRenderingHint.SingleBitPerPixel (am schnellsten).
HINWEIS
Die Einstellung TextRenderingHint.ClearTypeGridFit steht zurzeit nur unter Windows XP zur Verfügung. Auf älteren Windows-Versionen wird als Ersatz TextRenderingHint.SingleBitPerPixelGridFit verwendet. Wenn Sie schon mit anderen Grafikbibliotheken gearbeitet haben, ist Ihnen vielleicht aufgefallen, dass trotz der endlosen Liste von Möglichkeiten eine fehlt, nämlich die Einstellung des Zeichenmodus. Das Zeichnen im XOR-Modus, wie er vor allem bei dem berühmten Gummiband von Grafikanwendungen notwendig ist, ist mit GDI+ auf herkömmliche Art in der Tat nicht möglich. In manchen Fällen bieten die Methoden der Klasse ControlPaint aus System.Windows. Forms einen Ausweg. Ein entsprechendes Beispielprogramm finden Sie in Abschnitt 21.5.4 ab Seite 801.
Beispielprogramm Ein kleines Beispielprogramm zeigt, wie sich die Änderungen der verschiedenen Eigenschaften auswirken. Die Oberfläche des Beispielprogramms ist in Abbildung 21.34 dargestellt.
Sandini Bib
790
21 Grafikprogrammierung (GDI+)
Abbildung 21.34: Das Hauptformular im Entwurfsmodus
CD
Sie besteht aus mehreren ComboBox-Steuerelementen, die die einzelnen Werte für die dargestellten Eigenschaften aufnehmen. Mithilfe dieser Comboboxen können Sie die Werte selbst einstellen und die Veränderungen beobachten. Am besten sehen Sie das Ganze natürlich auf Ihrem Monitor, in einem Buch sind die Grafiken so fein aufgelöst, dass Darstellungsunterschiede oft nur schlecht sichtbar sind. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\QualityCheck.
Kernstück des Programms sind die beiden Methoden zum Zeichnen einmal eines Textes in verschiedenen Schriftgrößen und einmal einer Grafik (die Sie auch in Abbildung 21.35 sehen). Am Bildschirm können die Unterschiede deutlich ausgemacht werden. private void DrawGraphic( Graphics g ) { // Deklarationen float x = pnlDraw.Width / 2f; float y = pnlDraw.Height / 2f; float width = ( pnlDraw.Width / 2f ) - 10f; float height = ( pnlDraw.Height / 2f ) - 10f; // Zeichnen g.Clear( Color.White ); for ( int i = 0; i < 360; i++ ) { g.DrawLine( Pens.Black, x, y, (float)( x + Math.Sin( i / 180f * Math.PI ) * width ), (float)( y + Math.Cos( i / 180f * Math.PI ) * height ) ); } }
Sandini Bib
Fortgeschrittene Programmiertechniken
791
private void DrawText( Graphics g ) { // Text schreiben string text = "abcdefghijklmnopqrstuvwxyz"; int[] sizes = { 5, 6, 10, 16, 25, 48, 72 }; int x = 5; int y = 5; foreach ( int size in sizes ) { Font fnt = new Font( "Arial", size, FontStyle.Italic ); g.DrawString( text, fnt, Brushes.Blue, x, y ); fnt.Dispose(); y += 2 * size; } }
Damit die Zeichnungen bzw. der Text nach dem Verdecken des Fensters nicht verschwinden, wird wieder das Paint-Ereignis zum Zeichnen verwendet. Die beiden Buttons setzen eigentlich nur zwei Felder auf true oder false, die im Formular deklariert sind. Deren Wert wird in der Zeichenmethode überprüft, entsprechend wird gezeichnet. private void BtnGraphic_Click( object sender, System.EventArgs e ) { this.doDrawGraphic = true; this.doDrawText = false; this.Refresh(); } private void BtnText_Click( object sender, System.EventArgs e ) { this.doDrawGraphic = false; this.doDrawText = true; this.Refresh(); }
Die umfangreichen Zeilen vor dem eigentlichen Zeichenaufruf dienen der Übernahme der Werte aus dem Comboboxen. Da es sich um Werte von Aufzählungstypen handelt, müssen sie vor der Zuweisung geparsed und in den korrekten Wert gecastet werden. Beachten Sie bitte auch, dass es sich nicht um das Paint-Ereignis des Formulars handelt, sondern vielmehr um das des Panels (auf dem ja auch gezeichnet wird). private void PnlDraw_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { // Graphics-Objekt festlegen Graphics g = pnlDraw.CreateGraphics(); // Ermitteln der relevanten Werte g.CompositingMode = CompositingMode.SourceOver; g.CompositingQuality = (CompositingQuality)Enum.Parse( typeof( CompositingQuality ), cbxCompositing.SelectedItem.ToString(), true );
Sandini Bib
792
21 Grafikprogrammierung (GDI+)
g.PixelOffsetMode = (PixelOffsetMode)Enum.Parse( typeof( PixelOffsetMode ), cbxPixelOffset.SelectedItem.ToString(), true ); g.SmoothingMode = (SmoothingMode)Enum.Parse( typeof( SmoothingMode ), cbxSmoothing.SelectedItem.ToString(), true ); g.TextRenderingHint = (TextRenderingHint)Enum.Parse( typeof( TextRenderingHint ), cbxTextRendering.SelectedItem.ToString(), true ); // Zeichnen if ( this.doDrawGraphic ) DrawGraphic( g ); if ( this.doDrawText ) DrawText( g ); g.Dispose(); }
Abbildung 21.35 zeigt zwei Screenshots des Ausgabebereichs, an denen die Auswirkungen in diesem Fall der Einstellung SmoothingMode deutlich hervortreten. Die linke Grafik wurde in den Standardeinstellungen gezeichnet, die rechte in der Einstellung SmoothingMode. AntiAlias.
Abbildung 21.35: Zweimal die gleiche Grafik, bei der rechten wurde SmoothingMode auf SmoothingMode.AntiAlias eingestellt.
21.5.2
Grafikobjekte zusammensetzen (GraphicsPath)
Die Klasse GraphicsPath aus dem Namespace System.Drawing.Drawing2D ermöglicht es, mehrere grafische Bausteine zu einem neuen Objekt zusammenzufügen. Am Anfang steht der Aufruf der Methode StartFigure(), mit CloseFigure() wird die Figur abgeschlossen. Über die Methoden AddLine(), AddRectangle(), AddEllipse() usw. können Sie die gewünschten Bestandteile hinzufügen. Sogar das Hinzufügen einer Zeichenkette ist über AddString() möglich.
Sandini Bib
Fortgeschrittene Programmiertechniken
793
Zum Zeichnen der fertigen Figur dient die Methode DrawPath() der Klasse Graphics. Mit FillPath() wird der Inhalt der Figur ausgefüllt. Dabei können Sie über die Eigenschaft FillMode der Klasse GraphicsPath festlegen, auf welche Art die Füllfläche berechnet wird. Mit der Einstellung FillMode.Alternate, der Standardeinstellung, wird die Schnittmenge gebildet. Die genaue Vorgehensweise ist einfach – es wird eine Linie von einem beliebigen Punkt innerhalb des GraphicPath-Objekts zu einem beliebigen Punkt außerhalb dieses Objekts gezogen. Schneidet diese Linie eine ungerade Anzahl von Pfadsegmenten, liegt der Startpunkt innerhalb der zu füllenden Fläche, andernfalls nicht. Mit der Einstellung FillMode.Winding wird ebenso vorgegangen, es wird allerdings die Richtung der Pfadsegmente berücksichtigt. Verläuft ein Segment im Uhrzeigersinn, wird 1 addiert, ansonsten 1 subtrahiert. Ergibt sich am Ende der Wert 0, liegt das Segment außerhalb des Füllbereichs, ansonsten innerhalb.
Beispielprogramm Das folgende Beispielprogramm zeigt eine einfache Anwendung eines GraphicsPath. Das GraphicsPath-Objekt wird in Form1_Load initialisiert. Es besteht aus einer Ellipse und dem Text abc. Beachten Sie, dass AddString() kein Font-, sondern ein FontFamily-Objekt als Parameter erwartet.
CD
Die resultierende Figur wird im Paint-Ereignis zweimal ausgegeben: einmal als Linienzug mit DrawPath() und einmal als Fläche mit FillPath(). Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\GraphicsPathExample.
private GraphicsPath graphicsPath = new GraphicsPath(); private void Form1_Load(object sender, System.EventArgs e) { FontFamily ff = new FontFamily("Arial"); graphicsPath.StartFigure(); graphicsPath.AddEllipse(20, 20, 150, 80); graphicsPath.AddString( "abc", ff, (int)FontStyle.Bold, 50f, new PointF(30f, 30f), StringFormat.GenericDefault); graphicsPath.CloseFigure(); } private void Form1_Paint(object sender, System.Windows.Forms.PaintEventArgs e) { Graphics g = e.Graphics; g.DrawPath(Pens.Black, graphicsPath); g.TranslateTransform(0f, 130f); g.RotateTransform(-30f); g.FillPath(Brushes.Blue, graphicsPath); }
Sandini Bib
794
21 Grafikprogrammierung (GDI+)
Das Resultat des kurzen Quellcodes sehen Sie in Abbildung 21.36.
Abbildung 21.36: Ein einfaches GraphicsPath-Beispiel
21.5.3
Umgang mit Regionen und Clipping
Die Klasse Region repräsentiert einen Zeichenbereich. Sie dinet in der Hauptsache dazu, einerseits den Zeichenbereich zu beschränken oder andererseits zu überprüfen, ob ein Pixel innerhalb eines bestimmten Bereichs liegt. Ein Region-Objekt kann aus Rechtecken und GraphicsPath-Objekten zusammengesetzt werden und beinhaltet im Gegensatz zu GraphicsPath immer geschlossene Flächen.
Regions zusammensetzen Wenn Sie ein Region-Objekt ohne Parameter erzeugen, ist darin die gesamte Zeichenfläche erfasst. Wenn Sie mit einer leeren Region beginnen möchten, müssen Sie die Methode MakeEmpty() ausführen. Das Gegenstück zu MakeEmpty() ist MakeInfinite(), das ein RegionObjekt mit einem unbegrenzten Inneren initialisiert. Anschließend können Sie das Region-Objekt bearbeiten. Dazu stehen vor allem die Methoden Complement (), Exclude(), Intersect(), Union() und Xor() zur Verfügung. Alle Methoden nehmen als Parameter wahlweise ein Objekt der Typen Rectangle, RectangleF, GraphicsPath oder Region entgegen. Die folgende Tabelle beschreibt die Wirkung der fünf Methoden. Beachten Sie, dass bei Regionen mit Löchern bzw. Inseln bisweilen unerwartete Ergebnisse entstehen.
Sandini Bib
Fortgeschrittene Programmiertechniken
795
Methoden der Klasse Region (aus System.Drawing) verkleinert eine Region um den durch reg festgelegten Bereich. Das entspricht einer Subtraktion (die übergebene Region wird von der aufrufenden subtrahiert).
Exclude( Region reg )
Auch hierbei handelt es sich um eine Subtraktion. Die übergebene Region reg wird um den Bereich der aufrufenden Region verkleinert. Damit ist diese Methode das Gegenstück zu Complement().
Intersect( Region reg )
Intersect()
Union( Region reg )
Union()
Xor( Region reg )
Xor() invertiert die aufrufende Region im Bereich der übergebenen Region reg.
HINWEIS
Complement( Region reg )
bildet die gemeinsame Schnittmenge zweier Regionen.
vereint zwei Regionen (entspricht also einer Addition)
Das Region-Objekt implementiert die IDisposable-Schnittstelle. Sie sollten RegionObjekte mittels Dispose() löschen , wenn Sie sie nicht mehr benötigen.
Clipping Der Begriff Clipping bedeutet, dass Grafikausgaben innerhalb eines vorgegebenen Bereichs abgeschnitten werden. Wenn Sie also eine Linie von (0,0) bis (100,100) zeichnen, die Clipping-Region aber ein Rechteck von (20,20) bis (60,60) beinhaltet, dann wird von der Linie nur der Teil innerhalb des Clipping-Rechtecks gezeichnet – in diesem Fall also von (20,20) bis (60,60). GDI+ verwendet Clipping auch intern: Wenn Sie beispielsweise Grafikausgaben innerhalb eines Steuerelements durchführen, werden diese wie selbstverständlich am Rand des Steuerelements abgeschnitten (so dass es also unmöglich ist, über die Grenzen eines Steuerelements hinauszuzeichnen. Außerdem wird das Clipping-Gebiet bei Paint-Ereignissen automatisch auf den Bereich des Steuerelements verkleinert, der tatsächlich neu gezeichnet werden muss. Das bewirkt in erster Linie eine (bisweilen merkliche) Effizienzsteigerung und reduziert ein Flimmern am Bildschirm. Während viele Grafikbibliotheken nur rechteckige Clipping-Bereiche zulassen, akzeptiert GDI+ jedes beliebige Region-Objekt als Clipping-Gebiet. Das aktuelle Clipping-Gebiet des Graphics-Objekts können Sie über die Eigenschaft Clip lesen und verändern. Clip liefert bzw. erwartet ein Region-Objekt. Zur Einstellung des Clipping-Bereichs können Sie alternativ auch die Methode SetClip() einsetzen. Der Vorteil gegenüber der Eigenschaft Clip besteht darin, dass Sie mit SetClip() das bereits vorhandene Clipping-Gebiet verändern können. Außerdem akzeptieren die Varianten von SetClip() auch andere Objekttypen zur Angabe des Clipping-Bereichs (z.B. ein Rectangle-Objekt), was in einfachen Fällen den Umweg über ein Region-Objekt erspart.
Sandini Bib
796
21 Grafikprogrammierung (GDI+)
Sichtbarkeitstest Mit der Eigenschaft IsVisible können Sie testen, ob sich der als Parameter angegebene Koordinatenpunkt innerhalb der Region befindet bzw. ob ein angegebenes Rechteck sich zumindest teilweise mit der Region überlappt.
Beispielprogramm
CD
Das Beispielprogramm bildet eine Region aus dem Text GDI+ und einem Rechteck. Diese Region wird mit einem Quadrat XOR-verknüpft. Die so entstandene Region wird als Clipping-Gebiet für die Grafikausgabe innerhalb des Fensters verwendet. Innerhalb dieses Gebiets wird zuerst alles mit einer beliebigen Farbe (hier Blau) gefüllt, anschließend werden konzentrische weiße Kreise darüber gezeichnet. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\RegionTest.
Die Initialisierung der Region erfolgt im Ereignis Load des Formulars. Gezeichnet wird wieder auf einem Panel, dessen Hintergrundfarbe weiß ist. Der gesamte Code sollte eigentlich keine besonderen Schwierigkeiten bereiten. private void FrmMain_Load( object sender, System.EventArgs e ) { // Deklarationen GraphicsPath gp = new GraphicsPath(); int width = pnlDraw.Width; int height = pnlDraw.Height; int rectX = (int)( ( width - 200 ) / 2 ); int rectY = (int)( ( height - 200 ) / 2 ); // GraphicsPath erzeugen gp.AddString( "GDI+", new FontFamily( "Arial" ), (int)FontStyle.Bold, 150, new Point( 0, 0 ), StringFormat.GenericDefault ); // Mehrere Grafikobjekte hinzufügen this.region.MakeEmpty(); this.region.Union( gp ); this.region.Union( new Rectangle( 0, height - 50, width, 20 ) ); this.region.Xor( new Rectangle( rectX, rectY, 200, 200 ) ); }
Sandini Bib
Fortgeschrittene Programmiertechniken
797
private void PnlDraw_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { // Muster zeichnen Graphics g = e.Graphics; g.SetClip( this.region, CombineMode.Replace ); g.Clear( Color.Blue ); for ( int i = 0; i < 301; i += 5 ) g.DrawEllipse( Pens.White, 200 - i, 100 - i, 2 * i, 2 * i ); }
Das Ergebnis der Bemühungen sehen Sie in Abbildung 21.37.
Abbildung 21.37: Demonstrationsprogramm für Regionen
VORSICHT
Wenn nur Teile des Steuerelements neu zu zeichnen sind, dann wird an die PaintProzedur automatisch ein Graphics-Objekt übergeben, dessen Clipping-Bereich (Eigenschaft Clip) entsprechend verkleinert ist. Das steigert die Effizienz beim Neuzeichnen des Steuerelementinhalts und vermindert ein Flimmern bei Größenänderungen. Das Standardverhalten von Windows.Forms bei einer Größenänderung hat einen Nachteil: Wenn der Grafikinhalt eines Steuerelements von seiner Größe abhängig ist, dann muss bei einer Vergrößerung immer der gesamte Inhalt neu gezeichnet werden, nicht nur die neu dazugekommenen Teile. Ebenso muss der gesamte Inhalt auch bei einer Verkleinerung komplett neu gezeichnet werden. (In diesem Fall kommt es nur zu einem Resize-, aber zu keinem Paint-Ereignis.) Aus diesem Grund kommt es zu einer falschen Bildschirmdarstellung, wie in Abbildung 21.38 auf Seite 801 zu sehen. Abhilfe schafft in diesen Fällen die ResizeRedraw-Eigenschaft, die etwas weiter unten beschrieben wird.
Sandini Bib
798
21 Grafikprogrammierung (GDI+)
Im Regelfall brauchen Sie sich um die Clipping-Informationen nicht zu kümmern. In Ihrer Paint-Ereignisbehandlungsroutine zeichnen Sie einfach alles neu. Dank Clipping erfolgt die Ausgabe aber nur da, wo es notwendig ist. Wenn es Sie aber interessiert, welche Bereiche des Fensters neu zu zeichnen sind, dann können Sie die diversen Clipping-Eigenschaften des Parameters e in der Paint-Ereignisbehandlungsroutine sowie des Graphics-Objekts auswerten. Clipping-Eigenschaften innerhalb eines Paint-Ereignisses e.ClipRectangle
enthält ein Rectangle-Objekt, das das Clipping-Gebiet für die Ausgabe angibt.
e.Graphics.Clip
enthält das exakte Clipping-Gebiet als Region-Objekt. Wie bereits im vorigen Abschnitt beschrieben wurde, kann ein Region-Objekt aus mehreren Teilgebieten, z.B. aus mehreren Rechtecken zusammengesetzt sein.
e.Graphics.ClipBounds
enthält ein RectangleF-Objekt, das die äußere Begrenzung des größtmöglichen Clipping-Gebiets für das Steuerelement angibt. Eigenen Tests zufolge hat dieses Rechteck immer dieselbe Größe: X=-4194304, Y=-4194304, Width=8388608, Height=8388608.
e.Graphics.VisibleClipBounds
enthält ein RectangleF-Objekt, das den sichtbaren Bereich des Steuerelements angibt.
Grundsätzlich sollte es möglich sein, das über die Eigenschaft Clip ermittelte Region-Objekt weiter auszuwerten. So sollte die Methode GetBounds() die Umrandung des ClippingGebiets umgerechnet in die Koordinaten des als Parameter angegebenen Graphics-Objekts liefern. (Tatsächlich liefert GetBounds() aber dasselbe wertlose Ergebnis wie ClipBounds.) Ebenso sollte es mit GetRegionScans() möglich sein, rechteckige Teilbereiche der Region zu ermitteln. Allerdings sind sämtliche Versuche gescheitert, mit dieser Methode den Clipping-Bereich genauer zu analysieren. Die Methode liefert abermals nur ein einziges Rechteck mit beinahe unendlicher Größe.
HINWEIS
Das Fazit: Wenn Sie wissen möchten, welche Teile Ihres Steuerelements neu gezeichnet werden müssen, bietet einzig die Eigenschaft ClipRectangle des Parameters e eine zuverlässige Information. Allerdings ist das mit dieser Eigenschaft ermittelte Rechteck oft viel größer als der Bereich, der tatsächlich neu gezeichnet werden muss. Unabhängig vom Inhalt der Clipping-Eigenschaften werden Grafikausgaben immer am Rand des Steuerelements abgeschnitten. Das ist gewissermaßen eine zusätzliche Clipping-Ebene, auf die Sie keinen Zugriff haben.
Sandini Bib
Fortgeschrittene Programmiertechniken
799
Neuzeichnen manuell auslösen Das Paint-Ereignis wird automatisch ausgelöst, wenn Teile des Steuerelements sichtbar werden. Sie können das Ereignis aber auch manuell auslösen, indem Sie die Invalidate()Methode für das Steuerelement bzw. für das Formular ausführen. pictureBox1.Invalidate()
Wenn Sie möchten, dass nur ein bestimmter Bereich des Steuerelements neu gezeichnet werden soll, können Sie als Parameter ein Rectangle- oder Region-Objekt angeben. pictureBox1.Invalidate( new Rectangle( 50, 50, 50, 20 ) )
Falls in dem Steuerelement andere Steuerelemente eingebettet sind, können Sie mit einem zweiten optionalen Parameter angeben, ob das Paint-Ereignis auch für diese Steuerelemente ausgelöst werden soll. In den Beispielen haben Sie gesehen, dass Refresh() oder Update() zum Neuzeichnen verwendet wurden. Das funktioniert ebenfalls, bewirkt aber grundsätzlich das Neuzeichnen des gesamten Steuerelements.
Das Resize-Ereignis
VERWEIS
Das Resize-Ereignis tritt bei einer Änderung der Steuerelementgröße vor dem PaintEreignis auf. Der an die Ereignisbehandlungsroutine übergebene Parameter e enthält keine spezifischen Daten. Die neue Innengröße des Steuerelements (also die Größe des Zeichenbereichs) können Sie mit der Eigenschaft ClientSize des Steuerelements ermitteln. Neben dem Resize-Ereignis gibt es noch zwei andere Ereignisse, die bei der Änderung der Größe eines Steuerelements oder Fensters auftreten: Layout und SizeChanged. Das Layout-Ereignis tritt vor Resize auf und sollte dazu verwendet werden, Steuerelemente gegebenenfalls neu zu platzieren (soweit dies nicht aufgrund von Anchoroder Dock-Eigenschaften automatisch erfolgt). SizeChanged tritt nach Resize auf.
ResizeRedraw-Eigenschaft Die Eigenschaft ResizeRedraw ist im Eigenschaftenfenster nicht sichtbar. Sie ist als protected deklariert und stammt aus der Klasse Control. Über diese Eigenschaft wird festgelegt, ob ein Steuerelement (auch ein Formular ist in gewisser Weise ein Steuerelement) bei einer Größenänderung komplett neu gezeichnet wird oder nicht. In der Standardeinstellung wird beim Verkleinern nichts neu gezeichnet, bei einer Vergrößerung nur der Teil, der hinzugekommen ist. Wenn Sie diese Eigenschaft auf true setzen, wird das Steuerelement bei einer Größenänderung komplett neu gezeichnet. Da die Eigenschaft protected ist, kommen Sie auf herkömmliche Weise nicht heran. Sie müssen zunächst ein eigenes Steuerelement von einem bestehenden Steuerelement (oder von Control) ableiten (dann erhalten Sie Zugriff innerhalb
Sandini Bib
800
21 Grafikprogrammierung (GDI+)
Ihrer neuen Klasse). Im Falle eines Formulars ist das kein Problem, denn dieses ist ja bereits von der Klasse Form abgeleitet, wodurch der Zugriff ermöglicht wird. Dieselbe Wirkung wie durch ResizeRedraw=true erzielen Sie übrigens, wenn Sie in der Resize-Ereignisbehandlungsroutine eine Invalidate()-Anweisung für das betroffene Steuerelement angeben. Diese Vorgehensweise ist aber weniger elegant und kann dazu führen, dass das Paint-Ereignis für manche Teile des Steuerelements mehrfach ausgelöst wird. Alternativ (wie auch in den Beispielen häufiger praktiziert) können Sie auch den Hintergrund des Steuerelements bei jedem Zeichenvorgang löschen (bei umfangreichen Zeichnungen kommt es dann aber zu einem Flimmern).
Beispielprogramm
CD
Im folgenden Beispielprogramm wird lediglich eine Grafik gezeichnet. Dabei wird im LoadEreignis des Formulars einmal ResizeRedraw auf true eingestellt und einmal nicht. Das Ergebnis sehen Sie in Abbildung 21.38. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\ResizeRedraw.
private void FrmMain_Load( object sender, System.EventArgs e ) { // ResizeRedraw einstellen this.ResizeRedraw = true; // bzw. false ... this.Text = "ResizeRedraw: " + this.ResizeRedraw.ToString(); } private void FrmMain_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { // Deklarationen Graphics g = e.Graphics; float x = (float)( this.ClientSize.Width / 2 ); float y = (float)( this.ClientSize.Height / 2 ); // Zeichnen g.SmoothingMode = SmoothingMode.AntiAlias; // Kreise for ( float i = 0f; i < 1.1f; i += 0.05f ) g.DrawEllipse( Pens.Black, x * ( 1 - i ), y * ( 1 - i ), x * 2 * i, y * 2 * i ); // Linien g.DrawLine( Pens.Black, 0, 0, x * 2, y * 2 ); g.DrawLine( Pens.Black, 0, y * 2, x * 2, 0 ); }
Sandini Bib
Fortgeschrittene Programmiertechniken
801
Und hier nun die Ergebnisse im Vergleich. Die Fenster wurden zunächst verkleinert und dann auf die gleiche Größe gezogen. Das funktioniert auch im Beispiel – die Eigenschaften MaximumSize und MinimumSize wurden einfach entsprechend angepasst.
Abbildung 21.38: Die Auswirkungen der Eigenschaft ResizeRedraw
21.5.4
Rechteck-Auswahl mit der Maus (Rubberbox)
Eine häufig verwendete Methode der Auswahl eines Bereichs in Grafikprogrammen ist das so genannte »Gummiband«. Üblicherweise wird es erzeugt, indem ein Rechteck in einem XOR-Zeichenmodus gezeichnet wird. Wenn das Rechteck erneut gezeichnet wird, verschwindet es wieder.
CD
GDI+ hat keine Möglichkeit, einen solchen Zeichenmodus einzustellen. Prinzipiell wäre das nicht schlimm, allerdings gehört diese Fähigkeit zu den elementaren Dingen, was das Zeichnen angeht. In diesem Abschnitt möchten wir Ihnen zeigen, wie Sie trotzdem ein solches »Gummiband« (Rubberband oder auch als Rubberbox bezeichnet) erstellen können. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\RubberboxExample.
Grundlage des Zeichnens ist ein PictureBox-Steuerelement. Mit der Maus kann darin ein Rahmen aufgezogen werden, der dann nach dem Loslassen des Mausbuttons in einer zufälligen Farbe gefüllt wird. Wird eine Taste betätigt, wird die Auswahl aufgehoben.
Beispielprogramm Das Beispielprogramm basiert auf der statischen Methode DrawReversibleFrame() der Klasse ControlPaint aus dem Namespace System.Windows.Forms. Mit dieser Methode können Sie ein Rechteck zeichnen. Wenn Sie das Rechteck exakt an derselben Position ein zweites Mal zeichnen, verschwindet es wieder.
Sandini Bib
802
21 Grafikprogrammierung (GDI+)
HINWEIS
DrawReversibleFrame() arbeitet allerdings mit Desktop-Koordinaten und nicht mit den Koordinaten Ihres Formulars oder der verwendeten PictureBox. Außerdem werden Sie feststellen, dass Sie in der Tat auf dem Desktop zeichnen, nicht im Steuerelement. Wir müssen also die gewünschten Koordinaten in Desktop-Koordinaten umrechnen. Dabei ist die Methode PointToScreen() hilfreich, die genau das tut. ControlPaint kennt noch zwei verwandte, ebenfalls statische Methoden: DrawReversibleLine() zeichnet eine Linie, FillReversibleRectangle() zeichnet ein gefülltes
Rechteck. Auch bei diesen Methoden verschwindet das Objekt wieder, wenn der Zeichenvorgang wiederholt wird. Sie stellen eine Art Ersatz für den in GDI+ nicht existierenden XOR-Zeichenmodus dar. Da es also offensichtlich möglich ist, stellt sich die Frage, warum ein solcher XOR-Modus nicht gleich implementiert wird.
Am Anfang steht die Deklaration einiger Felder, die im Programm benötigt werden. Unter anderem brauchen wir die Informationen über Position und Größe der Rubberbox, sowie ob sie aktuell sichtbar oder sogar ob das Zeichnen gecancelled wurde. private private private private private
bool rubberVisible = false; bool rubberCancel = false; Point rubberPosition; Size rubberSize; Point rubberOrigin;
// // // // //
sichtbar oder nicht? gecancelled? Position in Desktop-Koordinaten Größe der Rubberbox Position in Formularkoordinaten
Zum Zeichnen und Entfernen der Rubberbox werden die Methoden DrawRubberBox() und RemoveRubberBox() verwendet. Der Unterschied besteht nur in der Auswertung von rubberVisible, was auch daran zu erkennen ist, dass RemoveRubberBox() um redundanten Code zu vermeiden DrawRubberBox() aufruft. private void DrawRubberBox() { this.rubberVisible = true; Rectangle rubberRect = new Rectangle( rubberPosition, rubberSize ); ControlPaint.DrawReversibleFrame( rubberRect, Color.Black, FrameStyle.Dashed ); } private void RemoveRubberBox() { if ( this.rubberVisible ) DrawRubberBox(); this.rubberVisible = false; }
Die eigentliche Rechteckauswahl erfolgt in den Ereignisbehandlungsroutinen MouseDown, MouseUp und MouseMove des PictureBox-Steuerelements. In MouseDown beginnt die Auswahl. Hier werden die benötigten Felder initialisiert, die Größe des Rechtecks auf 0 eingestellt und ein (wegen der Größe 0 unsichtbares) Rechteck gezeichnet.
Sandini Bib
Fortgeschrittene Programmiertechniken
803
In MouseMove wird die Größe des Rechtecks verändert. Zunächst wird aber kontrolliert, ob nicht etwa abgebrochen wurde (also z.B. bei einem Druck auf eine Taste bzw. beim Verlassen des Bereichs der PictureBox) und ob die linke Maustaste gedrückt ist. Die Auswahl geschieht also nur mit der linken Maustaste. Falls nicht abgebrochen wurde, wird ein bestehendes Rechteck durch Aufruf von RemoveRubberBox() entfernt und die Größe neu berechnet. Auf die Grenzen des Steuerelements muss dabei geachtet werden, denn Sie wissen ja, dass wir eigentlich auf dem Desktop zeichnen (und nicht auf der PictureBox). Im Ereignis MouseUp schließlich wird die Auswahl beendet, eine zufällige Farbe generiert und das Rechteck gefüllt. Die Berechnung der Eckkoordinaten wirkt umständlich, ist aber so notwendig. Das an DrawReversibleFrame() übergebene Rechteck muss nämlich positiv sein, d.h. die Koordinaten x0 und y0 müssen kleiner sein als die Koordinaten x1 und y1. Wir stellen das sicher, indem wir die Methode SwapValues() falls nötig aufrufen, um die Werte zu vertauschen. private void SwapValues( ref int a, ref int b ) { int tmp = a; a = b; b = tmp; } private void PicDraw_MouseDown( object sender, System.Windows.Forms.MouseEventArgs e ) { this.rubberCancel = false; this.rubberOrigin = new Point( e.X, e.Y ); this.rubberPosition = picDraw.PointToScreen( this.rubberOrigin ); this.rubberSize = new Size( 0, 0 ); // Startrechteck zeichnen DrawRubberBox(); } private void PicDraw_MouseMove( object sender, System.Windows.Forms.MouseEventArgs e ) { // Zeichnen nur bei linkem Mausbutton if ( ( e.Button == MouseButtons.Left ) && !this.rubberCancel ) { int x = e.X; int y = e.Y; // Rubberbox löschen RemoveRubberBox(); // Neue Größe ermitteln x = ( x < 0 ) ? 0 : x; x = ( x > picDraw.ClientSize.Width ) ? picDraw.ClientSize.Width : x;
Sandini Bib
804
21 Grafikprogrammierung (GDI+) y = ( y < 0 ) ? 0 : y; y = ( y > picDraw.ClientSize.Height ) ? picDraw.ClientSize.Height : y; // Neues Rechteck zeichnen this.rubberSize = new Size( x - this.rubberOrigin.X, y - this.rubberOrigin.Y ); DrawRubberBox();
} } private void PicDraw_MouseUp( object sender, System.Windows.Forms.MouseEventArgs e ) { if ( !this.rubberCancel ) { // Rubberbox löschen RemoveRubberBox(); // Gefülltes Rechteck zeichnen Graphics g = picDraw.CreateGraphics(); // Farbe ermitteln Random rnd = new Random( DateTime.Now.Millisecond ); Brush b = new SolidBrush( Color.FromArgb( rnd.Next( 256 ), rnd.Next( 256 ), rnd.Next( 256 ) ) ); // Koordinaten ermitteln int x0 = this.rubberOrigin.X; int x1 = x0 + this.rubberSize.Width; if ( x0 > x1 ) SwapValues( ref x0, ref x1 ); int y0 = this.rubberOrigin.Y; int y1 = y0 + this.rubberSize.Height; if ( y0 > y1 ) SwapValues( ref y0, ref y1 ); // Zeichnen g.FillRectangle( b, x0, y0, x1 - x0, y1 - y0 ); b.Dispose(); g.Dispose(); } }
Wird eine Taste gedrückt oder der Fokus der Picturebox verlassen, soll die Auswahl aufgehoben werden. Hier werden lediglich rubberCancel auf true gesetzt und die Rubberbox entfernt. private void PicDraw_MouseLeave( object sender, System.EventArgs e ) { RemoveRubberBox(); this.rubberCancel = true; }
Sandini Bib
Fortgeschrittene Programmiertechniken
805
private void FrmMain_KeyDown( object sender, System.Windows.Forms.KeyEventArgs e ) { RemoveRubberBox(); this.rubberCancel = true; }
Das Ergebnis des Programms sehen Sie in Abbildung 21.39.
Abbildung 21.39: Einige mittels RubberBox gezogene Rechtecke
21.5.5
Bitmap-Grafik zwischenspeichern (AutoRedraw)
In anderen Programmiersprachen gab oder gibt es die Möglichkeit, Grafiken zum Zwecke der schnelleren Anzeige zwischenzuspeichern. Auch Visual Basic 6 hatte eine solche Funktion (einzuschalten über die Eigenschaft AutoRedraw). In GDI+ gibt es leider keine fest eingebaute Puffer-Möglichkeit. Es ist jedoch mit einigen wenigen Zeilen Code möglich, diese Funktionalität nachzubauen und eine Bitmap im Hintergrund zu verwalten. Dazu gibt es zwei Möglichkeiten. f In der Variante 1 wird die Bitmap mit dem Steuerelement verbunden, das zur Anzeige dient. Sinnvollerweise sollte es sich dabei nicht um ein Panel handeln, sondern um eine PictureBox, bei der die anzuzeigende Grafik direkt über die Eigenschaft Image festgelegt werden kann. Diese Vorgehensweise hat den Vorteil, dass das Programm nicht mehr auf Paint-Ereignisse reagieren muss. Es reicht vielmehr aus, wenn nach Änderungen an der Bitmap für das Steuerelement die Methode Refresh() ausgeführt wird, damit die Änderungen in der Bitmap auch im Steuerelement sichtbar werden.
Sandini Bib
806
21 Grafikprogrammierung (GDI+)
f In der Variante zwei wird »richtig« gepuffert. Die anzuzeigende Bitmap wird getrennt vom Steuerelement verwaltet und dann im Paint-Ereignis unskaliert in das Steuerelement gezeichnet. Diese Variante scheint auf den ersten Blick weniger elegant, hat aber den Vorteil, dass sie mit jedem Steuerelement funktioniert. Ein entscheidender Punkt bei beiden Varianten besteht darin, dass die Bitmap mit dem Steuerelementinhalt automatisch an dessen Größe angepasst werden muss. Wenn also das Steuerelement in eine oder in beide Richtungen vergrößert wird, dann muss auch die Bitmap vergrößert werden (natürlich ohne dass ihr Inhalt verloren geht). Da GDI+ dazu keine Möglichkeit bietet, muss eine neue Bitmap erzeugt und die ursprüngliche Bitmap hineinkopiert werden.
Beispielprogramm zu Variante 1 Das erste Beispiel zeichnet auf Knopfdruck ein aus verschiedenfarbigen Kreisen bestehendes Muster. Die Grafik wird in einem PictureBox-Steuerelement angezeigt, deren Größe sich automatisch mit dem Fenster ändert. Im Load-Ereignis des Formulars wird eine Grafik erzeugt, die genau so groß ist, wie der Innenbereich der PictureBox. Diese Grafik wird der Eigenschaft Image zugewiesen. Im Resize-Ereignis wird getestet, ob die Breite oder Höhe der PictureBox größer ist als die bereits vorhandene Bitmap. Wenn das der Fall ist, wird eine neue Bitmap erzeugt. Dabei wird darauf geachtet, dass die neue Bitmap in keiner Dimension kleiner als die bisherige wird. Es könnte ja sein, dass das Fenster zwar breiter, aber gleichzeitig in der Höhe kleiner gemacht wurde.
CD
Die ursprüngliche Bitmap wird nun mithilfe der Methode DrawImageUnscaled() in die neue Bitmap kopiert. Dazu wird ein Graphics-Objekt für die neue Bitmap erzeugt, um die notwendigen Grafikmethoden ausführen zu können. Nun kann die neue Bitmap der ImageEigenschaft der PictureBox zugewiesen und die alte durch Dispose() freigegeben werden. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\AutoRedraw.
private Bitmap bmp; private void FrmMain_Load( object sender, System.EventArgs e ) { // Beim Laden Bitmap initialisieren und pbxBitmap zuweisen bmp = new Bitmap( picBitmap.ClientSize.Width, picBitmap.ClientSize.Height ); picBitmap.Image = bmp; } private void PicBitmap_Resize( object sender, System.EventArgs e ) { // falls bm noch nicht initialisiert wurde: Exit. // (Resize wird bereits vor Form1_Load das erste // Mal aufgerufen)
Sandini Bib
Fortgeschrittene Programmiertechniken
807
if ( bmp == null ) return; int newWidth; int newHeight; Graphics g; // Testen, ob PictureBox größer ist als die Bitmap if ( ( bmp.Width < picBitmap.ClientSize.Width ) || ( bmp.Height < picBitmap.ClientSize.Height ) ) { // Bitmap zwischenspeichern Bitmap oldBmp = bmp; // Neue Maße ermitteln newWidth = picBitmap.ClientSize.Width; newHeight = picBitmap.ClientSize.Height; newWidth = ( bmp.Width > newWidth ) ? bmp.Width : newWidth; newHeight = ( bmp.Height > newHeight ) ? bmp.Height : newHeight; // Neue Bitmap erzeugen bmp = new Bitmap( newWidth, newHeight ); // Inhalt der ursprünglichen Bitmap kopieren g = Graphics.FromImage( bmp ); g.DrawImageUnscaled( oldBmp, new Point( 0, 0 ) ); // Image-Eigenschaft von pbxBitmap ändern picBitmap.Image = bmp; // Alte Bitmap freigeben oldBmp.Dispose(); g.Dispose(); // Neuzeichnen BtnCalcGraphic_Click( sender, e ); } }
Der Code zur Erstellung der Grafik weist wenig Besonderheiten auf. Es werden einfach ein paar mit unterschiedlichen Farben gefüllte Kreise sowie einige Kreislinien gezeichnet. Entscheidend ist der Aufruf der Methode Refresh() für die PictureBox am Ende der Methode. Sie bewirkt, dass die neue berechnete Grafik tatsächlich sichtbar wird.
Sandini Bib
HINWEIS
808
21 Grafikprogrammierung (GDI+)
Wenn die Berechnung der Grafik relativ lange dauert, sollten Sie Refresh() während der Berechnung hin und wieder ausführen. Sie erreichen damit, dass die Anwender ein visuelles Feedback bekommen, dass die Berechnung voranschreitet. Führen Sie Refresh() aber nicht zu oft aus, denn dadurch wird die gesamte Bitmap auf den Bildschirm kopiert, was verhältnismäßig lange dauert.
private void BtnCalcGraphic_Click( object sender, System.EventArgs e ) { // Graphics-Objekt von Bitmap erzeugen Graphics g = Graphics.FromImage( bmp ); SolidBrush aBrush = new SolidBrush( Color.Black ); this.Cursor = Cursors.WaitCursor; g.SmoothingMode = SmoothingMode.AntiAlias; // Grafik zeichnen for ( int i = 511; i > 0; i-- ) { aBrush.Color = Color.FromArgb( 255, (int)( 255 - i / 2 ), (int)( 255 - i / 2 ) ); g.FillEllipse( aBrush, (float)( i * 1.5f ), (float)( i / 2f * ( 1f + Math.Sin( i / 20f ) ) ), i, i ); if ( i % 4 == 0 ) g.DrawEllipse( Pens.Black, (float)( i * 1.5f ), (float)( i / 2f * ( 1f + Math.Sin( i / 20f ) ) ), i, i ); } // PictureBox-Inhalt neu zeichnen picBitmap.Refresh(); // Aufräumen this.Cursor = Cursors.Default; g.Dispose(); aBrush.Dispose(); }
Abbildung 21.40 zeigt einen Screenshot der fertigen Anwendung mit der (hoffentlich etwas ansprechenden) Kreisgrafik. Die Grafik sieht in der Tat so aus, als sei die Berechnung recht kompliziert.
Sandini Bib
Fortgeschrittene Programmiertechniken
809
Abbildung 21.40: Die angezeigte Grafik in dem Beispielprogramm
Beispielprogramm zu Variante 2
CD
Als zweites Beispiel dient der sicherlich schon bekannte Mandelbrot-Algorithmus. Das Programm berechnet einen Ausschnitt aus der Mandelbrotmenge für die aktuelle Größe des Anzeige-Steuerelements. Dabei erfolgt die Berechnung in einem eigenen Thread, womit ein Abbruch jederzeit möglich ist. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\AutoRedrawMandelbrot.
Aufgrund des Ansatzes, in diesem Fall Multithreading zu verwenden, wird der Programmcode zwangsläufig ein wenig komplexer als beim vorangegangenen Beispiel. Eines der Probleme ist, dass es bei einem gleichzeitigen Zugriff aus beiden Threads auf das gleiche Objekt zu einer Fehlermeldung kommen würde. Ein solcher Zugriff ist allerdings zur Aktualisierung des Fensterinhalts notwendig. Wir müssen demnach Mechanismen einbauen, um ein Objekt zu sperren, sodass kein anderer Thread für den aktuellen Moment Zugriff erhält. Wir werden aufgrund der gestiegenen Komplexität wieder Schritt für Schritt vorgehen um uns die Funktionsweise des Programms zu erarbeiten. Das Hauptformular ist sehr einfach gehalten, es besteht eigentlich nur aus zwei Buttons (zum Berechnen und zum Abbruch der Berechnung) sowie einem PictureBox-Steuerelement für die Darstellung. Dieses ist über die Eigenschaft Anchor mit den Rändern des Formulars gekoppelt und ändert somit dynamisch seine Größe mit dem Formular. Da wir mit Threads arbeiten wollen, müssen wir den Namespace System.Threading einbinden. Danach deklarieren wir einige Variablen, die für das Puffern der Bitmap und die Berechnung des Mandelbrot-Ausschnitts erforderlich sind. Die Kommentare im Quellcode sollten eigentlich aussagekräftig genug sein.
Sandini Bib
810
21 Grafikprogrammierung (GDI+)
// Bitmap für PictureBox private Bitmap bmp; // Variablen zur Verwaltung des Berechnungs-Thread ThreadStart calcThreadStart; Thread calcThread; bool calcRunning = false; bool calcResultWaiting = false; // Bitmap für Updates während der Berechnung Bitmap tmpBmp; // Delegate zum um InvalidatePictureBox1() vom Berechnungs-Thread aus aufzurufen private delegate void InvalidatePicDelegate();
Den Delegate InvalidatePicDelegate benötigen wir zum threadsicheren Aufruf der Methode Invalidate() der PictureBox. Er verweist letztendlich auf eine parameterlose Methode, die folgendermaßen deklariert ist: private void InvalidatePic() { this.picBitmap.Invalidate(); }
Sicherlich werden Sie sich jetzt fragen, wozu wir einen Delegate verwenden um die PictureBox zu aktualisieren. Die Lösung ist relativ einfach und hat ihren Ursprung darin, dass Steuerelemente grundsätzlich nicht threadsicher sind. Damit ist ein sicherer Aufruf erst einmal nicht gewährleistet, wir können nicht mit Bestimmtheit sagen, dass die Methode Invalidate() der PictureBox nur von unserem Berechnungs-Thread aus aufgerufen wird. Daher überlassen wir es dem Haupt-Thread, Invalidate() aufzurufen, und verwenden dazu die Methode Invoke(). Diese benötigt allerdings einen Delegate, der ihr übergeben wird. Die Verwendung dieser Methode ist übrigens vorgeschrieben, denn der Aufruf einer Methode eines Steuerelements ist nur aus dem Thread erlaubt, in dem das Steuerelement erzeugt wurde.
Anzeigen der Grafik in der PictureBox tmpBmp enthält eine temporäre Bitmap, in die gezeichnet wird. Ist die Berechnung beendet, wird die Grafik aus tmpBmp gelesen und in der PictureBox dargestellt. Das Gleiche passiert
nach einer (im Quellcode einstellbaren) Zeit, wenn die Berechnung dann noch läuft. Das ermitteln wir über calcRunning, wenn diese Variable true ist, läuft auch der Berechnungsthread und damit die Berechnung. Die Variable calcResultWaiting gibt an, ob der Thread beendet ist. Sie wird ausgewertet, um das endgültige Ergebnis auf dem Bildschirm darzustellen. Diese Auswertung erfolgt im Paint-Ereignis der PictureBox, die ja dann aufgerufen wird, wenn das Fenster verdeckt war und neu gezeichnet werden muss. Läuft die Berechnung der Grafik zu diesem Zeitpunkt noch, wird die temporäre Grafik in die PictureBox geladen, ansonsten die endgültige. Paint wird natürlich auch durch Invalidate() aufgerufen.
Sandini Bib
Fortgeschrittene Programmiertechniken
811
private void PicBitmap_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { Graphics g = e.Graphics; if ( calcRunning ) { // Berechnung läuft gerade ... // daher temporäre Bitmap verwenden lock ( tmpBmp ) { g.DrawImageUnscaled( tmpBmp, new Point( 0, 0 ) ); } } else if ( calcResultWaiting ) { // Berechnung ist abgeschlossen // tmpBmp wird in bmp kopiert Graphics gr = Graphics.FromImage( bmp ); gr.DrawImageUnscaled( tmpBmp, new Point( 0, 0 ) ); g.DrawImageUnscaled( bmp, new Point( 0, 0 ) ); calcResultWaiting = false; tmpBmp.Dispose(); } else { // Berechnung läuft nicht // bmp zeichnen g.DrawImageUnscaled( bmp, new Point( 0, 0 ) ); } }
Das Resize-Ereignis wird Ihnen sicherlich bekannt vorkommen, da es sehr dem ResizeEreignis aus dem vorangegangenen Beispiel ähnelt. Was fehlt, ist die Zuweisung der Grafik an die Image-Eigenschaft der PictureBox. private void PicBitmap_Resize( object sender, System.EventArgs e ) { // falls bm noch nicht initialisiert wurde: Exit // (Resize wird bereits vor FrmMain_Load das erste Mal aufgerufen) if ( bmp == null ) return; int newWidth; int newHeight; Graphics g; // testen, ob PictureBox größer ist als die Bitmap if ( ( bmp.Width < picBitmap.ClientSize.Width ) || ( bmp.Height < picBitmap.ClientSize.Height ) ) { Bitmap oldBmp = bmp; newWidth = picBitmap.ClientSize.Width; newHeight = picBitmap.ClientSize.Height; newWidth = ( bmp.Width > newWidth ) ? bmp.Width : newWidth;
Sandini Bib
812
21 Grafikprogrammierung (GDI+) newHeight = ( bmp.Height > newHeight ) ? bmp.Height : newHeight; // Neue Bitmap erzeugen bmp = new Bitmap( newWidth, newHeight ); // Inhalt der alten Bitmap kopieren g = Graphics.FromImage( bmp ); g.DrawImageUnscaled( oldBmp, new Point( 0, 0 ) ); // alte Bitmap freigeben oldBmp.Dispose(); g.Dispose();
} }
Die Mandelbrot-Berechnung Die Berechnung der Grafik besteht aus zwei Methoden, einmal der Methode zum Errechnen des nächsten zu zeichnenden Punkts und dann aus der Gesamtberechnung selbst. Der Code ist relativ lang, aber nicht besonders kompliziert. Die Methode CalcMandelbrot() ist das Herzstück und auch die Methode, die beim Start des Threads aufgerufen wird. Am Anfang stehen eine Menge Initialisierungsanweisungen. Hier wird unter anderem festgelegt, nach welcher Zeit ein Refresh des Bildschirminhalts erfolgen soll (hier eine Sekunde), außerdem werden die Parameter für die Grafik initialisiert, die Farben festgelegt und die Bitmap zum Zeichnen erzeugt. Auf den eigentlichen Algorithmus für die Berechnung einer Mandelbrotmenge wollen wir an dieser Stelle nicht eingehen, das würde zu weit gehen und hätte im Prinzip auch nichts mehr mit C# zu tun (sondern eher mit Chaos-Theorie). Wichtig an dieser Stelle ist nur, dass eine lokale Bitmap (calcBmp) verwendet wird, um die berechneten Punkte einzuzeichnen, und dass diese nach abgelaufener Refresh-Zeit in die Bitmap tmpBmp kopiert wird. Danach wird die Methode InvalidatePic() über einen Delegate aufgerufen und sorgt dafür, dass die temporäre Grafik zum nächstmöglichen Zeitpunkt angezeigt wird. Am Ende der Berechnung wird calcBmp in das endgültige Bitmap-Objekt kopiert und calcRunning auf false gesetzt. Damit verwendet das Paint-Ereignis nun die endgültige Bitmap zur Anzeige. Da auch auf die Grafikobjekte bmp und tmpBmp von zwei Threads aus zugegriffen wird (einmal aus der Berechnung, einmal in Paint aus dem Haupt-Thread), sperren wir diese während des Kopierens der Daten durch die lock()-Anweisung. private void CalcMandelbrot() { // Parameter für Grafik const double real0 = -0.7476; const double real1 = -0.7452; const double imag0 = 0.0976; const int countMax = 200;
Sandini Bib
Fortgeschrittene Programmiertechniken // Zeit zum Refresh des Bilds in Sekunden const int refreshInterval = 1; DateTime nextRefresh; Color[] col = { Color.DarkBlue, Color.White }; // Bitmap zum Speichern der Grafik Bitmap calcBmp = null; // Threadsicherer Aufruf von PictureBox.Invalidate InvalidatePicDelegate DoInvalidate = new InvalidatePicDelegate( InvalidatePic ); // Variablen zur Berechnung der Grafik int result; int xMax, yMax; double real, imag, delta; // Größe der Picturebox ermitteln // Abbrechen, wenn Größe 0 xMax = picBitmap.ClientSize.Width; yMax = picBitmap.ClientSize.Height; if ( ( xMax < 1 ) || ( yMax < 1 ) ) return; // Berechnung absichern try { // Init calcBmp = new Bitmap( xMax, yMax ); if ( tmpBmp != null ) tmpBmp.Dispose(); tmpBmp = (Bitmap)( calcBmp.Clone() ); // Bitmap für Updates calcRunning = true; calcResultWaiting = false; delta = ( real1 - real0 ) / xMax; nextRefresh = DateTime.Now.AddSeconds( refreshInterval ); // Schleife über alle Punkte for ( int x = 0; x < xMax; x++ ) { real = real0 + delta * x; for ( int y = 0; y < yMax; y++ ) { imag = imag0 + delta * y; result = CalcMandelbrotPoint( real, imag, countMax ); calcBmp.SetPixel( x, y, col[result % ( col.GetUpperBound( 0 ) + 1 )] ); }
813
Sandini Bib
814
21 Grafikprogrammierung (GDI+) // Fenster aktualisieren // Nicht zu oft, sonst wird's langsam if ( DateTime.Now > nextRefresh ) { lock ( tmpBmp ) { // calcBmp nach tmpBmp kopieren Graphics g = Graphics.FromImage( tmpBmp ); g.DrawImageUnscaled( calcBmp, new Point( 0, 0 ) ); tmpBmp = (Bitmap)( calcBmp.Clone() ); g.Dispose(); } // Threadsicher neuzeichnen picBitmap.Invoke( DoInvalidate ); nextRefresh = DateTime.Now.AddSeconds( refreshInterval ); } }
// Endergebnis in bmp kopieren lock ( tmpBmp ) { Graphics g = Graphics.FromImage( tmpBmp ); g.Clear( picBitmap.BackColor ); g.DrawImageUnscaled( calcBmp, new Point( 0, 0 ) ); g.Dispose(); } calcRunning = false; calcResultWaiting = true; picBitmap.Invoke( DoInvalidate ); } finally { calcRunning = false; if ( calcBmp != null ) calcBmp.Dispose(); } }
Die Methode CalcMandelbrotPoint() errechnet einen neuen Punkt und ist hier der Vollständigkeit halber mit angegeben. private int CalcMandelbrotPoint( double realStart, double imagStart, int countMax ) { int counter = 0; double real, imag, realQuad, imagQuad; real = realStart; imag = imagStart; do { realQuad = real * real; // Math.Pow(real, 2f); geht auch, aber zu langsam
Sandini Bib
Fortgeschrittene Programmiertechniken
815
imagQuad = imag * imag; // Math.Pow(imag, 2f); if ( realQuad + imagQuad > 4 ) break; imag = real * imag * 2 + imagStart; real = realQuad - imagQuad + realStart; counter += 1; } while ( counter < countMax ); return counter; }
Starten, Stoppen und Initialisieren des Threads Die Initialisierung geschieht bereits im Load-Ereignis des Formulars. Hier wird die Bitmap erzeugt, die später die endgültige Grafik enthalten soll sowie die Startmethode des Threads festgelegt. private void FrmMain_Load( object sender, System.EventArgs e ) { // Bitmap erzeugen bmp = new Bitmap( picBitmap.ClientSize.Width, picBitmap.ClientSize.Height ); // ThreadStart festmachen calcThreadStart = new ThreadStart( this.CalcMandelbrot ); }
Der eigentliche Start erfolgt über den Button btnCalcGraphic. Wir müssen an dieser Stelle darauf achten, dass der Thread nicht mehrfach gestartet wird. Glücklicherweise haben wir aber durch unsere Felder eine Kontrollmöglichkeit. private void BtnCalcGraphic_Click( object sender, System.EventArgs e ) { // Berechnung starten // Nur wenn kein Thread läuft if ( calcRunning || calcResultWaiting ) return; if ( calcThread != null && calcThread.IsAlive ) return; Graphics g = Graphics.FromImage( bmp ); g.Clear( picBitmap.BackColor ); g.Dispose(); picBitmap.Invalidate(); // Neuen Thread starten calcThread = new Thread( calcThreadStart ); calcThread.Name = "Calculation Thread"; calcThread.Start();
Sandini Bib
816
21 Grafikprogrammierung (GDI+)
}
Das Stoppen des Threads zur Berechnungszeit ist ebenfalls kein Problem und wird über den Button btnStopCalc ausgelöst. private void BtnStopCalc_Click( object sender, System.EventArgs e ) { // Berechnung stoppen if ( calcThread != null ) { calcThread.Abort(); // Abbrechen calcThread.Join(); // Warten bis abgebrochen picBitmap.Invalidate(); } }
Eine Besonderheit muss ebenfalls noch berücksichtigt werden. Auch wenn der Anwender während der Berechnung das Programm schließen will, müssen wir den laufenden Thread sauber beenden. private void FrmMain_Closed( object sender, System.EventArgs e ) { if ( calcThread != null ) { calcThread.Abort(); calcThread.Join(); } }
Und das war's auch schon. Die Berechnung der Grafik wird jetzt in einem eigenen Thread durchgeführt, die Grafik auf dem Bildschirm automatisch jede Sekunde einmal aktualisiert. Damit hat der Anwender auch ein Feedback, dass etwas passiert (eine Sekunde wartet man eigentlich immer, und wenn dann etwas passiert, wartet man noch eine … und beim dritten Zeichenvorgang, sollte es sich wirklich um eine extrem große Grafik handeln, sollte der Anwender verstanden haben, dass die Berechnung ein wenig länger dauert).
Verbesserungsideen Kein Programm ist perfekt, Beispielprogramme für ein Buch in der Regel auch nicht. Deshalb gibt es auch hier einige Verbesserungsmöglichkeiten. f Die Grafikausgabe erfolgt immer in der Größe, in der sich das PictureBox-Steuerelement befand, als die Berechnung gestartet wurde. Eine automatische Größenänderung ist nicht vorhanden. Um diese zu bewerkstelligen, müsste die temporäre Grafik gezeichnet, der Thread beendet, eine neue Grafik erstellt und der Thread mit den neuen Werten wieder gestartet werden. f Eine Speichermöglichkeit für das erzeugte Bild wäre sicherlich eine sinnvolle Sache. f Probieren Sie außer den im Code angegebenen Farben auch noch weitere Farbkombinationen aus.
Sandini Bib
Fortgeschrittene Programmiertechniken
817
Abbildung 21.41: Die Mandelbrotgrafik in einfarbiger Ansicht
21.5.6
Flimmerfreie Grafik (Double-Buffer-Technik)
Das Neuzeichnen einer Grafik kann mitunter länger dauern, was ein störendes Flimmern am Bildschirm zur Folge hat. Wenn Sie eine Grafik bei jeder Größenänderung eines Fensters neu zeichnen, können Sie diesen Effekt leicht beobachten (je komplexer die Grafik, desto besser die Beobachtung). Bei einer Größenänderung wird der gesamte Inhalt komplett neu gezeichnet. Dazu wird erst einmal der Zeichenbereich gelöscht und danach die Grafik vollständig neu gezeichnet. Wenn der Rechner dazu auch noch ein wenig langsam ist, können die Anwender zusehen, wie die Grafik neu gezeichnet wird. Die Geschwindigkeit der Grafikdarstellung ist allerdings nicht nur von GDI+ abhängig, sondern auch von der Tatsache, dass es in Windows XP noch keine HardwareUnterstützung für GDI+ gibt. Das wird vermutlich mit der nächsten Windows-Version, Codename Longhorn, der Fall sein. Glücklicherweise implementiert GDI+ die Double-Buffer-Technik. Dabei wird eine Bitmap im Hintergrund gehalten, auf der gezeichnet wird (womit der Zeitverlust für die Darstellung entfällt) und diese Bitmap wird dann auf einen Schlag gezeichnet. In einer PictureBox ist diese Technik standardmäßig aktiv. Falls Sie in einem Steuerelement oder auf einem Formular zeichnen, können Sie die Anweisung SetStyle() verwenden, um Double-Buffering zu aktivieren. Diese Eigenschaft ist als protected deklariert, d.h. der Aufruf funktioniert nur in abgeleiteten Klassen. Ein Formular ist bekanntlich von der Basisklasse Form abgeleitet, daher funktioniert es hier problemlos. Wollen Sie Steuerelemente so präparieren, müssen Sie zuerst eine eigene Klasse von diesem Steuerelement ableiten und darin SetStyle() aufrufen (sinnvollerweise im Konstruktor).
Sandini Bib
818
21 Grafikprogrammierung (GDI+)
// Vollständiges Neuzeichnen, auch bei Größenänderung (im Formular) this.SetStyle( ControlStyles.DoubleBuffer | ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.ResizeRedraw, true );
Das Bitfeld ControlStyles Als Parameter für die Methode SetStyle() wird ControlStyles verwendet. Dieses Bitfeld ist im Namespace System.Windows.Forms definiert. Die einzelnen Elemente können beliebig kombiniert werden. Der letzte Parameter von SetStyle() ist ein boolescher Parameter, der angibt, ob die übergebenen ControlStyles-Werte aktiviert oder deaktiviert werden sollen. Die folgende Aufzählung liefert einen Überblick über die Einstellungsmöglichkeiten. Es wird bei der Beschreibung davon ausgegangen, dass die angegebenen Optionen aktiviert werden. f Die Einstellung ControlStyles.Opaque bewirkt, dass der Hintergrund des Steuerelements vor dem Paint-Ereignis nicht automatisch wiederhergestellt wird. Sie müssen dann den Hintergrund selbst zeichnen. Diese Einstellung ist sinnvoll, wenn Sie als Hintergrund nicht nur eine einfache Farbe, sondern ein Muster, einen Farbübergang oder etwas Vergleichbares haben möchten. In solchen Fällen wäre es sinnlos, wenn das Steuerelement zuerst mit einem Standardhintergrund und danach ein zweites Mal mit einem individuellen Hintergrund gefüllt würde. f Die Einstellung ControlStyles.ResizeRedraw entspricht dem Setzen der (protected) Eigenschaft ResizeRedraw auf true. f Über die Eigenschaft ControlStyles.DoubleBuffer legen Sie fest, dass die Grafikausgaben in einer unsichtbaren Bitmap zwischengespeichert werden. Dazu müssen auch die Optionen ControlStyles.UserPaint und ControlStyles.AllPaintingInWmPaint gesetzt sein. f Das Setzen der Eigenschaft ControlStyles.UserPaint bewirkt, dass das Steuerelement sich selbst zeichnet. Der Normalfall ist, dass das Betriebssystem das Steuerelement zeichnet. f Das Setzen von ControlStyles.AllPaintingInWmPaint bedeutet, dass das Steuerelement die Windows-Nachricht WM_ERASEBKGND ignoriert, die es normalerweise dazu auffordert, den Hintergrund doch bitte zu löschen. DoubleBuffer-Grafikausgaben erfolgen unter Zuhilfenahme eines Zwischenpuffers. (Damit das funktioniert, müssen laut Dokumentation außerdem die Attribute AllPaintingInWmPaint und UserPaint gesetzt sein.)
21.5.7
Scrollbereich für Grafik
Wenn eine Grafik zu groß ist, als dass sie komplett angezeigt werden könnte, wäre es sinnvoll, über Bildlaufleisten den sichtbaren Ausschnitt ändern zu können. Leider wird auch bei einer Picturebox das Bild einfach abgeschnitten, wenn es zu groß ist. Das geschieht automatisch. Es funktioniert aber doch …
Sandini Bib
Fortgeschrittene Programmiertechniken
819
Grundsätzlich gibt es hier zwei Szenarien. In beiden Fällen setzen Sie einfach die Eigenschaft SizeMode der PictureBox auf PictureBoxSizeMode.AutoSize. Damit nimmt die PictureBox automatisch die Größe der enthaltenen Grafik an. Nun können Sie als Container entweder ein Formular (das wäre Szenario 1) oder ein Panel (das bessere Szenario 2) verwenden. Letzteres ist vorzuziehen, da dann noch weitere Controls auf dem Formular Platz haben. Wenn Sie nun für das Panel die Eigenschaft AutoScroll auf true setzen, werden automatisch Bildlaufleisten angezeigt, da das enthaltene Steuerelement (die PictureBox) größer ist als der anzeigbare Bereich. Auch hier möchten wir Ihnen ein Beispielprogramm vorstellen. In diesem Fall wird eine Grafik geladen. Die Picturebox, die die Grafik anzeigt, befindet sich innerhalb eines Panels. Nun gibt es zwei Möglichkeiten. f Die Grafik ist größer als das Panel (nicht als die Picturebox, denn deren Größe könnte ja auch schon größer sein als das Panel). In diesem Fall wird AutoScroll auf true gesetzt, und die Eigenschaft SizeMode der Picturebox auf PictureBoxSizeMode.AutoSize. f Die Grafik ist kleiner als das Panel oder genauso groß. In diesem Fall wird die Eigenschaft AutoScroll des Panels auf false gesetzt (damit die Bildlaufleisten verschwinden) und die Eigenschaft SizeMode der Picturebox auf PictureBoxSizeMode.CenterImage. Das Bild wird dann also zentriert dargestellt.
CD
Diese Kalkulation führen wir immer aus, wenn ein Bild geladen oder die Größe des Formulars geändert wird. PictureBox und Panel sollten, damit das Ganze homogen aussieht, die gleiche Hintergrundfarbe besitzen (im Beispiel Weiß). Das Panel ist über die Eigenschaft Anchor mit den Rändern des Formulars verbunden. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\ScrollGraphic.
Bitmap bmp = null; private void CalcPictureBox() { if ( ( this.bmp.Width > pnlImage.Width ) || ( this.bmp.Height > pnlImage.Height ) ) { // Bild zu groß pbxImage.SizeMode = PictureBoxSizeMode.AutoSize; pnlImage.AutoScroll = true; } else { pnlImage.AutoScroll = false; pbxImage.Width = pnlImage.Width; pbxImage.Height = pnlImage.Height; pbxImage.SizeMode = PictureBoxSizeMode.CenterImage; } }
Sandini Bib
820
21 Grafikprogrammierung (GDI+)
private void AssignPicture() { // Eigenschaften PictureBox einstellen CalcPictureBox(); // Bitmap zuweisen pbxImage.Image = bmp; } private void BtnLoad_Click( object sender, System.EventArgs e ) { // Bild laden if ( dlgOpen.ShowDialog() == DialogResult.OK ) { FileStream fs = new FileStream( dlgOpen.FileName, FileMode.Open ); bmp = new Bitmap( fs ); fs.Close(); // Bild zuweisen AssignPicture(); } } private void PnlImage_SizeChanged( object sender, System.EventArgs e ) { // Größe geändert - PictureBox neu kalkulieren CalcPictureBox(); }
Das Ergebnis sehen Sie in Abbildung 21.42.
Abbildung 21.42: Eyes only …
21.5.8
Einfache Animationseffekte
GDI+ ist eine neue Grafikbibliothek, die vollständig objektorientiert ist. Sie wurde allerdings nicht in Hinblick auf maximale Geschwindigkeit entwickelt. Die Grafikoperationen werden nicht durch die Grafik-Hardware beschleunigt. Damit ist GDI+ nicht geeignet für die Entwicklung von Spielen, effizienten Animationseffekten oder 3D-Animationsprogrammen. Für solche Zwecke sollten Sie GDI oder DirectX verwenden (die allerdings nicht in diesem Buch besprochen werden).
Sandini Bib
Fortgeschrittene Programmiertechniken
821
Wenn Sie einfache Animationseffekte dennoch mit GDI+ erzielen möchten, hier ein paar Tipps: f Wenn Sie Geschwindigkeitsprobleme haben, sollten Sie auf eine optimale Darstellungsqualität zugunsten einer höheren Zeichengeschwindigkeit verzichten (siehe Abschnitt 21.5.1). Testen Sie Ihr Programm auch auf einem langsamen Rechner! f Verwenden Sie Double-Buffering, um ein Flimmern der Grafik zu vermeiden (siehe Abschnitt 21.5.6). f Achten Sie darauf, dass Sie nach Möglichkeit nicht immer den gesamten Fensterinhalt neu zeichnen, sondern wirklich nur die Teile, die sich geändert haben. Das bedeutet z.B., dass Sie an die Invalidate()-Methode Rechtecke mit der alten bzw. der neuen Position der Spielfigur übergeben. Diese Technik wird im folgenden Beispiel demonstriert.
Beispiel Wir wollen ein verhältnismäßig einfaches Beispiel für die Erstellung bewegter Grafik zeigen. Dieses Beispiel zeigt einen sich bewegenden Kreissektor. Er dreht sich um sich selbst und wird zusätzlich im Fenster ebenfalls bewegt. Der Hintergrund besteht aus einem Farbverlauf von Weiß nach Grau.
CD
Die Bewegung wird über ein Timer-Steuerelement gesteuert. In dessen Tick-Ereignis wird die Figur bewegt. Durch die Einstellung des Intervalls des Timers können Sie auf die Bewegungsgeschwindigkeit Einfluss nehmen. Damit nicht die gesamte Formularfläche neu gezeichnet werden muss, wird bei der Berechnung der neuen Position ein Rechteck ermittelt, das die bisherige und die neue Figur umfasst, und nur dieser Bereich neu gezeichnet. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\Animation.
Der Programmcode beginnt wie immer mit der Deklaration einiger benötigter Variablen. private private private private private private
int int int int int int
figureSize = 100; figureX, figureY; oldX, oldY; deltaX, deltaY; figureAngle; deltaAngle;
private Brush backgroundBrush;
// // // // // //
Größe der zu bewegenden Figur Position alte Position Bewegungsvektor Drehungswinkel Drehwinkeländerung
// Hintergrund des Fensters
Die Kommentare sollten eindeutig sein. Wir benötigen natürlich einmal die Größe der Figur, weiterhin die Daten für die ursprüngliche und die neue Position. Ebenso werden benötigt die Positionsänderung zur Berechnung und der Drehwinkel der Figur, sowohl der aktuelle als auch der Wert für die Drehwinkeländerung.
Sandini Bib
822
21 Grafikprogrammierung (GDI+)
Wenn noch kein Hintergrund-Brush existiert, wird er neu erzeugt. Dazu wird die Hilfsmethode CreateBackgroundBrush() verwendet, die aus dem Resize-Ereignis und bei Bedarf auch aus dem Paint-Ereignis aufgerufen wird. private void CreateBackgroundBrush() { // Brush für Hintergrund neu erstellen if ( backgroundBrush != null ) backgroundBrush.Dispose(); backgroundBrush = new LinearGradientBrush( new Point( 0, 0 ), new Point( this.ClientSize.Width, 0 ), Color.White, Color.Gray ); }
Im Load-Ereignis des Formulars wird die Figur initialisiert und die Fenstereigenschaften werden verändert. Es soll Double-Buffering verwendet werden und das Programm kümmert sich selbst um den Hintergrund des Formulars. Zum Initialisieren der Startposition und des Startwinkels werden einfach Zufallszahlen benutzt, bei deren Berechnung die aktuelle Formulargröße mit einbezogen wird. Damit befindet sich die Figur am Anfang einfach irgendwo auf dem Formular.
private void FrmMain_Load( object sender, System.EventArgs e ) { // Initialisierung Random rnd = new Random( DateTime.Now.Millisecond ); this.MinimumSize = new Size( figureSize * 2, figureSize * 2 ); this.SetStyle( ControlStyles.DoubleBuffer | ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.ResizeRedraw | ControlStyles.Opaque, true ); deltaX = 3 + rnd.Next( 5 ); deltaY = 3 + rnd.Next( 5 ); deltaAngle = 3 + rnd.Next( 5 ); figureX = (int)( ( this.ClientSize.Width - figureSize - 1 ) * rnd.NextDouble() ); figureY = (int)( ( this.ClientSize.Height - figureSize - 1 ) * rnd.NextDouble() ); }
Im Resize-Ereignis wird einfach nur ein Brush-Objekt in der Größe des Formulars und mit der Füllung für den Hintergrund erzeugt. Der eigentliche Fensterinhalt wird wie gehabt im Paint-Ereignis gezeichnet. Sollte dieses aufgerufen worden sein, ohne dass ein Hintergrund-Brush existiert, wird dieser einfach nochmals erzeugt. Zum Zeichnen der Figur und des Hintergrunds werden die Methoden FillRectangle() und FillPie() der Klasse Graphics verwendet.
Sandini Bib
Fortgeschrittene Programmiertechniken
823
private void FrmMain_Resize( object sender, System.EventArgs e ) { // Hintergrund-Brush erstellen CreateBackgroundBrush(); } private void FrmMain_Paint( object sender, System.Windows.Forms.PaintEventArgs e ) { // Fensterinhalt zeichnen Graphics g = e.Graphics; // Antialiasing macht das Ganze u.U. langsam g.SmoothingMode = SmoothingMode.AntiAlias; // evt. Brush-Objekt für Hintergrund initialisieren if ( backgroundBrush == null ) CreateBackgroundBrush(); // Hintergrund g.FillRectangle( backgroundBrush, 0, 0, this.ClientSize.Width, this.ClientSize.Height ); // Figur zeichnen g.FillPie( Brushes.Yellow, figureX, figureY, figureSize, figureSize, figureAngle, 300 ); }
Das Tick-Ereignis der Timer-Komponente ist für die eigentliche Bewegung zuständig. Die neue Position der Spielfigur wird berechnet und ein rechteckiger Bereich ermittelt, der sowohl die aktuelle Figur als auch die neue Position der Figur umfasst. Nur dieser Bereich wird neu gezeichnet. Verwendet wird hier die statische Methode Rectangle.Union(), mit der eine Gesamtmenge aus zwei Rechtecken gebildet werden kann. Dieses neue Rechteck wird nun an Invalidate() übergeben, wodurch nur dieser Bereich neu gezeichnet wird. private void TmrMove_Tick( object sender, System.EventArgs e ) { Rectangle aRect; // Position der Spielfigur berechnen int w = this.ClientSize.Width - figureSize; int h = this.ClientSize.Height - figureSize; figureAngle = ( figureAngle + deltaAngle ) % 360; figureX += deltaX; if ( this.figureX < 0 ) { figureX = 0; deltaX *= -1; } else if ( figureX > w ) { figureX = w; deltaX *= -1; }
Sandini Bib
824
21 Grafikprogrammierung (GDI+)
figureY += deltaY; if ( figureY < 0 ) { figureY = 0; deltaY *= -1; } else if ( figureY > h ) { figureY = h; deltaY *= -1; } // Neuzeichnen veranlassen aRect = Rectangle.Union( new Rectangle( figureX - 1, figureY - 1, figureSize + 2, figureSize + 2 ), new Rectangle( oldX - 1, oldY - 1, figureSize + 2, figureSize + 2 ) ); this.Invalidate( aRect ); oldX = figureX; oldY = figureY; }
Das wäre auch schon der gesamte benötigte Code. Das Ergebnis wird natürlich in einem Buch nicht sichtbar, die Figur können wir Ihnen aber trotzdem mit einem Screenshot zeigen (Abbildung 21.43). Falls Sie möchten, können Sie auch zum Testen den Wert des TimerIntervalls verändern. Die Figur bewegt und dreht sich dadurch schneller oder langsamer. Im Programm ist hier ein Wert von 50 eingestellt, es wird also 20-mal pro Sekunde bewegt. Das ergibt schon einen verhältnismäßig flüssigen Ablauf.
Abbildung 21.43: Pacman im Wirbelsturm – das Animationsprogramm im Einsatz
Sandini Bib
Fortgeschrittene Programmiertechniken
21.5.9
825
Bitmaps direkt manipulieren
Bisher haben wir eine Menge Dinge gesehen, die man mit Bitmaps tun kann. Professionelle Bildbearbeitungsprogramme allerdings können noch mehr, mit ihnen ist es beispielsweise möglich, Grafiken in Graustufen umzuwandeln, sie aufzuhellen, zu invertieren, den Kontrast zu verändern usw. Hierzu müssen wir allein schon aus Geschwindigkeitsgründen irgendwie direkt auf die Daten der Bitmap zugreifen, der Zugriff über GetPixel()/SetPixel() ist dazu viel zu langsam. Glücklicherweise stellt das .NET Framework eine Klasse BitmapData zur Verfügung, mit der ein solcher direkter Zugriff möglich ist. Erzeugt wird ein Objekt des Typs BitmapData durch den Aufruf der Methode LockBits() der Klasse Bitmap. Genauer gesagt wird die Bitmap jetzt in einem bestimmten Speicherbereich festgesetzt und nicht mehr verschoben. Ab dieser Stelle greifen wir direkt auf die Bytes der Bitmap zu. Die Eigenschaft Scan0 von BitmapData liefert das erste Byte der gesamten Bitmap, die Eigenschaft Stride die Anzahl Bytes einer Zeile. Die Eigenschaften Height und Width entsprechen der Höhe und Breite der Bitmap, PixelFormat dem Pixelformat (32Bit, 16Bit usw. …). Da wir in diesem Fall auch den Speicherbereich direkt manipulieren, müssen wir die Bytes über die Klasse Marshal aus dem Namespace System.Runtime.InteropServices lesen und schreiben. Die Methode ReadByte() liest ein Byte, die Methode WriteByte() schreibt es. Wichtig in diesem Zusammenhang ist auch die Organisation der Bitmap, denn wir greifen hier wirklich auf die einzelnen Farb-Bytes zu und nicht auf den gesamten Farbwert. Es wird also pro Farbanteil (Rot, Grün, Blau) ein Byte ausgelesen und verändert. Damit ergibt sich die gesamte Anzahl zu kontrollierender Bytes aus der dreifachen Breite der Bitmap mal ihrer Höhe.
Beispielprogramm BitmapManipulation Anhand eines Beispielprogramms wird die Vorgehensweise etwas deutlicher. Das Programm dient dazu, eine Bitmap zu laden, zu manipulieren und wieder zu speichern. Als Manipulationsmöglichkeiten stehen zur Verfügung Aufhellen, Abdunkeln, Invertieren und in Graustufen verwandeln. Selbstverständlich steht es Ihnen frei, das Programm auszubauen und weitere Manipulationsmöglichkeiten zu implementieren.
CD
Die Oberfläche des Programms besteht aus einer PictureBox, in der die geladene Grafik angezeigt wird, und einigen Buttons für die Manipulationen und zum Laden bzw. Speichern der Grafik. Abbildung 21.44 zeigt das Hauptformular im Entwurfsmodus. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_21\BitmapManipulation.
Sandini Bib
826
21 Grafikprogrammierung (GDI+)
Abbildung 21.44: Das Hauptformular für die Bitmap-Manipulation
Die eigentliche Methode zur Manipulation ist zentral und wird mit einem Parameter aufgerufen, der die Art der Manipulation angibt. Für diesen Parameter wird ein Aufzählungstyp deklariert, der folgendermaßen aussieht: public enum ManipulationType { Greyout, Invert, Lighter, Darker }
Vorarbeiten Für das Hauptprogramm müssen die Namensräume System.Drawing.Imaging, System.Runtime.InteropServices und System.IO zusätzlich zu den standardmäßig eingebundenen Namensräumen eingebunden werden. Und wie üblich stehen am Anfang wieder einige Deklarationen. // Delegate zum Manipulieren private delegate byte[] BmpManipulationDelegate( byte[] b ); private Bitmap bmp; private byte darklightValue = 3;
// Grafik zum Bearbeiten // Wert für Heller/Dunkler
Wir arbeiten hier mit einem Delegate. Dieser wird entsprechend der Art der Manipulation auf die korrekte Manipulationsmethode festgelegt. In dieser werden nur die Pixel manipuliert, es wird nichts gelesen oder geschrieben. Auf diese Weise können wir zentral die Pixel
Sandini Bib
Fortgeschrittene Programmiertechniken
827
auslesen, sie dann mittels Delegate manipulieren und zentral wieder schreiben. Doch zunächst müssen wir eine Grafik haben. Daher hier die Methoden zum Laden bzw. Speichern der Grafik, die Sie in dieser Form schon des Öfteren im Buch gesehen haben. private void BtnLoad_Click( object sender, System.EventArgs e ) { // Datei laden if ( dlgOpen.ShowDialog() == DialogResult.OK ) { FileStream fs = new FileStream( dlgOpen.FileName, FileMode.Open ); this.bmp = new Bitmap( fs ); fs.Close(); pbxImage.Image = bmp; } } private void BtnSave_Click( object sender, System.EventArgs e ) { if ( this.bmp == null ) // Gibt es eine Bitmap? return; // Bitmap speichern if ( dlgSave.ShowDialog() == DialogResult.OK ) { FileStream fs = new FileStream( dlgSave.FileName, FileMode.Create ); bmp.Save( fs, ImageFormat.Bmp ); fs.Close(); } }
Direkt nach dem Laden wird die Bitmap mit der Picturebox verbunden. Damit genügt es, wenn wir die Bitmap manipulieren und dann die Picturebox zum Neuzeichnen animieren. Diese verwendet dann die geänderte Bitmap und zeigt sie an. Die eigentliche Arbeit erfolgt also sozusagen »im Hintergrund«.
Manipulationsmethoden Die eigentliche Hauptmethode zur Manipulation ermittelt zunächst die Manipulationsart und legt dementsprechend den Delegate fest. Danach wird die Bitmap mittels LockBits() gesperrt und mit Scan0 das erste Byte ermittelt. Als Pixelformat wird das aktuelle Format der Grafik verwendet. Das Array pixelByte nimmt jeweils drei Bytes aus der Grafik auf. Dabei handelt es sich um die Rot-, Grün- und Blauanteile der aktuellen Farbe. Dieses Array wird dann an die jeweilige Manipulationsmethode übergeben, die die RGB-Werte ändert, und zurückgeschrieben. Danach muss lediglich noch die Picturebox neu gezeichnet werden, was durch einen Aufruf von Refresh() geschieht. private void ManipulateBitmap( ManipulationType manipulationType ) { // Existiert die Bitmap? if ( bmp == null ) return;
Sandini Bib
828
21 Grafikprogrammierung (GDI+)
// Manipulationsart BmpManipulationDelegate DoManip = null; switch ( manipulationType ) { case ManipulationType.Greyout: DoManip = new BmpManipulationDelegate( break; case ManipulationType.Invert: DoManip = new BmpManipulationDelegate( break; case ManipulationType.Lighter: DoManip = new BmpManipulationDelegate( break; case ManipulationType.Darker: DoManip = new BmpManipulationDelegate( break; }
MakeGrey );
Invert );
LightenUp );
Darken );
// Manipulieren Rectangle rect = new Rectangle( 0, 0, bmp.Width, bmp.Height ); BitmapData bmpData = bmp.LockBits( rect, ImageLockMode.ReadWrite, bmp.PixelFormat ); IntPtr pixelPointer = bmpData.Scan0; int numBytes = ( bmpData.Width * 3 ) * bmp.Height; byte[] pixelByte = new byte[3]; for ( int i = 0; i < numBytes; i += 3 ) { // Bytes lesen pixelByte[0] = Marshal.ReadByte( pixelPointer, i ); pixelByte[1] = Marshal.ReadByte( pixelPointer, i + 1 ); pixelByte[2] = Marshal.ReadByte( pixelPointer, i + 2 ); // Die eigentliche Manipulation pixelByte = DoManip( pixelByte ); // Bytes schreiben Marshal.WriteByte( pixelPointer, i, pixelByte[0] ); Marshal.WriteByte( pixelPointer, i + 1, pixelByte[1] ); Marshal.WriteByte( pixelPointer, i + 2, pixelByte[2] ); } // Unlocken und Refreshen bmp.UnlockBits( bmpData ); picImage.Refresh(); }
Sandini Bib
Fortgeschrittene Programmiertechniken
829
Die eigentliche Manipulation geschieht in den Manipulationsmethoden. Dort werden die Werte der Pixel wirklich verändert. Diese Methoden sollten relativ leicht zu verstehen sein. Zum Invertieren wird der aktuelle Wert mit 255 exklusiv-oder-verknüpft (das entspricht einer Invertierung). Graustufen werden in diesem Fall erzeugt, indem die Helligkeitswerte der einzelnen Farben berücksichtigt werden. Eine einfache Methode wäre es, einfach einen Mittelwert aus den drei Farbwerten zu bilden und diesen zuzuweisen. Die Methode über die Leuchtkraft der Farben ist allerdings zuverlässiger. Die Werte, die hier verwendet werden, können Sie sehr leicht selbst ermitteln, indem Sie ein handelsübliches Grafikprogramm verwenden. Füllen Sie eine Grafik mit einer der Farben Rot, Grün oder Blau und konvertieren Sie sie in Graustufen. Sehen Sie sich dann den RGB-Wert an. Normalerweise sollte jedes halbwegs brauchbare Programm dazu in der Lage sein. Das Aufhellen und Abdunkeln ist die einfachste Möglichkeit, denn hier wird lediglich ein Wert zum aktuellen Wert hinzugefügt bzw. abgezogen. Die Methoden im Zusammenhang sehen Sie hier: private byte[] Invert( byte[] b ) { // Invertieren byte[] returnByte = new byte[3]; for ( int i = 0; i < 3; i++ ) returnByte[i] = (byte)( b[i] ^ 255 ); return returnByte; } private byte[] LightenUp( byte[] b ) { // Heller - Konstante hinzufügen byte[] returnByte = new byte[3]; for ( int i = 0; i < 3; i++ ) returnByte[i] = ( ( b[i] + darklightValue ) > 255 ) ? (byte)255 : (byte)( b[i] + darklightValue ); return returnByte; } private byte[] Darken( byte[] b ) { // Dunkler - Konstante abziehen byte[] returnByte = new byte[3]; for ( int i = 0; i < 3; i++ ) returnByte[i] = ( ( b[i] - darklightValue ) < 0 ) ? (byte)0 : (byte)( b[i] - darklightValue ); return returnByte; } private byte[] MakeGrey( byte[] b ) { // Grauwert berechnen
Sandini Bib
830
21 Grafikprogrammierung (GDI+)
// Die Konstanten stammen aus Corel PhotoPaint byte[] returnByte = new byte[3]; byte greyValue = (byte)( ( ( b[0] * 77 ) + ( b[1] * 149 ) + ( b[2] * 28 ) ) / 255 ); for ( int i = 0; i < 3; i++ ) returnByte[i] = greyValue; return returnByte; }
Der Aufruf der Methode ManipulateBitmap() in den Ereignisbehandlungsroutinen der einzelnen Buttons besteht lediglich aus einer einzelnen Zeile: private void BtnGrey_Click( object sender, System.EventArgs e ) { ManipulateBitmap( ManipulationType.Greyout ); } private void BtnInvert_Click( object sender, System.EventArgs e ) { ManipulateBitmap( ManipulationType.Invert ); } private void BtnDarker_Click( object sender, System.EventArgs e ) { ManipulateBitmap( ManipulationType.Darker ); } private void BtnLighter_Click( object sender, System.EventArgs e ) { ManipulateBitmap( ManipulationType.Lighter ); }
Und damit wäre das Programm auch schon fertig. Abbildung 21.45 zeigt noch einmal einen Screenshot zur Laufzeit.
Abbildung 21.45: Eine Grafik, die invertiert und danach stark aufgehellt wurde
Sandini Bib
22 Drucken In diesem Kapitel steht das Drucken aus einem C#-Programm heraus im Vordergrund. Dabei geht es allerdings weniger um das Drucken von Reports mithilfe der mitgelieferten Crystal-Report-Komponenten, sondern vielmehr um die Druckmöglichkeiten, die das .NET Framework von sich aus zur Verfügung stellt. Der Druck selbst basiert eigentlich auch nur auf einem Graphics-Objekt, nur dass dieses jetzt nicht mehr eine Oberfläche auf dem Bildschirm, sondern sozusagen das Papier im Drucker repräsentiert. Der Vorteil liegt auf der Hand, alle GDI+-Methoden, die auch im vorangegangenen Kapitel beschrieben wurden, können weiterhin angewendet werden. Dieses Kapitel stellt die Steuerelemente zum Drucken sowie die wichtigsten Klassen aus dem Namespace System.Drawing.Printing vor. Anschließend finden Sie eine Reihe konkreter Beispiele, wie Sie den Ausdruck von ein- und mehrseitigen Dokumenten durchführen können.
22.1
Überblick
Das Drucken unter .NET (mit den Möglichkeiten, die das .NET Framework zur Verfügung stellt) erfolgt grundsätzlich ereignisorientiert. Kern des Ganzen ist ein Objekt des Typs PrintDocument, das zur Steuerung des Drucks verwendet wird. Die eigentliche Druckroutine ist das Ereignis PrintPage, in der sich der Code zum Drucken befindet. Sie wird ausgelöst, nachdem die Methode Print() von PrintDocument aufgerufen wurde. Die Klasse PrintDocument ist in der Toolbox zu finden und kann somit einfach per Drag&Drop auf ein Formular platziert werden. Da es sich um eine nicht-visuelle Komponente handelt, erscheint sie im Bereich unterhalb des Formularentwurfs, wie auch die Steuerelemente ImageList oder MenuStrip.
22.1.1
Limitationen und weitere Werkzeuge zum Drucken
Die Anzahl der Klassen, die zum Drucken verwendet werden können bzw. benötigt werden, und die Anzahl der Möglichkeiten, die das .NET Framework an dieser Stelle bietet, sind sehr umfangreich. Dennoch haben auch diese Klassen und die dazugehörigen Steuerelemente ihre Grenzen. f Formulare können nicht als Bitmap ausgedruckt werden – zumindest nicht einfach mal so. Es gibt natürlich einen Workaround, um eine Bitmap aus einem Fenster zu erstellen, ganz ohne Aufwand ist dies aber nicht möglich. f Der Inhalt von Steuerelementen kann ebenfalls nicht einfach mal so ausgedruckt werden. Das macht sich vor allem beim Steuerelement RichTextBox oder allgemein bei Textboxen negativ bemerkbar. Hier wäre sicherlich eine Methode sinnvoll gewesen, die den Inhalt einer TextBox ohne Umwege auf einem Drucker ausgeben kann.
Sandini Bib
832
22 Drucken
f Datenbankanwendungen benötigen häufig den Ausdruck umfangreicher Listen oder Formulare. Hier sollten Sie auf keinen Fall eigene Druckroutinen schreiben, sondern auf die Möglichkeiten entweder der Crystal-Reports-Komponenten oder der neuen Reporting-Komponente zurückgreifen. Wenn es um Drittanbieter geht, wird häufig die Software List&Label vom Combit beworben. List&Label arbeitet nach einem anderen Prinzip als beispielsweise Crystal Reports. Es ist vollständig datenbankunabhängig, was den Vorteil hat, dass man sich um die Datenübergabe selbst kümmern kann (es hat natürlich auch den Nachteil, dass man sich um die Datenübergabe selbst kümmern muss). Ein großer Vorteil ist aber, dass dieses Tool zum Ausdruck beliebiger Daten verwendet werden kann, die nicht aus einer Datenbank kommen müssen, ja, die nicht einmal in Form einer Schleife übergeben werden (wenn das der Fall sein soll, muss die Schleife eben programmiert werden). Falls Sie das nicht möchten, können Sie List&Label aber auch in einem datenbankgebundenen Modus ausführen.
22.1.2
Die wichtigsten Klassen und Steuerelemente
Die Klassen zum Drucken bzw. zum Verwalten der zahlreichen Einstellungen finden Sie im Namespace System.Drawing.Printing. Dieser Namespace geizt nicht mit Klassen, daher ist es uns nicht möglich, das gesamte Thema wirklich erschöpfend zu behandeln. Die Fülle der Möglichkeiten ist so groß, dass man (wie so oft) ein eigenes Buch darüber schreiben könnte. Dieses Kapitel beschränkt sich auf die wichtigsten Informationen und legen mehr Wert auf praxisorientierte Beispiele, statt alle Funktionen nur in der Theorie zu beschreiben. In jedem Fall werden Sie einen umfangreichen Einstieg in das Thema erhalten, der Ihnen das nötige Handwerkszeug für eigene Experimente an die Hand gibt. Die folgende Tabelle gibt Ihnen eine Übersicht über die wichtigsten auf den Druck bezogenen Klassen. Alle sind im Namespace System.Drawing.Printing beheimatet. Wichtige Klassen aus System.Drawing.Printing PrintDocument
Die Klasse PrintDocument dient der Administration des Druckauftrags.
PrintPageEventArgs
Parameter, der an die PrintPage-Ereignisprozedur des PrintDocument-Objekts übergeben wird.
PageSettings
Die Klasse PageSettings dient der Seiteneinrichtung (Seitenränder, Hoch/ Querformat etc.).
PrinterSettings
Ein PrinterSettings-Objekt enthält die Druckerrelevanten Einstellungen (Auswahl des Druckers, Anzahl der Kopien etc.).
Margins
Die Klasse Margins dient zur Angabe der Seitenränder.
Die für den aktuellen Ausdruck relevanten Objekte der in obiger Tabelle angeführten Klassen sind über das PrinterDocument-Objekt zugänglich. So enthält die Eigenschaft Prin-
Sandini Bib
Grundlagen
833
terSettings das für den Ausdruck relevante PrinterSettings-Objekt, DefaultPageSettings enthält ein PageSettings-Objekt mit den Standardeinstellungen für die Seiten usw.
Steuerelemente Grundsätzlich reichen die Klassen aus System.Drawing.Printing zwar aus, um einen Ausdruck zu steuern, die Verwendung der speziell für gewisse Druckzwecke angepassten Steuerelemente erleichtern die Arbeit doch erheblich. Die folgende Tabelle zählt die wichtigsten Steuerelemente auf. Alle Steuerelemente sind im Namespace System.Windows.Forms zu finden. Steuerelemente zum Drucken PrintDialog
ein Standarddialog zur Auswahl des Druckers und zur Angabe des Druckbereichs
PageSetupDialog
der Standarddialog zur Einstellung des Seitenlayouts
PrinterPreviewDialog
der Standarddialog zur Durchführung einer Druckvorschau
PrintPreviewControl
ein Steuerelement zur Programmierung einer eigenen Druckvorschau
22.2
Grundlagen
22.2.1
Die Komponente PrintDocument
PrintDocument dient zur Verwaltung des Ausdrucks eines beliebigen Dokuments. In den diversen Eigenschaften der Komponente können die wichtigsten Einstellungen für den Ausdruck vorgenommen werden. Der Druck wird durch einen Aufruf der Methode Print() gestartet, läuft aber im Prinzip in den Ereignissen der Komponente ab. Ein Aufruf von Print() führt zu folgenden Ereignissen:
f BeginPrint zur Initialisierung des Druckvorgangs f QueryPageSettings zur individuellen Einstellung des Seitenlayouts für die folgende Seite. Dieses Ereignis benötigen Sie eigentlich nur dann, wenn die einzelnen Seiten des Dokuments mit unterschiedlichem Seitenlayout ausgedruckt werden sollen – z.B. drei Seiten im Hochformat, die vierte Seite im Querformat. f PrintPage zum Ausdruck einer Seite. An den Ereignishandler wird als Parameter ein PagePrintEventArgs-Objekt übergeben. In dessen Eigenschaften finden Sie die wichtigen Einstellungen bzgl. des Seitenlayouts wie auch ein Graphics-Objekt, das die Zeichenfläche des Druckers (also ein Blatt Papier) repräsentiert. f EndPrint für allfällige Aufräumarbeiten
Sandini Bib
HINWEIS
834
22 Drucken
Die Anwendung des Steuerelements PrintDocument setzt voraus, dass am Rechner zumindest ein Drucker installiert ist. (Ist das nicht der Fall, führt die Methode Print() zu einer Exception des Typs InvalidPrinterException.) Standardmäßig wird der Windows-Standarddrucker in seinen Standardeinstellungen verwendet. Wenn Sie dem Anwender die Möglichkeit geben möchten, den Drucker auszuwählen oder Druckeinstellungen zu verändern, können Sie dazu das PrintDialog-Steuerelement einsetzen.
Information über den Druckfortschritt Während des Ausdrucks wird automatisch eine kleine Dialogbox angezeigt, die in Abbildung 22.1 zu sehen ist. Der dort angezeigte Name (hier document) kann über die Eigenschaft DocumentName des PrintDocument-Objekts eingestellt werden. Der Seitenzähler beginnt in jedem Fall mit 1. (Wenn Sie Seite 3-5 ausdrucken, zeigt der Dialog also Seite 1-3 an.) Wenn der Anwender den ABBRUCH-Button anklickt, wird die PrintPage-Ereignisprozedur für die aktuelle Seite zwar bis zum Schluss ausgeführt, die Ausgaben werden aber nicht mehr an den Drucker gesandt.
Abbildung 22.1: Automatische Information über den Druckvorgang
Steuerung des Druckvorgangs Falls ein Ausdruck mehrere Seiten umfasst, müssen Sie bei allen Seiten außer der letzten die Eigenschaft HasMorePages des Parameters e auf true setzen. Das PrintPage-Ereignis wird daraufhin erneut ausgelöst. Wie Sie sich vorstellen können, kann das zu einer relativ komplexen Angelegenheit werden, da Sie sich selbst darum kümmern müssen, die Seiten zu zählen und die korrekten Daten auf dem Graphics-Objekt auszugeben. Ein Beispiel für einen mehrseitigen Ausdruck finden Sie in Abschnitt 22.3 ab Seite 847. Das Abbrechen eines Ausdrucks gestaltet sich da schon etwas einfacher. Setzen Sie dazu innerhalb des PrintPage-Ereignisses einfach die Eigenschaft Cancel des Parameters e auf true. Eine bereits begonnene Seite wird nicht mehr an den Druckerspooler weitergegeben. Daten, die sich bereits im Druckerspooler befinden, werden allerdings durchaus noch gedruckt, diese können Sie nicht widerrufen.
Sandini Bib
Grundlagen
835
Einführungsbeispiel
CD
Das folgende Beispiel zeigt einen einfachen Ausdruck. Das Formular des Programms beinhaltet nur drei Buttons. Der erste dient der Druckereinrichtung, der zweite dem Drucken und der dritte schließlich zum Beenden des Programms. Weiterhin wird eine PrintDocument-Komponente mit dem Namen pdSimple hinzugefügt, die den Ausdruck durchführen wird. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_22\SimplePrinter.
Über einen Button wird der Druck ausgelöst. In der Ereignisbehandlungsroutine PrintPage werden die Papiergröße und der druckbare Bereich durch Rechtecke und Ellipsen dargestellt. Ermittelt werden diese Werte über die Eigenschaften PageBounds und MarginBounds des Parameters e, bei denen es sich um Werte des Typs Rectangle handelt. Außerdem wird noch ein Text ausgegeben. Die Druckroutine soll nicht nur eine, sondern fünf Seiten ausspucken. Hierzu dient die Variable pageCount, die auf Formularebene angelegt ist. Sie wird mit jeder Seite um 1 hochgezählt und am Ende des Drucks (also wenn keine weiteren Seiten mehr gedruckt werden sollen) wieder auf 0 gesetzt. private int pageCount = 0; private void PdSimple_PrintPage( object sender, PrintPageEventArgs e ) { // Hier erfolgt der eigentliche Druck Graphics g = e.Graphics; Rectangle pageRect = e.PageBounds; Rectangle printRect = e.MarginBounds; Font fnt = new Font( "Arial", 12f ); // Zeichnen auf das Papier ... g.DrawRectangle( Pens.Black, pageRect ); g.DrawEllipse( Pens.Black, pageRect ); g.DrawRectangle( Pens.Black, printRect ); g.DrawEllipse( Pens.Black, printRect ); g.DrawString( "Das ist die Testseite " + ( pageCount + 1 ).ToString(), fnt, Brushes.Black, (RectangleF)printRect ); fnt.Dispose(); pageCount++; e.HasMorePages = ( pageCount < 5 ); if ( !e.HasMorePages ) pageCount = 0; }
Sandini Bib
836
22 Drucken
private void btnPrint_Click( object sender, EventArgs e ) { if ( dlgPrintPreview.ShowDialog() == DialogResult.OK ) this.pdSimple.Print(); }
Das Ergebnis des Ausdrucks ist in Abbildung 22.2 zu sehen. Verwendet wurde hier ein virtueller Drucker aus dem Adobe-Acrobat-Produktpaket. Dieser Acrobat Distiller funktioniert im Prinzip wie ein Postscript-Drucker, erzeugt aber pdf-Dateien, die Sie sich dann am Bildschirm ansehen können. Dieses Programm hat sich als äußerst nützlich erwiesen, um Druckfunktionen zu testen ohne Unmengen von Papier zu vergeuden. Beachten Sie bitte, dass bei einem Ausdruck auf Papier Teile der äußeren Ellipse abgeschnitten werden, weil kein Drucker die gesamte Seite bedrucken kann. Mehr Informationen zu diesem Problem folgen in Abschnitt 22.2.4.
22.2.2
Die Dialoge PrintDialog und PageSetupDialog
Normalerweise besitzt ein Programm zwei Möglichkeiten, einen Druck zu starten. Die erste Möglichkeit funktioniert wie in unserem Beispielprogramm und verwendet für den Ausdruck den Standarddrucker des Systems. Üblicherweise wird hierbei das gesamte Dokument ausgedruckt. Die zweite Möglichkeit, mit der wir uns hier beschäftigen, lässt den Anwender eingreifen. Über einen Dialog können die Druckereinstellungen verändert oder ein neuer Drucker gewählt werden. Der Dialog PrintDialog dient zur Druckerauswahl, zum Einstellen der Druckqualität und generell der für den Drucker verfügbaren Eigenschaften. Er entspricht dem in den diversen Programmen verwendeten Druck-Dialog. Der Dialog PageSetupDialog dient der Seiteneinrichtung. Hier können Sie festlegen, ob im Hoch- oder Querformat gedruckt werden soll (was allerdings der PrintDialog über die Druckereigenschaften in der Regel ebenso ermöglicht) sowie die Seitenränder festlegen. Außerdem können Sie auch aus dem PageSetupDialog heraus den gewünschten Drucker einstellen. Es hat sich eingebürgert, dass der Dialog zum Einrichten der Seite auch genau dazu genutzt wird, während der eigentliche Druck über den PrintDialog erfolgt. Ob Sie das ebenso machen oder ob Sie einen eigenen Dialog zum Einrichten der Seite zur Verfügung stellen, bleibt alleine Ihnen überlassen. Für die grundlegenden Einstellungen ist der enthaltene Dialog aber durchaus brauchbar.
Sandini Bib
Grundlagen
837
Abbildung 22.2: Ein Beispielausdruck, dargestellt mit Adobe Acrobat
Anwendung Die Anwendung der Dialoge ist einfach, sie werden wie andere Dialoge auch einfach in das Formular eingefügt (bzw. in die Leiste für unsichtbare Steuerelemente unterhalb des Formulars) und im Programm mittels ShowDialog() aufgerufen. Natürlich wird hier wieder ein Wert zurückgeliefert, den Sie kontrollieren können. Einen Unterschied gibt es aber doch, denn die Dialoge benötigen dringend Zusatzinformationen. Dazu müssen Sie bei den Dialogen die Eigenschaft Document auf das verwendete PrintDocument-Objekt einstellen. Alternativ können Sie beim PrintDialog auch die Eigenschaft PrinterSettings verwenden, beim PageSetupDialog die Eigenschaften PrinterSettings oder PageSettings. Diese sind allerdings nur zur Laufzeit einstellbar, nicht über das Eigenschaftenfenster. Am einfachsten ist der Einsatz von PrintDialog bzw. PageSetupDialog daher in Kombination mit einer PrintDocument-Komponente, in diesem Fall reicht die Zuweisung über das Eigenschaftenfenster aus. Falls Sie die Steuerelemente dagegen losgelöst von PrintDocument verwenden möchten, müssen Sie ein PrinterSettings- oder ein PageSettings-Objekt erzeugen und diese den genannten Eigenschaften zur Laufzeit zuweisen.
PrintDialog Die Einstellmöglichkeiten für den PrintDialog können über mehrere Eigenschaften gesteuert werden. f AllowPrintToFile ermöglicht den Ausdruck in eine Datei. f AllowSelection ermöglicht es, nur den markierten Bereich eines Dokuments auszudrucken.
Sandini Bib
838
22 Drucken
f AllowSomePages ermöglicht die Angabe eines Seitenbereichs, der gedruckt werden soll. In der Standardeinstellung steht AllowPrintToFile auf true, d.h. es wird Ihnen ermöglicht, den Ausdruck in eine Datei vorzunehmen. Die beiden anderen Eigenschaften sind auf false eingestellt. Wenn der Dialog mittels OK beendet wurde, erfolgt üblicherweise der Start des Ausdrucks über die Print()-Methode des PrintDocument-Objekts. Da der Dialog über die Eigenschaft Document eine Referenz darauf enthält, werden die Einstellungen direkt weitergegeben. Die für den Ausdruck relevanten Druckereinstellungen werden dann im PrintPageEreignis ausgewertet. Dabei sind einige Klassen zu berücksichtigen, die Sie im Verlauf des Kapitels kennen lernen werden. Einen Screenshot des PrintDialog-Dialogs sehen Sie in Abbildung 22.3.
Abbildung 22.3: PrintDialog zur Druckerauswahl und zur Einstellung des Druckbereichs
PageSetupDialog Auch die Einstellmöglichkeiten für den Dialog PageSetupDialog können über Eigenschaften gesteuert werden. f AllowMargins ermöglicht die Einstellung der Seitenränder. f AllowOrientation ermöglicht den Wechsel zwischen Hoch- und Querformat. f AllowPaper ermöglicht die Auswahl der Papiergröße.
Sandini Bib
Grundlagen
839
f AllowPrinter ermöglicht die Druckerauswahl (in einem Dialog, Button DRUCKER). In der Standardeinstellung sind alle vier Eigenschaften auf true eingestellt. Bei der Einstellung der Seitenränder können Minimalwerte über die Eigenschaft MinMargins vorgegeben werden. Diese Eigenschaft verweist auf ein Margins-Objekt, dessen Eigenschaften Left, Right, Top und Bottom die Minimalwerte angeben. Die Eigenschaften haben die für uns Europäer ungewöhnliche Einheit 1/100 Zoll (0,254 mm). Abbildung 22.4 zeigt einen PageSetupDialog im Einsatz. Der Dialog wird wie üblich über ShowDialog() angezeigt. Im Programmcode ist keine Auswertung des Ergebnisses erforderlich. Wenn der Dialog mit OK beendet wird, werden die neuen Einstellungen in dem angegebenen PrintDocument-Objekt gespeichert, andernfalls bleiben sie unverändert. Die Auswertung erfolgt erst in den Ereignisprozeduren beim Ausdrucken.
Abbildung 22.4: PageSetupDialog zur Einstellung des Seitenlayouts
Metrische Probleme im PageSetupDialog Bei der Auswertung der Seitenränder war Microsoft in .NET 1.x ein Fehler unterlaufen, der dazu führte, dass bei einem erneuten Öffnen des Dialogs die eingetragenen Werte nicht mehr korrekt waren. Das allerdings nur, wenn das metrische System eingestellt war, was in Deutschland üblicherweise der Fall sein dürfte. Für diesen Fehler gab es einen Workaround, die Werte wurden einfach nach dem Verlassen des Dialogs in das korrekte Format umgerechnet. In der Version 2.0 hat Microsoft die-
Sandini Bib
840
22 Drucken
sen Fehler nun behoben, allerdings auf intelligente Art und Weise. Der PageSetupDialog hat eine zusätzliche Eigenschaft namens EnableMetric spendiert bekommen, mit der Sie angeben können, ob der Umrechnungsfehler automatisch beseitigt werden soll. Für den Fall, dass Sie bereits mit 1.1 gearbeitet haben und den Fehler durch nachträgliche Berechnung beseitigt haben, belassen Sie EnableMetric einfach auf false. Sollten Sie neue Applikationen entwickeln, stellen Sie EnableMetric auf true.
22.2.3
Der Dialog PrintPreviewDialog
Die Einstellungen für den Drucker können Sie jetzt bereits leicht vornehmen, was noch nicht geht, ist eine Druckvorschau. Dank des Dialogs PrintPreviewDialog ist das allerdings mit drei einfachen Codezeilen erledigt. Voraussetzung ist wieder, dass die Eigenschaft Document des Dialogs auf das verwendete PrintDocument-Objekt eingestellt wird, wie auch bei den anderen beiden Dialogen einfach über das Eigenschaftenfenster oder aber falls so gewünscht im Code. Erfolgt die Einstellung über das Eigenschaftenfenster, sind zur Vorschau sogar nur zwei Codezeilen erforderlich.
ACHTUNG
if ( printPreviewDialog1.ShowDialog()==DialogResult.OK ) printDocument1.Print(); // Drucken
Verwechseln Sie nicht den PrintPreviewDialog mit dem Steuerelement PrintPreviewControl. Die Namen sind zwar ähnlich, bei letzterem handelt es sich aber um ein sichtbares Steuerelement, das dazu dient, einen eigenen Dialog für die Seitenvorschau zu erzeugen. Die Vorgehensweise dazu wird in Abschnitt 22.6 ab Seite 857 kurz erklärt.
HINWEIS
Wenn Sie Wert auf eine möglichst hohe Darstellungsqualität legen, müssen Sie die Eigenschaft UseAntiAlias auf true setzen. Große Schriften und Linien erscheinen dann glatter, allerdings ist der Rechenaufwand pro Seite je nach Seiteninhalt zum Teil deutlich höher. Aus diesem Grund sollten Sie diese Option mit Vorsicht verwenden bzw. dem Anwender Ihres Programms die Möglichkeit geben, die Option nach Bedarf zu verändern. Der Seitenzähler im Seitenvorschaufenster orientiert sich leider nicht an den tatsächlichen Seitenzahlen, sondern beginnt mit der Zählung immer bei 1. Wenn Sie also die Seiten 4 bis 7 eines Dokuments ausdrucken, dann zeigt der Seitenzähler die Zahl 1 für die Seite 4, 2 für die Seite 5 etc. an.
Intern funktioniert die Druckvorschau übrigens so, dass durch PrintPreviewDialog1. ShowDialog() die PrintDocument-Ereignisprozeduren aufgerufen werden, als würde tatsächlich ein Ausdruck durchgeführt. Aus diesem Grund ist auch kein weiterer Code erforderlich. Allerdings werden die durchgeführten Ausgaben nicht an den Drucker gesendet, sondern gespeichert und im Druckvorschaudialog angezeigt.
Sandini Bib
Grundlagen
841
Für die Vorschau werden die PrintDocument-Ereignisprozeduren für das gesamte Dokument aufgerufen, bevor die erste Seite im Vorschaudialog sichtbar wird. Das hat zwei Konsequenzen: Erstens dauert es bei umfangreichen Dokumenten, die viele Seiten umfassen, ziemlich lange, bis die erste Seite endlich angezeigt wird. (Immerhin können Sie den Ausdruck abbrechen – dann werden nur die ersten Seiten angezeigt.) Und zweitens kann der Speicherbedarf für die Seitenansicht ziemlich groß werden, weil alle zu druckenden Seiten im Arbeitsspeicher gehalten werden, solange der Dialog sichtbar ist.
22.2.4
Druckereigenschaften und Seitenlayout
HINWEIS
Egal, ob Sie dem Anwender die Möglichkeit geben, Druckparameter und Seitenlayout einzustellen oder nicht, müssen Sie auf jeden Fall bei der Durchführung des Ausdrucks in den PrintDocument-Ereignisprozeduren die Papiergröße ermitteln und gegebenenfalls weitere Parameter auswerten. Dieser Abschnitt gibt Ihnen einen Überblick über die wichtigsten Objekte, die Sie dabei berücksichtigen müssen. Die Syntaxzusammenfassung im nächsten Abschnitt liefert darüber hinaus noch eine Menge weiterer Details zu den Eigenschaften dieser Objekte. Der Abschnitt geht davon aus, dass Sie zum Drucken ein PrintDocument-Steuerelement verwenden. Zusammen mit diesem Steuerelement stehen in Ihrem Programm dann automatisch auch Objekte der Klassen PrinterSettings und PageSettings zur Verfügung. Der Zugriff darauf erfolgt in der PrintPage-Ereignisbehandlungsroutine entweder über die entsprechenden Eigenschaften des Parameters e oder aber direkt über Eigenschaften der PrintDocument-Klasse.
Koordinatensystem Der Ausdruck erfolgt wie bereits bekannt über ein Graphics-Objekt, das die »Zeichenfläche« des Druckers repräsentiert. Auf dieses Objekt können Sie über die Eigenschaft Graphics des Parameters e der Ereignisbehandlungsroutine PrintPage zugreifen. Dieses Graphics-Objekt legt auch das Koordinatensystem fest. Über die Eigenschaft PageUnit können Sie die verwendete Maßeinheit ermitteln oder ändern, standardmäßig wird hierbei die Einstellung GraphicsUnit.Display verwendet. Der Punkt (0,0) bezeichnet wie gehabt das linke obere Eck der Seite. Die x-Achse zeigt nach rechts, die y-Achse nach unten. Das gilt auch dann, wenn die Seite im Querformat gedruckt wird. In diesem Fall wird das Koordinatensystem automatisch relativ zur Seite gedreht, Sie brauchen sich also nicht darum zu kümmern. Grundsätzlich können Sie natürlich auch ein anderes Koordinatensystem verwenden (z.B. GraphicsUnit.Millimeter). Da aber sämtliche Eigenschaften, die Maßangaben für Druckerobjekte enthalten, die Maßeinheit 1/100 Zoll verwenden, handeln Sie sich damit eine Menge Einheitsumrechnungen (und ein entsprechend hohes Fehlerpotenzial) ein.
Sandini Bib
842
22 Drucken
Seitengröße und Seitenränder Die Eigenschaften PageBounds und MarginBounds des Parameters e verweisen jeweils auf eine Rectangle-Instanz, die den Koordinatenbereich der gesamten Seite bzw. der um den Rand verkleinerten Seite angeben. Die Angaben erfolgen in der gleichen Maßeinheit wie beim Graphics-Objekt (also in 1/100 Zoll, entsprechend GraphicsUnit.Display). Das durch PageBounds festgelegte Rechteck beginnt mit (0,0) und endet bei (x,y), wobei dieser Punkt exakt das Ende der Seite beschreibt. Allerdings gibt es keinen Drucker, der das gesamte Papier ohne Ränder bedrucken kann. Wie groß die Seitenränder tatsächlich sind, hängt vom jeweiligen Drucker ab. Diese Information kann mit .NET-Methoden aber leider nicht ermittelt werden. Das Rechteck, das durch MarginBounds festgelegt ist, ist an allen vier Rändern um 100 Einheiten (2,54 cm) verkleinert. Dieses Rechteck reicht daher unabhängig vom Drucker und unabhängig vom tatsächlich bedruckbaren Raum von (100,100) bis (x-100, y-100). Diese Einstellung ist ziemlich unbefriedigend, weil unnötig viel Platz vergeudet wird. Ein realistischer Seitenrand betrüge etwa 0,5 bis 1 cm links und rechts und etwa 1 bis 1,5 cm oben und unten. Grundsätzlich ist MarginBounds aber nur eine Richtlinie. Wenn Sie möchten, können Sie Ihre Ausgabe auch in den Grenzen des PageBounds-Rechtecks durchführen – dann müssen Sie allerdings damit rechnen, dass der Ausdruck an den Rändern abgeschnitten wird. Die beste Lösung besteht wohl darin, beim Programmstart realistische Vorgaben für den Seitenrand vorzugeben (siehe oben) und dem Anwender dann die Möglichkeit zu geben, die Seitenränder bei Bedarf selbst zu verändern. Dazu können Sie beispielsweise den PageSetupDialog verwenden. Zur Veränderung der Standardseitenränder können Sie sich an dem folgenden Code orientieren. Dieser Code verändert die Einstellungen eines Margins-Objekts. Das Objekt stammt aus dem PageSettings-Objekt, das die Eigenschaft DefaultPageSettings der Klasse PrintDocument liefert. Die Eigenschaften des Margins-Objekts erwarten die Angaben in 1/100 Zoll, weswegen die cm-Angaben durch 0,0254 dividiert werden. private void Form1_Load(object sender, System.EventArgs e) { // Ändern der Standard-Seitenränder // 1 cm seitlich, 1,5 cm oben und unten Margins m = pdDocument.DefaultPageSettings.Margins; m.Left = (int)( 1 / 0.0254 ); m.Right = (int)( 1 / 0.0254 ); m.Top = (int)( 1.5 / 0.0254 ); m.Bottom = (int)( 1.5 / 0.0254 ); }
Falls Sie sich sicher sind, dass an dieser Stelle immer aufgerundet werden muss (damit nicht Werte wie 14,9 herauskommen), können Sie auch folgende Berechnung durchführen: m.Left = (int)Math.Ceiling( 1 / 0.0254 );
Sandini Bib
Grundlagen
843
Seiteneigenschaften (PageSettings) Auf die Seiteneinstellungen können Sie auf drei Arten zugreifen. Sofern Sie sich in der Ereignisbehandlungsroutine PrintPage befinden, ist der einfachste Zugriff über die Eigenschaft PageSettings des Parameters e. Ebenfalls möglich ist der Zugriff über die Eigenschaft DefaultPageSettings des PrintDocument-Objekts. Schließlich funktioniert es auch noch über ein PrinterSettings-Objekt, sofern Sie eines verwenden. Auch diese Klasse verfügt über eine Eigenschaft DefaultPageSettings. Über PageSettings können Sie verschiedene Dinge einstellen. Unter anderem die Druckausrichtung (über die Eigenschaft Landscape), die Druckerauflösung (über die Eigenschaft PrinterResolution) oder die Papiergröße (Eigenschaft Bounds, Einheit ist 1/100 Zoll). Eine ausführlichere Beschreibung der Eigenschaften folgt in der Syntaxzusammenfassung im nächsten Abschnitt.
ACHTUNG
Vom PrintDocument-Objekt können Sie zwei PageSettings-Objekte ansprechen! Es handelt sich dabei um unterschiedliche Objekte, die nicht denselben Inhalt aufweisen! f Die Eigenschaft PrinterSettings.DefaultPageSettings verweist auf die Standardeinstellungen des Druckers, der durch PrinterSettings momentan ausgewählt ist. f Die Eigenschaft DefaultPageSettings verweist auf die für den Ausdruck relevanten Einstellungen. Wenn Sie Parameter für den Ausdruck verändern möchten, dann müssen Sie die Eigenschaften dieses PageSettings-Objekts verändern.
Druckereinstellung (PrinterSettings) Während es bei der Klasse PageSettings um die Seiteneinstellungen geht, verwaltet das PrinterSettings-Objekt entsprechend die für den Drucker gültigen Einstellungen. Dazu gehören unter anderem die Eigenschaften PrinterName (der Name des Druckers) oder auch PrinterResolutions (unterstützte Auflösungen). Auch hier erfolgt eine Beschreibung der verschiedenen Eigenschaften in der Syntaxzusammenfassung im nächsten Abschnitt. Wieder können Sie das benötigte PrinterSettings-Objekt auf verschiedene Arten ermitteln. Am einfachsten ist es in der Ereignisbehandlungsroutine PrintPage über den Parameter e. Das benötigte Objekt ist in der Eigenschaft e.PageSettings.PrinterSettings enthalten. Der zweite Weg führt über das PrintDocument-Objekt und dessen Eigenschaft PrinterSettings. Weiterhin können Sie natürlich auch die Eigenschaft PrinterSettings eines PageSettingsObjekts auswerten, falls Sie ein solches verwenden.
Anwendung der PrinterSettings- und PageSettings-Objekte Das übliche Anwendungsszenario sieht so aus, dass Sie in Ihrem Programm zumindest die Steuerelemente PrintDocument und PrintDialog verwenden, optional auch die Dialoge PageSetupDialog und PrintPreviewDialog. In diesem Fall ist eine direkte Änderung der Printer-
Sandini Bib
844
22 Drucken
Settings- und PageSettings-Objekte nicht erforderlich. Erst wenn tatsächlich ein Dokument ausgedruckt werden soll, werten Sie die Eigenschaften aus (also in der Ereignisbehandlungsroutine des PrintDocument-Steuerelements). Diese Vorgehensweise ist in den Beispielen in Abschnitt 22.3 demonstriert.
Eine alternative Vorgehensweise besteht darin, dass Sie auf die gerade aufgezählten Steuerelemente verzichten und deren Funktionen selbst nachbilden (etwa durch eigene Dialoge). In diesem Fall müssen Sie die Eigenschaften der Page- und PrinterSettings-Objekte selbst verändern.).
22.2.5
Syntaxzusammenfassung
Steuerelemente und Dialoge Eigenschaften und Methoden der Klasse PrintDocument (aus System.Drawing.Printing) Print()
startet den Ausdruck und löst nacheinander die Ereignisse BeginPrint, PrintPage (einmal pro Seite) und EndPrint aus.
DefaultPageSettings
verweist auf ein PageSettings-Objekt.
DocumentName
gibt den Namen des Dokuments an, der im Druckmanager erscheint.
PrinterSettings
verweist auf ein PrinterSettings-Objekt.
Eigenschaften und Methoden des Dialogs PrintDialog (aus System.Windows.Forms) ShowDialog()
zeigt den Dialog zur Druckerauswahl und zur Angabe des Druckbereichs an. Die Anwendung ist analog zu allen anderen Dialogen.
Document
verweist auf das dazugehörende PrintDocument-Objekt. Diese Eigenschaft muss vor der Verwendung des Dialogs zugewiesen werden.
AllowPrintFile
ermöglicht es dem Anwender, in eine Datei zu drucken (das entsprechende Auswahlkästchen wird angezeigt).
AllowSelection
ermöglicht es, nur einen bestimmten markierten Bereich des Dokuments zu drucken.
AllowSomePages
ermöglicht es, nur einen bestimmten Seitenbereich auszudrucken.
Eigenschaften und Methoden des Dialogs PageSetupDialog ShowDialog()
zeigt den Dialog zur Einstellung des Seitenlayouts an. Die Anwendung ist analog zu allen anderen Dialogen.
Document
verweist auf das dazugehörende PrintDocument-Objekt. Diese Eigenschaft muss vor der Verwendung des Dialogs zugewiesen werden.
AllowMargins
ermöglicht die Einstellung der Seitenränder.
Sandini Bib
Grundlagen
845
Eigenschaften und Methoden des Dialogs PageSetupDialog AllowOrientation
ermöglicht die Auswahl der Seitenausrichtung (Hoch- oder Querformat).
AllowPaper
ermöglicht die Auswahl der Papiergröße.
AllowPrinter
ermöglicht die Auswahl des zu verwendenden Druckers.
MinMargins
verweist auf ein Margins-Objekt mit den minimalen Werten für die Seitenränder.
Eigenschaften und Methoden des Dialogs PrintPreviewDialog ShowDialog()
zeigt das Fenster zur Druckvorschau (Seitenansicht) an.
Document
verweist auf das dazugehörende PrintDocument-Objekt. Diese Eigenschaft muss vor der Verwendung des Dialogs zugewiesen werden.
UseAntiAlias
bewirkt eine geglättete Darstellung der Vorschau, was allerdings zu einer längeren Wartezeit führen kann.
Häufig benötigte Klassen des Namensraums System.Drawing.Printing Eigenschaften des Parameters e (Klasse PrintPageEventArgs) Cancel
bewirkt einen Abbruch des Druckvorgangs, wenn sie auf true gesetzt wird.
Graphics
verweist auf ein Graphics-Objekt, das den Ausgabebereich repräsentiert (in diesem Fall das Druckerpapier).
HasMorePages
bewirkt einen erneuten Aufruf des Ereignisses PrintPage zum Ausdruck von weiteren Seiten, wenn sie auf true gesetzt wird.
MarginBounds
verweist auf eine Rectangle-Instanz, die den bedruckbaren Bereich der Seite angibt.
PageBounds
verweist auf eine Rectangle-Instanz, die den Bereich der gesamten Seite angibt.
PageSettings
verweist auf ein PageSettings-Objekt, das detaillierte Informationen über das Seitenlayout liefert.
Eigenschaften der Klasse PageSettings Bounds
verweist auf eine Rectangle-Instanz, die den Koordinatenbereich der gesamten Seite (ohne Berücksichtigung der Seitenränder) in der Einheit 1/100 Zoll angibt.
Color
liefert einen booleschen Wert, der angibt, ob in Farbe gedruckt werden kann. Diese Eigenschaft enthält allerdings auch bei vielen Schwarzweiß-Druckern den Wert true.
Sandini Bib
846
22 Drucken
Eigenschaften der Klasse PageSettings Landscape
dient zur Festlegung, ob im Hoch- oder im Querformat gedruckt wird. Der Wert true ergibt einen Druck im Querformat.
Margins
verweist auf ein Margins-Objekt, das die aktuelle Einstellung der Seitenränder enthält (Left, Right, Top, Bottom). Alle Werte sind in 1/100 Zoll.
PaperSize
verweist auf ein PaperSize-Objekt, das Informationen über das Seitenformat liefert. Unter anderem kann über die Eigenschaft Kind vom Typ PaperKind ermittelt werden, welches Papierformat verwendet wird.
PaperSource
verweist auf ein PaperSource-Objekt, das angibt, aus welchem Einzug das Papier entnommen wird. Dazu wird die Eigenschaft Kind vom Typ PaperSourceKind abgerufen.
PrinterResolution
verweist auf ein PrinterResolution-Objekt, dessen Eigenschaften X und Y die Druckauflösung in DPI (dots per inch, Punkte pro Zoll) angeben. Die Auflösung kann für beide Richtungen getrennt eingestellt werden.
PrinterSettings
verweist auf ein PrinterSettings-Objekt.
Eigenschaften der Klasse PrinterSettings CanDuplex
gibt an, ob der Drucker einen beidseitigen Druck prinzipiell unterstützt
Collate
gibt an, in welcher Reihenfolge die Seiten bei einem Mehrfachdruck gedruckt werden sollen. true bedeutet, dass das komplette Dokument mehrmals hintereinander ausgedruckt werden soll. false bedeutet, dass jede einzelne Seite mehrfach ausgedruckt werden soll. (Das ist im Regelfall schneller, allerdings muss der Anwender danach die Seiten selbst ordnen.)
Copies
gibt an, wie viele Kopien ausgedruckt werden sollen.
DefaultPageSettings
verweist auf ein PageSettings-Objekt.
Duplex
gibt an, ob der Ausdruck beidseitig erfolgen soll.
FromPage, ToPage
gibt an, welcher Seitenbereich ausgedruckt werden soll, allerdings nur, wenn die Eigenschaft PrintRange den Wert PrintRange.SomePages enthält.
InstalledPrinters
liefert eine Collection aus Strings (Typ PrinterSettings.StringCollection) zurück, die die Namen der installierten Drucker enthält. Diese Eigenschaft ist statisch.
IsDefaultPrinter
liefert einen booleschen Wert, der angibt, ob der aktuelle Drucker der Standarddrucker des Systems ist.
PaperSizes
liefert eine Collection mit allen unterstützten Papiergrößen. Die Elemente der Collection sind vom Typ PaperSize.
PrinterName
liefert den Namen des Druckers.
PrinterResolutions
liefert eine Collection mit allen verfügbaren Auflösungen. Die Elemente dieser Aufzählung sind vom Typ PrinterResolution.
Sandini Bib
Beispiel – Mehrseitiger Druck
847
Eigenschaften der Klasse PrinterSettings PrintRange
gibt an, welcher Bereich gedruckt werden soll. Mögliche Werte sind PrintRange.AllPages (das gesamte Dokument), PrintRange.SomePages (vom Benutzer ausgewählte Seiten) oder PrintRange.Selection (drucken einer markierten Auswahl).
PrintToFile
liefert einen booleschen Wert, der angibt, ob der Ausdruck in eine Datei umgeleitet werden soll.
Eigenschaften der Klasse Margins Left, Right
linker und rechter Seitenrand (in 1/100 Zoll)
Top, Bottom
oberer und unterer Seitenrand
22.3
Beispiel – Mehrseitiger Druck
An diesem Beispiel möchten wir demonstrieren, wie das Zusammenspiel der verschiedenen Klassen funktioniert. Bei dem auszudruckenden Dokument handelt es sich um zehn Seiten, die in etwa so aufgebaut sind wie das Einführungsbeispiel des Kapitels. Auch hier werden also Ellipsen und Rechtecke gezeichnet sowie ein Text auf der Seite ausgegeben. Jede Seite erhält außerdem eine große Seitennummer. Zur Laufzeit haben Sie mehrere Möglichkeiten: f Mit dem Button SEITE EINRICHTEN können Sie das Seitenlayout verändern (Hoch-/Querformat, Seitenränder). f Mit dem Button DRUCKVORSCHAU können Sie sich den Ausdruck papiersparend im Voraus ansehen. f Mit dem Button DRUCKEN können Sie einen Drucker sowie den gewünschten Seitenbereich angeben (z.B. um die Seiten vier bis sieben auszudrucken). f Weiterhin können Sie die Anzahl der Seiten einstellen, die das Dokument haben soll (das entspricht dann auch der maximalen Seitenzahl).
CD
Abbildung 22.5 zeigt die Entwurfsansicht des Formulars. Zusätzlich wird natürlich ein PrintDocument-Objekt benötigt, weiterhin die Dialoge PrintDialog, PageSetupDialog und PagePreviewDialog. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_22\PrintMultiPages.
Sandini Bib
848
22 Drucken
Abbildung 22.5: Die Entwurfsansicht des Formulars
Initialisierung Bei einem mehrseitigen Ausdruck, bei dem der Druck beliebiger Seiten möglich sein soll, müssen Sie sich selbst darum kümmern, Ihr Dokument entsprechend zu unterteilen. In diesem Beispiel sind alle Seiten gleich aufgebaut, alles was sich ändert ist die Seitennummer, die groß auf die Mitte der Seite gedruckt werden soll. Daher können wir vereinfacht arbeiten. Wir deklarieren drei Felder. pageNumber beinhaltet die aktuell zu druckende Seitennummer, startPageNumber die Nummer der ersten zu druckenden Seite und maxPageNumber die Nummer der als letztes zu druckenden Seite. Dieses Feld enthält standardmäßig die Gesamtzahl der zu druckenden Seiten, die Sie über eine Textbox festlegen können. int pageNumber = -1; int maxPageNumber = 20; int startPageNumber = 1;
Im Load-Ereignis des Formulars wird den Document-Eigenschaften der Dialoge das PrintDocument-Objekt zugewiesen. private void FrmMain_Load( object sender, System.EventArgs e ) { // Document-Eigenschaften zuweisen dlgPageSetup.Document = pdDocument; dlgPrint.Document = pdDocument; dlgPreview.Document = pdDocument; }
Sandini Bib
Beispiel – Mehrseitiger Druck
849
Ereignisbehandlungsroutinen der Buttons Die Click-Ereignisse der Buttons beinhalten im Prinzip nichts Neues, weshalb sie hier ohne weitere Erklärung abgedruckt werden können. private void BtnSetup_Click( object sender, System.EventArgs e ) { dlgPageSetup.ShowDialog(); } private void BtnPreview_Click( object sender, System.EventArgs e ) { // Druckvorschau if ( dlgPreview.ShowDialog() == DialogResult.OK ) this.pdDocument.Print(); } private void BtnPrint_Click( object sender, System.EventArgs e ) { // Drucken dlgPrint.AllowSomePages = true; dlgPrint.AllowSelection = false; if ( dlgPrint.ShowDialog() == DialogResult.OK ) this.pdDocument.Print(); }
Druckstart und -ende Am Anfang steht das Ereignis BeginPrint. Da wir ja selbst festlegen können, welche Seiten ausgedruckt werden sollen, bzw. auch die Seitenzahl bestimmen können, stellen wir an diesem Punkt die benötigten Werte ein. BeginPrint wird nur einmal beim Druckstart aufgerufen. private void PdDocument_BeginPrint( object sender, PrintEventArgs e ) { // Druck starten // Korrekte Seitennummern ermitteln this.pageNumber = 1; if ( this.pdDocument.PrinterSettings.PrintRange.Equals( PrintRange.SomePages ) ) { this.startPageNumber = pdDocument.PrinterSettings.FromPage; this.maxPageNumber = pdDocument.PrinterSettings.ToPage; } else { this.startPageNumber = 1; this.maxPageNumber = Int32.Parse( txtPages.Text ); } }
Sandini Bib
850
22 Drucken
Falls der Benutzer nur bestimmte Seiten ausgewählt hat, werden die Werte startPageNumber und maxPageNumber auf die Werte der Eigenschaften FromPage bzw. ToPage des verwendeten PrinterSettings-Objekts eingestellt. Dieses können wir leicht über das PrintDocument-Objekt ermitteln. Falls alles gedruckt werden soll, wird startPageNumber auf 1 und maxPageNumber auf den Wert in der entsprechenden Textbox des Dialogs eingestellt. Auch das Ereignis EndPrint wird verwendet, allerdings nur, um den Wert von pageNumber wieder zurückzusetzen. private void PdDocument_EndPrint( object sender, PrintEventArgs e ) { this.pageNumber = -1; }
Drucken einer Seite Das interessanteste Ereignis ist PrintPage. Hier werden die einzelnen Seiten gedruckt. Dabei wird auf jede Seite die Seitenzahl groß und mittig gedruckt, zusätzlich werden die gewohnten Rechtecke und Ellipsen gezeichnet und die Einstellungen als Text ausgegeben. Am Anfang der Ereignisbehandlungsroutine wird die aktuelle Seitenzahl mit den auszudruckenden Seitenzahlen verglichen. Solange diese kleiner ist als der Wert von startPageNumber, findet kein Ausdruck statt. Innerhalb der eigentlichen Druckroutine findet sich im Prinzip nichts Neues. Es werden zwei verschiedene Schriftarten für den Druck der Seitenzahl bzw. des Textes verwendet (kontrollieren Sie bei der Ausführung, ob diese bei Ihnen installiert sind – beim Font Arial ist das sicher der Fall, der Font Stencil ist aber kein Standard-Zeichensatz). Weiterhin werden wieder die bekannten Ellipsen und Rechtecke gezeichnet. Am Ende der Methode wird kontrolliert, ob noch weitere Seiten zu drucken sind. Falls ja, wird e.HasMorePages auf true eingestellt (was einen erneuten Aufruf des Ereignisses zur Folge hat), falls nicht, wird der Druck an dieser Stelle beendet. private void PdDocument_PrintPage( object sender, PrintPageEventArgs e ) { // Drucken einer Seite // Kontrolle, ob die Seite gedruckt werden soll while ( this.pageNumber < this.startPageNumber ) { this.pageNumber++; } // Initialisierung Graphics g = e.Graphics; PageSettings ps = e.PageSettings; Rectangle pageRect = e.PageBounds; Rectangle printRect = e.MarginBounds;
Sandini Bib
Eine Textbox zum Drucken
851
// Schriftarten Font nrFont = new Font( "Stencil", 250f, FontStyle.Bold ); Font txtFont = new Font( "Arial", 10 ); StringFormat sf = new StringFormat(); // Seite ausgeben sf.Alignment = StringAlignment.Center; sf.LineAlignment = StringAlignment.Center; // Text vorbereiten string s = ps.ToString() + "\r\n" + pageRect.ToString() + "\r\n" + printRect.ToString(); // Seitennummer schreiben g.DrawString( pageNumber.ToString(), nrFont, Brushes.LightBlue, (RectangleF)printRect, sf ); // Text schreiben g.DrawString( s, txtFont, Brushes.Black, (RectangleF)printRect ); // Linien und Ellipsen g.DrawRectangle( Pens.Black, pageRect ); g.DrawEllipse( Pens.Gray, pageRect ); g.DrawRectangle( Pens.Black, printRect ); g.DrawEllipse( Pens.Gray, printRect ); // Objekte entsorgen txtFont.Dispose(); nrFont.Dispose(); sf.Dispose(); // Seitennummer erhöhen und kontrollieren this.pageNumber++; e.HasMorePages = ( this.pageNumber <= this.maxPageNumber ); }
22.4
Eine Textbox zum Drucken
Bei einer TextBox, die ja recht häufig in Projekten Verwendung findet, möchten viele Programmierer eine einfache Möglichkeit haben, den Inhalt auszudrucken. Leider bietet die TextBox auch im Visual Studio 2005 diese Funktionalität nicht. Allerdings sind alle Möglichkeiten gegeben, auf einfache Art und Weise eine erweiterte TextBox zu erstellen, die diese Funktionalität bietet. Eigentlich ist alles, was dazu benötigt wird, ein Algorithmus zum Ausdrucken und ein integriertes PrintDocument-Objekt.
Sandini Bib
852
22 Drucken
Mit dem hier vorgestellten Steuerelement können Sie enthaltene Texte mit einem einzigen Befehl auf den aktuell eingestellten Drucker ausgeben. Es handelt sich um eine recht einfache Komponente, die Sie selbstverständlich für eigene Zwecke erweitern können. Grundlage ist das Steuerelement TextBox (mit einer RichTextBox wäre es etwas komplizierter). Die daraus entstehende PrintableTextBox soll folgende Fähigkeiten beinhalten: f Ausdruck des Inhalts unter Berücksichtigung der Schriftart und Größe auf dem aktuell eingestellten Drucker f Anzeige eines Druckdialogs falls gewünscht mit Übernahme der darin vorgenommenen Einstellungen f Anzeige einer Druckvorschau (falls gewünscht)
Deklarationen Erstellen Sie ein neues Projekt des Typs Klassenbibliothek oder Steuerelement-Bibliothek. In jedem Fall sollten Sie danach die enthaltene Klasse bzw. das Basis-Steuerelement löschen. Wir leiten bekanntlich von einer TextBox ab und benötigen keine weitere Basisfunktionalität. Die Benennung des Projekts ist nicht relevant, im Buch heißt das Projekt AdditionalControls und ist in der Projektmappe MdiEditor enthalten. Erstellen Sie eine neue Klasse namens PrintableTextBox und leiten Sie diese von dem Steuerelement TextBox ab. Referenzen zur System.Windows.Forms.Dll müssen hinzugefügt werden, der entsprechende Namespace sollte eingefügt werden. Ebenso benötigt (da ja auch Drucken auf den Zeichenroutinen des .NET Frameworks basiert) wird die System.Drawing.dll, die die Namespaces zum Drucken beinhaltet. Die PrintableTextBox benötigt einige wenige Eigenschaften. Einmal natürlich ein PrintDocument-Objekt zum Drucken. Des Weiteren je einer der Dialoge PrintDialog und PrintPreviewDialog. Im Beispiel werden diese als Felder hinzugefügt, nicht visuell. Ebenfalls benötigt wird ein Feld currentChar, das später beim Ausdruck eine wichtige Rolle spielt. private private private private
PrintDocument document = new PrintDocument(); // Printdocument zum Drucken PrintDialog dlgPrint = new PrintDialog(); // Dialog zum Drucken (intern) PrintPreviewDialog dlgPreview = new PrintPreviewDialog(); // Der Vorschaudialog int currentChar = 0; // Aktuelles Zeichen zum Drucken
Damit auch andere Dialoge noch verwendet werden können, wird document als read-OnlyEigenschaft veröffentlicht. [EditorBrowsable(EditorBrowsableState.Always)] [Browsable( false )] public PrintDocument Document { get { return this.document; } }
Sandini Bib
Eine Textbox zum Drucken
853
Die Methoden Veröffentlicht werden nur drei Methoden zum Aufruf des Drucks. Print() druckt sofort auf den aktuell eingestellten Drucker aus. PrintWithDialog() zeigt zuerst den DruckDialog an, PrintWithPreview() zeigt zuerst den Vorschaudialog an. public void Print() { // Druckt den in diesem Control enthaltenen Text this.document.Print(); } public void PrintWithDialog() { if ( this.dlgPrint.ShowDialog() == DialogResult.OK ) this.document.Print(); } public void PrintWithPreview() { if ( this.dlgPreview.ShowDialog() == DialogResult.OK ) this.document.Print(); }
Die Methoden für den Ausdruck sollten kein Problem darstellen. Letztendlich handelt es sich nur um die Anzeige der Dialoge. Allerdings muss deren Document-Eigenschaft ja auch mit dem PrintDocument-Objekt verbunden sein. Das erledigen wir im Konstruktor. public PrintableTextBox() { // Initialisieren der Elemente InitializeComponent(); this.document.PrintPage += new PrintPageEventHandler( Document_PrintPage ); this.dlgPrint.Document = this.document; this.dlgPreview.Document = this.document; }
Der Ausdruck Die Routine für den Ausdruck des Textes benötigt etwas mehr Aufmerksamkeit. Wie Sie bereits aus dem vorangegangenen Kapitel wissen, kann die Methode DrawString() Text basierend auf einem Rechteck ausgeben, mit Zeilenumbrüchen, ohne dass Sie sich weiter darum kümmern müssten. Allerdings ist der Ausgabebereich diesmal begrenzt, denn es handelt sich um Papier, d.h. die Anzahl der Gesamtseiten muss ermittelt werden. Allerdings nicht im Voraus – es genügt, am Ende eines Ausdrucks zu ermitteln, ob noch Text vorhanden ist, der ausgedruckt werden muss. Dazu wird zunächst das verfügbare Rechteck berechnet, das für den Ausdruck verwendet wird. Dieses basiert natürlich auf den Randeinstellungen, die in der Eigenschaft DefaultPageSettings von document enthalten sind. Bei dieser Berechnung wird auch noch berücksichtigt, ob der Drucker auf Hoch- oder Querformat eingestellt ist.
Sandini Bib
854
22 Drucken
Die maximale Zeilenanzahl lässt sich ebenfalls leicht errechnen, nämlich durch eine simple Division der Seitenhöhe durch die Höhe der aktuell verwendeten Schriftart. Danach erfolgt die Berechnung der maximalen Anzahl Zeichen, die in den Druckbereich passen. Jetzt wird auch die Bedeutung des Felds currentChar deutlich – darin wird gespeichert, welches Zeichen zuletzt gedruckt wurde bzw. mit welchem Zeichen fortgefahren werden soll. Die Anzahl der Zeichen, die in den Druckbereich passen, wird nach dem Druck zu currentChar hinzuaddiert. Gedruckt wird einfach der gesamte Text ab currentChar. Das automatische Clipping sorgt dafür, dass nicht über den Seitenrand gedruckt wird, d.h. es werden nur die Zeichen auf die Seite ausgegeben, die auch wirklich darauf passen. Und auch das Vorhandensein weiterer Seiten ist leicht ermittelt. Wenn currentChar einen Wert hat, der größer ist als die Anzahl der vorhandenen Zeichen, muss keine weitere Seite gedruckt werden. Die gesamte Routine für den Ausdruck sehen Sie im folgenden Listing. private void Document_PrintPage( object sender, PrintPageEventArgs e ) { // Der eigentliche Druckvorgang // Voreinstellungen int paHeight; // Höhe int paWidth; // Breite Rectangle rectPrint; // Druckbereich int linesFilled; // Gedruckte Zeilen, wird später benötigt int charsFitted; // Zeichenanzahl, die in den Druckbereich passt Font fntPrint = this.Font; // Druckerfont // Format für Ausgabe StringFormat format = new StringFormat( StringFormatFlags.LineLimit ); // Druckbereich berechnen paHeight = this.document.DefaultPageSettings.PaperSize.Height this.document.DefaultPageSettings.Margins.Top this.document.DefaultPageSettings.Margins.Bottom; paWidth = this.document.DefaultPageSettings.PaperSize.Width this.document.DefaultPageSettings.Margins.Left this.document.DefaultPageSettings.Margins.Right; // Bei Querformat Maße tauschen if ( this.document.DefaultPageSettings.Landscape ) { int tmp = paHeight; paHeight = paWidth; paWidth = tmp; }
Sandini Bib
Eine Textbox zum Drucken // Rechteck für Druckbereich rectPrint = new Rectangle( this.document.DefaultPageSettings.Margins.Left, this.document.DefaultPageSettings.Margins.Top, paWidth, paHeight );
// Maximale Zeilenanzahl basierend auf gewähltem font berechnen int lineCount = (int)( paHeight / fntPrint.Height ); // MeasureString berechnet die Anzahl Zeichen, die in den Druckbereich passen // charsFitted wird ByRef übergeben und später benutzt, um zu kontrollieren, // ob noch weitere Seiten gedruckt werden sollen e.Graphics.MeasureString( this.Text.Substring( this.currentChar ), fntPrint, new SizeF( paWidth, paHeight ), format, out charsFitted, out linesFilled ); e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; // Drucken e.Graphics.DrawString( this.Text.Substring( this.currentChar ), fntPrint, Brushes.Black, rectPrint, format ); // Aktuelles Zeichen anpassen this.currentChar += charsFitted; // Kontrolle, ob noch weitere Seiten zu drucken sind. // Ist das nicht der Fall, muss currChar zurückgesetzt werden, da Static if ( this.currentChar < this.Text.Length ) { e.HasMorePages = true; } else { e.HasMorePages = false; this.currentChar = 0; } }
Damit ist die Komponente fertig und Sie können sie in eigenen Produkten einsetzen.
855
Sandini Bib
856
22 Drucken
22.5
Weitere Programmiertechniken
22.5.1
Unterschiedliches Seitenlayout
Normalerweise haben alle Seiten eines Ausdrucks dasselbe Layout. Das gemeinsame Seitenlayout wird bereits vor Beginn des Ausdrucks eingestellt, nämlich durch die Eigenschaften der Objekte PageSettings bzw. PrinterSettings. In einigen Fällen kann es aber notwendig sein, für einzelne Seiten (oder sogar für jede Seite) ein besonderes Seitenlayout oder spezielle Druckereinstellungen zu verwenden. Beispielsweise, wenn Sie eine zweispaltige Seite oder eine bestimmte Seite im Querformat drucken wollen. Zur Umsetzung solcher Spezialwünsche ist das QueryPageSettings-Ereignis der PrintDocument-Klasse vorgesehen. Das Ereignis tritt unmittelbar vor dem PrintPageEreignis auf, also für jede Seite ein Mal. Innerhalb der Ereignisprozedur kann die Eigenschaft e.PageSetting verändert werden. Die hier durchgeführten Änderungen gelten dann nur für die jeweils nächste zu druckende Seite.
22.5.2
Drucken ohne Status- bzw. Abbruch-Dialog
Sobald Sie die Methode Print() ausführen, erscheint automatisch ein kleiner Dialog, der angibt, wie viele Seiten bereits gedruckt wurden, und die Möglichkeit zum Abbruch bietet (siehe auch Abbildung 22.1). Der Dialog wird von einem Objekt der Klasse PrintControllerWithStatusDialog erzeugt (Namespace System.Windows.Forms). Dieses Objekt gilt als Standardeinstellung für die Eigenschaft PrintController des PrintDocument-Objekts. Der PrintController ist in erster Linie für den Aufruf der diversen Ereignisprozeduren beim Drucken verantwortlich. Wenn Sie diesen Dialog verbergen möchten, müssen Sie eine eigenen PrintControllerInstanz verwenden. Besondere Angaben sind nicht notwendig, der folgende Aufruf vor der Verwendung des PrintDocument-Objekts genügt: printDocument1.PrintController = new StandardPrintController()
HINWEIS
Damit verwenden Sie statt eines PrintControllerWithStatusDialog-Objekts nun ein Objekt der Klasse StandardPrintController (Namespace System.Drawing.Printing), das die gleichen Eigenschaften besitzt, aber eben keinen Dialog anzeigt. Der Statusdialog erscheint wieder, wenn Sie eine Seitenvorschau durchführen. Das liegt daran, dass zur Seitenvorschau automatisch die dritte Variante der PrintController-Klasse zum Einsatz kommt, nämlich ein Objekt der Klasse PreviewPrintController (Namespace System.Drawing.Printing). Es scheint keine Möglichkeit zu geben, den Statusdialog auch bei der Seitenvorschau zu vermeiden.
Sandini Bib
Eigene Seitenvorschau
22.6
857
Eigene Seitenvorschau
Mit dem PrintPreviewDialog-Steuerelement kann eine Seitenvorschau mit wenigen Zeilen zusätzlichem Code realisiert werden (siehe auch Abschnitt 22.2.3). Der Nachteil dieser Vorgehensweise besteht darin, dass Sie diesen Dialog in keiner Weise verbessern können. Vor allem was das Design des Dialogs angeht, wären einige Verbesserungen notwendig. Glücklicherweise liefert das Visual Studio mit PrintPreviewControl ein Steuerelement mit, das den eigentlichen Vorschaubereich des PrintPreviewDialogs darstellt und das Sie in einem eigenen Dialog verwenden können. Auch hier muss die Document-Eigenschaft wieder mit dem zu verwendenden PrintDocument-Objekt verbunden werden (was logisch ist, da das Steuerelement sonst nicht wissen kann, was es anzeigen soll). Außer der Tatsache, dass es sich »nur« um ein Steuerelement handelt, verhält sich PrintPreviewControl bei der Anzeige wie PrintPreviewDialog. Zusätzlich haben Sie die Möglichkeit, durch einige Eigenschaften das Verhalten des Steuerelements festzulegen. f Die Eigenschaft AutoZoom ermöglicht, dass der Zoomfaktor automatisch so eingestellt wird, dass die ganze Seite auf dem Steuerelement erscheint, egal wie groß dieses dargestellt wird. f Wenn die Eigenschaft AutoZoom auf false eingestellt ist, können Sie über Zoom den Vergrößerungsfaktor der Seite steuern. Eine Veränderung der Eigenschaft Zoom bewirkt eine sofortige Deaktivierung von AutoZoom. Der Standardwert beträgt 0,3. f Die Eigenschaften Rows und Columns geben an, wie viele Seiten gleichzeitig dargestellt werden sollen (in Zeilen und Spalten gemessen). Wenn Sie also Rows=2 und Cols=3 angeben, werden innerhalb des Steuerelements sechs Seiten gleichzeitig angezeigt. Der Standardwert für beide Eigenschaften ist 1. f Die Eigenschaft StartPage gibt die Nummer der ersten Seite an. Die Nummerierung beginnt mit 0 für die erste auszudruckende Seite. Die Nummerierung ist dabei unabhängig von den tatsächlichen Seitennummern. Wenn die Seiten drei bis sechs eines Dokuments ausgedruckt wurden, dann bewirkt StartPage=0, dass die dritte Seite sichtbar ist. Falls aufgrund einer entsprechenden Rows- und Columns-Einstellung mehrere Seiten gleichzeitig angezeigt werden, bezieht sich StartPage auf die erste sichtbare Seite.
TIPP
f UseAntiAlias gibt an, ob Texte und Linien im Steuerelement geglättet werden sollen. Das bewirkt eine höhere Bildqualität, verursacht aber auch einen höheren Rechenaufwand. Der Standardwert ist false. Leider gibt es keine Eigenschaft, aus der Sie die Gesamtseitenanzahl entnehmen können. Sie können diese Zahl aber mit einem kleinen Trick ermitteln: Weisen Sie StartPage einen riesigen Wert zu (z.B. 100000). Dadurch wird automatisch die letzte Seite angezeigt und der Wert der Eigenschaft korrigiert. Allerdings funktioniert dieser Test erst, nachdem sämtliche Seiten für die Vorschau erzeugt wurden. Zu dem Zeitpunkt, zu dem Form_Load ausgeführt wird, ist das noch nicht der Fall.
Sandini Bib
858
22 Drucken
Beispielprogramm In folgendem kleinen Programm wird ein eigener Vorschaudialog dargestellt, der etwas besser aussieht als der vom Design her doch recht altbackene PrintPreviewDialog. Die Einstellmöglichkeiten sind die gleichen, auch hier kann der Zoom eingestellt werden, die Anzahl der Seiten kann vorgegeben werden und ein Druck direkt aus dem Dialog heraus ist ebenfalls möglich.
CD
Der gesamte Dialog ist außerdem so aufgebaut, dass er als Dialog verwendbar ist. Das bedeutet, er wird über die Methode ShowDialog() angezeigt, diese Methode liefert auch einen DialogResult-Wert zurück, der dann ausgewertet werden kann. Wichtig ist hierbei lediglich der Programmcode, das Design des Dialogs bleibt ganz Ihnen überlassen. Den gesamten Beispieldialog finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_22\FePreviewDialog.
Abbildung 22.6 zeigt den Dialog im Entwurfsmodus. Er besteht im Grunde nur aus ein paar Buttons, einer Textbox für die Angabe der Seitenzahl und einer ComboBox um die Art der Anzeige auszuwählen. Die ComboBox wird bei der Anzeige des Dialogs gefüllt, die Eigenschaft DropDownStyle wird auf ComboboxStyle.DropDownList eingestellt. Der Button zum Drucken soll, falls kein Dokument zugewiesen ist, deaktiviert werden.
Abbildung 22.6: Ein eigener Dialog für die Druckvorschau
Sandini Bib
Eigene Seitenvorschau
859
Die Anzeige erfolgt nach Zuweisung des Dokuments wie bei allen anderen Dialogen auch durch ShowDialog(). Wird der Button SCHLIEßEN angeklickt, soll DialogResult.Cancel zurückgeliefert werden, nur beim DRUCKEN-Button DialogResult.OK. Wir weisen diese Werte direkt den entsprechenden DialogResult-Werten der Buttons zu, wodurch wir uns das explizite Schließen des Dialogs sparen. Das erledigt die Laufzeitumgebung für uns. Da der DRUCKEN-Button gegebenenfalls deaktiviert werden muss, müssen wir die Methode ShowDialog() überschreiben und darin die Kontrolle vornehmen, ob ein Dokument zugewiesen ist. Die Zuweisung erfolgt natürlich über eine Eigenschaft, für die aber kein Feld benötigt wird. Stattdessen erfolgt die Zuweisung direkt an die Eigenschaft Document des PrintPreviewControl-Steuerelements. public PrintDocument Document { get { return this.preview.Document; } set { this.preview.Document = value; } }
Die überschriebene Methode ShowDialog() besteht eigentlich nur aus der angesprochenen Kontrolle und dem Aufruf der gleichnamigen Basismethode. public new DialogResult ShowDialog() { // Drucken-Button disablen this.btnPrint.Enabled = ( this.preview.Document != null ); preview.StartPage = 0; preview.AutoZoom = true; return base.ShowDialog(); }
Jetzt können wir wieder der richtigen Reihenfolge nach vorgehen. Zunächst müssen wir die Combobox für die Ansicht mit entsprechenden Werten füllen. Das erfolgt natürlich im Load-Ereignis des Formulars. private void FePrintPreviewDialog_Load( object sender, EventArgs e ) { // Combobox mit den angezeigten Seiten füllen this.cbxShownPages.Items.Add( "1 Seite" ); this.cbxShownPages.Items.Add( "2 Seiten" ); this.cbxShownPages.Items.Add( "4 Seiten" ); this.cbxShownPages.Items.Add( "6 Seiten 3|2" ); this.cbxShownPages.Items.Add( "6 Seiten 2|3" ); this.cbxShownPages.Items.Add( "8 Seiten 2|4" ); this.cbxShownPages.Items.Add( "8 Seiten 4|2" ); this.cbxShownPages.SelectedIndex = 0; this.txtPageNumber.Text = "1"; }
Sandini Bib
860
22 Drucken
Im Ereignis SelectedIndexChanged erfolgt die eigentliche Ansichtsänderung. In diesem Fall haben wir es uns einfach gemacht und nur die Werte entsprechend dem ausgewählten Index verändert. Da der Inhalt der Combobox nur über die Auswahl eines Elements erfolgen kann (wegen der DropDownStyle-Einstellung), ist das durchaus legitim (beachten Sie aber, dass Sie diesen Code ändern müssen, wenn Sie das Beispiel erweitern). private void cbxShownPages_SelectedIndexChanged( object sender, EventArgs e ) { switch ( cbxShownPages.SelectedIndex ) { case 0: preview.Rows = 1; preview.Columns = 1; break; case 1: preview.Rows = 1; preview.Columns = 2; break; case 2: preview.Rows = 2; preview.Columns = 2; break; case 3: preview.Rows = 2; preview.Columns = 3; break; case 4: preview.Rows = 3; preview.Columns = 2; break; case 5: preview.Rows = 4; preview.Columns = 2; break; case 6: preview.Rows = 2; preview.Columns = 4; break; } }
Die übrigen Methoden sind nicht weiter kompliziert. Es werden lediglich der Zoomfaktor und die aktuelle Seitenzahl erhöht oder erniedrigt. Damit der Anwender im Textfeld für die aktuelle Seite nichts eingeben kann, wird beim Anklicken dieses Steuerelements der Fokus auf die ComboBox gelegt.
Sandini Bib
Eigene Seitenvorschau
861
private void btnPagePlus_Click( object sender, EventArgs e ) { preview.StartPage++; txtPageNumber.Text = ( preview.StartPage + 1 ).ToString(); } private void btnPageMinus_Click( object sender, System.EventArgs e ) { if ( preview.StartPage > 0 ) preview.StartPage--; txtPageNumber.Text = ( preview.StartPage + 1 ).ToString(); } private void btnZoomPlus_Click( object sender, System.EventArgs e ) { preview.Zoom += 0.1; } private void btnZoomMinus_Click( object sender, System.EventArgs e ) { if ( preview.Zoom > 0.1 ) preview.Zoom -= 0.1; } private void btnZoomAuto_Click( object sender, System.EventArgs e ) { preview.AutoZoom = true; } private void txtPageNumber_Enter( object sender, EventArgs e ) { cbxShownPages.Focus(); }
Das wäre auch schon der gesamte Code für das Vorschaufenster. Im Hauptformular besteht der Code eigentlich nur aus dem für den Druck zuständigen Code und dem Aufruf des Dialogs. Den Dialog im Einsatz sehen Sie in Abbildung 22.7.
Sandini Bib
862
Abbildung 22.7: Der eigene Preview-Dialog im Einsatz
22 Drucken
Sandini Bib
Teil V Programmiertechniken
Sandini Bib
Sandini Bib
23 Lokalisierung von Anwendungen
VERWEIS
Der Begriff Lokalisierung beschreibt den Prozess, ein Programm an mehrere Sprachen anzupassen. Normalerweise wird dahinter vermutet, dass alle Texte an die verschiedenen Sprachen angepasst werden müssen. Das ist so weit auch richtig, aber Lokalisierung im .NET Framework geht einen Schritt weiter. Sie können nicht nur die verschiedenen Texte anpassen, sondern auch das Aussehen der Benutzeroberfläche. Somit kann ein Button sich in Größe und Position automatisch an die neuen Gegebenheiten anpassen. Detaillierte Informationen zum Thema Lokalisierung finden Sie auch in der OnlineHilfe, wenn Sie nach Lokalisierung suchen: ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.VisualStudio.v80.de/dv_fxwalkthrough/html/9a59696bd89b-45bd-946d-c75da4732d02.htm
23.1
Eigenschaften von Steuerelementen und Formularen lokalisieren
Der einfachste Weg zu einer lokalisierbaren Anwendung führt über die Eigenschaft Localizable eines Formulars. Sobald Sie diese Eigenschaft auf true stellen, ist das Formular lokalisierbar. In der Folge werden alle Texte, die Positionen der Steuerelemente oder auch deren Größe sowie die Größe des Formulars in Ressourcendateien gespeichert, die die Entwicklungsumgebung automatisch anlegt. Um die Sprache zu wechseln, ändern Sie den Wert der Eigenschaft Language des Formulars. Vorsicht ist bei der Standardeinstellung angebracht. Wann immer das Programm auf einem System ausgeführt wird, für dessen Landeseinstellung keine Ressourcendatei existiert, wird diese Sprache verwendet. Sollten Sie vorhaben ein Programm in mehreren Sprachen zu vertreiben, empfiehlt es sich daher, vor Beginn der Arbeit Localizable auf true und Language auf Deutsch (Deutschland) einzustellen (und dann erst die Texte einzutippen). Die für die Standardeinstellung gültige Sprache sollte Englisch sein, denn damit kommen die meisten Leute weltweit zurecht – auch wenn es sich nicht um ihre Landessprache handelt.
23.1.1
Ressourcendateien
Auch wenn ein Blick in das Eigenschaftsfenster das Gegenteil vermuten lässt, sind Localizable und Language gar keine Eigenschaft der Form-Klasse. Vielmehr fügt das Eigenschaftsfenster diese Schlüsselwörter ein, um Ihnen die Lokalisierung so einfach wie möglich zu machen. Wenn Sie Localizable auf true stellen, werden unzählige Eigenschaften aller Steuerelemente in der zum Formular gehörenden Ressourcendatei gespeichert. Diese Datei im XML-Format speichert normalerweise nur relativ wenige Objekte, die nicht im Textformat ausgedrückt werden können (z.B. Bitmaps, Icons etc.). Bei lokalisierbaren Dateien werden
Sandini Bib
866
23 Lokalisierung von Anwendungen
darüber hinaus aber von allen Steuerelementen ca. ein Dutzend Eigenschaftseinstellungen gespeichert, z.B. Text, TextAlign, Font, Image etc. Der Grund für diese umfangreiche Anzahl an Eigenschaften ergibt sich aus der Tatsache, dass nicht nur die Sprache, sondern wirklich die gesamte Anwendungsumgebung lokalisiert werden muss. Und zu dieser gehören unter anderem auch Hintergrundbilder für Steuerelemente, deren Größe, die Ausrichtung des Textes oder auch die Einstellung der Leserichtung (bei uns von links nach rechts, es gibt aber Länder, wo von rechts nach links gelesen wird. Dort muss natürlich die gesamte Benutzerschnittstelle umgebaut werden). Der Name der automatisch erstellten Ressourcendateien folgt einem fest vorgegebenen Schema. Die Endung einer solchen Datei lautet immer resx.
TIPP
HINWEIS
Der Name der automatisch erstellten Ressourcendatei ergibt sich aus den Namen des Formulars, d.h. die Ressourcendatei zu Form1.cs hat den Namen Form1.resx. Sobald Sie bei der Einstellung von Language eine bisher noch unbekannte Sprache auswählen, erzeugt die Entwicklungsumgebung eine weitere Ressourcendatei, in deren Name auch die Sprach- und Landeskennung eingebaut wird. Beispielsweise dient Form1.de.resx zur Speicherung der deutschen Lokalisierung. In den sprachspezifischen Ressourcendateien werden nur die Abweichungen gegenüber der Defaultressourcendatei gespeichert. Aus diesem Grund sind diese Dateien deutlich kleiner. Vorsicht ist aber geboten, wenn Sie Eigenschaften verändern, die für alle Sprachen gelten sollen. Dann müssen Sie unbedingt daran denken, Language vorher auf Default zurückzusetzen – sonst gilt die Änderung nur für die gerade aktuelle Sprache. Das gilt auch für Eigenschaften, die auf den ersten Blick gar nichts mit der Lokalisierung zu tun haben (z.B. Image oder BackgroundImage).
Wenn Sie sich die Ressourcendateien ansehen möchten, klicken Sie im Projektmappenexplorer den Button ALLE DATEIEN ANZEIGEN an. Anschließend können Sie den Eintrag Form1.cs auseinander klappen und finden darunter alle dazugehörenden Ressourcendateien.
23.1.2
Auswertung der Lokalisierungseinstellungen
Die Einstellungen für die Standardsprache werden beim Kompilieren direkt in die ausführbare Datei integriert. Für alle anderen Sprachen werden eigene *.dll-Dateien erstellt und in Verzeichnissen gespeichert, deren Name den Sprach- oder Ländercode angibt. Diese ist in Form zweier kurzer Zeichenketten angegeben. Der erste Teil steht für die Sprache, der zweite Teil für das Land (also den Sprachdialekt). Die Sprache Deutsch hat grundsätzlich das Kürzel »de«, Deutsch in Deutschland besitzt das Kürzel »de-de«, US-Englisch das Kürzel »en-us«. Dementsprechend werden auch die Unterverzeichnisse automatisch erstellt.
Sandini Bib
Eigenschaften von Steuerelementen und Formularen lokalisieren
867
Ausgewertet werden die lokalisierten Dateien in der Methode InitializeComponent(), die vom Windows.Forms-Designer automatisch generiert wird. Für das Laden der lokalisierten Texte und Einstellungen ist die Klasse ResourceManager aus dem Namespace System.Resources zuständig. Über GetString() werden die Texte aus der jeweils gültigen Ressourcendatei geladen, mittels GetObject() die übrigen Einstellungen. Das ResourceManagerObjekt kümmert sich dabei automatisch um die korrekte Auswahl der verwendeten Kultur (bzw. Sprache). Anders als in .NET 1.1 kommen Sie als Programmierer allerdings nicht mehr mit dem ResourceManager in Verbindung, da das Visual Studio automatisch eine Klasse für den Zugriff auf die Ressourcen erzeugt.
23.1.3
Auswahl der aktuellen Kultur
Vorweg sei gesagt, dass ein Wechsel der Sprache im laufenden Programm leider nicht möglich ist. Beim Start eines Programms wird automatisch die Landeseinstellung des Betriebssystems verwendet, Sie können jedoch leicht eine andere Einstellung vornehmen. Dies muss jedoch an einem Punkt geschehen, an dem die verschiedenen Steuerelemente noch nicht initialisiert sind (also vor dem Aufruf der Methode InitializeComponent()). Sinnvollerweise wird diese Einstellung im Konstruktor des Hauptformulars der Anwendung vorgenommen. Die Änderung der Sprache erfolgt durch eine Neueinstellung der Eigenschaft CurrentCulture bzw. CurrentUICulture des aktuellen Threads (also Thread.CurrentThread.CurrentUICulture). Um auf diese Eigenschaft zugreifen zu können, muss also der Namenspace System.Threading eingebunden werden. Kulturen (Landeseinstellungen) werden durch die Klasse CultureInfo repräsentiert, die Sie im Namespace System.Globalization finden. Eine neue Kultur erzeugen Sie einfach, indem Sie dem Konstruktor der Klasse CultureInfo das passende Länderkürzel übergeben (also »de-de« für Deutsch, Deutschland).
VERWEIS
Wenn Sie nur CurrentUICulture ändern, werden die Einstellungen für die Zeichenformate nicht berücksichtigt. Ändern Sie deshalb immer auch die Eigenschaft CurrentCulture mit. Eine Referenz aller Sprach- und Landeszeichenketten zur Erzeugung neuer CultureInfo-Objekte finden Sie in der Hilfe bei der Beschreibung der CultureInfo-Klasse.
23.1.4
Zusätzliche Zeichenketten in den Lokalisierungsdateien speichern
Natürlich besteht Ihr Programm nicht allein aus Steuerelementen, Sie geben sicherlich auch Meldungen an den Benutzer aus. Diese müssen also auch lokalisiert werden, am besten ebenfalls in Ressourcendateien. Genau das ist auch möglich, allerdings müssen diese Ressourcendateien manuell erstellt und auch für jede verwendete Sprache erstellt werden. Bei der Namensgebung eigener Ressourcendateien muss, um den Automatismus des ResourceManager-Objekts nutzen zu können, ebenfalls eine Konvention verwendet werden, und zwar die gleiche wie bei den automatisch erstellten Ressourcendateien. Angenommen Sie erstellen eine Ressourcendatei namens Strings.resx, um darin alle Zeichenketten zu spei-
Sandini Bib
868
23 Lokalisierung von Anwendungen
chern. In diesem Fall würde die entsprechende deutsche Ressourcendatei Strings.de-de.resx heißen, die französische Strings.fr-fr.resx. Das Länderkürzel kommt also zwischen den Namen der Ressource und die Dateiendung. Eine neue Ressourcendatei können Sie über das Kontextmenü des Projekts hinzufügen (Menüpunkt NEUES ELEMENT HINZUFÜGEN, dann den Eintrag RESSOURCENDATEI auswählen). Um die korrekte Namensgebung müssen Sie sich selbst kümmern. Mit einem Doppelklick im Projektmappen-Explorer können Sie die noch leere Datei in einer Tabellenansicht öffnen. Nun geben Sie für jede Zeichenkette, die Sie in Ihrem Programm benötigen, jeweils einen Namen und den Inhalt an.
Zugriff auf die Ressourcen Der Zugriff auf Ressourcendateien ist in .NET 2.0 wesentlich einfacher, als das in den Vorgängerversionen der Fall war. Dort mussten Sie sich noch selbst mit der Klasse ResourceManager herumschlagen und über deren Methoden (z.B. GetString()) die gewünschte Ressource laden. In .NET 2.0 ist das viel einfacher geworden, denn die Entwicklungsumgebung erstellt automatisch eine Klasse, die einen sehr einfachen Zugriff auf die Ressourcen einer Anwendung erlaubt. Diese Klasse namens Resources finden Sie im Namespace Properties (den entsprechenden Ordner können Sie auch im Projektmappen-Explorer finden). Um nun Texte, die nicht direkt mit einem Steuerelement zusammenhängen, zu lokalisieren, legen Sie einfach eine neue Ressourcendatei an, die folgender Namensgebung entspricht: Resources..resx
Für die deutsche Sprache also entweder Resources.de.resx oder (wenn sie ganz genau sein wollen) Resources.de-DE.resx. Der Zugriff zur Laufzeit erfolgt über die Klasse Resources. Sämtliche von Ihnen eingefügten Zeichenketten (oder auch andere Ressourcen wie beispielsweise Grafiken) sind in dieser Klasse als Eigenschaften ausgelegt und somit leicht verfügbar. Allerdings müssen Sie darauf achten, auch wirklich alle Elemente zu lokalisieren – ansonsten erfolgt ein Fallback auf die Standardsprache, was nicht immer gut aussieht.
23.2
Beispielprogramm
CD
Das folgende Beispielprogramm besitzt ein lokalisiertes Formular und zwei Ressourcendateien, von denen die deutsche Ressourcendatei auf die beschriebene Art eingefügt wurden. In den Ressourcen findet sich nur eine einzige Zeichenkette mit dem Namen »BoxMessage«. Den Aufbau des Formulars und die Übersicht im Projektmappenexplorer zeigt Abbildung 23.1. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_23\Lokalisierung.
Sandini Bib
Beispielprogramm
869
Abbildung 23.1: Ein Formular, lokalisiert, und der Projektmappen-Explorer
In diesem Fall wurde folgendermaßen vorgegangen. Da die Oberfläche nicht sofort geändert werden kann, sobald die Sprache geändert wird, werden die Spracheinstellungen in einem eigenen kleinen Konfigurationsobjekt gespeichert. Dessen Aufbau ist trivial, es speichert lediglich ein CultureInfo-Objekt. [Serializable] public class ConfigObject { private CultureInfo uiCulture = CultureInfo.InvariantCulture; public CultureInfo UICulture { get { return this.uiCulture; } } public ConfigObject( CultureInfo aCulture ) { this.uiCulture = aCulture; } }
Über das Attribut Serializable wurde die Klasse serialisierbar gemacht. Bei einer Änderung der Sprache wird das Objekt einfach in eine Datei serialisiert. Beim Start des Programms (also in der Methode Main()) wird die Datei geladen, wenn sie existiert, und die Landesinformation entsprechend eingestellt. Das Speichern geschieht in der Ereignisbehandlungsroutine Click des Buttons btnSwitchLanguage; In diesem Fall sind es nur zwei Sprachen, zwischen denen gewechselt werden muss, daher gestaltet sich das Vorgehen recht einfach. private void BtnSwitchLanguage_Click( object sender, EventArgs e ) { // Konfigurationsobjekt ConfigObject co; // Vergleichskultur CultureInfo ci = new CultureInfo( "de-de" );
Sandini Bib
870
23 Lokalisierung von Anwendungen
if ( Thread.CurrentThread.CurrentCulture.LCID.Equals( ci.LCID ) ) co = new ConfigObject( CultureInfo.InvariantCulture ); else co = new ConfigObject( new CultureInfo( "de-de" ) ); // Pfad für das Speichern string aPath = Path.GetDirectoryName( Application.ExecutablePath ); aPath += @"\" + "config.dat"; // Speichern FileStream fs = new FileStream( aPath, FileMode.Create ); BinaryFormatter bf = new BinaryFormatter(); bf.Serialize( fs, co ); fs.Close(); }
Das Laden der Datei und die Einstellung von CurrentCulture bzw. CurrentUICulture finden in der Methode Main() statt. Dass dies durchgeführt wird, bevor das Formular angezeigt wird, ist nicht relevant; das Programm startet ja nicht mit der Anzeige des Formulars, sondern mit der ersten Anweisung in der Methode Main(). static void Main() { // Deserialisieren string aPath = Path.GetDirectoryName( Application.ExecutablePath ); aPath += @"\" + "config.dat"; ConfigObject co; // Kontrolle ob Datei existiert, ggf. Laden if ( File.Exists( aPath ) ) { FileStream fs = new FileStream( aPath, FileMode.Open ); BinaryFormatter bf = new BinaryFormatter(); co = (ConfigObject)bf.Deserialize( fs ); fs.Close(); } else { // Neues Konfigurationsobjekt co = new ConfigObject( CultureInfo.InvariantCulture ); } Thread.CurrentThread.CurrentUICulture = co.UICulture; Thread.CurrentThread.CurrentCulture = co.UICulture; Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault( false ); Application.Run( new Form1() ); }
Sandini Bib
Beispielprogramm
871
Da die Ressourcendateien existieren (für die deutsche Sprache müssen Sie selbstverständlich eine Ressourcendatei hinzufügen), ist der Zugriff auf die darin enthaltene Nachricht nicht weiter schwierig. private void BtnShowMessage_Click( object sender, EventArgs e ) { MessageBox.Show( Resources.BoxMessage ); }
Damit wäre eine lokalisierte Applikation bereits fertig und Sie können sie testen. Beachten Sie, dass nach einem Wechsel der Sprache ein Neustart notwendig ist.
Sandini Bib
Sandini Bib
24 Externe Programme steuern (Automation) Als Automation bezeichnet man die Möglichkeit, aus C# (oder auch aus einer anderen Programmiersprache, die mit COM-Servern umgehen kann) andere Programme zu steuern. Damit ist nicht gemeint, dass man diese Programme starten kann (das ist einfach), sondern dass die Steuerung, z.B. das Einfügen von Texten in Word, das Formatieren von Zellen in Excel, durch das C#-Programm geschieht. Das Schöne daran ist, dass die jeweilige Applikation dazu nicht einmal sichtbar sein muss.
24.1
Automation mittels COM-Komponenten
Die Voraussetzung dafür, dass Automation möglich ist, ist, dass das zu steuernde Programm ein Objektmodell zur Verfügung stellt, mit dem man arbeiten kann. Grundsätzlich können Sie davon ausgehen, dass das bei jedem Programm der Fall ist, das VBA unterstützt. Was bei der Automation durch C# gemacht wird (oder auch durch VB.NET), entspricht eigentlich nur dem, was man normalerweise auch in einem Makro machen würde. Eine weitere Voraussetzung, diesmal für den Programmierer einer Automationsanwendung, besteht darin, dass dieser das Objektmodell des zu steuernden Programms kennt. Diese Hürde ist nicht zu unterschätzen. Beispielsweise kennt Excel (2000) ca. 150 Klassen mit weit über 1000 Methoden und Eigenschaften, deren Beschreibung ein ganzes Buch füllt.
HINWEIS
Zu diesen Voraussetzungen, die gar nichts mit .NET zu tun haben, kommen die grundsätzlichen Schwierigkeiten der .NET/COM-Kompatibilität hinzu, die in diesem Buch nicht einmal angedeutet werden. Microsoft hat sich .NET auf die Fahnen geschrieben, deshalb gibt es für das Visual Studio auch die so genannten Visual Studio Tools for Office, die eine leichtere Anwendungsentwicklung von Office-Anwendungen mit .NET ermöglichen. Diese werden hier aus zwei Gründen nicht angesprochen. Erstens ist das Paket unglaublich umfangreich, zweitens sind die Visual Studio Tools for Office nicht Bestandteil des Visual Studio 2005 Professional sondern werden erst mit der Team Suite verfügbar sein. Der Zugriff auf Word & Co ist aber dennoch möglich.
24.1.1
Verwendung der Klassenbibliothek
Das besondere Kennzeichen eines Programms, das sich durch Automation steuern lässt, besteht darin, dass es eine Klassenbibliothek zur Verfügung stellt (ähnlich wie die .NETBibliotheken, die Sie täglich nutzen). Diese Klassenbibliothek gibt Zugriff auf alle Objekte des Programms und ermöglicht somit die Steuerung. (Um es an einem Beispiel zu illustrie-
Sandini Bib
874
24 Externe Programme steuern (Automation)
ren: Bei Excel können Sie mit dem Worksheet-Objekt auf einzelne Tabellenblätter zugreifen, mit dem Range-Objekt auf Zellen oder Zellbereiche etc.) Damit Sie eine derartige Klassenbibliothek in Ihrer Applikation nutzen können, müssen Sie einen Verweis auf die Bibliothek einrichten (im Projektmappenexplorer oder mit PROJEKT|VERWEISE). Sie finden die Klassenbibliothek unter den COM-Bibliotheken – die Bibliothek für Word z.B. unter dem Namen Microsoft Word x.x Object Library. Der Term x.x gibt dabei die Versionsnummer an, im Falle von Word 2000 ist diese Nummer 9.0, bei Word 2002 10.0 und im Falle von Office 2003 ist es 11.0. VBA-Programmierern wird auffallen, dass viele Klassen, die in der VBA-Entwicklungsumgebung als Klassen dargestellt werden, von der .NET-Entwicklungsumgebung als Schnittstellen bezeichnet werden. Das hat aber keinen spürbaren Effekt auf die Programmierung. Nach der Auswahl der Bibliothek erstellt die Entwicklungsumgebung eine so genannte Wrapper-Bibliothek (z.B. Microsoft.Office.Interop.Word), die die Schnittstelle zwischen .NET und COM bildet. Bei Programmen wie Word oder Excel mit einer ziemlich großen Klassenbibliothek dauert die Erzeugung der Wrapper-Bibliothek verhältnismäßig lang.
HINWEIS
Die Verwendung der enthaltenen Klassen erfolgt dann wie gewohnt, als ob es sich um .NET-Klassen handeln würde. Im Prinzip ist das ja auch der Fall, denn der Wrapper stellt alle Klassen so zur Verfügung, als seien sie Bestandteil des .NET Frameworks. Bei der täglichen Arbeit mit Automation hat sich allerdings ergeben, dass unter Umständen Ereignisse nicht korrekt ausgelöst werden – Sie sollten sich also nicht unbedingt darauf verlassen. Bei der Installation des Office-Pakets können Sie die .NET-Programmierunterstützung anwählen, müssen dies aber explizit tun und für jede Anwendung einzeln. Dadurch werden die so genannten Primary Interop Assemblies installiert, die einen verbesserten Zugriff auf die Office-Funktionalität erlauben und im Gegensatz zu der reinen COM-Komponente für .NET optimiert sind. Auch die Visual Studio Tools für Office nutzen die PIAs.
24.1.2
Beispiel – RichTextBox mit Word ausdrucken
Das RichTextBox-Steuerelement hat einen großen Nachteil – nämlich keine Methode, um den Inhalt auszudrucken. Falls Word auf Ihrem System installiert ist, können Sie es aber dazu verwenden, den Inhalt zu drucken – über Automation. Was im folgenden Beispiel gemacht wird, ist eigentlich das, was Sie normalerweise von Hand machen würden, nämlich den Inhalt der RichTextBox in Word kopieren, vielleicht noch eine Kopfzeile hinzufügen und das Ganze dann drucken. Damit Sie nicht gezwungen sind zu drucken, wurde es auch ermöglicht, den Text nur anzusehen. Word wird nicht automatisch beendet. Die Oberfläche des Programms zeigt Abbildung 24.1. Das Programm kann eine Datei laden und sie dann entweder in Word ausdrucken oder anzeigen. Die relevante Methode ist dabei die Methode ShowInWord(), die die meiste Arbeit verrichtet (nämlich das Einfügen des Texts und der Kopfzeile). Die Methode PrintInWord()
Sandini Bib
Automation mittels COM-Komponenten
875
verwendet ShowInWord() und druckt nach der Anzeige einfach aus. Die Datei finden Sie auf der Buch-CD im Verzeichnis \Buchdaten\Beispiele\Kapitel_24\WordTest.rtf. Wichtig für Sie als C#-Programmierer sind zwei Dinge: f C# kennt keine optionalen Parameter, VBA schon. VB-Entwickler haben es hier einfacher, denn mit VB ist es ebenfalls möglich, mit optionalen Parametern zu arbeiten. In C# müssen Sie für jeden Parameter, den wir nicht verwenden wollen, die Referenz auf einen leeren Wert übergeben. Speziell für diesen Fall gibt es die Klasse Missing aus System.Reflection. f Wenn Sie einen Wert übergeben wollen, der aus dem .NET Framework kommt, müssen Sie diesen zuerst in ein Object »boxen«.
Abbildung 24.1: Das Programm zum Drucken mit Word mit geladener Datei
CD
So ausgerüstet sollte der Code keine Schwierigkeiten mehr darstellen. Da die Word-Applikation und auch das Dokument von zwei Methoden angesprochen werden, deklarieren wir beide auf Formularebene. Der Code ist ausreichend dokumentiert und wird daher komplett abgedruckt (wie üblich natürlich nur die relevanten Teile). Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_24\Automation. Die RTF-Datei finden Sie im übergeordneten Verzeichnis.
Zunächst erfolgt die Deklaration der Bestandteile. Da der Namespace für die WordObjekte (Microsoft.Office.Interop.Word) mit eingebunden wurde und es in diesem Namespace ebenfalls wie in System.Drawing eine Font-Klasse gibt, wird das Feld zum Zwischenspeichern der Schriftart vollständig qualifiziert. Diese Schriftart wird für die Kopfzeile verwendet, die in das Word-Dokument eingefügt wird.
Sandini Bib
876
24 Externe Programme steuern (Automation)
// Deklarationen private _Application wordApp; private _Document wordDoc; private System.Drawing.Font headerFont = new System.Drawing.Font( "Arial", 9f, FontStyle.Regular );
Der erste Schritt ist das Laden des Dokuments. Das ist jedoch sehr einfach, da das RichTextBox-Steuerelement eine Methode LoadFile() zur Verfügung stellt. Außerdem wird eine Methode benötigt, um die Schriftart für den Seitenkopf einzustellen. Aber auch das ist einfach, dank des Font-Dialogs. private void BtnChooseFont_Click( object sender, EventArgs e ) { this.dlgFont.Font = headerFont; if ( this.dlgFont.ShowDialog() == DialogResult.OK ) { this.headerFont = dlgFont.Font; this.txtFont.Text = headerFont.Name + ", " + headerFont.SizeInPoints.ToString() + " pt"; } } private void BtnLoad_Click( object sender, EventArgs e ) { if ( this.dlgOpen.ShowDialog() == DialogResult.OK ) { this.rtbTextToPrint.LoadFile( this.dlgOpen.FileName ); } }
Die Methoden zum Starten von Word bzw. dem Drucken aus Word heraus sind nicht ganz so trivial. Leider ist die Übergabe der ganzen Missing-Parameter notwendig. Der Quelltext ist ausreichend kommentiert und sollte keine Schwierigkeiten bereiten. Die Übergabe des Textes erfolgt durch das Clipboard, d.h. der Text wird in Word einfach eingefügt. Selbstverständlich steht Ihnen aber, falls Sie komplexere Dinge vorhaben, das gesamte Objektmodell von Word zur Verfügung. private void ShowInWord() { // Word kennt keine optionalen Parameter, daher Missing-Objekt erstellen object oMissing = Missing.Value; //Initialisierung wordApp = new ApplicationClass(); wordApp.Visible = true; // Neues Dokument wordDoc = wordApp.Documents.Add( ref oMissing, ref oMissing, ref oMissing, ref oMissing );
Sandini Bib
Automation mittels COM-Komponenten
877
// Inhalt einfügen wordDoc.Application.Selection.Paste(); // Kopfzeile auswählen wordDoc.ActiveWindow.ActivePane.View.Type = WdViewType.wdPrintView; wordDoc.ActiveWindow.ActivePane.View.SeekView = WdSeekView.wdSeekCurrentPageHeader; // Text einfügen und Größe ändern wordDoc.Application.Selection.Text = "Gedruckt aus dem Visual Studio mit C#"; wordDoc.Application.Selection.Font.Name = this.headerFont.Name; wordDoc.Application.Selection.Font.Size = this.headerFont.SizeInPoints; // Zurück zum Hauptdokument wordDoc.ActiveWindow.ActivePane.View.SeekView = WdSeekView.wdSeekMainDocument; } private void PrintInWord() { // Word kennt keine optionalen Parameter, daher Missing-Objekt erstellen object oMissing = Missing.Value; object oTrue = true; ShowInWord(); // Leider sind die ganzen oMissings nötig ... wordApp.ActiveDocument.PrintOut( ref oTrue, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing, ref oMissing ); }
Der Aufruf der Methoden über die eingefügten Buttons ist wieder trivial: private void BtnStartWord_Click( object sender, EventArgs e ) { this.rtbTextToPrint.SelectAll(); this.rtbTextToPrint.Copy(); ShowInWord(); } private void BtnPrintWord_Click( object sender, EventArgs e ) { this.rtbTextToPrint.SelectAll(); this.rtbTextToPrint.Copy(); PrintInWord(); }
Sandini Bib
878
24 Externe Programme steuern (Automation)
Dass Word den Text wirklich korrekt anzeigt, sehen Sie in Abbildung 24.2.
Abbildung 24.2: Der eingefügte Text in Word
24.2
API-Aufrufe (P/Invoke)
Das .NET Framework unterstützt bei weitem noch nicht alle möglichen Betriebssystemfunktionen, es gibt jedoch die Möglichkeit, auf die Funktionen des Betriebssystems zuzugreifen. Diese Technik wird als Platform Invocation Services oder kurz P/Invoke bezeichnet. Eine zentrale Rolle spielt dabei wieder ein Attribut, nämlich das DllImport-Attribut aus dem Namespace System.Runtime.InteropServices. Anders als in VB.NET gibt es in C# keine Declare-Anweisung, mit der der Aufruf von Funktionen aus fremden DLLs möglich wäre.
24.2.1
Grundlagen zu P/Invoke
Mithilfe von P/Invoke wird eigentlich nur eines gemacht: Methoden, die in unverwalteter Form vorliegen, werden erneut in managed Code deklariert, wobei über das Attribut DllImport angegeben wird, welche DLL die Funktion enthält und wo der Einsprungpunkt liegt. Dieser Einsprungpunkt wird angegeben durch den Namen der aufzurufenden Methode.
Sandini Bib
API-Aufrufe (P/Invoke)
879
Sofern die Methode unter dem gleichen Namen deklariert wird, wie sie auch in der unverwalteten DLL vorliegt, ist die Angabe eines Einsprungpunkts nicht notwendig. Intern wird in einem solchen Fall zunächst LoadLibrary() aufgerufen, um die DLL zu laden, und dann GetProcAddress(), um die Adresse der aufzurufenden Funktion zu ermitteln. Bei diesen beiden Methoden handelt es sich natürlich auch um Methoden des Windows API. Danach wird die Funktion mit den angegebenen Parametern aufgerufen. Wenn ein Einsprungpunkt aber angegeben wird, kann die unverwaltete Methode (die ja letztendlich im Code aufgerufen wird) benannt werden, wie Sie es wollen – es spielt keine Rolle, weil durch die Festlegung des Einsprungpunkts bereits definiert ist, welche real existierende Methode wirklich angesprungen wird. Beachten müssen Sie bei der Deklaration externer Methoden auch, dass diese immer als static und extern deklariert werden. Die Deklaration als extern ist logisch, denn es handelt sich um eine externe Methode. Die Deklaration als static ergibt sich aus der Tatsache, dass
eine Methode aus einer DLL niemals Bestandteil einer Instanz sein kann, weil sie per Definitionem global verfügbar ist.
Das Attribut DllImport Das DllImport-Attribut besitzt mehrere benannte Parameter (also Felder), die Einfluss auf den Aufruf haben. Die folgende Liste zeigt diese Felder und ihre Bedeutung. Für die Aufzählungen, die bei manchen benannten Parametern verwendet werden, gilt, dass auch sie in System.Runtime.InteropServices definiert sind. f Über den Parameter BestFitMapping geben Sie an, wie Unicode-Zeichen konvertiert werden sollen. Da beispielsweise Windows 98 oder Windows ME kein Unicode unterstützen, können sie auch nichts mit Zeichen anfangen, die dem Unicode-Zeichensatz entsprechen. In diesem Fall versucht die Laufzeitumgebung, diese Zeichen in eine möglichst originalgetreue ASCII-Entsprechung umzuwandeln. Der Standardwert für diesen Parameter ist true. f Über den benannten Parameter EntryPoint legen Sie den Einsprungpunkt für die DLL fest. Dabei handelt es sich um den Namen der Funktion, die Sie aufrufen wollen. Das Schöne daran ist, dass das Betriebssystem bei dieser Suche (zumindest in der Standardeinstellung) hilft. So gibt es für zahlreiche API-Funktionen zwei Versionen, wobei eine auf »A« endet und eine auf »W«. Die erste Version gilt für Systeme, die kein Unicode unterstützen (z.B. Windows 98 oder ME), die zweite Version für Systeme, die Unicode unterstützen (z.B. Windows 2000 oder XP). Als EntryPoint muss allerdings nur »MessageBox« angegeben werden, es wird immer die richtige Methode gefunden (eben jene, die auf das aktuelle Betriebssystem passt). Diese Funktionalität kann aber auch ausgeschaltet werden. f Über den Parameter ExactSpelling können Sie das bei EntryPoint beschriebene Verhalten abstellen. In diesem Fall müssen dann der Name der Funktion bzw. der Name des Einsprungpunkts exakt mit dem Namen der realen Methode übereinstimmen (es werden keine Suchverfahren angewendet). Standardmäßig ist der Wert dieses Felds false.
Sandini Bib
880
24 Externe Programme steuern (Automation)
f Der Parameter CallingConvention wird eigentlich nicht benötigt. Hiermit wird angegeben, auf welche Art und Weise der Stack aufgeräumt wird. Dieser Parameter ist vom Typ CallingConvention, bei dem es sich um eine Aufzählung handelt. In der Standardeinstellung ist dieser Wert CallingConvention.WinAPI, d.h. das Betriebssystem kümmert sich automatisch um den richtigen Aufruf. f Der Parameter CharSet gibt an, wie Zeichenfolgen an die aufgerufene Methode übergeben bzw. gemarshalled werden. Dieses Feld ist vom Typ CharSet, wiederum einer Aufzählung. Normalerweise geben Sie hier CharSet.Auto an (wenn Sie es überhaupt angeben), wodurch das Betriebssystem die Art der Übergabe bestimmt. f Über den Parameter SetLastError können Sie einen eventuell aufgetretenen Fehler (im unverwalteten Code) »speichern« lassen. Die Standardeinstellung ist in C# false. VBUmsteiger werden sich umgewöhnen müssen, dort ist der Standard hierfür true. Ermittelt wird der Fehler über die statische Methode GetLastWin32Error() der Klasse Marshal. f ThrowOnUnmappableChar bekommt seinen Sinn im Zusammenhang mit der Konvertierung von Zeichen durch BestFitMapping. Dieser Parameter bewirkt, wenn auf true eingestellt, dass bei einer misslungenen Konvertierung eines Zeichens (also dann, wenn keine dem Original möglichst nahe Entsprechung gefunden werden kann) eine Ausnahme ausgelöst wird. Damit können Sie verhindern, dass unzulässige Zeichen erzeugt werden (wo dies nötig ist). Unter Windows 2000 oder XP ist dieser Parameter vernachlässigbar.
24.2.2
Konvertierungen
Da die Datentypen der aufrufenden Methode in der Regel nicht mit denen der aufgerufenen Methode übereinstimmen (wie könnten sie auch), müssen diese Datentypen konvertiert werden. Das geschieht direkt im Speicher, wo es möglich ist auch vollautomatisch. Manchmal muss man aber auch ein wenig nachhelfen. Mithilfe des Attributs MarshalAs (ebenfalls deklariert in System.Runtime.InteropServices) können Sie die Art der Konvertierung bestimmen. In der Regel geben Sie dabei lediglich einen positionalen Parameter des Typs UnmanagedType an. In dieser Aufzählung sind zahlreiche Datentypen für unverwaltete Aufrufe angegeben. Für eine Struktur könnten Sie beispielsweise folgende Angabe machen: [DllImport("kernel32.dll", SetLastError = true)] public static extern int SetLocalTime ( [MarshalAs(UnmanagedType.Struct)] SYSTEMTIME lpSystemTime );
Sie sehen, dass das Attribut MarshalAs hier direkt bei dem Parameter angewendet wird, der der Methode übergeben wird. Aber das ist nicht immer notwendig. Bei einigen Datentypen entspricht ihre Repräsentation in einer verwalteten Umgebung der Repräsentation in unverwalteter Umgebung, d.h. diese Datentypen müssen nicht umgewandelt werden. Man nennt sie dann laut Dokumentation »blitfähige Datentypen«. Offensichtlich gibt es kein passendes deutsches Wort für das originale »blittable Datatype«. Die folgenden Datentypen sind blitfähig und können direkt verwendet werden:
Sandini Bib
API-Aufrufe (P/Invoke)
881
Datentyp
Alias
C-Typ
float
System.Single
float
double
System.Double
double
sbyte
System.Sbyte
signed char
byte
System.Byte
unsigned char
short
System.Int16
short
ushort
System.UInt16
unsigned short
int
System.Int32
long
uint
System.UInt32
unsigned long
long
System.Int64
int64
ulong
System.UInt64
unsigned int64
System.IntPtr
INT_PTR
System.UIntPtr
UINT_PTR
VERWEIS
Ebenfalls direkt übernommen werden können Strukturen und Arrays, die nur Daten aus den in der Tabelle genannten Datentypen enthalten. Anders sieht es bei den übrigen Datentypen aus, hier muss (unter Umständen) manuell nachgeholfen werden, wobei vieles auch durch den Compiler erledigt wird. Eine genaue Aufschlüsselung blitfähiger und nicht-blitfähiger Datentypen finden Sie in der Online-Hilfe, wenn Sie nach »blitfähige Datentypen« suchen: ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.VisualStudio.v80.de/dv_fxinterop/html/d03b050e-291649a0-99ba-f19316e5c1b3.htm
CD
24.2.3
Aufruf von DLL-Funktionen
Die folgenden Funktionsaufrufe sind allesamt in einem kleinen Beispielprogramm zusammengefasst. Sie finden es auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_24\PInvoke.
Ein einfacher Aufruf: Die Windows-MessageBox Einer der einfachsten Aufrufe einer unverwalteten Funktion ist die Funktion MessageBox() aus der DLL User32.dll. Um die Methode aufrufen zu können, deklarieren Sie sie unter ihrem originalen Namen und versehen sie mit dem DllImport-Attribut. Ob die DLL wirklich vorhanden ist, müssen wir in diesem Fall nicht kontrollieren, denn wenn die User32.dll
Sandini Bib
882
24 Externe Programme steuern (Automation)
nicht vorhanden ist, ist auch Windows nicht vorhanden ☺. Der folgende Codeausschnitt zeigt die Deklaration der Methode. [DllImport("user32.dll")] public extern static int MessageBox( uint HWnd, string lpText, string lpCaption, uint uType );
Der Aufruf erfolgt dann wie bei einer normalen Methode: private void btnMessageBoxAPI_Click( object sender, System.EventArgs e ) { MessageBox(0, "Das ist ein Meldungsfenster aus dem API", "Meldung", 0); }
Funktion unter anderem Namen aufrufen Wenn Sie für die Funktion einen anderen Namen angeben wollen, müssen Sie lediglich den Einsprungpunkt angeben. Sie vergeben damit quasi einen Alias, sodass der Methodenname innerhalb Ihrer Applikation anders lauten kann als das Original. Wie aus obiger Auflistung zu entnehmen ist dafür der benannte Parameter EntryPoint zuständig. [DllImport("user32.dll", EntryPoint="MessageBox")] public extern static int ApiMessageBox( uint HWnd, string lpText, string lpCaption, uint uType );
Der gleiche Aufruf funktioniert jetzt unter der Bezeichnung ApiMessageBox. private void btnMessageBoxNamed_Click( object sender, EventArgs e ) { ApiMessageBox( 0, "Das ist ein Meldungsfenster aus dem API", "Meldung", 0 ); }
Funktion mit ExactSpelling aufrufen Wenn Sie ExactSpelling auf true setzen, müssen Sie den Namen der aufzurufenden Funktion exakt angeben. Im obigen Fall wurde lediglich der Name MessageBox verwendet, was zur Folge hat, dass je nach verwendetem System MessageBoxW oder MessageBoxA aufgerufen wird. Mit ExactSpelling sieht das Ganze dann so aus: [DllImport( "user32.dll", EntryPoint = "MessageBoxW", CharSet=CharSet.Unicode, ExactSpelling = true )] public extern static int MsgUnicoded( uint HWnd, string lpText, string lpCaption, uint uType );
Die Angabe des CharSet ist in diesem Fall notwendig, weil standardmäßig eben nicht Unicode verwendet wird (in diesem Fall würde falsch gemarshalled). Das Ergebnis sähe dann aus wie in Abbildung 24.3.
Sandini Bib
API-Aufrufe (P/Invoke)
883
VERWEIS
Abbildung 24.3: Die MessageBox ohne die Festlegung des korrekten CharSet
Ein weiteres Beispiel für einen Funktionsaufruf mittels P/Invoke finden Sie auf Seite 330, wo Dateien in den Papierkorb verschoben werden.
Sandini Bib
Sandini Bib
25 Reflection Das Stichwort Reflection beschreibt eine ebenso interessante wie wichtige Technik, die auch innerhalb des .NET Frameworks selbst verwendet wird. Unter anderem ist es dieser Technik zu verdanken, dass die IntelliSense-Hilfe funktioniert, und auch die Programmierung von Add-Ins für eine Applikation bzw. das so genannte späte Binden einer DLL geschieht mittels Reflection. Generell ausgedrückt beschreibt Reflection die Möglichkeit, Metadaten einer .NETAssembly und der enthaltenen Datentypen zu ermitteln und auszuwerten. Die Metadaten sind die Daten, die dafür zuständig sind, dass eine .NET-Assembly selbstbeschreibend ist. Anders ausgedrückt können Sie über die Metadaten bzw. über Reflection ermitteln, welche Datentypen in einer Assembly enthalten sind, welche Member diese Datentypen haben, von welchem Typ diese Member sind usw. Generell können Sie also alles über die Datentypen einer Assembly erfahren und nicht nur das – Sie können sie auch instanzieren oder Werte ändern, an die Sie normalerweise nicht herankommen (beispielsweise von privaten Membern einer Klasse).
25.1
Grundlagen zu Reflection
Die Grundlage für die Ermittlung von Informationen über einen Datentyp ist die Klasse Type aus dem Namespace System. Diese Klasse beschreibt einen Datentyp und findet sehr häufig Verwendung beispielsweise bei der Kontrolle einer Variablen oder eines Objekts auf einen bestimmten Datentyp. Eine Type-Instanz können Sie auf mehrere Arten erzeugen. Entweder durch Verwendung des Operators typeof (in dieser Form nur in C# enthalten, in VB hat das dort verwendete Typeof eine andere Bedeutung) oder über die Methode GetType(), die jeder Datentyp besitzt, da die Grundform dieser Methode im Datentyp object deklariert ist. Ebenfalls möglich ist die Verwendung der statischen Methode GetType() der Klasse Type.
25.1.1
Grundlegende Eigenschaften und Methoden von Type
Type besitzt einige interessante Eigenschaften und Methoden zur Ermittlung der Typinformationen, von denen hier einige exemplarisch aufgezählt werden. Zunächst einige wichtige Eigenschaften:
f Die Eigenschaft IsAbstract liefert die Information, ob es sich bei dem Typ um eine abstrakte Klasse handelt. Äquivalent liefert die Eigenschaft IsSealed die Information, ob ein Datentyp versiegelt ist, also nicht vererbt werden kann. f Die Eigenschaft IsClass liefert die Information, ob es sich bei dem Datentyp um eine Klasse, also einen Referenztyp, handelt. Äquivalent funktionieren die Eigenschaften IsEnum (handelt es sich um ein Enum), IsInterface (handelt es sich um ein Interface) oder IsValueType (handelt es sich um einen Wertetyp, d.h. einen struct).
Sandini Bib
886
25 Reflection
f Die Eigenschaft IsPublic liefert die Information, ob es sich um einen als public deklarierten Datentyp handelt. Äquivalent funktioniert IsNotPublic. f Die Eigenschaft Namespace liefert den Namespace, in dem sich der Datentyp befindet. Namespaces sind lediglich virtuelle Unterteilungsmöglichkeiten für die Klassen des .NET Frameworks oder auch Ihres Programms, d.h. es existiert keine Klasse mit Namen »Namespace«, über die Sie alle darin enthaltenen Klassen ermitteln könnten. Das ginge auch gar nicht – ein Namespace kann auf mehrere DLLs gesplittet sein, und wenn eine dieser DLLs nicht referenziert ist, könnten auch die darin enthaltenen Klassen nicht ermittelt werden. Hier nun einige Methoden der Klasse Type, die für die Ermittlung von Informationen wichtig sind: f Die Methode GetFields() liefert die Felder des Datentyps. Zurückgeliefert wird ein Array aus FieldInfo-Elementen, die Informationen über das Feld beinhalten. Die Methode GetField() liefert Informationen über ein einzelnes Feld. f Äquivalent zu GetFields() bzw. GetField() funktionieren GetProperties() und GetProperty(), der zurückgelieferte Typ ist hier PropertyInfo. Sie werden es erraten haben: An die Methoden kommen Sie mittels GetMethods()/GetMethod() heran, an die Ereignisse mittels GetEvents()/GetEvent(). f Über die Methode GetCustomAttributes() ermitteln Sie die Attribute des Datentyps. Die Methode liefert ein Array des Typs object zurück, da die unterschiedlichen Attribute ja auch unterschiedliche Typen darstellen. Da aber alle Attribute von der Klasse Attribute abgeleitet sind, können Sie auch nach Attribute[] casten. f Die Methode GetInterfaces() liefert alle Interfaces, die der Datentyp implementiert. Diese werden als Array des Typs Type zurückgeliefert (denn Interfaces sind ebenfalls Datentypen). Sind keine Interfaces implementiert, wird ein leeres Array zurückgeliefert. Entsprechend liefert GetInterface() ein bestimmtes Interface zurück, falls der Typ es implementiert. f Die Methode Invoke() ermöglicht es, eine Instanz eines Datentyps zu erstellen oder aber auf einen bestimmten Member (nach Erstellen der Instanz) schreibend bzw. lesend zuzugreifen. Das sind bei Weitem noch nicht alle Methoden und Eigenschaften, die Type bietet; grundsätzlich können Sie alles ermitteln, was im Bezug auf einen Datentyp oder den Member eines Datentyps relevant ist. Allerdings ist das natürlich erst möglich, wenn der Datentyp auch verfügbar ist – und dazu muss zunächst einmal die Assembly, in der sich der Typ befindet, geladen werden.
25.1.2
Relevante Klassen für Reflection
Sämtliche wichtigen Klassen für Reflection (außer Type) finden Sie im Namespace System.Reflection. Die folgende Liste kann wieder nur einen Ausschnitt liefern, allerdings beinhaltet sie die Klassen, die bei der Auswertung von Datentypen am häufigsten gebraucht werden.
Sandini Bib
Beispielapplikation: Informationen über die BCL ermitteln
887
f Die Klasse Assembly steht, wie der Name schon sagt, für eine Assembly. Zum Laden einer Assembly dienen mehrere Methoden, die eine unterschiedliche Ausprägung haben. Am häufigsten, wenn nur der Dateiname bekannt ist, wird die Methode LoadFrom() verwendet, die ein Assembly-Objekt zurückliefert. An die Datentypen, die in einer Assembly definiert sind, kommen Sie mithilfe der Methode GetTypes() heran. Weiterhin besitzt Assembly einige interessante Eigenschaften. So liefert die Eigenschaft GlobalAssemblyCache die Information, ob die Assembly aus dem GAC kommt, die Eigenschaft ImageRuntimeVersion liefert eine Zeichenfolge, die die Version der CLR darstellt, für die die Assembly erstellt wurde. FullName schließlich liefert den Anzeigenamen der Assembly. f PropertyInfo liefert Informationen über die Eigenschaften eines Datentyps. Diese können über die Methode GetProperties() ermittelt werden. Über PropertyInfo können beispielsweise der Datentyp einer Eigenschaft, ihr Name oder Informationen über den Sichtbarkeitsgrad ermittelt werden. f MethodInfo liefert Informationen über die Methoden eines Datentyps und funktioniert äquivalent zu PropertyInfo. Hinzu kommt hier allerdings, dass auch die an eine Methode übergebenen Parameter ermittelt werden können. Diese werden durch die Klasse ParameterInfo repräsentiert. f FieldInfo liefert Informationen über die Felder eines Datentyps. Auch FieldInfo besitzt Eigenschaften, die die gleichen Informationen liefern wie PropertyInfo (allerdings bezogen auf Felder). f EventInfo liefert wiederum Informationen über die Ereignisse eines Datentyps. Auch diese Klasse funktioniert ähnlich zu ihren Vorgängern. f Die Klasse Activator dient nicht der Informationsermittlung, sondern vielmehr der Instanzierung eines Objekts. Die statische Methode CreateInstance() erzeugt ein Objekt eines Datentyps, wobei auch eine Werteübergabe möglich ist, falls die betreffende Klasse keinen Standardkonstruktor zur Verfügung stellt. Alle angesprochenen Klassen verfügen ihrerseits wiederum über eigene Eigenschaften und Methoden, die die Information liefern, ob dieses bestimmte Element öffentlich oder nichtöffentlich verfügbar ist, welchen Typ das Element besitzt, im Falle von Eigenschaften beispielsweise ob diese beschreibbar bzw. lesbar sind und noch viele Informationen mehr, die hier nicht aufgelistet sind. Die Verwendung des Reflection-Features ist zunächst also weniger eine komplexe Geschichte als vielmehr umfangreiche Informationsermittlung.
25.2
Beispielapplikation: Informationen über die BCL ermitteln
Wollten Sie nicht auch schon immer einmal wissen, wie viele Datentypen sich denn nun wirklich im .NET Framework, d.h. in den DLLs befinden, die by Default im Global Assembly Cache enthalten sind? Diese Beispielapplikation liefert die Antwort, und nicht nur das, sie liefert auch noch einige Informationen über die in einer Assembly enthaltenen
Sandini Bib
888
25 Reflection
Datentypen. Gleichzeitig wird gezeigt, wie Sie mithilfe von Reflection Dateien laden und auswerten können.
CD
Der Einfachheit halber arbeitet die Applikation nur mit den Klassen bzw. Dateien, die in der Version 2.0 des .NET Frameworks enthalten sind. Und da das Laden der Assemblies und das Zählen der enthaltenen Datentypen auch etwas Zeit kostet, wird ein Splashscreen implementiert, der eine Fortschrittsanzeige enthält. Diese wird mittels eines Ereignisses, das aus der eigentlich arbeitenden Klasse stammt, aktualisiert. Das gesamte Beispielprogramm finden Sie auf der beiliegenden CD im Verzeichnis :\Buchdaten\Beispiele\Kapitel_25\ReflectionSample.
Für die Applikation werden folgende Bestandteile benötigt: f Eine Klasse, die die Datentypen aus den Assemblies des .NET Frameworks lädt und diese dabei auch noch zählt. Diese Klasse heißt im Beispielprogramm AssemblyReflector. f Eine Klasse zur Auswertung der Datentypen, die in einer Assembly enthalten sind. Diese Klasse wird TypeReflector genannt. f Einen Splashscreen mit Fortschrittsanzeige, der beim Programmstart angezeigt wird f Ein Hauptformular für die Anzeige der im GAC enthaltenen Datentypen und Namespaces, denn wie auch im .NET Framework sollen die Datentypen in ihren Namespaces enthalten sein f Einige Delegates für die Ereignisse, die die Klasse AssemblyReflector auslösen soll. Hierfür wird auch noch jeweils eine passende EventArgs-Klasse benötigt.
25.2.1
Das Hauptformular der Anwendung – der Aufbau
An dieser Stelle soll der Aufbau des Hauptformulars gezeigt und die spätere Funktionalität dargestellt werden. Das Hauptformular besteht lediglich aus einem ToolStrip-Element, das lediglich einen Button zum Ändern der Schriftgröße enthält (und natürlich der möglichen späteren Erweiterung des Programms dient) und einem SplitContainer. Dieser enthält auf der linken Seite eine TreeView-Komponente und rechts eine TextBox. In der TextBox werden Detailinformationen zum gewählten Datentyp angezeigt, die TreeViewKomponente enthält die Datentypen geordnet nach Namespaces. Um es vorwegzunehmen: Bezüglich des .NET Frameworks selbst, also der Namespaces, die am häufigsten Verwendung finden und mit System beginnen, dürfte sich die Zahl der öffentlichen Klassen auf ca. 4500 belaufen. Die Anzahl der Datentypen in allen Assemblies, die mit dem Visual Studio 2005 Professional installiert werden, ist ungleich höher. Daher macht es keinen Sinn, alle Datentypen auf einen Schlag in das TreeView einzugliedern und anzuzeigen. Stattdessen werden nur die Namespaces eingebaut und die darin enthaltenen Typen ermittelt, wenn der entsprechende Knoten per Doppelklick geöffnet wird. Die Klasse AssemblyReflector, die zum Ermitteln der Datentypen dient, beinhaltet eine entsprechende Methode.
Sandini Bib
Beispielapplikation: Informationen über die BCL ermitteln
889
Die Ermittlung der Datentypen auf diese Art ist natürlich etwas zeitaufwändig, allein aus dem Grund, weil alle Datentypen auf einen bestimmten Namespace kontrolliert werden müssen – es gibt ja keine Klasse Namespace. Die Funktionalität des Hauptformulars wird später genauer beleuchtet, zunächst kommen die Klassen, die die eigentliche Arbeit verrichten. Abbildung 25.1 zeigt eine Abbildung des Hauptformulars zur Entwurfszeit.
Abbildung 25.1: Das Hauptformular der Anwendung in der Entwurfsansicht
25.2.2
Die Klasse AssemblyReflector
Die Klasse AssemblyReflector dient der Ermittlung aller enthaltenen Datentypen des Global Assembly Cache. Gleich beim Start der Applikation sollen alle im GAC enthaltenen Assemblies geladen und ihre enthaltenen Datentypen ermittelt werden. Außerdem wird auch eine Liste der Namespaces erstellt. Da dieses Vorgehen sehr zeitaufwändig ist, soll der Anwender Rückmeldung über eine Fortschrittsanzeige in einem Splashscreen erhalten. Diese Fortschrittsanzeige wird über Ereignisse der Klasse AssemblyReflector gesteuert.
Vorarbeiten Bevor es an die Implementierung geht, erst die Vorarbeit. Vier Ereignisse sollen von der Klasse AssemblyReflector ausgelöst werden können: f Alle Assemblies wurden gezählt (Ereignis AssemblyFilesCounted). f Alle Typen wurden gezählt (Ereignis AssemblyTypesCounted). f Alle Typen wurden geladen (Ereignis AssemblyTypesLoaded). f Die nächste Assembly wird bearbeitet (Ereignis NextAssembly).
Sandini Bib
890
25 Reflection
Beim letzten Ereignis handelt es sich um ein Standardereignis, das keine weitere Behandlung erfordert und einfach in Form eines EventHandler implementiert werden kann. Mit diesem Ereignis wird später im Splash-Formular die Fortschrittsanzeige weitergeschaltet. Die übrigen Ereignisse benötigen da schon etwas mehr Aufmerksamkeit. Für die Ereignisse, die irgendetwas zählen bzw. deren Ereignisparameter einen Zahlenwert übergeben, soll eine eigene Argumentklasse erstellt werden. Da diese nur den Zweck hat, den gezählten Wert zu übergeben, ist sie nicht weiter schwer zu erstellen oder zu verstehen: public class CountedEventArgs : EventArgs { private int itemCount; public int ItemCount { get { return this.itemCount; } } public CountedEventArgs( int itemCount ) { this.itemCount = itemCount; } }
Diese Klasse wird für die Ereignisse AssemblyFilesCounted und AssmblyTypesCounted verwendet. Etwas mehr wird für das Ereignis AssemblyTypesLoaded benötigt, denn hier soll auch noch der Name der Assembly mit übergeben werden. Aber auch diese Argumentklasse stellt kein Problem dar. public class AssemblyTypesLoadedEventArgs : EventArgs { private int typeCount; private string assemblyName; public int TypeCount { get { return this.typeCount; } } public string AssemblyName { get { return this.assemblyName; } } public AssemblyTypesLoadedEventArgs( string assemblyName, int typeCount ) { this.assemblyName = assemblyName; this.typeCount = typeCount; } }
Die Delegates für die Ereignisse (außer für das Ereignis NextAssembly, da hier der StandardDelegate EventHandler verwendet werden kann) werden in einer eigenen Datei definiert.
Sandini Bib
Beispielapplikation: Informationen über die BCL ermitteln
891
Das hat den Vorteil, dass alle Delegates beisammen sind. Zwar sind Delegates auch Klassen, und eigentlich sollte es immer so sein, dass pro Klasse eine Datei verwendet wird, aber die Deklaration eines Delegate ähnelt mehr der einer Methode und ist daher so platzsparend, dass durchaus mehrere Delegates in einer Datei Platz haben, ohne dass die Datei unübersichtlich wird. public delegate void AssemblyFilesCountedEventHandler( object sender, CountedEventArgs e ); public delegate void AssemblyTypesCountedEventHandler( object sender, CountedEventArgs e ); public delegate void AssemblyTypesLoadedEventHandler( object sender, AssemblyTypesLoadedEventArgs e );
Damit wären die Ereignisse bereits vorbereitet und die Arbeit an der eigentlichen Klasse AssemblyReflector kann beginnen.
Die Klasse AssemblyReflector Die Ereignisse, die wir gerade vorbereitet haben, sind auch die ersten Elemente, die in der Klasse AssemblyReflector implementiert werden. public class AssemblyReflector { public public public public
event event event event
AssemblyFilesCountedEventHandler AssemblyFilesCounted; AssemblyTypesCountedEventHandler AssemblyTypesCounted; AssemblyTypesLoadedEventHandler AssemblyTypesLoaded; EventHandler NextAssembly;
Es folgt die einzige Konstante der Klasse, die allerdings nicht als Konstante ausgeführt sein müsste (ein string würde genügen – da er private wäre und nicht geändert würde, würde das keinen Unterschied machen). Es handelt sich um den Pfad zu den Dateien des .NET Frameworks, und hier kommt eine Überraschung – diese liegen nicht allein im Unterverzeichnis assembly des Windows-Verzeichnisses, sondern auch noch einmal in C:\WINDOWS\ Microsoft.NET\Framework\v2.0.50727. Der Hintergrund ist einfach zu verstehen, wenn Sie sich nochmals klar machen, wie das .NET Framework arbeitet. Alle Dateien, die Sie erstellen, werden bei der Ausführung gejittet, d.h. von IL-Code in nativen Code übersetzt. Das muss aus verschiedenen Gründen der Fall sein. Zum einen, weil sich diese Dateien ändern könnten (beispielsweise beim Debuggen einer Anwendung). Zum anderen, weil Sie nie wissen, auf welchem Rechner der Code ausgeführt wird und die Jitter eine Optimierung durchführen. Im Falle des .NET Frameworks selbst muss das alles nicht sein – das .NET Framework ändert sich nicht und kann bei der Installation daher problemlos auf den Prozessor des aktuellen Rechners optimiert und komplett kompiliert werden. Daher liegen die Assemblies des .NET Frameworks in mehreren Versionen vor, einmal als IL-Code und einmal in Form nativer, vorkompilierter Dateien. Unter anderem auch im angegebenen Verzeichnis.
Sandini Bib
892
25 Reflection
Das Verzeichnis ist innerhalb der Klasse AssemblyReflector als Konstante hinterlegt. Bei Konstanten wird bevorzugt die reine Großschreibung verwendet, so auch hier: private const string BASEPATH = @"C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727";
Felder und Eigenschaften Die Klasse benötigt außerdem einige Felder, um die ermittelten Informationen aufnehmen zu können. private private private private private private
List allTypes = null; // Liste aller Typen in den Assemblies List<string> allNamespaces = null; // Liste aller Namespaces int assemblyCount = 0; // Anzahl aller Assemblies int typeCount = 0; // Anzahl aller Datentypen string[] assemblyNames; // Die Namen aller Assemblies int validAssemblyCount = 0; // Die Anzahl valider Assemblies
Einige dieser Felder sind allein für den internen Gebrauch bestimmt, andere werden veröffentlicht. Da an den Werten selbst aber keine Änderung möglich sein soll (sie würde ja auch keinen Sinn machen), sind die passenden Eigenschaften als read-only-Eigenschaften ausgelegt. public string[] Namespaces { get { return this.allNamespaces.ToArray(); } } public int ValidAssemblyCount { get { return this.validAssemblyCount; } } public int TypeCount { get { return this.typeCount; } }
Hilfsmethoden Die meisten Methoden der Klasse AssemblyReflector sind Hilfsmethoden, die nur gebraucht werden, wenn das Programm gestartet und die Assemblies bzw. Datentypen gezählt werden. Die Methoden selbst sind nicht weiter schwer zu verstehen. Die erste der Methoden dient dem Vergleich zweier Datentypen zum Zweck des Sortierens. Die Applikation wird später alle Datentypen sortiert nach Namespaces anzeigen, d.h. es werden immer nur die Datentypen zurückgeliefert, die Bestandteil eines bestimmten Namespaces sind. Diese sollen sortiert zurückgeliefert werden, weshalb eine Methode zum Sortieren benötigt wird. Diese Methode führt, wie Sie es vom Interface IComparer kennen, lediglich einen Vergleich durch und liefert einen Zahlenwert zurück. Die Möglichkeit, mittels einer derartigen Methode zu sortieren (statt eine komplette Klasse, die IComparer implementiert zu erstellen) ist neu in .NET 2.0.
Sandini Bib
Beispielapplikation: Informationen über die BCL ermitteln
893
private int TypeCompareFunction( Type t1, Type t2 ) { // Vergleicht zwei Datentypen if ( t1 != null && t2 != null ) return t1.Name.CompareTo( t2.Name ); return -1; }
Die nächste Methode dient dem Laden einer Assembly. Allerdings ist nicht jede DLL auch eine .NET-Assembly, deshalb liefert die Methode TryLoadAssembly() null zurück, wenn die geladene DLL keine .NET-DLL oder aber nicht Bestandteil des Global Assembly Cache ist. private Assembly TryLoadAssembly( string fileName ) { // Versuch, ein Assembly zu laden // Handelt es sich um ein Assembly aus dem GAC, wird es zurückgeliefert, // Ansonsten wird null zurückgeliefert. Assembly result = null; try { result = Assembly.LoadFrom( fileName ); // Assembly muss Bestandteil des GAC sein if ( !result.GlobalAssemblyCache ) result = null; } catch { // Keine Aktion; Dient nur dem Abfangen invalider Assemblies } return result; }
Für den Splashscreen wird die Angabe benötigt, wie viele Assemblies letztlich geladen werden, denn das ist der Maximalwert für die Fortschrittsanzeige. Da das Kontrollieren einer Assembly auf .NET bzw. darauf, ob sie im GAC enthalten ist, ebenfalls eine Menge Zeit kostet, wird hier stattdessen die Anzahl aller DLLs zurückgeliefert. Später wird die Fortschrittsanzeige dann mit jeder DLL erhöht, ob es sich um eine .NET-DLL handelt oder nicht. Allerdings werden Datentypen natürlich nur von .NET-Assemblies geladen. Diese Vorgehensweise ist schneller, begründet aber auch, dass eben diese Fortschrittsanzeige sich mitunter ruckartig vorbewegt. Die Methode CountAvailableDlls() zählt die verfügbaren DLLs im angegebenen Verzeichnis. Unterverzeichnisse müssen nicht mit einbezogen werden. private void CountAvailableDlls() { this.assemblyNames = Directory.GetFiles( BASEPATH, "*.dll", SearchOption.TopDirectoryOnly ); this.assemblyCount = this.assemblyNames.Length; // Ereignis aufrufen OnAssemblyFilesCounted( this.assemblyCount ); }
Sandini Bib
894
25 Reflection
Sie wissen bereits, dass laut Konvention die Methode OnAssemblFilesCounted() das Ereignis AssemblyFilesCounted auslöst. Diese Methoden, die die Ereignisse auslösen, werden später behandelt – die Vorgehensweise ist dabei aber immer die gleiche, letztlich ist es nur eine Kontrolle, ob das Ereignis abonniert wurde und dann der Aufruf desselben. Die Methode GetTypesFromAssembly() liefert alle Datentypen, die in einer bestimmten Assembly enthalten sind. Die Datentypen werden (falls noch nicht enthalten) der internen Liste der Aufzählungen hinzugefügt. Für die Namespaces gilt das gleiche. private int GetTypesFromAssembly( Assembly currentAssembly ) { // Ermittlung aller Datentypen in der Assembly // Die Datentypen werden der internen Auflistung hinzugefügt, // die Anzahl wird zurückgeliefert. int result = 0; try { // Typen ermitteln Type[] types = currentAssembly.GetTypes(); result = types.Length; // Hinzufügen zur internen Liste - Typen und Namespaces foreach ( Type t in types ) { if ( !this.allTypes.Contains( t ) ) this.allTypes.Add( t ); if ( !this.allNamespaces.Contains( t.Namespace ) ) this.allNamespaces.Add( t.Namespace ); } } catch { } return result; }
Die Methode GetAllTypes() liefert alle Datentypen aller Assemblies und sortiert danach die Namespace-Liste. Diese Methode ist die eigentlich aufgerufene Methode, die alle Datentypen und Assemblies (und Namespaces) zählt. Allerdings ist sie nicht öffentlich, sondern wird aus einer Initialisierungsmethode heraus aufgerufen. private void GetAllTypes() { // Ermittlung aller Datentypen aus allen Assemblies foreach ( string assemblyName in this.assemblyNames ) { // Versuch, Assembly zu laden; bei Fehlschlag wird null geliefert Assembly currentAssembly = TryLoadAssembly( assemblyName ); if ( currentAssembly != null ) { // Valide Assemblies hochzählen this.validAssemblyCount++;
Sandini Bib
Beispielapplikation: Informationen über die BCL ermitteln
895
// Typen zählen int typeCount = GetTypesFromAssembly( currentAssembly ); // Ereignis AssemblyTypesLoaded auslösen OnAssemblyTypesLoaded( currentAssembly.GetName().Name, typeCount ); } // Ereignis NextAssembly auslösen OnNextAssembly(); } // Jetzt ist alles geladen ... Namespaceliste sortieren this.allNamespaces.Sort(); this.typeCount = this.allTypes.Count; }
Hauptmethoden Als Hauptmethoden werden hier die Methoden bezeichnet, die von außen aufgerufen werden können. Es handelt sich um deren zwei. Zunächst die Methode GetTypesFromNamespace(), mit der die Datentypen eines bestimmten Namespace ermittelt und zurückgeliefert werden. public Type[] GetTypesFromNamespace( string currentNamespace ) { // liefert alle Datentypen eines bestimmten Namespace if ( String.IsNullOrEmpty( currentNamespace ) ) return null; List result = new List(); foreach ( Type t in this.allTypes ) if ( !String.IsNullOrEmpty( t.Namespace ) ) if ( t.Namespace.Equals( currentNamespace ) ) if ( !result.Contains( t ) ) result.Add( t ); // Ergebnis nach Namen der Typen sortieren result.Sort( TypeCompareFunction ); return result.ToArray(); }