Obiekty w C# i VB.NET

Dwa główne języki platformy .NET są obiektowe. Mimo że różnią się składnią, pewne podstawowe pojęcia i koncepcje są identyczne - zarówno w C#, jak i VB.NET. A dzięki wspólnej platformie uruchomieniowej - system informatyczny bez trudności może łączyć moduły napisane w obu tych językach oraz w dowolnym innym języku zgodnym z CLS.

Dwa główne języki platformy .NET są obiektowe. Mimo że różnią się składnią, pewne podstawowe pojęcia i koncepcje są identyczne - zarówno w C#, jak i VB.NET. A dzięki wspólnej platformie uruchomieniowej - system informatyczny bez trudności może łączyć moduły napisane w obu tych językach oraz w dowolnym innym języku zgodnym z CLS.

Jak w każdym języku obiektowym - w C# i w VB.NET można deklarować klasy, dziedziczyć po nich, tworzyć instancje, przesłaniać składowe w strukturach potomnych itp.

Klasa może zawierać następujące elementy:

  • pola (zmienne) ustalonego typu,
  • procedury (elementy, które nie zwracają wartości),
  • funkcje (elementy zwracające wartość),
  • właściwości (elementy, które pozwalają kontrolować dostęp do wartości "zawartych" w klasie),
  • delegaty (wskaźniki do funkcji).

Procedury i funkcje to pojęcia z VB.NET. W C# funkcja, która zwraca typ void, nie zwraca w rzeczywistości niczego i pełni funkcję procedury.

W .NET są dwa typy do definicji klas. Klasa może być deklarowana jak zwykła struktura (z użyciem słowa kluczowego struct). Może też być "pełnoprawną" klasą (deklaracja z użyciem słowa kluczowego class). Różnica polega na tym, że instancja obiektu zadeklarowanego jako class jest zawsze tworzona na stercie, a w wypadku struct możliwe jest tworzenie obiektu na stosie. Różnica polega również na tym, że elementy umieszczane na stosie mogą być znacznie szybciej kasowane (i tworzone). Ale struktura, jak klasa, może mieć metody, konstruktor itp.

Na podstawie definicji klasy może powstać instancja obiektu, czyli jego wystąpienie.

W klasie można zdefiniować metodę o tej samej nazwie, co nazwa klasy - pełni ona funkcję konstruktora. Gdy tworzymy obiekt (używając operatora new), właściwie wywołujemy tę funkcję. W ten sposób można np. wymagać, aby klasa opisująca klienta zawsze podczas tworzenia miała zainicjowane ID:

1 class Customer {

2 ...

3 public Customer(string id, string name, string surName) {...}

4 public Customer(string id) {...}

5 }

Konstruktorów może być, oczywiście, kilka - z różnymi zestawami parametrów. Można np. przyjąć, że konstruktor z wiersza 3 tworzy nowego klienta, a z wiersza 4, sprawdza, czy w bazie danych znajduje się wpis o danym ID - i jeżeli tak, to odpowiednio inicjuje pola w klasie.

Klasy mogą być ze sobą powiązane - mogą być dziedziczone, a część funkcjonalności może być przesłonięta. Mogą też być używane jako wynik działania funkcji czy parametr. Jednak - w .NET można stosować tylko "pojedyncze" dziedziczenie - innymi słowy, klasa może mieć tylko jednego rodzica (inaczej jest w C+ + , gdzie tzw. dziedziczenie wielobazowe jest dozwolone, ale rzadko stosowane). W .NET wprowadzono bardzo wygodny mechanizm - deklarowania tzw. interfejsów. Jest to zestaw funkcji, które klasa musi mieć (mówi się wtedy, że implementuje dany interfejs). Interfejs jest idealnym narzędziem do implementacji określonych typów zachowań. Klasa może dziedziczyć po innej klasie i dodatkowo implementować dowolną liczbę interfejsów. Na przykład IComparable określa jak porównywane będą obiekty danego typu, a ISerializable - własne mechanizmy serializacji. Co więcej, łatwo sprawdzić, czy klasa implementuje dany interfejs, i np. wymagać, aby w określonych sytuacjach była używana tylko klasa, która ma pewien zestaw interfejsów - bo wtedy wiadomo, że może wykonać określone operacje.

W VB.NET kod definicji interfejsu może mieć następującą postać:

Interface I2

Function FI2() As Integer

End Interface

Oznacza on, że jeżeli klasa implementuje interfejs I2, to musi mieć funkcję FI2 zwracającą liczbę typu Integer. Interfejs może zawierać: właściwości, zdarzenia, metody. Nie może zawierać pól (składowych) klasy. W kodzie C#:

interface I2 {

int FI2();

int Prop { get; set; };

int Info { get; };

dodatkowo określono, że klasa musi mieć dwie właściwości (omówione w dalszej części artykułu): Prop do odczytu i zapisu oraz Info tylko do odczytu.

Składowe klasy mogą mieć różny zakres widoczności - np. do składowych prywatnych może odwołać się tylko inna metoda klasy, ale już nie kod, który utworzył instancję danej klasy. Pozwala to ukryć pewne szczegóły implementacyjne przed wywołującym - zapewnić hermetyzację. Załóżmy, że mamy następujące klasy w C#:

using System;

public class ParentInCS {

public void ParentSomething() {

Console.WriteLine("CSParent");

}

public virtual void PrintSomething() {

Console.WriteLine("CSParent");

}

public

class ChildInCS : ParentInCS, IDisposable {

public void Dispose() { }

public int MyOwnFunction() {

return 1;

}

public override void PrintSomething() {

Console.WriteLine("CSChild");

}

}

Aby utworzyć instancję takiej klasy, można napisać:

ParentInCS p = new ParentInCS();

Następnie można wywołać metodę:

p.PrintSomething();

Klasa ChildInCS dziedziczy po ParentInCS i przestania funkcję PrintSomething. Dodatkowo implementuje interfejs IDisposable (o którym kilka stów można znaleźć w części "Garbage collector". Proszę zauważyć, że w kodzie należy wskazać zarówno to, które funkcje można przesłaniać (dodając stówo kluczowe virtual), jak i to, która funkcja przestania (stówo override).

Jeżeli użyjemy następującego kodu:

ChildInCS c = new ChildInCS();

c.PrintSomething();

c.ParentSomething();

c.MyOwnFunction();

to zostanie wywołana przesłonięta funkcja PrintSomething. Dodatkowo możemy sprawdzić, czy obiekt c implementuje jakiś interfejs, a jeżeli tak, to zrzutować obiekt na interfejs i używając zmiennej, która jest typu IDisposable, wywołać funkcję. (Oczywiście można bezpośrednio wywołać Dispose, ale ten przykład pokazuje, jak postępować, gdy nie znamy dokładnego typu obiektu, a chcemy tylko odwołać się do pewnego interfejsu, który powinien być zaimplementowany):

if (c is IDisposable) {

IDisposable d= c;

d.Dispose();

}

W VB.NET kod jest bardzo podobny:

Public Class ParentInVB

Public Overridable Sub PrintSomething()

Console.WriteLine("VBParent")

End Sub

Public Sub ParentSomething()

Console.WriteLine("VBParent")

End Sub

End Class

Public Class ChildInVB

Inherits ParentInVB

Implements IDisposable

Public Overloads Sub Dispose() Implements

IDisposable.Dispose

'Nie mamy zasobów niezarzadzanych

End Sub

Public Function MyOwnFunction() As Integer

Return 1

End Function

Public Overrides Sub PrintSomething()

Console.WriteLine("VBChild")

End Sub

End Class

VB rozróżnia, czy klasa dziedziczy (wtedy używane jest słowo kluczowe Inherits), czy też implementuje interfejs (Implements). Jeżeli ponadto metoda stanowi część implementacji interfejsu, musi jawnie wskazać, co dokładnie implementuje (właściwie jest to wymaganie .NET i MSIL validator, sprawdzającego poprawność typów - w C# kompilator automatycznie dodaje odpowiednie sygnatury do metody). Użycie klas z poziomu VB.NET także różni się jedynie składnią:

Dim parent As New ParentInVB parent.PrintSomething()

Dim child As New ChildInVB

child.PrintSomething()

child.ParentSomething()

child.MyOwnFunction()

If TypeOf cild Is IDisposable Then

Dim i As IDisposable

i = child

i.Dispose()

End If

Dzięki wspólnemu systemowi typów możliwe jest dziedziczenie klas pomiędzy językami, w innych środowiskach bardzo trudne bądź niemożliwe. Jeżeli zostaną dodane odpowiednie referencje pomiędzy projektami (czy też wskazany plik DLL), wtedy można napisać (w C#):

public class ChildInCS : ParentInVB, IDisposable {...}

W .NET podstawowy element, który zwiera kod MSIL, to pakiet. Każda klasa (czy funkcja) jest zawarta w pewnej przestrzeni nazw. Również klasy systemowe (czyli to, co oferuje .NET Framework) są umieszczone w odpowiednich przestrzeniach nazw. Pozwala to zachować porządek - na przykład w System. IO znajdują się wszystkie funkcje związane z operacjami na plikach, w System.Collection - elementy związane z kolekcjami itd. Gdy do projektu dołączony jest odpowiedni pakiet (systemowy lub inny projekt), odpowiednie przestrzenie nazw są dostępne dla aplikacji. Aby użyć Console.WriteLine, zwykle trzeba napisać:

System.Console.WriteLine("Tekst");

Ale można też wskazać kompilatorowi, że jeżeli w danym pliku natrafi na symbol, którego nie może znaleźć, to ma sprawdzić, czy nie istnieje on w określonej przestrzeni nazw:

using System;

...

Console.WriteLine("Tekst");

W VB.NET służy do tego polecenie:

Imports System.Collections

(VB automatycznie dodaje niektóre przestrzenie nazw - i wiele często używanych klas jest dostępnych od razu).

Aby funkcje znajdowały się w odpowiedniej przestrzeni nazw, muszą być zamknięte w odpowiednich nawiasach:

namespace LangTest

{

class T1

{

//...

}

}

W VB.NET postać jest analogiczna:

Namespace AAA

...

End Namespace

Odwołanie do klasy zawartej w przestrzeni nazw ma postać: nazwa_przestrzeni.nazwa_klasy.

W jednej przestrzeni nazw nie może być dwóch obiektów o tej samej nazwie, ale nic nie stoi na przeszkodzie, żeby klasa Test istniała w różnych przestrzeniach. Można też definiować klasy, których definicja "zawarta" jest w innych klasach. Załóżmy, że mamy taki fragment kodu w C#:

class OwnClass {

class System {

}

public OwnClass() {

//System.Console ????

global::System.Console.WriteLine(

"Globalna przestrzeń nazw");

}

}

Z poziomu konstruktora OwnClass nie można użyć składni System. Console. .., bo odwołanie nastąpi do najbliższego obiektu o danej nazwie - czyli klasy System, zawartej w OwnClass. Aby odwołać się do globalnej przestrzeni, wyrażenie trzeba poprzedzić słowem kluczowym global: :. Analogicznie jest w VB.NET - trzeba napisać:

Global.System.Console.WriteLine.

Warto dodać, że częstym błędem jest pominięcie using czy imports, co powoduje, że pewne klasy czy funkcje nie mogą być znalezione. W dokumentacji MSDN przy opisie każdego elementu wyraźnie zaznaczono, do jakiej przestrzeni nazw należy.

Modyfikatory w C# i VB .NET

Deklarując metody, właściwości i funkcje w C#/VB.NET, można stosować następujące modyfikatory:

Overloads (VB.NET)

Wykorzystywane jest podczas deklarowania wielu funkcji o tej samej nazwie, ale innych parametrach. Proszę spojrzeć na poniższy przykład:

Overloads Sub

Show(ByVal theChar As Char)

End Sub

Overloads Sub

Show (ByVal theInteger As Integer)

End Sub

Overloads Sub

Show (ByVal theDouble As Double)

End Sub

W C# nie trzeba w analogicznym wypadku stosować dodatkowego słowa kluczowego.

Overrides (VB.NET) / override (C#)

Określa, że dany element przesłania identyczny element w klasie bazowej.

Overridable (VB.NET) / virtual (C#)

Określa, że element może być przesłonięty w klasie dziedziczącej.

NotOverridable (VB.NET) / sealed (C#)

Określa, że element nie może być przesłonięty w klasie dziedziczącej.

MustOverride (VB.NET) / abstract (C#)

Określa, że element musi być przesłonięty w klasie dziedziczącej. Jeżeli cała klasa zdefiniowana jest jako abstract, to nie można utworzyć jej instancji.

Przykładem klasy abstrakcyjnej jest TextReader, przeznaczona do odczytu sekwencji znaków. Nie można utworzyć jej instancji, ale ma ona dwie specjalizacje - StringReader, która czyta sekwencję ze zmiennej łańcuchowej i StreamReader, czytającą ze strumienia ze zdefiniowanym sposobem kodowania znaków - których można już używać w kodzie.

Shadows (VB.NET) / new (C#)

Określa, że element przesłania inny element z klasy bazowej, odmiennego typu. Praktycznie rzadko stosowane:

Public Class CBase

Public Z As Integer

End Class

Public Class CDerived

Inherits CBase

Public Shadows Z As String

End Class

W klasie CDerived Z jest typu String, mimo że w klasie bazowej jest inaczej.

W C# deklaracja tego typu zmiennej w klasie pochodnej ma postać:

public class CDerived:CBase

{

new public String Z;

}

Należy jeszcze raz podkreślić, że zwykle lepiej tego mechanizmu nie stosować. Właściwie jednym z zastosowań shadows/new jest sytuacja, gdy funkcja w klasie bazowej zwraca jakiś inny typ bazowy, a funkcja w klasie potomnej - odpowiedni typ potomny.

Typy danych

W językach obiektowych jest zwykle pewna niespójność. Z jednej strony, mamy obiekty - klasy z określonymi właściwościami, metodami itp., z drugiej - tzw. typy proste, czyli np. liczbę.

Są języki (jak Smalltalk), w których wszystko jest obiektem. Liczba jest taką strukturą, która ma operator "zwróć wartość". Jednak wpływa to wówczas bardzo negatywnie na prędkość działania aplikacji. Są też języki - jak C+ + - w których te dwa światy są zupełnie oddzielone. W .NET zastosowano rozwiązanie pośrednie. Każdy typ dziedziczy po typie System. Object (co powoduje, że jeżeli jakaś funkcja przyjmuje jako argument wartość tego typu, to de facto można jej przekazać dowolną zmienną z .NET). Ale dodatkowo typy proste dziedziczą po System.ValueTypes. Na przykład typ int (Integer w VB.NET) to liczba, ale jest też struktura:

public struct Int32 : IComparable, IFormattable, IConvertible

będąca odpowiednikiem obiektowym tego typu.

(W C# można jeszcze definiować aliasy do typów. Takimi predefiniowanymi aliasami są int, string itp.).

Jeżeli trzeba przekształcić wartość pomiędzy typem prostym a obiektowym, .NET stosuje tzw. operacje opakowywania i rozpakowywania danych (box/unbox). Przeanalizujmy taki fragment kodu:

1 int liczba;

2 double liczba2;

3 object obj,obj2;

4 liczba = 123;

5 obj = liczba;

6 liczba2 = 123.4;

7 obj2 = liczba2;

8 if (obj is System.Int32)

Console.WriteLine("typ - System.Int32");

9 // liczba = (int)obj2;

Przypisanie w wierszach 4 i 5 przekłada się na następujący kod MSIL:

ldc.i4.s 123

stloc.3

ldloc.3

box int32

stloc.s obj1

Najpierw liczba 123 jest ładowana na stos, następnie opakowywana, po czym wynik tej operacji ląduje w zmiennej obj. Od tej chwili zmienna obj zawiera typ System. Int32.

Podobna operacja jest przeprowadzana ze zmienną liczba2 i obj2 (typu double).

W wierszu 10 następuje rozpakowanie:

unbox.any int32

i w konsekwencji ze zmiennej System.Int32 na stosie tworzony jest typ prosty int.

W VB.NET opakowywanie jest wykonywane tam, gdzie występuje operator CType, np.:

num1 = CType(obj1, Integer)

Uwaga! Operacja box/unbox jest najbardziej kosztowną (w sensie czasu wykonania) instrukcją MSIL. Skompilowany kod VB.NET bez mocnej kontroli typów w wielu miejscach stosuje te instrukcje, dlatego czasem ten język wolniej działa.

Warto dodać, że jeżeli dany typ jest zgodny z CLS, to każdy język działający na platformie .NET (i zgodny z CLS) musi go rozumieć.

Nawet jeżeli dany język nie ma aliasu dla danego typu, można np. w VB.NET napisać:

Dim ui As System.UInt32

ui = 1

aby utworzyć zmienną odpowiedniego typu.

Można wyróżnić dwa poziomy zgodności języków .NET. Szeroki zakres wymagań nakłada na dany język specyfikacja CLS (Common Language Specification), określająca zasady współpracy języków. Ta specyfikacja mówi, że np. identyfikatory różnej wielkości są tymi samymi obiektami, każdy typ zwracany przez funkcję jest typem zgodnym z CLS, że właściwości nie są przeciążane tak, że różni się ich typ itp. Jeżeli projektowana biblioteka ma być udostępniona innym programistom, autor powinien przeczytać Common Language Specification (i w zasadzie caty rozdział w MSDN - Design Guidelines for Class Library Developers). Zawiera on bardzo dużo informacji o tym, jak napisać kod, który będzie rozumiał każdy język .NET zgodny z CLS. A przy okazji - naprawdę doradza, jak skonstruować profesjonalną bibliotekę, aby inni nie mieli problemów z jej użyciem. Aby zaznaczyć, że pakiet (czy klasa) jest zgodny z CLS, dodaje się do niego atrybut CLSCompliant. Mniejsze wymagania w stosunku do języka ma CTS (Common Type System), czyli wspólny system typów. Model ten określa zgodność na poziomie deklaracji, używania i zarządzania typami. Mała uwaga - typem w .NET jest też klasa czy sygnatura metody. Dzięki temu można wymieniać informacje między językami, wywoływać kod itp. - ale nie można zakładać, jak klasa jest konstruowana - to narzuca dopiero CLS.

W wypadku języków obiektowych stosunkowo prosto można osiągnąć CLS, a na przykład w funkcyjnych zwykle osiągany jest poziom CTS. Bywają też języki częściowo zgodne z CTS, lub takie, które w ogóle nie zachowują zgodności, a tylko działają w środowisku .NET i mogą korzystać z bibliotek klas.

Skrócona lista języków działających na platformie.NET: C#, VB.NET, J#, JScript, C+ + (.NET 2.0), Forth.NET, F#, Smalltalk (S#), ADA (A#), Haskell, Perl.NET, Python.NET, PHP.NET, Eiffel.NET (dzięki specjalnemu trikowi z punktu widzenia programu Eiffel można stosować wielobazowe dziedziczenie, a z punktu widzenia CLR jest to dziedziczenie jednokrotne), Delphi.NET, Pascal.NET, Merkury, Mondrian, Oberon, sML, Dyalog APL i wiele innych.

Tablice

Deklarując tablicę w .NET, deklarujemy obiekt, który dziedziczy po System. Array. Dzięki temu każda tablica ma metody - Length, Clear itp. Jest także LongLength - do tablic liczących do 2 ~ 64 elementów (jednak gdy mamy tyle informacji, lepiej rozejrzeć się za bardziej wyrafinowanym sposobem przechowywania danych).

Deklarując tablicę jednowymiarową, można od razu podać listę elementów do zainicjowania struktury (tak samo podaje się wartość początkową, deklarując zmienną):

int[] arr1D ={ 1, 2, 3 };

W VB.NET jest analogicznie:

Dim arr1D As Integer() = New Integer() { 1, 2, 3 }

Podobnie można utworzyć tablicę dwuwymiarową:

int[,] arr2D ={ { 1, 2 }, { 1, 2 } };

I w VB.NET:

Dim arr2D(,) As Integer = {{1, 2}, {1, 2}}

W .NET (C#) można deklarować także tablice skośne, które mają nierówną liczbę elementów w kolejnych wierszach. W poniższym listingu podano kilka przykładów deklaracji i użycia tego typu tablic:

int[][] arr = new int[3][]

{

new int[3] { 1, 2, 3 }

new int[2] { 1, 2 }

new int[1] { 1 }

};

Console.WriteLine(arr[2][0]); / /1

//arr[2][2] - błąd

Tablicę skośną można również zadeklarować w taki sposób, że najpierw tworzymy zewnętrzny wymiar, a potem każdemu wymiarowi przypisujemy "podtablicę" o danej długości:

int[][] arr1 = new int[3][];

for (int i = 0; i < 3; i++)

{

arr1 [i] = new int[i + 100];

}

Uwaga! W wypadku tablic jednowymiarowych elementy będą umieszczane w pamięci jeden za drugim - gdy deklaruje się struktury wielowymiarowe (zwłaszcza tablice skośne), zdarza się, że wiersze tablic są rozrzucone w pamięci. Można to kontrolować, używając odpowiednich atrybutów, co opisano w dalszej części.

Warto dodać, że System.Array zawiera kilka typów ogólnych (patrz też artykuł "Generics - typy ogólne"), które na przykład pozwalają zdefiniować algorytm wyszukujący elementy w tablicy danego typu.

Typy wyliczeniowe

W .NET można definiować także typy wyliczeniowe (dziedziczą one po System.Enum). Pozwalają one zdefiniować zmienną, która przyjmuje pewien zestaw wartości. De facto typ wyliczeniowy jest typem całkowitoliczbowym (i można go rzutować np. na int):

public enum eSex

{

M, F

}

Potem można używać tego typu jak każdego innego:

eSex sex = eSex.M;

Deklarując typ, można określić, jakie wartości przyjmuje typ wyliczeniowy. Programiści C czy C+ + pamiętają, że można do przechowywania flag używać kolejnych bitów jakiejś liczby. W C# można zrobić to samo, używając wyliczeń - określając, jaką wartość będzie miała każda z wartości typu wyliczeniowego. Dzięki temu można napisać:

public enum eMisc {

Big=1

Medium=2

Small=4

Yellow=8

Green=16

}

Kolejne wartości to potęgi dwójki (odpowiada to kolejnym ustawionym bitom w liczbie). Następnie można tego użyć w następujący sposób:

eMisc misc;

misc = eMisc.Big | eMisc.Yellow;

if ((misc & eMisc.Yellow) != 0)

Console.WriteLine("Yellow"); / / Tak

if ((misc & eMisc.Green) != 0)

Console.WriteLine("Green");

Zmienna misc przyjmuje wartość typu eMisc, ale dzięki odpowiedniej definicji może przyjąć kilka różnych opcji - w tym wypadku oznaczających, że coś jest duże i żółte. Proszę jednak zauważyć, że np. takie porównanie:

if (misc == eMisc.Yellow)

Console.WriteLine("Yellow");

nie zwróciło oczekiwanego wyniku.

Właściwości

Dużą rolę w .NET odgrywają właściwości. Załóżmy, że jest klasa Person, która ma dwa pola - określające wiek (age), płeć (sex) - a także wskaźnik, czy osoba jest na emeryturze (isRetired):

public class Person {

int age;

eSex sex;

bool isRetired;

}

Można w zasadzie określić, że wszystkie zmienne są publicznie dostępne (dodając atrybut public). Wiadomo jednak, że w Polsce wiek emerytalny zaczyna się po przekroczeniu przez kobietę 60, a mężczyznę - 65 lat. Czyli warto utworzyć mechanizm powodujący, że podczas przypisywania wartości do pola age czy sex zostanie sprawdzona płeć i w zależności od tego - będzie ustawione lub nie pole isRetired. Ponadto programiście stosującemu tę klasę trzeba uniemożliwić samodzielne modyfikowanie znacznika. Właśnie do tego służą właściwości:

public class Person {

int age=0;

eSex sex=eSex.M;

bool isRetired=false;

private bool CheckRetired() {

switch (sex) {

case eSex.F:

if (age > 60) return true;

break;

case eSex.M:

if (age > 65) return true;

break;

}

return false;

}

public int Age {

get { return age; }

set {

if (value < 0 | | value > 200) throw new

ArgumentOutOfRangeException("value");

age = value;

isRetired=CheckRetired();

}

}

public eSex Sex {

get { return sex; }

set {

sex = value;

isRetired = CheckRetired();

}

}

public bool IsRetired {

get { return isRetired; }

}

}

Funkcja CheckRetired sprawdza warunki przejścia na emeryturę. Age, Sex i IsRetired to zdefiniowane publiczne właściwości - coś, co jest widoczne dla używającego tej klasy.

Właściwość może mieć zdefiniowane dwa operatory - get (do zwracania wartości) i set (do ustawiania). Proszę zauważyć, źe IsRetired jest właściwością tylko do odczytu - nie ma operatora set.

Specjalne słowo kluczowe value w ciele operatora set określa, jaka wartość przypisywana jest do naszej właściwości. W kodzie set w Sex i Age najpierw zapisywana jest wartość w odpowiedniej zmiennej, a następnie sprawdza się, czy należy ustawić zmienną isRetired (zwracaną przez właściwość IsRetired).

Można teraz użyć naszej klasy:

Person per = new Person();

per Age = 50;

per Sex = eSex.M;

Console.WriteLine(

"Na emeryturze: " + per .IsRetired);

per.Age = 61;

Console.WriteLine(

"Na emeryturze: " + per .IsRetired);

per.Sex = eSex.K;

Console.WriteLine(

"Na emeryturze: " + per .IsRetired); //Tak

Dopiero w trzecim przypadku osoba, której odpowiada instancja per, zostanie zakwalifikowana jako uprawniona do emerytury.

Static

W VB.NET można definiować tzw. moduły, które od razu zawierają funkcje czy procedury. Mimo że większość aplikacji jest pisana w sposób obiektowy, programiście zawsze przyda się procedura, która zwróci losowy łańcuch znaków, zapisze ustawienia formularza, pomoże w parsowaniu wiersza poleceń itp.

W VB.NET rozwiązanie jest proste - dodaje się taki kod do modułu:

Public Module ModuleUtil

Public Function

WriteSettings(ByVal key As String, ByVal value As String) As Boolean

'...

End Function

End Module

Potem w kodzie można odwołać się do modułu i uruchomić daną funkcję:

ModuleUtil.WriteSettings("aaa", "bbb")

W C# - takiej możliwości nie ma. Zwykle używane są składowe statyczne:

class ClassUtil {

public static bool

WriteSettings(string key, string value){

//...

}

}

Można się teraz odwołać do:

ClassUtil.WriteSettings("aaa", "bbb");

bez tworzenia instancji klasy.

Uwaga! Metoda oznaczona jako static może tylko odwoływać się do innych elementów static w danej klasie.

W C# 2.0 pojawiła się także możliwość definiowania klas jako statycznych. Przydaje się to w wypadku np. klasy System.Math, która zawiera zestaw statycznych metod. Nie można utworzyć instancji takiej klasy, nie można po niej dziedziczyć, klasa nie może mieć konstruktora. Jest to bardzo przydatny mechanizm, pozwalający zachować przejrzystość kodu.

Inne zastosowanie słowa kluczowego static to utworzenie zmiennej, której zawartość przetrwa kolejne wywołania czy kolejne tworzenie obiektu (można deklarować zarówno pole typu static, jak i np. zmienną w jakiejś funkcji):

public class CountInstance {

static int cnt = 0;

public CountInstance() { cnt++; }

public int Cnt { get { return cnt; } }

}

W tym wypadku w klasie jest zmienna statyczna, która zostaje zwiększona o jeden podczas każdego wywołania konstruktora, a więc za każdym razem, gdy tworzona jest instancja danej klasy. Dzięki temu właściwość Cnt zwraca liczbę utworzonych obiektów:

CountInstance i1 = new CountInstance();

CountInstance i2 = new CountInstance();

Console.WriteLine(i2.Cnt); //2

Klasycznym przykładem stosowania pól static jest tzw. schemat singletona (jeden z wielu wzorców projektowych), który zapewnia, że dana klasa będzie miała dokładnie jedną instancję, tak jakby była zmienną typu static. Dzięki typom ogólnym można utworzyć ogólną implementację singletona do dowolnego typu:

public class

GenericSingleton<T> where T : new() {

GenericSingleton() { }

public static T Instance {

get { return InternalCreator.instance; }

}

class InternalCreator {

static InternalCreator() { }

internal static readonly T instance = new T();

}

}

(kod powstał na podstawie przykładu ze strony http://www.yoda.arachsys.com/csharp/ ).

Pułapki singletona

Niełatwo utworzyć naprawdę bezpieczny wzorzec singletona, to znaczy np. taki, którego wiele wątków może używać jednocześnie. Prosty przykład realizacji:

1 public sealed class Singleton

2 {

3 static Singleton instance=null;

4 Singleton() { }

5 public static Singleton Instance

6 {

7 get {

8 if (instance==null) {

9 instance = new Singleton();

10 }

11 return instance;

12 }

13 }

14 }

zawiera wiele błędów. Jeżeli dwa wątki (A i B) dojdą do 8. wiersza, może się zdarzyć, że A wykryje, iż zmienna instance jest równa null i zacznie tworzyć nowy obiekt, a równocześnie to samo zrobi obiekt B.

Pierwsze odwołanie do GenericSingleton<Person>. Instance spowoduje, że obiekt typu Person zostanie utworzony. Wszystkie kolejne odwołania do tego wyrażenia będą wskazywać właściwie na tę samą instancję:

//Singleton

GenericSingleton<Person>.Instance.Age = 100;

Console.WriteLine(

GenericSingleton<Person>.Instance.Age); //100

Przedstawiony kod GenericSingleton korzysta z tego, że konstruktor statyczny w C# jest uruchamiany jedynie wówczas, gdy tworzona jest instancja danej klasy albo ktoś odwoła się do zmiennej typu static - i taki kod uruchamiany jest raz dla danej AppDomain (przestrzeń w pamięci i zestaw wątków stanowiący aplikację .NET, czyli program i wszystkie powiązane z nim dodatkowe biblioteki). To "automatycznie" zapewnia, że powstanie tylko jedna instancja obiektu.

String a StringBuilder.

W .NET są dwa typy danych do przechowywania łańcuchów - String i StringBuilder. Różnica pomiędzy nimi polega na tym, że String jest obiektem statycznym - jeżeli go modyfikujemy (np. w prostej instrukcji str = str + "a";), to tworzony jest nowy obiekt (który powstaje w wyniku złożenia str i litery a), a następnie stary obiekt str jest zwalniany. Warto to sprawdzić, np. używając poniższego kodu:

string str = "Tekst";

Stopwatch st = new Stopwatch();

st.Start();

for (int i = 1; i < 10000; i++)

str = str + "a";

st.Stop();

Console.WriteLine( "string: " + st.ElapsedTicks);

st.Reset();

StringBuilder strb = new StringBuilder();

st.Start();

for (int i = 1; i < 10000; i++)

strb.Append("a"); st.Stop(); Console.WriteLine(

"StringBuilder: " + st.ElapsedTicks);

Na testowym komputerze kod wykorzystujący StringBuilder jest 332 razy szybszy (!), a to przecież prosta operacja - dodanie jednego znaku. Uwaga! To samo się dzieje, jeżeli np. zmieniamy jeden znak w środku ciągu przechowywanego w polu typu string. Należy przyjąć zasadę, że jeżeli element jest niezmienny, stanowi opis czegoś itp., to można stosować string. Natomiast gdy dynamicznie budujemy wyrażenie SQL, doklejamy coś do strony ASP.NET itp., należy używać StringBuilder. Mimo że na pierwszy rzut oka jest mniej wygodny, to zwiększenie wydajności naprawdę usprawiedliwia dodatkowy wysiłek.

Garbage collector

Pamięć w .NET jest zarządzana przez automatyczny odśmiecacz i programista w ogóle nie musi się o to troszczyć. Jednak nie można o tym mechanizmie zapomnieć, bo co prawda, dokładnie wiadomo, kiedy obiekt jest tworzony, ale nie wiadomo, kiedy jest kasowany.

Jeżeli zależy nam na jawnym kasowaniu obiektu, trzeba zaimplementować specjalny interfejs - IDispose. Zakłada on, że klasa implementuje metodę Dispose (), wywoływaną w momencie, gdy zakończyliśmy pracę z daną instancją:

public class DisposableObject : IDisposable {

private bool disposed = false;

//Wymagane przez interfejs

public void Dispose() {

Dispose(true);

GC.SuppressFinalize(this);

}

private void Dispose(bool disposing) {

if (!this.disposed) {

if (disposing) {

// Zwolnij zasoby zarządzalne

// (zerowanie referencji itp).

}

//Zwolnij zasoby niezarządzalne

}

disposed = true;

}

~DisposableObject() {

Dispose(false);

}

}

Poprawnie zaimplementowany schemat Dispose powinien uwzględniać możliwość, że użytkownik klasy nie zastosuje tego mechanizmu, dlatego zaimplementowany został również finalizator (destruktor) - ~DisposableObject (). Gdy wywoływana jest funkcja Dispose, zazwyczaj zwalniane są zasoby zarządzalne i niezarządzalne, a dokładniej - wywoływana jest przeciążona metoda Dispose (bool disposing) z parametrem true. Dodatkowo zaznacza się, że dla tego obiektu odśmiecacz (garbage collector) ma nie wywoływać finalizatora (kasowanie klas, w których finalizator ma być wywoływany, jest znacznie kosztowniejsze - dokładniej: w .NET odrębny wątek uruchamia destruktory). Służy do tego polecenie gc. SuppressFinalize.

Jeżeli nie jest wywoływana funkcja Dispose, a tylko odśmiecacz wywołuje finalizator, to wywoływana jest metoda Dispose(bool disposing) z parametrem false. Wtedy zostaną zwolnione tylko zasoby niezarządzalne - ponieważ może się to zdarzyć tylko wówczas, gdy odśmiecacz już usuwa klasę, co oznacza, że wszystkie obiekty zarządzane używane przez daną instancję są już skasowane. Warto oznaczyć, że Dispose było już wywoływane (po zwolnieniu obiektów) - na wypadek, gdyby ktoś wywołał funkcję Dispose() jak normalną funkcję .NET, a potem chciał np. jeszcze normalnie używać klasy (oczywiście to zakłada, że wszystkie metody "usługowe" sprawdzają najpierw, czy zmienna disposed ma wartość false).

A jeśli operacje wykonywane na obiekcie spowodują wyjątek? Trzeba użyć schematu, który zapewni, że nawet wtedy funkcja Dispose będzie wywoływana:

DisposableObject d1 = new DisposableObject();

try {

//Operacje

} finally {

d1.Dispose();

}

(finally zaznacza blok wykonany zawsze - niezależnie od tego, czy wyjątek zostanie zgłoszony, czy nie).

Na szczęście, użycie schematu IDisposable ułatwiają specjalne instrukcje. Nawiasy usingfunkcjonalnie odpowiadają powyższemu blokowi try finally:

using (DisposableObject d = new DisposableObject()) {

//Operacje z użyciem d

} // Tu jest wołany d.Dispose

W nawiasach using można wykonywać dowolne operacje na obiekcie d. Po wyjściu z danego zakresu automatycznie wywoływana jest metoda Dispose i (domyślnie) zwalniane są zasoby, a następnie obiekt czeka na swoją kolej do ostatecznego usunięcia przez odśmiecacz. W VB.NET 2005 wprowadzono analogiczny mechanizm:

Using d As New DisposableObject()

...

End Using

Schemat Dispose bezwzględnie należy stosować wtedy, gdy używamy niezarządzalnych zasobów - np. uchwytów okna, plików itp. Co prawda, wcześniej czy później odśmiecacz zwolniłby takie obiekty, ale ponieważ jest ich znacznie mniej niż dostępnej pamięci, mogłoby to spowodować nieoczekiwane działanie aplikacji. Załóżmy, że piszemy aplikację serwerową (ASP.NET). Strona startowa otwiera połączenie do bazy i nie używa schematu Dispose, tylko zakłada, że odśmiecacz usunie obiekt. Ponieważ zwykle serwer ma dużo pamięci, zanim odśmiecacz podejmie decyzję o kasowaniu, zużyte zostaną wszystkie połączenia serwera bazodanowego, a w konsekwencji aplikacja przestanie działać.

Warto dodać, że w "zwykłych" klasach stosowanie opisanego schematu trochę przeszkadza, bo pojawia się kosztowny w obsłudze "finalizator". Dlatego nie ma sensu implementować wspomnianego schematu, jeżeli klasa nie musi czegoś jawnie zwalniać.

Warto pamiętać o kilku elementach działania odśmiecacza. Jest generacyjny - zakłada, że im nowszy jest obiekt, tym (prawdopodobnie) krótszy będzie jego czas życia. Natomiast jeżeli pojawia się obiekt długo już dostępny i używany, to prawdopodobnie będzie żył przez cały okres działania programu. W .NET wprowadzono trzy generacje i jeżeli obiekt przetrwał próbę usunięcia (z powodu pewnych zależności), to jest przesuwany do kolejnej generacji. Jeżeli brakuje pamięci, to najpierw usuwane są obiekty z generacji 0 (pierwszej), a dopiero potem, gdy nadal brakuje RAM, odśmiecacz analizuje generację 1 i ewentualnie 2. Poza tym duże obiekty (większe niż 8500 bajtów w .NET 2.0) są umieszczane na oddzielnej stercie, zawsze analizowanej pod kątem "zwalniania".

W wielu programach można znaleźć dużo instrukcji GC.Collect(). To polecenie wymusza uruchomienie odśmiecacza i zwolnienie pamięci, natomiast jeżeli programista czuje, że musi ręcznie nim sterować, zwykle sygnalizuje to błędy w aplikacji.

Jeżeli zachowamy w zmiennej statycznej referencję do jakiegoś olbrzymiego obiektu, po czym będziemy się do niego odwoływać z różnych miejsc aplikacji, to odśmiecacz raczej go nie usunie. A gdy ten obiekt będzie dodatkowo miał referencje do innych instancji...

Zatem warto pamiętać, że odśmiecacz upraszcza wiele operacji, całkowicie zabezpiecza przed wyciekaniem pamięci, ale mimo to programista może popełnić błąd i napisać kod, którego nawet odśmiecacz nie będzie mógł odpowiednio "pozwalniać".

W .NET 2.0 wprowadzono dodatkowy mechanizm, który trochę pomaga zarządzać obiektami w pamięci (również niezarządzalnymi). Pozwala dodać specjalny licznik, który kontroluje liczbę zasobów o danej nazwie. Załóżmy, że mamy taką klasę:

public class HandleCollectorSample {

static System.Runtime.InteropServices.HandleCollector hc = new System.Runtime.InteropServices.HandleCollector("Test", 10, 50);

public HandleCollectorSample() {

// Coś niezarządzalnego jest alokowane

hc.Add();

}

~HandleCollectorSample() {

hc.Remove();

}

}

Nie implementuje schematu IDisposable, a zamiast tego dodaje HandleCollector zwiększany przy każdej nowej instancji, aby sygnalizować, że nowy zasób niezarządzalny został zaalokowany. Oczywiście obiekty jakoś będą kasowane, ale nie wiadomo, czy dostatecznie szybko. Natomiast dzięki użyciu HandleCollector, jeżeli licznik przekroczy 50, to GC.Collect() będzie musiał być uruchomiony (a gdy przekroczy 10 - to zwiększy się prawdopodobieństwo jego uruchomienia). Zwykle ten schemat pojawia się obok IDisposable, zabezpieczając przed skutkami lenistwa programisty, który "zapomina" o wywoływaniu Dispose. .NET zawiera trzy różne odśmiecacze (można je wybrać, odpowiednio konfigurując aplikację):

1) do stacji roboczej, bez oddzielnego wątku

2) z oddzielnym wątkiem do odśmiecania (najczęściej uruchamiany; najbardziej efektywny, gdy nie chcemy czekać na odśmiecenie pamięci)

3) serwerowy - zoptymalizowany do maszyn wieloprocesorowych.

.NET domyślne wybiera odpowiedni sposób działania odśmiecacza - zwykle do stacji jednoprocesorowych wybiera drugą opcję, a gdy procesorów jest więcej - trzecią.

Aby wybrać odpowiedni odśmiecacz, trzeba ustawić właściwą opcję w pliku konfiguracyjnym aplikacji (patrz też "Zasoby i konfiguracja aplikacji" w artykule "Pod maską Visual Studio 2005"). Wyłączenie oddzielnego wątku wymaga:

<configuration>

<runtime>

<gcConcurrent enabled="false"/>

</runtime>

</configuration>

W tym wypadku, gdy zabraknie pamięci, aplikacja zostanie zamrożona na czas odśmiecania. Po ustawieniu tej opcji na true zwolni, ale będzie reagować; dokładniej - wątki też będą zamrażane, ale nie na cały czas odśmiecania, tylko na krótkie okresy, aby jak najmniej przeszkadzać w działaniu aplikacji.

Wymuszenie serwerowego odśmiecacza również sprowadza się do odpowiedniego wpisu w konfiguracji aplikacji:

<configuration>

<runtime>

<gcServer enabled="true"/>

</runtime>

</configuration>

Serwerowy GC jest zupełnie inny niż przeznaczone do stacji roboczych. Po pierwsze, działają oddzielne wątki odśmiecaczy dla oddzielnych procesorów. Każdy z nich zarządza własną stertą. Gdy trzeba włączyć odśmiecanie, sygnalizowane jest zdarzenie do danego odśmiecacza i w czasie jego działania na danym procesorze wstrzymywane są wątki robocze.


Zobacz również