PłuapkiPyt NR.1 Pamiętam swoje pierwsze przygody z językiem C# i swoje błędy. Pamiętam jak z kolegą pisaliśmy algorytm ewolucyjny i zastanawialiśmy się dlaczego mamy przepełnienie stosu. Zdecydowanie wtedy obaj o czymś zapomnieliśmy.  Często zdarzało mi się zapomnieć, że istnieją inne kolekcję w C# niż tablice. Korzystałem więc z tablic bo przy nich czułem się bezpiecznie i to one były najczęściej używane na zajęciach.

Jak widać nie byłem takim zdolnym programistą w czasach studenckich. Postanowiłem więc zrobić listę pospolitych pomyłek nowicjusza.

Błąd numer 1: Używanie złej kolekcji

C# posiada ogromną ilość kolekcji do dyspozycji. Oto ich lista:

  • Array
  • ArrayList
  • BitArray
  • BitVectory32
  • Dictionary<T1,T2>
  • HashTable
  • HybridDictionary
  • List<T>
  • NameValueCollection
  • OrderedDictionary
  • Queque
  • Queque<T>
  • ConcurrentQueue<T>
  • SortedList
  • Stack
  • Stack<T>
  • ConcurrentStack<T>
  • StringCollection
  • StringDictionary
  • ReadOnlyCollection

C# możliwości jest wiele. C# jest przykładem, że jednak lepiej jest mieć większy wybór. Każda kolekcja w C# została stworzona w jakimś celu. Warto to wykorzystać. Przykładowo, jeśli chcesz mieć pewność, że twoja kolekcja będzie tylko do odczytu warto skorzystać z ReadOnlyCollection.

Jeśli wiesz, że twoja kolekcja skupi się na napisach czy bitach, to warto sprawdzić czy nie istnieje specjalna kolekcja zoptymalizowana do tego celu. Pamiętaj jednak, że decyzja należy do ciebie i w tej kwestii optymalizacja być może nie będzie warta specyficznej kolekcji.

O czym jednak warto pamiętać? No o tym, że mamy do dyspozycji kolekcje generyczne. <T> w parametrze generycznym deklarujesz typ obiektu, który będzie przetrzymywany w kolekcji.

Gdy używasz niegenerycznych kolekcji, wtedy kompilator, który skanuje twój kod nie jest w stanie sprawdzić czy typ, którym się obsługujesz jest tym prawidłowym. Poza tym w kolekcjach niegenerycznych pojawia się problem z pakowaniem i wypakowywaniem wartości, co skutkuje negatywnie na optymalizację.

Na studiach wszyscy wykładowcy mają obsesję na punkcie tablic. O wykładach na temat tablic wielowymiarowych słyszałem wiele razy, tylko po to, aby pokazać, że w ten sposób można policzyć macierz. Nie zmienia to faktu, że tablice w jakiejkolwiek formie to zła praktyka ze względu na ustalaną wielkość. Przyznam się jednak, że pisząc swoją pierwszą stronę w ASP.NET korzystałem z tablicy wielowymiarowej do przetrzymywania pewnych danych. Zgodnie z zajęciami wydawało się to spoko, ale w prawdziwym życiu wydawało się to koszmarem. Lepiej było utworzyć listę obiektów pewnej mojej klasy. Nie było tego na zajęciach, ale tak się robi.

Kolejnym pospolitym błędem jest pisanie swojej własnej kolekcji. Widziałem raz taki kod w poważnej firmie i stwierdziłem, że tworzy to zdecydowanie więcej problemów niż ich rozwiązuje. W .NET masz do dyspozycji tyle kolekcji, że taki problem nie powinien nigdy wystąpić. 

Błąd numer 2 : Nie rozumienie wyjątków i po co jest try-catch

W C# zawsze istnieje gwarancja, że dany typ obiektu jest zalokowany w pewnej zmiennej. W C# łatwiej jest zlokalizować błędy niż w języku C++. Podziękować można wyjątkom, które zazwyczaj jasno określają, co jest nie tak w naszej aplikacji.

Gdy ten wyjątek wystąpi zawsze warto zadbać o to, aby ten wyjątek w jakiś sposób został zakomunikowany, albo w przypadku krytycznego błędu wyrzucony do aplikacji.

Jeśli chodzi o błędy to zdarzyło mi się podczas studiów widzieć aplikację studencką, która przechwytywała wszystko nawet w głównej pętli aplikacji. Problem się jednak pojawił, gdy aplikacja nie działała, a ja jako konsultant nie byłem w stanie ustalić dlaczego tak się dzieje, bo aplikacja nie wyrzuca, żadnych wyjątków.

Fatalny błąd polega więc na przechwyceniu wyjątku i nie wysłaniu żadnej informacji zwrotnej, że taka sytuacja zaszła.

try
{

}
catch (Exception)
{
    //NIC
}

Przez taką łatkę możesz potem spędzić wiele godzin na zastanawianiu się dlaczego coś nie działa, ale dla aplikacji wszystko jest porządku. Widać, że przechwycenie wyjątków to duża odpowiedzialność.

Warto też być świadomym tego, jakie wyjątki w kodzie mogą wystąpić i jak im zaradzać.

public static void Method(object a)
{
    Point p1 = (Point)a;

    Point p2 = a as Point;
}

Przykładowo w tym kodzie mogą wystąpić dwa wyjątki. Jeden będzie polegał na nieprawidłowym rzutowaniu, drugi może polegać na braku referencji.

InvalidCastException

Wyrzucanie swoich wyjątków moim zdaniem jest tylko zalecane, gdy chcemy bardziej sprecyzować inny wyjątek, który i tak by wystąpił. Przykładowo do metody ktoś mógłby wysłać wartość bez referencji. Ja w swoim wyjątku bardziej bym określił co poszło nie tak.

public static void Method(object a)
{
    if (a == null)
        throw new InvalidOperationException("Zmienna a jest null");

W innych przypadkach wyrzucanie swoich wyjątków bądź nawet tworzenie nowych nie ma sensu.

Warto też pamiętać o tym, że try-catch nie powinno nam służyć jako mechanizm kontroli przepływu aplikacji. Najlepiej to wyjaśnić przy kodzie parsowania.

string mystr = "1";
int number;

if (int.TryParse(mystr, out number))
{
    // number
}
else
{
    //domyślna wartość
    number = 11;
}

Taki kod jest bardziej czytelny i akceptowany niż taki.

try
{
    number = int.Parse(mystr);
    // number
}
catch (FormatException)
{
    number = 11;
}

Ogólnie używanie bloku try-catch do kontrolowania przepływu aplikacji jest czymś czego nie powinno się robić. Warto też zaznaczyć, że jak już wyjątek wystąpi, to nie powinien być wyrzucony dalej w taki sposób, ponieważ tracimy w ten sposób informację o stosie. Stos daje nam informację o tym jak cała aplikacja przepływała. Bez stosu wiesz tylko, że wyjątek wystąpił, ale nie wiesz gdzie.

throw ex

Dlatego, jeśli przechwyciłeś wyjątek wyrzucić go w taki sposób.

throw

Jak widać przy użyciu try-catch można popełnić wiele błędów.

Błąd numer 3 : Mylenie typu wartościowego z referencyjnym

Pamiętasz tę historię na początku wpisu. Wielki wstyd, ale nasz algorytm ewolucyjny przepełniał  stos ponieważ zapomnieliśmy o tym jak typy referencyjne działają.

Po pierwsze początkujący programiści zapominają o domyślnych wartościach, które występują dla typów wartościowych. W tym kodzie mamy klasę z liczbą całkowitą, napisem i swoja klasą.

class ClassDefaultTest
{
    public static int defaultOne;
    public static string defaultTwo;
    public static SomeClass defaultThree;
}

public class SomeClass
{
    public static int Value;
}

Oczywiste jest to, że dla typów wartościowych domyślna wartość zostanie ustawiona. Wydaje się to oczywiste, ale nie dla kogoś, kto stawia pierwsze kroki w C#. Może jednak pojawić się wielkie zaskoczenie, gdy ktoś może założyć, że dla klas zostanie uruchomiony ich domyślnych konstruktor. W końcu dlaczego dla liczby całkowitej ustawiło się zero.

ClassDefault

Wróćmy jednak do bardziej popularnego błędu. Dla typów wartościowych automatycznie zachodzi kopiowanie wartości. Oznacza to, że liczby a i b są od siebie niezależne.

int a = ClassDefaultTest.defaultOne;
int b = ClassDefaultTest.defaultOne;
a = a + 2;
b = b + 6;

ClassDefaultTest.defaultThree = new SomeClass();

SomeClass sc1 = ClassDefaultTest.defaultThree;
SomeClass sc2 = ClassDefaultTest.defaultThree;

sc1.Value = 2;
sc2.Value = 3;

Dla typów referencyjnych do zmiennej przesyłasz referencję. Oznacza to, że te dwie zmienne referują się do tego samego obiektu. Zmiana w jednej zmiennej wpłynie więc na zmiany wszystkich innych zmiennych, ponieważ one wszystkie się referują do tego samego obiektu.

Typ wartościwy a referecyjny

To jednak nie wszystko. Pamiętaj o strukturach. Co prawda w C# występują one rzadko, ale w aplikacjach jak Windows Forms praca nad strukturą Point występuje dosyć często.

class ClassDefaultTest
{
    public static Point defaultThree;
}

public struct Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

Struktura pomimo podobnych kolorów składniowych jak klasa, nie jest klasą i nie jest typem referencyjnym. Jest ona typem wartościowym. Utworzenie więc nowego punktu i przesyłanie go pomiędzy zmiennymi kopiuje go.

ClassDefaultTest.defaultThree = new Point();

Point sc1 = ClassDefaultTest.defaultThree;
Point sc2 = ClassDefaultTest.defaultThree;

sc1.X = 2;
sc2.X = 3;

Jak widać to na poniższym obrazku.

Typ wartościwy a referecyjny Struktura

Istnieje więc duża różnica pomiędzy typem wartościowym a typem referencyjnym i jak on działa.

Błąd numer 4 : Porównanie napisów

Porównywanie napisów to prawdziwy ból głowy. Będę szczery, ale teraz to wszystko robię na poziomie intuicyjnym i jak do tej pory nie myślałem o tym tak mocno.

Zazwyczaj programiści do porównywania napisów używają operatora ==. Jest to jednak najmniej pożądany sposób na porównywanie napisów. Nie określa on dokładnie jak chcesz, aby ten napis był porównywany. Na pomoc rusza metoda Equals i jej drugie przeciążenie.

public bool Equals(string value);
public bool Equals(string value, StringComparison comparisonType);

Dlaczego jest to ważne. Po pierwsze musisz wiedzieć czego chcesz. Operator == i metoda Equals porówna napisy według domyślnych zasad. Być może osoba, która czyta kod będzie myślała, że chciałeś czegoś zupełnie innego.

Oto przykład numer 1. Wielkość liter.

string s0 = "A";

Console.WriteLine(s0 == "a"); //false
Console.WriteLine(s0.Equals("a")); //false
Console.WriteLine(s0.Equals("a",
    StringComparison.Ordinal)); //false

Console.WriteLine(s0.Equals("a",
    StringComparison.CurrentCulture)); //false

Console.WriteLine(s0.Equals("a",
    StringComparison.OrdinalIgnoreCase)); //true

Console.WriteLine(s0.Equals("a",
    StringComparison.CurrentCultureIgnoreCase)); //true

Console.WriteLine(s0.Equals("a",
    StringComparison.InvariantCulture)); //false

Console.WriteLine(s0.Equals("a",
    StringComparison.InvariantCultureIgnoreCase)); //true

Console.ReadKey();
Console.Clear();

Jak widzisz wielkość liter ma wpływ na porównywanie. Jeśli to nie jest twoim celem warto wybrać opcję IgnoreCase. Jak widać w takich wypadkach metoda zwraca prawdę.

Sprawa bardziej się komplikuje, gdyż wpływ na to czy napisy są sobie równe ma także kultura. Dlatego zazwyczaj w kodzie dla bezpieczeństwa wybierałem opcję InvariantCulture tak, aby nie zdarzały się takie kwiatki.

Nie powiem tak myślałem w swojej karierze, ale być może to nie jest to, czego chcesz. Czasem warto zastanowić się czy porównywanie bit po bicie nie jest tym właściwym porównaniem. Tak domyślnie jest porównywany napis w metodzie Equals.

Litera ß występuje w alfabecie niemieckim i jest ona określona jako podwójne ss. Oznacza to, że traktowanie tej litery jako podwójne ss może być prawidłowe, bądź błędne w zależności od tego, czego chcesz. Jedno jest pewne, jeśli chcesz porównywać gołe napisy wybieraj opcję Ordinal.

string s = "ß";

Console.WriteLine(s == "ss"); //false
Console.WriteLine(s.Equals("ss")); //false
Console.WriteLine(s.Equals("ss", 
    StringComparison.Ordinal)); //false

Console.WriteLine(s.Equals("ss", 
    StringComparison.CurrentCulture)); //true

Console.WriteLine(s.Equals("ss", 
    StringComparison.OrdinalIgnoreCase)); //false

Console.WriteLine(s.Equals("ss", 
    StringComparison.CurrentCultureIgnoreCase)); //true

Console.WriteLine(s.Equals("ss",
    StringComparison.InvariantCulture)); //true

Console.WriteLine(s.Equals("ss",
    StringComparison.InvariantCultureIgnoreCase)); //true

Console.ReadKey();
Console.Clear();

Z ciekawości sprawdziłem łacińską literę æ, która jest ligaturą liter a i e. Wynik jest taki sam.

string s1 = "æ";

Console.WriteLine(s1 == "ae"); //false
Console.WriteLine(s1.Equals("ae")); //false
Console.WriteLine(s1.Equals("ae",
    StringComparison.Ordinal)); //false

Console.WriteLine(s1.Equals("ae",
    StringComparison.CurrentCulture)); //true

Console.WriteLine(s1.Equals("ae",
    StringComparison.OrdinalIgnoreCase)); //false

Console.WriteLine(s1.Equals("ae",
    StringComparison.CurrentCultureIgnoreCase)); //true

Console.WriteLine(s1.Equals("ae",
StringComparison.InvariantCulture)); //true Console.WriteLine(s1.Equals("ae",
StringComparison.InvariantCultureIgnoreCase)); //true Console.ReadKey();

Jak widzisz sprawy są trochę skomplikowane, ale wszystko można streścić do trzech punktów:

  • Gdy porównujesz napisy wysłane przez użytkownika i są one wrażliwe kulturowo, to do porównywania używaj “CurrentCulture” i “CurrentCultureIgnoreCase”.
  • Gdy porównujesz napisy od strony programistycznej używaj Ordinal albo OrdinalIgnoreCase
  • InvariantCulture i InvariantCulutureIgnoreCase jak się okazuje nie są takie idealne jak mój szef mi polecał w pierwszej pracy, gdy miałem problem z porównywaniem napisów. Dla bezpieczeństwa lepiej stosować porównywanie Ordinal.

Błąd numer 5 : Uwolnienie zasobów

Środowisko CLR posiada swój Garbage Collector, który sprząta nieużywane obiekty z pamięci. Nie musisz więc tego jawnie robić. Właściwie to nawet nie możesz. C# nie ma operatora delete z C++ ani funkcji free z C.

Jako student myślałem,  że zawsze pamięć jest obsługiwana przez środowisko, ale nie zawsze tak jest. Pamiętam swoje zdziwienie, gdy pisaliśmy kod, który tworzył i otwierał obrazy bitowe. Istnieje wiele typów obiektów, które reprezentują pliki na dysku, połączenia bazodanowe, połączenia sieciowe i one muszą zostać zwolnione ze swojej funkcji.

Nie robienie tego na dłuższą metę skutkuje działaniem losowym aplikacji oraz przepełnieniem pamięci.

Te specjalne obiekty definiują interfejs IDisposable, który wymusza na nich implementacja metody Dispose. Oto przykład użycia tej metody. Plik zostaje otwarty, a później bez względu na to, czy coś poszło nie tak czy nie, źródło jest likwidowane.

string line;
StreamReader reader = null;
try
{
    reader = new StreamReader("file.txt");
    line = reader.ReadLine();
}
finally
{
    if (reader != null)
        reader.Dispose();
}

Składnia ta jest trochę skomplikowana dlatego C# daje nam do dyspozycji słowo kluczowe using, która robi dokładnie to samo.

string line;
using (StreamReader reader = new StreamReader("file.txt"))
{
    line = reader.ReadLine();
}
Console.WriteLine(line);

Oto przykład z połączeniem do bazy.

SqlCeConnection conn;
using (conn = new SqlCeConnection(ConnectionString))
{
   conn.Open();
   using (SqlCeCommand cmd = 
       new SqlCeCommand("SELECT Name FROM Persons", conn))
   {

   }
}

Jak i jego odpowiednikiem. W tym przypadku jednak połączenie jest zamykane, ale filozofia jest dokładnie taka sama.

try
{
    conn = new SqlCeConnection(ConnectionString);
    conn.Open();

    SqlCeCommand cmd = conn.CreateCommand();
    cmd.CommandText = "SELECT Name FROM Persons";

    cmd.ExecuteNonQuery();
}
finally
{
    conn.Close();
}

Podsumowanie

C# jest potężnym i elastycznym językiem z wieloma mechanizmami. Mam nadzieję, że jako początkujący programista nie będziesz popełniał tych błędów.

Pułapek jest dużo więcej, ale może napiszę o nich kiedy indziej.