Fehlerhafter C#-Code: Die 10 häufigsten Fehler bei der C#-Programmierung
Veröffentlicht: 2022-03-11Über Cis
C# ist eine von mehreren Sprachen, die auf die Microsoft Common Language Runtime (CLR) abzielen. Sprachen, die auf die CLR abzielen, profitieren von Funktionen wie sprachübergreifender Integration und Ausnahmebehandlung, verbesserter Sicherheit, einem vereinfachten Modell für die Komponenteninteraktion sowie Debugging- und Profilerstellungsdiensten. Von den heutigen CLR-Sprachen wird C# am häufigsten für komplexe, professionelle Entwicklungsprojekte verwendet, die auf Windows-Desktop-, Mobil- oder Serverumgebungen abzielen.
C# ist eine objektorientierte, stark typisierte Sprache. Die strenge Typüberprüfung in C# sowohl zur Kompilierungs- als auch zur Laufzeit führt dazu, dass die meisten typischen C#-Programmierfehler so früh wie möglich gemeldet und ihre Position ziemlich genau lokalisiert werden. Dies kann bei der C-Sharp-Programmierung viel Zeit sparen, verglichen mit dem Aufspüren der Ursache von rätselhaften Fehlern, die lange nach der Ausführung der störenden Operation in Sprachen auftreten können, die bei der Durchsetzung der Typsicherheit liberaler sind. Viele C#-Programmierer werfen jedoch unwissentlich (oder fahrlässig) die Vorteile dieser Erkennung weg, was zu einigen der Probleme führt, die in diesem C#-Tutorial besprochen werden.
Über dieses Tutorial zur C-Sharp-Programmierung
Dieses Tutorial beschreibt 10 der häufigsten C#-Programmierfehler oder zu vermeidenden Probleme von C#-Programmierern und bietet ihnen Hilfestellungen.
Während die meisten der in diesem Artikel behandelten Fehler C#-spezifisch sind, sind einige auch für andere Sprachen relevant, die auf die CLR abzielen oder die Framework Class Library (FCL) verwenden.
Häufiger C#-Programmierfehler Nr. 1: Verwendung einer Referenz wie eines Werts oder umgekehrt
Programmierer von C++ und vielen anderen Sprachen sind daran gewöhnt, die Kontrolle darüber zu haben, ob die Werte, die sie Variablen zuweisen, einfach Werte oder Verweise auf vorhandene Objekte sind. Bei der C-Sharp-Programmierung wird diese Entscheidung jedoch von dem Programmierer getroffen, der das Objekt geschrieben hat, und nicht von dem Programmierer, der das Objekt instanziiert und es einer Variablen zuweist. Dies ist ein allgemeiner Fallstrick für diejenigen, die versuchen, die C#-Programmierung zu lernen.
Wenn Sie nicht wissen, ob das von Ihnen verwendete Objekt ein Werttyp oder ein Referenztyp ist, könnten Sie auf einige Überraschungen stoßen. Zum Beispiel:
Point point1 = new Point(20, 30); Point point2 = point1; point2.X = 50; Console.WriteLine(point1.X); // 20 (does this surprise you?) Console.WriteLine(point2.X); // 50 Pen pen1 = new Pen(Color.Black); Pen pen2 = pen1; pen2.Color = Color.Blue; Console.WriteLine(pen1.Color); // Blue (or does this surprise you?) Console.WriteLine(pen2.Color); // Blue
Wie Sie sehen können, wurden die Point
und Pen
-Objekte auf genau die gleiche Weise erstellt, aber der Wert von point1
blieb unverändert, wenn point2 ein neuer X
-Koordinatenwert zugewiesen wurde, während der Wert von point2
pen1
wurde , als eine neue Farbe zugewiesen wurde pen2
. Wir können daher ableiten , dass point1
und point2
jeweils ihre eigene Kopie eines Point
-Objekts enthalten, während pen1
und pen2
Verweise auf dasselbe Pen
-Objekt enthalten. Aber wie können wir das wissen, ohne dieses Experiment durchzuführen?
Die Antwort ist, sich die Definitionen der Objekttypen anzusehen (was Sie in Visual Studio ganz einfach tun können, indem Sie den Cursor über den Namen des Objekttyps platzieren und F12 drücken):
public struct Point { ... } // defines a “value” type public class Pen { ... } // defines a “reference” type
Wie oben gezeigt, wird in der C#-Programmierung das Schlüsselwort struct
verwendet, um einen Werttyp zu definieren, während das Schlüsselwort class
verwendet wird, um einen Referenztyp zu definieren. Für diejenigen mit einem C++-Hintergrund, die durch die vielen Ähnlichkeiten zwischen C++- und C#-Schlüsselwörtern in ein falsches Sicherheitsgefühl eingelullt wurden, ist dieses Verhalten wahrscheinlich eine Überraschung, die Sie möglicherweise dazu veranlasst, ein C#-Tutorial um Hilfe zu bitten.
Wenn Sie auf ein Verhalten angewiesen sind, das sich zwischen Wert- und Referenztypen unterscheidet – wie die Möglichkeit, ein Objekt als Methodenparameter zu übergeben und diese Methode den Zustand des Objekts ändern zu lassen – stellen Sie sicher, dass Sie sich mit dem befassen richtigen Objekttyp, um C#-Programmierprobleme zu vermeiden.
Häufiger C#-Programmierfehler Nr. 2: Missverständnis von Standardwerten für nicht initialisierte Variablen
In C# dürfen Werttypen nicht null sein. Per Definition haben Werttypen einen Wert, und selbst nicht initialisierte Variablen von Werttypen müssen einen Wert haben. Dies wird als Standardwert für diesen Typ bezeichnet. Dies führt bei der Prüfung, ob eine Variable nicht initialisiert ist, zu folgendem, meist unerwartetem Ergebnis:
class Program { static Point point1; static Pen pen1; static void Main(string[] args) { Console.WriteLine(pen1 == null); // True Console.WriteLine(point1 == null); // False (huh?) } }
Warum ist point1
nicht null? Die Antwort ist, dass Point
ein Werttyp ist und der Standardwert für einen Point
(0,0) und nicht null ist. Dies nicht zu erkennen, ist ein sehr einfacher (und häufiger) Fehler, der in C# gemacht wird.
Viele (aber nicht alle) Werttypen haben eine IsEmpty
-Eigenschaft, die Sie überprüfen können, um zu sehen, ob sie gleich ihrem Standardwert ist:
Console.WriteLine(point1.IsEmpty); // True
Wenn Sie überprüfen, ob eine Variable initialisiert wurde oder nicht, stellen Sie sicher, dass Sie wissen, welchen Wert eine nicht initialisierte Variable dieses Typs standardmäßig hat, und verlassen Sie sich nicht darauf, dass sie null ist.
Häufiger C#-Programmierfehler Nr. 3: Verwenden ungeeigneter oder nicht spezifizierter Vergleichsmethoden für Zeichenfolgen
Es gibt viele verschiedene Möglichkeiten, Zeichenfolgen in C# zu vergleichen.
Obwohl viele Programmierer den ==
-Operator für String-Vergleiche verwenden, ist dies eigentlich eine der am wenigsten wünschenswerten Methoden, vor allem, weil es nicht explizit im Code angibt, welche Art von Vergleich erwünscht ist.
Die bevorzugte Methode zum Testen auf Zeichenfolgengleichheit in der C#-Programmierung ist vielmehr die Equals
-Methode:
public bool Equals(string value); public bool Equals(string value, StringComparison comparisonType);
Die erste Methodensignatur (dh ohne den comparisonType
-Parameter) ist eigentlich dasselbe wie die Verwendung des ==
-Operators, hat aber den Vorteil, dass sie explizit auf Zeichenfolgen angewendet wird. Es führt einen Ordinalvergleich der Zeichenfolgen durch, der im Grunde ein Byte-für-Byte-Vergleich ist. In vielen Fällen ist dies genau der gewünschte Vergleichstyp, insbesondere wenn Zeichenfolgen verglichen werden, deren Werte programmgesteuert festgelegt werden, z. B. Dateinamen, Umgebungsvariablen, Attribute usw. In diesen Fällen, sofern ein ordinaler Vergleich tatsächlich der richtige Typ ist Für diese Situation besteht der einzige Nachteil bei der Verwendung der Equals
-Methode ohne einen comparisonType
darin, dass jemand, der den Code liest, möglicherweise nicht weiß, welche Art von Vergleich Sie durchführen.
Die Verwendung der Equals
-Methodensignatur, die jedes Mal, wenn Sie Zeichenfolgen vergleichen, einen comparisonType
enthält, wird Ihren Code jedoch nicht nur klarer machen, sondern Sie auch explizit darüber nachdenken lassen, welche Art von Vergleich Sie durchführen müssen. Dies lohnt sich, denn auch wenn Englisch nicht viele Unterschiede zwischen ordinalen und kultursensiblen Vergleichen bietet, bieten andere Sprachen viele, und das Ignorieren der Möglichkeit anderer Sprachen eröffnet Ihnen viel Potenzial für Fehler auf der Straße. Zum Beispiel:
string s = "strasse"; // outputs False: Console.WriteLine(s == "straße"); Console.WriteLine(s.Equals("straße")); Console.WriteLine(s.Equals("straße", StringComparison.Ordinal)); Console.WriteLine(s.Equals("Straße", StringComparison.CurrentCulture)); Console.WriteLine(s.Equals("straße", StringComparison.OrdinalIgnoreCase)); // outputs True: Console.WriteLine(s.Equals("straße", StringComparison.CurrentCulture)); Console.WriteLine(s.Equals("Straße", StringComparison.CurrentCultureIgnoreCase));
Am sichersten ist es, der Equals
Methode immer einen comparisonType
-Parameter bereitzustellen. Hier sind einige grundlegende Richtlinien:
- Verwenden Sie beim Vergleichen von Zeichenfolgen, die vom Benutzer eingegeben wurden oder dem Benutzer angezeigt werden sollen, einen kulturabhängigen Vergleich (
CurrentCulture
oderCurrentCultureIgnoreCase
). - Verwenden Sie beim Vergleichen von programmgesteuerten Zeichenfolgen den ordinalen Vergleich (
Ordinal
oderOrdinalIgnoreCase
). -
InvariantCulture
undInvariantCultureIgnoreCase
sollten im Allgemeinen nur unter sehr begrenzten Umständen verwendet werden, da ordinale Vergleiche effizienter sind. Wenn ein kulturbewusster Vergleich erforderlich ist, sollte er normalerweise gegen die aktuelle Kultur oder eine andere spezifische Kultur durchgeführt werden.
Zusätzlich zur Equals
-Methode bieten Strings auch die Compare
-Methode, die Ihnen Informationen über die relative Reihenfolge von Strings liefert, anstatt nur einen Test auf Gleichheit durchzuführen. Diese Methode ist den Operatoren <
, <=
, >
und >=
vorzuziehen, und zwar aus denselben Gründen wie oben beschrieben – um C#-Probleme zu vermeiden.
Häufiger C#-Programmierfehler Nr. 4: Verwenden von iterativen (anstelle von deklarativen) Anweisungen zum Manipulieren von Sammlungen
In C# 3.0 hat das Hinzufügen von Language-Integrated Query (LINQ) zur Sprache die Art und Weise, wie Sammlungen abgefragt und bearbeitet werden, für immer verändert. Wenn Sie seitdem iterative Anweisungen zum Bearbeiten von Sammlungen verwenden, haben Sie LINQ nicht verwendet, wenn Sie es wahrscheinlich hätten tun sollen.
Einige C#-Programmierer wissen nicht einmal von der Existenz von LINQ, aber glücklicherweise wird diese Zahl immer kleiner. Viele denken jedoch immer noch, dass es aufgrund der Ähnlichkeit zwischen LINQ-Schlüsselwörtern und SQL-Anweisungen nur in Code verwendet wird, der Datenbanken abfragt.
Während Datenbankabfragen eine weit verbreitete Verwendung von LINQ-Anweisungen sind, funktionieren sie tatsächlich über jede aufzählbare Sammlung (dh jedes Objekt, das die IEnumerable-Schnittstelle implementiert). Wenn Sie also beispielsweise ein Array von Accounts haben, anstatt für jeden eine C#-Liste zu schreiben:
decimal total = 0; foreach (Account account in myAccounts) { if (account.Status == "active") { total += account.Balance; } }
du könntest einfach schreiben:
decimal total = (from account in myAccounts where account.Status == "active" select account.Balance).Sum();
Obwohl dies ein ziemlich einfaches Beispiel dafür ist, wie dieses häufige C#-Programmierproblem vermieden werden kann, gibt es Fälle, in denen eine einzelne LINQ-Anweisung problemlos Dutzende von Anweisungen in einer iterativen Schleife (oder verschachtelten Schleifen) in Ihrem Code ersetzen kann. Und weniger allgemeiner Code bedeutet weniger Gelegenheiten für das Einführen von Fehlern. Beachten Sie jedoch, dass es zu Abstrichen bei der Leistung kommen kann. In leistungskritischen Szenarien, insbesondere wenn Ihr iterativer Code Annahmen über Ihre Sammlung treffen kann, die LINQ nicht kann, sollten Sie unbedingt einen Leistungsvergleich zwischen den beiden Methoden durchführen.
Häufiger C#-Programmierfehler Nr. 5: Versäumnis, die zugrunde liegenden Objekte in einer LINQ-Anweisung zu berücksichtigen
LINQ eignet sich hervorragend, um die Aufgabe der Bearbeitung von Sammlungen zu abstrahieren, unabhängig davon, ob es sich um In-Memory-Objekte, Datenbanktabellen oder XML-Dokumente handelt. In einer perfekten Welt müssten Sie nicht wissen, was die zugrunde liegenden Objekte sind. Aber der Fehler hier ist anzunehmen, dass wir in einer perfekten Welt leben. Tatsächlich können identische LINQ-Anweisungen unterschiedliche Ergebnisse zurückgeben, wenn sie für genau dieselben Daten ausgeführt werden, wenn diese Daten zufällig in einem anderen Format vorliegen.
Betrachten Sie zum Beispiel die folgende Aussage:
decimal total = (from account in myAccounts where account.Status == "active" select account.Balance).Sum();
Was passiert, wenn einer der Konten des Objekts den account.Status
„Aktiv“ hat (beachten Sie das große A)? Nun, wenn myAccounts
ein DbSet
Objekt wäre (das mit der Standardkonfiguration ohne Berücksichtigung der Groß-/Kleinschreibung eingerichtet wurde), würde der where
-Ausdruck immer noch mit diesem Element übereinstimmen. Wenn sich myAccounts
“ jedoch in einem In-Memory-Array befände, würde es nicht übereinstimmen und daher ein anderes Ergebnis für „total“ liefern.
Aber warte mal. Als wir vorhin über den Vergleich von Strings gesprochen haben, haben wir gesehen, dass der Operator ==
einen ordinalen Vergleich von Strings durchführt. Warum also führt der Operator ==
in diesem Fall einen Vergleich ohne Berücksichtigung der Groß-/Kleinschreibung durch?
Die Antwort lautet: Wenn es sich bei den zugrunde liegenden Objekten in einer LINQ-Anweisung um Verweise auf SQL-Tabellendaten handelt (wie in diesem Beispiel beim DbSet-Objekt von Entity Framework), wird die Anweisung in eine T-SQL-Anweisung konvertiert. Operatoren folgen dann den T-SQL-Programmierregeln, nicht den C#-Programmierregeln, sodass der Vergleich im obigen Fall die Groß-/Kleinschreibung nicht berücksichtigt.
Obwohl LINQ eine hilfreiche und konsistente Methode zum Abfragen von Objektsammlungen ist, müssen Sie im Allgemeinen immer noch wissen, ob Ihre Anweisung unter der Haube in etwas anderes als C# übersetzt wird, um sicherzustellen, dass das Verhalten Ihres Codes dies tut zur Laufzeit wie erwartet sein.
Häufiger C#-Programmierfehler Nr. 6: Durch Erweiterungsmethoden verwirrt oder vorgetäuscht werden
Wie bereits erwähnt, funktionieren LINQ-Anweisungen mit jedem Objekt, das IEnumerable implementiert. Zum Beispiel addiert die folgende einfache Funktion die Salden auf jeder Sammlung von Konten:
public decimal SumAccounts(IEnumerable<Account> myAccounts) { return myAccounts.Sum(a => a.Balance); }
Im obigen Code wird der Typ des Parameters myAccounts als IEnumerable<Account>
deklariert. Da myAccounts
auf eine Sum
-Methode verweist (C# verwendet die bekannte „Punktnotation“, um auf eine Methode in einer Klasse oder Schnittstelle zu verweisen), würden wir erwarten, eine Methode namens Sum()
in der Definition der IEnumerable<T>
-Schnittstelle zu sehen. Die Definition von IEnumerable<T>
bezieht sich jedoch nicht auf eine Sum
-Methode und sieht einfach so aus:
public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); }
Wo ist also die Methode Sum()
definiert? C# ist stark typisiert. Wenn also der Verweis auf die Sum
-Methode ungültig wäre, würde der C#-Compiler dies sicherlich als Fehler kennzeichnen. Wir wissen daher, dass es sie geben muss, aber wo? Wo sind außerdem die Definitionen aller anderen Methoden, die LINQ zum Abfragen oder Aggregieren dieser Sammlungen bereitstellt?
Die Antwort ist, dass Sum()
keine Methode ist, die auf der IEnumerable
Schnittstelle definiert ist. Vielmehr handelt es sich um eine statische Methode (als „Erweiterungsmethode“ bezeichnet), die in der System.Linq.Enumerable
-Klasse definiert ist:
namespace System.Linq { public static class Enumerable { ... // the reference here to “this IEnumerable<TSource> source” is // the magic sauce that provides access to the extension method Sum public static decimal Sum<TSource>(this IEnumerable<TSource> source, Func<TSource, decimal> selector); ... } }
Was unterscheidet also eine Erweiterungsmethode von jeder anderen statischen Methode und was ermöglicht uns, in anderen Klassen darauf zuzugreifen?
Das Unterscheidungsmerkmal einer Erweiterungsmethode ist der this
-Modifikator für ihren ersten Parameter. Dies ist die „Magie“, die es dem Compiler als Erweiterungsmethode identifiziert. Der Typ des geänderten Parameters (in diesem Fall IEnumerable<TSource>
) bezeichnet die Klasse oder Schnittstelle, die dann erscheint, um diese Methode zu implementieren.
(Nebenbei bemerkt, die Ähnlichkeit zwischen dem Namen der IEnumerable
Schnittstelle und dem Namen der Enumerable
-Klasse, für die die Erweiterungsmethode definiert ist, hat nichts Magisches. Diese Ähnlichkeit ist nur eine willkürliche stilistische Wahl.)
Mit diesem Verständnis können wir auch sehen, dass die oben eingeführte Funktion sumAccounts
stattdessen wie folgt hätte implementiert werden können:

public decimal SumAccounts(IEnumerable<Account> myAccounts) { return Enumerable.Sum(myAccounts, a => a.Balance); }
Die Tatsache, dass wir es auf diese Weise hätten implementieren können, wirft stattdessen die Frage auf, warum es überhaupt Erweiterungsmethoden gibt? Erweiterungsmethoden sind im Wesentlichen eine Annehmlichkeit der Programmiersprache C#, mit der Sie Methoden zu vorhandenen Typen „hinzufügen“ können, ohne einen neuen abgeleiteten Typ zu erstellen, den ursprünglichen Typ neu zu kompilieren oder anderweitig zu ändern.
Erweiterungsmethoden werden in den Geltungsbereich gebracht, indem ein using [namespace];
Anweisung am Anfang der Datei. Sie müssen wissen, welcher C#-Namespace die gesuchten Erweiterungsmethoden enthält, aber das lässt sich ziemlich einfach feststellen, wenn Sie wissen, wonach Sie suchen.
Wenn der C#-Compiler auf einen Methodenaufruf für eine Instanz eines Objekts stößt und diese Methode nicht in der referenzierten Objektklasse definiert findet, prüft er alle Erweiterungsmethoden innerhalb des Gültigkeitsbereichs, um eine Methode zu finden, die der erforderlichen Methode entspricht Signatur und Klasse. Wenn es eines findet, wird es die Instanzreferenz als erstes Argument an diese Erweiterungsmethode übergeben, dann werden die restlichen Argumente, falls vorhanden, als nachfolgende Argumente an die Erweiterungsmethode übergeben. (Wenn der C#-Compiler keine entsprechende Erweiterungsmethode innerhalb des Gültigkeitsbereichs findet, gibt er einen Fehler aus.)
Erweiterungsmethoden sind ein Beispiel für „syntaktischen Zucker“ seitens des C#-Compilers, der es uns ermöglicht, Code zu schreiben, der (normalerweise) klarer und wartungsfreundlicher ist. Klarer, das heißt, wenn Sie sich ihrer Verwendung bewusst sind. Ansonsten kann es vor allem am Anfang etwas verwirrend sein.
Obwohl die Verwendung von Erweiterungsmethoden sicherlich Vorteile hat, können sie Probleme und einen Schrei nach C#-Programmierhilfe für diejenigen Entwickler verursachen, die sich ihrer nicht bewusst sind oder sie nicht richtig verstehen. Dies gilt insbesondere, wenn Sie sich online Codebeispiele oder anderen vorgefertigten Code ansehen. Wenn solcher Code Compilerfehler erzeugt (weil er Methoden aufruft, die eindeutig nicht für die Klassen definiert sind, für die sie aufgerufen werden), besteht die Tendenz zu der Annahme, dass der Code für eine andere Version der Bibliothek oder für eine ganz andere Bibliothek gilt. Es kann viel Zeit damit verbracht werden, nach einer neuen Version oder einer „fehlenden Bibliothek“ zu suchen, die nicht existiert.
Selbst Entwickler, die mit Erweiterungsmethoden vertraut sind, werden gelegentlich noch erwischt, wenn eine Methode mit demselben Namen auf dem Objekt vorhanden ist, sich ihre Methodensignatur jedoch auf subtile Weise von der der Erweiterungsmethode unterscheidet. Es kann viel Zeit verschwendet werden, nach einem Tippfehler oder Fehler zu suchen, der einfach nicht vorhanden ist.
Die Verwendung von Erweiterungsmethoden in C#-Bibliotheken wird immer häufiger. Neben LINQ sind der Unity Application Block und das Web-API-Framework Beispiele für zwei stark genutzte moderne Bibliotheken von Microsoft, die ebenfalls Erweiterungsmethoden verwenden, und es gibt viele andere. Je moderner das Framework ist, desto wahrscheinlicher ist es, dass es Erweiterungsmethoden enthält.
Natürlich können Sie auch Ihre eigenen Erweiterungsmethoden schreiben. Beachten Sie jedoch, dass Erweiterungsmethoden zwar scheinbar wie normale Instanzmethoden aufgerufen werden, dies jedoch nur eine Illusion ist. Insbesondere können Ihre Erweiterungsmethoden nicht auf private oder geschützte Member der Klasse verweisen, die sie erweitern, und können daher nicht als vollständiger Ersatz für die traditionellere Klassenvererbung dienen.
Häufiger C#-Programmierfehler Nr. 7: Verwendung des falschen Sammlungstyps für die anstehende Aufgabe
C# bietet eine große Auswahl an Sammlungsobjekten, wobei die folgende Liste nur eine unvollständige Liste ist:
Array
, ArrayList
, BitArray
, BitVector32
, Dictionary<K,V>
, HashTable
, HybridDictionary
, List<T>
, NameValueCollection
, OrderedDictionary
, Queue, Queue<T>
, SortedList
, Stack, Stack<T>
, StringCollection
, StringDictionary
.
Während es Fälle geben kann, in denen zu viele Auswahlmöglichkeiten genauso schlecht sind wie zu wenige Auswahlmöglichkeiten, ist dies bei Sammlungsobjekten nicht der Fall. Die Anzahl der verfügbaren Optionen kann definitiv zu Ihrem Vorteil wirken. Nehmen Sie sich im Voraus etwas mehr Zeit, um zu recherchieren und den optimalen Sammlungstyp für Ihren Zweck auszuwählen. Dies führt wahrscheinlich zu einer besseren Leistung und weniger Raum für Fehler.
Wenn es einen Sammlungstyp gibt, der speziell auf den Typ Ihres Elements ausgerichtet ist (z. B. Zeichenfolge oder Bit), neigen Sie dazu, diesen zuerst zu verwenden. Die Implementierung ist im Allgemeinen effizienter, wenn sie auf einen bestimmten Elementtyp ausgerichtet ist.
Um die Typsicherheit von C# zu nutzen, sollten Sie in der Regel eine generische Schnittstelle einer nicht generischen vorziehen. Die Elemente einer generischen Schnittstelle sind von dem Typ, den Sie angeben, wenn Sie Ihr Objekt deklarieren, während die Elemente von nicht generischen Schnittstellen vom Typ Objekt sind. Wenn Sie eine nicht generische Schnittstelle verwenden, kann der C#-Compiler Ihren Code nicht typüberprüfen. Beim Umgang mit Sammlungen primitiver Werttypen führt die Verwendung einer nicht generischen Sammlung zu wiederholtem Boxen/Unboxing dieser Typen, was im Vergleich zu einer generischen Sammlung des entsprechenden Typs zu erheblichen negativen Auswirkungen auf die Leistung führen kann.
Ein weiteres häufiges C#-Problem besteht darin, ein eigenes Sammlungsobjekt zu schreiben. Das soll nicht heißen, dass es nie angemessen ist, aber mit einer so umfassenden Auswahl, wie sie .NET bietet, können Sie wahrscheinlich viel Zeit sparen, indem Sie eine bereits vorhandene verwenden oder erweitern, anstatt das Rad neu zu erfinden. Insbesondere die C5 Generic Collection Library für C# und CLI bietet eine breite Palette zusätzlicher Sammlungen „out of the box“, wie z. B. persistente Baumdatenstrukturen, heapbasierte Prioritätswarteschlangen, Hash-indizierte Array-Listen, verknüpfte Listen und vieles mehr.
Häufiger C#-Programmierfehler Nr. 8: Vernachlässigung freier Ressourcen
Die CLR-Umgebung verwendet einen Garbage Collector, sodass Sie den für ein Objekt erstellten Speicher nicht explizit freigeben müssen. Tatsächlich können Sie nicht. Es gibt kein Äquivalent zum C++- delete
oder zur free()
Funktion in C. Aber das bedeutet nicht, dass Sie alle Objekte einfach vergessen können, nachdem Sie mit ihnen fertig sind. Viele Arten von Objekten kapseln eine andere Art von Systemressource ein (z. B. eine Plattendatei, eine Datenbankverbindung, ein Netzwerk-Socket usw.). Das Offenlassen dieser Ressourcen kann die Gesamtzahl der Systemressourcen schnell erschöpfen, die Leistung beeinträchtigen und letztendlich zu Programmfehlern führen.
Während eine Destruktormethode für jede C#-Klasse definiert werden kann, besteht das Problem mit Destruktoren (in C# auch Finalizer genannt) darin, dass Sie nicht sicher wissen können, wann sie aufgerufen werden. Sie werden vom Garbage Collector (in einem separaten Thread, der zusätzliche Komplikationen verursachen kann) zu einem unbestimmten Zeitpunkt in der Zukunft aufgerufen. Der Versuch, diese Einschränkungen zu umgehen, indem die Garbage Collection mit GC.Collect()
wird, ist keine bewährte Methode für C#, da dies den Thread für eine unbekannte Zeit blockiert, während er alle für die Sammlung in Frage kommenden Objekte sammelt.
Das soll nicht heißen, dass Finalizer nicht sinnvoll eingesetzt werden können, aber das Freigeben von Ressourcen auf deterministische Weise gehört nicht dazu. Wenn Sie mit einer Datei-, Netzwerk- oder Datenbankverbindung arbeiten, möchten Sie vielmehr die zugrunde liegende Ressource explizit freigeben, sobald Sie damit fertig sind.
Ressourcenlecks sind in fast jeder Umgebung ein Problem. C# bietet jedoch einen robusten und einfach zu verwendenden Mechanismus, der bei Verwendung dazu führen kann, dass Lecks viel seltener auftreten. Das .NET-Framework definiert die IDisposable
Schnittstelle, die ausschließlich aus der Dispose()
Methode besteht. Jedes Objekt, das IDisposable
implementiert, erwartet, dass diese Methode immer dann aufgerufen wird, wenn der Konsument des Objekts mit der Bearbeitung fertig ist. Dies führt zu einer expliziten, deterministischen Freigabe von Ressourcen.
Wenn Sie ein Objekt im Kontext eines einzelnen Codeblocks erstellen und verwerfen, ist es im Grunde unentschuldbar, den Aufruf von Dispose()
zu vergessen, da C# eine using
-Anweisung bereitstellt, die sicherstellt, dass Dispose()
unabhängig vom Codeblock aufgerufen wird beendet wird (sei es eine Ausnahme, eine return-Anweisung oder einfach das Schließen des Blocks). Und ja, das ist dieselbe zuvor erwähnte using
-Anweisung, die verwendet wird, um C#-Namespaces am Anfang Ihrer Datei einzufügen. Es hat einen zweiten, völlig unabhängigen Zweck, dessen sich viele C#-Entwickler nicht bewusst sind; nämlich um sicherzustellen, dass Dispose()
für ein Objekt aufgerufen wird, wenn der Codeblock verlassen wird:
using (FileStream myFile = File.OpenRead("foo.txt")) { myFile.Read(buffer, 0, 100); }
Indem Sie im obigen Beispiel einen using
-Block erstellen, wissen Sie sicher, dass myFile.Dispose()
aufgerufen wird, sobald Sie mit der Datei fertig sind, unabhängig davon, ob Read()
eine Ausnahme auslöst oder nicht.
Häufiger C#-Programmierfehler Nr. 9: Vor Ausnahmen zurückschrecken
C# setzt die Durchsetzung der Typsicherheit in der Laufzeit fort. Dadurch können Sie viele Arten von Fehlern in C# viel schneller lokalisieren als in Sprachen wie C++, wo fehlerhafte Typkonvertierungen dazu führen können, dass den Feldern eines Objekts willkürliche Werte zugewiesen werden. Aber auch hier können Programmierer dieses großartige Feature vergeuden, was zu C#-Problemen führt. Sie tappen in diese Falle, weil C# zwei verschiedene Möglichkeiten bietet, Dinge zu tun, eine, die eine Ausnahme auslösen kann, und eine, die dies nicht tut. Einige werden vor der Exception-Route zurückschrecken, weil sie sich vorstellen, dass ihnen das Schreiben eines try/catch-Blocks einiges an Codierung erspart.
Hier sind beispielsweise zwei verschiedene Möglichkeiten, eine explizite Typumwandlung in C# durchzuführen:
// METHOD 1: // Throws an exception if account can't be cast to SavingsAccount SavingsAccount savingsAccount = (SavingsAccount)account; // METHOD 2: // Does NOT throw an exception if account can't be cast to // SavingsAccount; will just set savingsAccount to null instead SavingsAccount savingsAccount = account as SavingsAccount;
Der offensichtlichste Fehler, der bei der Verwendung von Methode 2 auftreten könnte, wäre ein Versäumnis, den Rückgabewert zu überprüfen. Das würde wahrscheinlich zu einer eventuellen NullReferenceException führen, die möglicherweise zu einem viel späteren Zeitpunkt auftaucht und es viel schwieriger macht, die Ursache des Problems aufzuspüren. Im Gegensatz dazu hätte Methode 1 sofort eine InvalidCastException
wodurch die Ursache des Problems viel deutlicher erkennbar wäre.
Außerdem, selbst wenn Sie daran denken, den Rückgabewert in Methode 2 zu überprüfen, was werden Sie tun, wenn Sie feststellen, dass er null ist? Ist die Methode, die Sie schreiben, ein geeigneter Ort, um einen Fehler zu melden? Gibt es etwas anderes, das Sie versuchen können, wenn diese Besetzung fehlschlägt? Wenn nicht, dann ist das Auslösen einer Ausnahme das Richtige, also können Sie es genauso gut so nah wie möglich an der Quelle des Problems geschehen lassen.
Hier sind ein paar Beispiele für andere gängige Methodenpaare, bei denen eine eine Ausnahme auslöst und die andere nicht:
int.Parse(); // throws exception if argument can't be parsed int.TryParse(); // returns a bool to denote whether parse succeeded IEnumerable.First(); // throws exception if sequence is empty IEnumerable.FirstOrDefault(); // returns null/default value if sequence is empty
Einige C#-Entwickler sind so „ausnahmefeindlich“, dass sie automatisch davon ausgehen, dass die Methode, die keine Ausnahme auslöst, überlegen ist. Während es bestimmte ausgewählte Fälle gibt, in denen dies zutreffen mag, ist es als Verallgemeinerung überhaupt nicht korrekt.
Als spezifisches Beispiel könnte in einem Fall, in dem Sie eine alternative legitime (z. B. standardmäßige) Aktion zu ergreifen hätten, wenn eine Ausnahme erzeugt worden wäre, der Nicht-Ausnahme-Ansatz eine legitime Wahl sein. In einem solchen Fall könnte es tatsächlich besser sein, so etwas zu schreiben:
if (int.TryParse(myString, out myInt)) { // use myInt } else { // use default value }
anstatt:
try { myInt = int.Parse(myString); // use myInt } catch (FormatException) { // use default value }
Es ist jedoch falsch anzunehmen, dass TryParse
deshalb zwangsläufig die „bessere“ Methode ist. Manchmal ist das so, manchmal nicht. Deshalb gibt es zwei Möglichkeiten, dies zu tun. Verwenden Sie die richtige für den Kontext, in dem Sie sich befinden, und denken Sie daran, dass Ausnahmen sicherlich Ihr Freund als Entwickler sein können.
Häufiger C#-Programmierfehler Nr. 10: Zulassen, dass sich Compiler-Warnungen ansammeln
Obwohl dieses Problem definitiv nicht C#-spezifisch ist, ist es in der C#-Programmierung besonders ungeheuerlich, da es die Vorteile der strengen Typprüfung aufgibt, die der C#-Compiler bietet.
Warnungen werden aus einem bestimmten Grund generiert. Während alle C#-Compilerfehler auf einen Fehler in Ihrem Code hindeuten, gilt dies auch für viele Warnungen. Was die beiden unterscheidet, ist, dass der Compiler im Falle einer Warnung kein Problem damit hat, die Anweisungen auszugeben, die Ihr Code darstellt. Trotzdem findet es Ihren Code ein wenig faul, und es besteht eine vernünftige Wahrscheinlichkeit, dass Ihr Code Ihre Absicht nicht genau widerspiegelt.
Ein allgemeines einfaches Beispiel für dieses C#-Programmiertutorial ist, wenn Sie Ihren Algorithmus ändern, um die Verwendung einer von Ihnen verwendeten Variablen zu eliminieren, aber vergessen, die Variablendeklaration zu entfernen. Das Programm wird einwandfrei laufen, aber der Compiler wird die nutzlose Variablendeklaration markieren. Die Tatsache, dass das Programm einwandfrei läuft, führt dazu, dass Programmierer es versäumen, die Ursache der Warnung zu beheben. Darüber hinaus profitieren Programmierer von einer Visual Studio-Funktion, die es ihnen erleichtert, die Warnungen im Fenster „Fehlerliste“ auszublenden, sodass sie sich nur auf die Fehler konzentrieren können. Es dauert nicht lange, bis es Dutzende von Warnungen gibt, die alle glücklicherweise ignoriert (oder noch schlimmer, versteckt) werden.
Aber wenn Sie diese Art von Warnung ignorieren, kann so etwas früher oder später sehr wohl seinen Weg in Ihren Code finden:
class Account { int myId; int Id; // compiler warned you about this, but you didn't listen! // Constructor Account(int id) { this.myId = Id; // OOPS! } }
Und bei der Geschwindigkeit, mit der Intellisense uns Code schreiben lässt, ist dieser Fehler nicht so unwahrscheinlich, wie es aussieht.
Sie haben jetzt einen schwerwiegenden Fehler in Ihrem Programm (obwohl der Compiler ihn aus den bereits erläuterten Gründen nur als Warnung gekennzeichnet hat), und je nachdem, wie komplex Ihr Programm ist, könnten Sie viel Zeit damit verschwenden, diesen Fehler zu finden. Hätten Sie diese Warnung überhaupt beachtet, hätten Sie dieses Problem mit einer einfachen fünfsekündigen Lösung vermieden.
Denken Sie daran, dass der C-Sharp-Compiler Ihnen viele nützliche Informationen über die Robustheit Ihres Codes gibt … wenn Sie zuhören. Ignorieren Sie Warnungen nicht. Die Behebung dauert normalerweise nur wenige Sekunden, und wenn Sie neue beheben, wenn sie auftreten, können Sie Stunden sparen. Üben Sie sich darin, zu erwarten, dass das Fenster „Fehlerliste“ von Visual Studio „0 Fehler, 0 Warnungen“ anzeigt, sodass Sie sich bei allen Warnungen so unwohl fühlen, dass Sie sie sofort ansprechen.
Natürlich gibt es Ausnahmen von jeder Regel. Dementsprechend kann es vorkommen, dass Ihr Code für den Compiler etwas faul aussieht, obwohl er genau so ist, wie Sie es beabsichtigt haben. Verwenden Sie in diesen sehr seltenen Fällen #pragma warning disable [warning id]
nur um den Code herum, der die Warnung auslöst, und nur für die Warnungs-ID, die sie auslöst. Dadurch wird diese Warnung unterdrückt, und zwar nur diese Warnung, sodass Sie weiterhin auf neue Warnungen achten können.
Einpacken
C# ist eine leistungsstarke und flexible Sprache mit vielen Mechanismen und Paradigmen, die die Produktivität erheblich verbessern können. Wie bei jedem Software-Tool oder jeder Sprache kann ein begrenztes Verständnis oder eine begrenzte Wertschätzung ihrer Fähigkeiten manchmal eher ein Hindernis als ein Vorteil sein und einen in dem sprichwörtlichen Zustand zurücklassen, „genug zu wissen, um gefährlich zu sein“.
Die Verwendung eines C-Sharp-Tutorials wie diesem, um sich mit den wichtigsten Nuancen von C# vertraut zu machen, wie (aber keineswegs beschränkt auf) die in diesem Artikel angesprochenen Probleme, hilft bei der C#-Optimierung und vermeidet gleichzeitig einige der häufigeren Fallstricke des Sprache.
Weiterführende Literatur im Toptal Engineering Blog:
- Grundlegende C#-Interviewfragen
- C# vs. C++: Was ist der Kern?