Класовете в C# са основните единици, от които се състоят програмите. Те моделират обектите от реалния свят и могат да дефинират различни членове (член-променливи, методи, свойства и др.). Нека видим как изглежда един примерен клас на езика C#:
class Student
{
// Private member declarations
private string mFirstName;
private string mLastName;
private string mStudentId;
// Constant
private const double PI = 3.1415926535897932384626433;
// Constructor
public Student(string aStudentId)
{
mStudentId = aStudentId;
}
// Property
public string FirstName
{
get
{
return mFirstName;
}
set
{
mFirstName = value;
}
}
// Read-only property
public string StudentId
{
get
{
return mStudentId;
}
}
// Method
public string StoreExamResult(
string aSubject, double aGrade)
{
// ...
}
}
|
В горния пример е дефиниран класът Student, илюстриращ някои от видовете членове, които класовете могат да реализират – капсулираните полета mFirstName, mLastName и mStudentId, константата PI, конструкторът Student(…), свойствата FirstName и StudentId и методът StoreExamResult(…). С течение на темата ще се запознаем по-отблизо с всеки от тези видове членове.
Членове на тип
В .NET типовете "клас" и "структура", като реализация на понятието клас от ООП, могат да съдържат в себе си членове (members), подобно на други обектно-ориентирани езици като Java и C++. Членовете могат да бъдат от един от следните видове:
-
полета, или член-променливи (fields)
-
константи (constants)
-
методи, или член-функции (methods)
-
свойства (properties)
-
индексатори (indexers)
-
събития (events)
-
оператори (operators)
-
конструктори (constructors)
-
деструктори (destructors)
-
вложени типове (класове, структури, изброени типове и др.)
Видимост на членовете
Множеството от типове, които могат да "виждат" определен член на даден клас се определя от видимостта. Правилното задаване на видимостта на членовете е ключов момент в разработването на йерархии от класове, тъй като основен принцип в ООП е клиентът на класа да вижда само това, което му е необходимо, и нищо повече. Следва описание на нивата на видимост в .NET Framework.
public
Глобална видимост – членовете с такова ниво на достъп могат да се достъпват от всеки тип.
protected internal
Това са членовете, видими от всички типове, дефинирани в асемблито, в което е дефиниран дадения, a също и от наследниците на типа.
internal
Членове, които се достъпват от всички типове, дефинирани в асемблито, в което е дефиниран дадения.
protected
Членове, видими само от наследниците на дадения тип.
private
Капсулирани членове, видими единствено в рамките на типа.
Член-променливи
Данните, с които инстанцията на класа работи, се съхраняват в член-променливи (или още полета). Те се дефинират в тялото на класа и могат да се достъпват от други видове членове – методи, конструктори, индексатори, свойства. В следващия пример ще покажем няколко декларации на член-променливи, за които в последствие ще дадем обяснения.
class Student
{
private string mFirstName;
private string mLastName;
private string mStudentId;
private int mCourse = 1;
private string mSpeciality;
private Course[] mCoursesTaken;
// Avoid missing the visibility modifier
string mRemarks = "(няма забележки)";
}
| Дефиниране на ниво на видимост
Дефиницията на всяко поле започва с ниво на видимост. Допустими са всички по-горе изброени нива на видимост, но в примера са използвани само private, защото скриването на полетата от използващите класа, т.е. указването на видимост private или protected, е утвърдена практика в ООП. Когато искаме да предоставим данните на класа на околния свят в .NET е прието вместо полета с ниво на достъп "public" да се използват свойства, на които ще се спрем малко по-късно. Степента на видимост може и да не бъде определена явно, както е в последния ред за полето mRemarks от примера и в този случай се подразбира private. Тази практика не се препоръчва, защото води до по-неясен код.
Дефиниране на тип
Следващият елемент от дефиницията на член-променлива е типът, който се указва задължително. Може да бъде произволен .NET тип от CTS или дефиниран от потребителя.
Задаване на име
След типа следва името на дефинираното поле, чрез което се обръщаме към него. То представлява идентификатор, т. е. последователност от unicode символи – главни и малки букви, цифри, -(тире) и _(подчертаващо тире), незапочваща с цифра или тире.
Имената на полетата и въобще на членовете в .NET Framework могат да бъдат идентични със съществуващи имена на типове или пространства от имена (на тях ще се спрем в края на темата). Например класът Student може да има свойство със същото име Student. Могат да бъдат и запазени думи, но само ако бъдат предшествани от @. Допуска се и използването на нелатински букви в имената, но не се препоръчва.
Задаване на стойност
При дефиницията на поле можем да му зададем стойност, както в примера това е направено за mCourse и mRemarks. Ако началната стойност бъде пропусната, на член-променливата се задава стойност по подразбиране. За референтните типове това е null, а за стойностните типовете е 0 или неин еквивалент (например false за boolean). В .NET Framework всички членове и променливи се инициализират автоматично. Това намалява грешките, възникващи заради използването на неинициализирани променливи.
Константни полета
Константните полета (или само константи) много приличат на обикновените полета, но имат някои особености. Нека обърнем внимание на следния пример, който показва няколко дефиниции на константи:
public class MathConstants
{
public const string PI_SYMBOL = "π";
public const double PI = 3.1415926535897932385;
public const double SQRT2 = 1.4142135623731;
}
|
От примера виждаме, че дефиницията на константа е дефиницията на поле с добавена ключовата дума const. Има и някои други разлики.
При декларирането на константно поле е задължително да се предостави стойност. Освен това стойността на константата не може да бъде променяна по време на работата с типа, в който е дефинирана – може само да бъде прочетена. Константите реално не съществуват като полета в типа, а съществуват само в сорс кода и се заместват със стойността им по време на компилация. Поради тази причина const декларациите в C# се наричат още compile-time константи, т. е. константи, които съществуват само по време на компилацията.
Полета само за четене
Друг специален вид полета, подобни на константите, са полетата само за четене (read-only fields). Те се различават от константните по това, че стойността им освен при дефиницията може да бъде зададена и в конструктор, но от там нататък не може да бъде променяна. Член-променлива само за четене се декларира, като се използва запазената дума readonly, като в примера:
class ReadOnlyDemo
{
private readonly int mSize;
public ReadOnlyDemo(int aSize)
{
mSize = aSize; // cannot be further modified!
}
}
|
За разлика от константите, полетата само за четене са реални полета в типа, които обаче, задължително трябва да се инициализират в конструктора на класа или при деклариране, защото след това не може да им бъде присвоявана стойност и биха останали с подразбиращата се. Поради тази причина те се наричат още run-time константи, т. е. константи, които се инициализират по време на изпълнение на програмата.
Методи
Методите (или още член-функции) дефинират операции за типа, в който са дефинирани. Те могат да боравят с членовете му, независимо от степента им на видимост, да ги достъпват и променят (освен полетата обявени като константни или само за четене).
В C# функции могат да бъдат дефинирани единствено като членове на клас или структура, за разлика от други обектно-ориентирани езици, където се използват глобални функции – такива, които не са обвързани с конкретен тип и са общодостъпни. В C# функции, които се достъпват без да е нужна инстанция на даден клас, се дефинират като статични. На тях ще се спрем след малко.
Задаване на видимост
Подобно на полетата, и методите могат да имат ниво на видимост. И синтактично, и от гледна точка на стила на програмиране, на методите е допустимо да се зададе коя да е от възможните нива на видимост, тъй като те представляват действията с типа и за някои от тях е необходимо да бъдат видими за околния свят, а за други – не. Отново подразбиращото се ниво на видимост е private, но е препоръчително да се декларира изрично.
Параметри и върната стойност
Методите могат да приемат параметри и да връщат стойност. Параметрите имат тип, който може да бъде всеки валиден .NET тип. Върнатата стойност може да бъде също от всеки възможен тип, а може и да отсъства. Нека обърнем внимание на следния пример:
class MethodsDemo
{
public void SayHiGeorgi()
{
SayHi("Гошо");
}
public void SayHiPeter()
{
SayHi("Пешо");
}
private void SayHi(string aName)
{
if (aName == null || aName == "" )
{
return;
}
Console.WriteLine("Здравей, {1}", aName);
}
public int Multiply(int x, int y)
{
return x * y;
}
}
|
Първите два метода, SayHiGeorgi() и SayHiPeter(), не приемат никакви параметри и не връщат стойност. Третият, SayHi(string aName), приема един параметър от тип string и не връща стойност. Последният, Multiply(int x, int y), приема два параметъра от тип int и връща стойност също от тип int.
В дефинициите на първите три метода от примера забелязваме ключовата дума void – тя се използва при методи, които не връщат стойност. За методи, които връщат стойност, вместо ключовата дума void се указва типа на връщаната стойност.
В последния метод забелязваме как се употребява ключовата дума return за връщане на стойност. Същата ключова дума използваме и за прекратяване на изпълнението на метод, който не връща стойност, както в метода SayHi(aName).
Методи с еднакви имена
В C# е допустимо един тип да има два и повече метода с едно и също име, но с някои ограничения. Ще въведем понятие, свързано с използването на едно и също име за няколко метода. Комбинацията от името, броя и типа на параметрите на метод наричаме сигнатура. Ако два метода имат едно и също име, те задължително трябва да се различават по сигнатура. Следващият пример илюстрира дефинирането на три метода с еднакви имена:
int Sum(int a, int b)
{
return a + b;
}
int Sum(int a, int b, int c)
{
return a + b + c;
}
long Sum(long a, long b, long c) // avoid this
{
return a + b + c;
}
|
Горните дефиниции са напълно валидни – първите два метода се различават по броя на параметрите си, а вторият и третият – по типа.
|
Трябва да сме особено внимателни с дефиниции като последните две и е препоръчително да се избягват, тъй като не е очевидно кой метод ще бъде извикан при обръщение като int sumTest = sum(1,2,3). Компилаторът по никакъв начин не ни предупреждава за двусмислието. В горния пример ще бъде извикан първият метод – sum(int a, int b, int c).
| Статични членове
Както вече споменахме, в C# функции, които могат да се извикват без да е нужна инстанция на клас, се реализират като статични (или общи) методи. Това става, като в дефиницията им включим ключовата дума static. Статичните членове се споделят от всички инстанции и се използват за пресъздаване на свойства и действия, които са постоянни за всички обекти от дадения клас. Достъпът до статичните членове на типа се извърша директно, а не през инстанция, както в следващия пример:
class Bulgaria
{
private static int mNumberOfCities = 267;
public static int NumberOfCities
{
get
{
return mNumberOfCities;
}
}
public static void AddCity(string aCityName)
{
mNumberOfCities++;
// ...
}
// ...
static void Main()
{
Console.WriteLine(
"В България има {0} града.", Bulgaria.NumberOfCities);
}
}
|
В примера видяхме дефинирането и използването на статични полета, методи и свойства. Използвахме статичните свойства без да инстанцираме класа Bulgaria никъде.
|
Важна особеност, която трябва да имаме предвид при използването на статични методи и свойства, е че те могат да използват само статични полета. Полетата, които са обвързани с инстанция могат да се достъпват само в нейния контекст, а статичните методи и свойства са независими от инстанцията.
|
Статичните полета на типа много приличат на глобалните променливи в по-старите езици за програмиране като C, C++ и Pascal. Както глобалните променливи, статичните полета са достъпни от цялото приложение и имат само една инстанция.
От членовете на типа, освен полетата, свойствата и методите също и конструкторите, индексаторите и събитията могат да бъдат статични. Константите също са общи за всички инстанции на типа, но не могат да бъдат статични. Деструкторите също не могат да бъдат статични, докато операторите задължително са.
Конструктори
Конструкторите се използват при създаване на обекти и служат за инициализация, или начално установяване на състоянието на полетата на обекта. Механизмът на работа и синтаксисът за дефиниране на конструкторите в C# са подобни на други обектно-ориентирани езици, като Java и C++ с някои особености, на които ще обърнем внимание. Допуска се използването на повече от един конструктор, като конструкторите трябва да се различават по броя и/или типа на параметрите. Възможно е и да не се дефинира конструктор и в такъв случай компилаторът създава подразбиращ се – публичен, с празно тяло и без параметри.
Инициализиране на полетата
Съществуват три възможности за инициализацията на полетата на обекта – да бъдат инициализират в конструктор, при декларацията им или да нямат изрично зададена стойност.
Инициализациите, описани в тялото на конструктора се изпълняват по време на изпълнението този конструктор – при създаване на обект от съответния клас с ключовата дума new в C#.
Инициализациите, дефинирани при декларацията на полетата се изпълня-ват директно преди конструктора. Можем да приемем, че при компилацията инициализациите на полетата се добавят в началото на всеки конструктор. Всъщност C# компилаторът прави точно това скрито от програмиста – поставя код, който инициализира всички член-променливи на типа във всички негови конструктори.
Полетата, които нямат зададена начална стойност, получават стойност по подразбиране (нулева стойност). Това поведение се изисква от спецификацията на езика C# и не зависи от конкретната имплементация на компилатора.
Конструктори – пример
Със следващия пример ще разгледаме примерни дефиниции на конструктори на базов клас с един наследник:
class Student
{
private string mName;
private int mStudentId;
private string mPosition = "Student";
public Student(string aName, int aStudentId)
{
mName = aName;
mStudentId = aStudentId;
}
public Student(string aName) : this(aName, -1)
{
}
public static void Main()
{
Student s = new Student("Бай Киро", 12345);
}
}
public class Kiro : Student
{
public Kiro() : base("Бай Киро", 12345)
{
}
// ...
}
|
Забелязваме употребата на ключовите думи this и base след дефиницията на конструкторите на класа. Те представляват съответно обръщения към друг конструктор на същия клас и към конструктор на базовия клас, като в скобите се изреждат параметрите, които се подават на извиквания конструктор. В примера е използване наследяване, на което ще с спрем в детайли след малко (класът Kiro наследява класа Student).
Изследване на MSIL кода за конструкторите в C#
В следващата демонстрация ще си послужим с инструмента IL DASM (ildasm.exe), който е част от .NET Framework SDK, за да разгледаме MSIL кода, който C# компилаторът генерира за класа Student, който дефинирахме в примера по-горе. С това упражнение не само ще се запознаем с работата с инструмента, но и ще забележим особеностите в генерирания код, свързани с полетата със зададена стойност при декларацията. Ето стъпките, които трябва да направим:
-
Отваряме Demo-1-Constructors.sln, елементарен Visual Studio .NET проект с единствен C# файл, който съдържа кода от горния пример. Компилираме проекта.
-
Стартираме командния интерпретатор към Visual Studio .NET. Не използваме стандартния cmd.exe, а този, който се намира в Start -> Programs -> Microsoft Visual Studio 2003 -> Visual Studio Tools, защото той се стартира с регистрирани пътища към .NET инструментите, които се използват от командния ред.
-
Избираме директорията, където се намира изпълнимият файл, получен при компилиране на проекта – Demo-1-Constructors.exe. Ако не сме променили настройките на Visual Studio .NET, това ще е директорията <директория на проекта>\bin\Debug.
-
Извикваме от командния ред инструмента ildasm и му подаваме като параметър компилираното приложение:
-
ildasm Demo-1-Constructors.exe
|
Ето как изглежда прозорецът на инструмента, в който е заредено асемблито от приложението, когато разпънем всички елементи от дървото:
IL DASM показва дърво за асемблито, в което различаваме класа Student и членовете му. Ако се придвижим по дървото до конструкторите на класа, можем да изследваме техния IL код, както е показано на следващата картинка:
В кода, генериран за конструктора с един параметър, се вижда обръщението към този с два параметъра. Ако повторим същото действие и с втория конструктор, можем да наблюдаваме и неговия IL код (на картинката по-долу).
Забелязваме, че задаването на стойност на полетата с инициализация при декларацията реално се извършва в началото на втория конструктор. Реално тези полета се инициализират и от първия конструктор, защото той извиква втория.
Singleton клас
В този пример ще представим един популярен шаблон в обектно-ориентирания дизайн – клас, който може да има най-много една инстанция в рамките на цялото приложение. Такъв клас наричаме singleton. За реализирането на такива класове се използва следният подход:
public sealed class Singleton
{
private static Singleton mInstance = null;
private Singleton()
{
}
public static Singleton Instance
{
get
{
if (mInstance == null)
{
mInstance = new Singleton();
}
return mInstance;
}
}
}
|
Целта на задаването на private видимост за конструктора на класа е за да не могат да се създават инстанции освен от членове на класа, както в случая статичното свойство Instance. В дефиницията на класа е използвана ключовата дума sealed, която указва, че класът не може да бъде наследяван.
Горният пример само демонстрира използването на sealed класове и частен конструктор. В реална ситуация при реализацията на singleton шаблона трябва да се вземе предвид, че е възможно няколко нишки (threads) едновременно да се опитат да извлекат инстанцията на singleton класа и да се получи нежелано поведение. Затова обикновено реализацията на този шаблон изисква допълнителни усилия за нишково обезопасяване на работата на класа. На работата с нишки ще обърнем специално внимание в темата "Многонишково програмиране и синхронизация".
Статичен конструктор
Конструкторите, подобно на други видове членове на класа, могат да бъдат обявени за статични, с тази особеност че статичният конструктор може да бъде най-много един и не може да приема параметри и модификатори за достъп.
Извикване на статичен конструктор
Статичният конструктор се използва за инициализация на статичните членове и се извиква автоматично. Извикването на статичният конструктор се извършва "зад кулисите" от CLR. Това става по време на изпълнението на програмата и моментът на стартирането му не е точно определен. Това, което е сигурно, е че статичният конструктор е вече извикан когато се създаде първата инстанция на класа или когато се достъпи някой негов статичен член. В рамките на програмата, статичният конструктор може да бъде извикан най-много веднъж.
Статичен конструктор – пример
В следващия пример ще разгледаме класа SqrtPrecalculated, който използва статичен конструктор:
class SqrtPrecalculated
{
public const int MAX_VALUE = 10000;
private static int[] mSqrtValues; // static field
// Static constructor
static SqrtPrecalculated()
{
mSqrtValues = new int[MAX_VALUE + 1];
for (int i = 0; i <= MAX_VALUE; i++)
mSqrtValues[i] = (int) Math.Sqrt(i);
}
// Static method
public static int GetSqrt(int aValue)
{
return mSqrtValues[aValue];
}
static void Main()
{
Console.WriteLine(GetSqrt(1000));
}
}
|
Класът SqrtPrecalculated служи за бързо изчисляване на корен квадратен. Той предоставя статичния метод SqrtPrecalculated(), който връща цялата част на квадратния корен на аргумента си.
За по-голямо бързодействие всички квадратни корени на числата от 0 до 10000 се изчисляват предварително в статичния конструктор и после се използват наготово. Множеството от стойностите се съхранява в статичното поле mSqrtValues[], което се инициализира в статичния конструктор, който се изпълнява преди първия опит за достъп до класа.
Ще илюстрираме поведението на статичните конструктори в .NET Framework, като с помощта на дебъгера на VS.NET наблюдаваме как преди да започне да бъде използван даден клас се изпълнява първо статичният му конструктор.
Проследяване на изпълнението на примера
Ще използваме дебъгера на Visual Studio .NET за да проследим изпълнението на кода от горния пример, който се съдържа в приложението Demo-2-TestStaticConstructor от демонстрациите. Ще изпълним последователно следните стъпки:
-
Отваряме с VS.NET TestStaticConstructor.sln и го компилираме.
-
Слагаме точки на прекъсване (breakpoints) на първия ред на статичния конструктор (static SqrtPrecalculated()) и във функцията Main() като щракаме с мишката на равнището на тези редове в празното поле от ляво на областта за редактиране на код. След като поставим точките на прекъсване средата изглежда по следния начин:
-
Стартираме програмата в дебъг режим (от меню Debug -> Start или с [F5]) и проследяваме как дебъгерът на Visual Studio .NET спира първо в статичния конструктор, а след като му зададем да продължи, спира в метода Main(). Това илюстрира как функционалността от статичния конструктор се изпълнява преди първото използване на класа. Ето как изглежда средата в момента, в който програмата е спряла в статичния конструктор:
Свойства
Свойствата са членове на класовете, структурите и интерфейсите, които обикновено се използват за да контролират достъпа до полетата на типа.
Свойствата приличат на член-променливите по това, че имат име, по-което се достъпват, и стойност от някакъв предварително определен тип. От гледна точка на синтаксиса за достъп до тях, свойствата изглеждат по същият начин както полетата. Разликата се състои в това, че свойства съдържат код, който се изпълнява при обръщение към тях, т. е. извършват действия. Свойствата могат да бъдат и статични.
Прочитане и присвояване на стойност
Свойствата могат да имат два компонента (accessors):
-
код за прочитане на стойността (get accessor)
-
код за присвояване на стойността (set accessor)
Когато създаваме свойства можем да предоставим дефиниции на двата компонента, както и на само един от тях, но задължително трябва да е дефиниран поне единият. Според предоставените компоненти делим свойствата на три вида:
-
Свойства само за чете (read only) - такива, които дефинират само код за прочитане на стойността им.
-
Свойства за четене и писане (read and write) - когато имат и двата компонента.
-
Свойства само за писане (write only) - когато е предоставен само код за присвояване на стойност.
Пример за свойства
Ще дефинираме класа Person за да илюстрираме дефинирането и използването на свойства:
public class Person
{
private string mName;
private DateTime mDateOfBirth;
// Property Name of type string
public string Name
{
get
{
return mName;
}
set
{
if ((value != null) && (value.Length > 0))
{
mName = value;
}
else
{
throw new ArgumentException("Invalid name!");
}
}
}
// Property DateOfBirth of type DateTime
public DateTime DateOfBirth
{
get
{
return mDateOfBirth;
}
set
{
if ((value.Year >= 1900) &&
(value.Year <= DateTime.Now.Year))
{
mDateOfBirth = value;
}
else
{
throw new ArgumentOutOfRangeException(
"Invalid date of birth!");
}
}
}
// Read-only property Age of type int
public int Age
{
get
{
DateTime now = DateTime.Now;
int yearsOld = now.Year - mDateOfBirth.Year;
DateTime birthdayThisYear =
new DateTime(now.Year, mDateOfBirth.Month,
mDateOfBirth.Day, mDateOfBirth.Hour,
mDateOfBirth.Minute, mDateOfBirth.Second);
if (DateTime.Compare(now, birthdayThisYear) < 0)
{
yearsOld--;
}
return yearsOld;
}
}
}
// Property usage example
class PropertiesDemo
{
static void Main()
{
Person person = new Person();
person.Name = "Svetlin Nakov";
person.DateOfBirth = new DateTime(1980, 6, 14);
Console.WriteLine("{0} is born on {1:dd.MM.yyyy}.",
person.Name, person.DateOfBirth);
Console.WriteLine("{0} is {1} years old.",
person.Name, person.Age);
}
}
|
В примерния клас виждаме дефинициите на две свойства за четене и писане - Name от тип string и DateOfBirth от тип DateTime, както и едно само за четене – Age от тип int.
Можем да доловим различните аспекти на употребата на свойства - едно свойство може да бъде просто обвивка около поле на типа, но може и да реализира по-сложна логика. Например свойствата Name и DateOfBirth в примера просто връщат стойността на полетата, които обвиват, или я задават след съответните проверки за валидност. Свойство може да бъде и абстракция на данни, извличането и съхранението на които би могло да бъде свързано със сложна обработка. Опростен пример за това е Age, което връща стойност, резултат от извършване на изчисления, в случая разликата между текущата дата и рождената дата на лицето.
Проследяване на изпълнението на свойствата
Ще си изясним работата със свойства като проследим хода на програмата по време на достъпа до тях. За целта ще си послужим с кода от примера, който се съдържа в приложението Demo-4-Properties от демонстрациите. Той съдържа горния пример. Нека изпълним следните стъпки:
-
Отваряме с VS.NET Demo-4-Properties.sln и го компилираме.
-
Стартираме програмата в режим на проследяване с [F11]. Програмата спира изпълнението си на първия ред, но без той да е изпълнен, ето така:
-
Натискаме отново [F11] при което се създава обекта person и маркерът спира на следващия ред.
-
Когато още веднъж натиснем [F11], забелязваме, че кодът, който следва да бъде изпълнен, е тялото на компонента за достъп до свойството Name на класа Person:
Това ни показва, че зад операцията "присвояване на стойност" на свойството стои кодът му за присвояване.
-
С [Shift-F11] продължаваме изпълнението на програмата до напускането на текущият блок – то спира отново на следващия ред в тялото на метода Main(…).
-
С [F10] продължаваме изпълнението на програмата с още една стъпка. Резултатът е същият, както при натискането на [F11] с тази разлика, че не се изпълняват стъпка по стъпка вложените блокове. Така преминаваме през изпълнението на кода за присвояване на стойност на свойството DateOfBirth на "един дъх" и маркерът се позиционира на операцията Console.WriteLine("{0} is born on {1:dd.MM.yyyy}.", person.Name, person.DateOfBirth).
-
Ако в този момент натиснем [F11] ставаме свидетели на изпълнението и на кода за достъп на свойството Name:
Така се убеждаваме, че обръщението към свойство се равнява на изпълнение на кода му за прочитане на стойност.
-
С [F5] продължаваме изпълнението на програмата до края и виждаме резултата от изпълнението и:
-
|
В режим на дебъгване прозорецът, в който се изпълнява приложението, се затваря веднага след приключване на изпълнението на кода и резултатът трудно може да бъде видян. Ако искаме да видим отпечатания резултат, трябва или да сложим точка на прекъсване преди края на Main() метода, или да се придвижим до последната операция стъпка по стъпка или да изпълним програмата не с [F5], а с [Ctrl-F5].
| -
Нека изследваме с IL DASM генерирания междинен код за примерното приложение, за да си изясним вътрешното представяне на свойствата.
Като стартираме ildasm и разгледаме с него IL кода за класа Person, забелязваме нещо много интересно – в класа Person има методи с префикс set_, отговарящи на компонентите за присвояване на дефинираните от нас свойства, и методи с префикс get_, които съответстват на компонентите за връщане на стойност.
На практика след компилация get и set частите на свойствата са се превърнали в методи, а достъпът до тях се е превърнал в операции за извикване на метод. Това е начинът, по който C# компилаторът компилира свойствата – превръща ги в методи, а достъпът до тях превръща в извиквания на методи.
Ето как изглежда класът Person в инструмента IL DASM:
Индексатори
Индексаторите в C# (indexers) са членове на класовете, структурите и интерфейсите, които предоставят индексиран достъп до данни на типа, подобно на достъпа до елементите на масив.
Индексаторите по синтаксис и семантика много приличат на свойства, но получават като параметър индекс на елемент, с който да работят. На практика, те представляват свойства, приемащи параметър и дори в някои .NET езици, например VB.NET, синтаксисът на декларирането им е същият като при свойствата.
Индексатори – пример
За да си изясним най-лесно как се дефинират индексатори, да разгледаме следния пример:
private object[] mElements;
public object this[int index]
{
get
{
return mElements[index];
}
}
|
Виждаме, че дефиницията на индексатор прилича на тази на свойство, но има и някои разлики. На индексатора не се задава име, а вместо него се задава запазената дума this.
Достъпът до индексатор на обект се извършва посредством името на променливата от типа, дефиниращ индексатора, последвана от индекса в квадратни скоби, също както се извършва достъпа до елемент на масив, например myArrayList[5].
Позовавайки се на начина, по който се обръщаме към индексаторите, можем да ги разглеждаме като средство за предефиниране на оператора []. Използването на индексатори позволява интуитивен достъп до обекти, които се състоят от множество компоненти, каквито са масивите и колекциите.
Имитация на масив чрез индексатори – пример
За да илюстрираме по-пълно дефинирането и използването на индексатори, ще използваме следващия пример. Ще дефинираме клас, който имитира поведението на масив от 32 стойности, всяка от които е или 0 или 1:
struct BitArray32
{
private uint mValue;
// Indexer declaration
public int this [int index]
{
get
{
if (index >= 0 && index <= 31)
{
// Check the bit at position index
if ((mValue & (1 << index)) == 0)
return 0;
else
return 1;
}
else
{
throw new ApplicationException(String.
Format("Index {0} is invalid!", index));
}
}
set
{
if (index < 0 || index > 31)
throw new ApplicationException(
String.Format("Index {0} is invalid!", index));
if (value < 0 || value > 1)
throw new ApplicationException(
String.Format("Value {0} is invalid!", value));
// Clear the bit at position index
mValue &= ~((uint)(1 << index));
// Set the bit at position index to value
mValue |= (uint)(value << index);
}
}
}
class IndexerTest
{
static void Main()
{
BitArray32 arr = new BitArray32();
arr[0] = 1;
arr[5] = 1;
arr[5] = 0;
arr[25] = 1;
arr[31] = 1;
for (int i=0; i<=31; i++)
{
Console.WriteLine("arr[{0}] = {1}", i, arr[i]);
}
}
}
|
Класът BitArray32 представлява масив от битове с 32 елемента, който вътрешно съхранява стойностите им в едно 32-битово поле. Елементите му достъпваме посредством дефинирания индексатор по същия начин, по който достъпваме елементите на вградените в CTS масиви. На масивите в .NET Framework ще се спрем в темата "Масиви и колекции".
Виждаме компонентите за прочитане и присвояване на стойността, които извършват проверка дали индексът е в съответния диапазон, след което чрез битови операции осъществяват достъп до посочения като параметър бит. При невалидни параметри се предизвиква изключение, чрез което се уведомява извикващия код за проблема. На изключенията ще се спрем подробно в темата "Управление на изключенията в .NET".
Проследяване на работата на индексатор
За да проследим работата на индексатора ще си послужим с приложението от демонстрациите Demo-5-Indexers.sln, което съдържа кода от горния пример. Ще изпълним следните стъпки:
-
Отваряме приложението и го компилираме.
-
С [F11] стартираме изпълнение в режим на проследяване и маркерът се позиционира на първия ред от тялото на метода Main(…):
-
Със следващото натискане на [F11] инициализираме обекта arr с подразбиращия се конструктор и текущ ред става присвояването arr[0] = 1.
-
Когато продължим проследяването, виждаме как следващият код, който се изпълнява, е компонента за присвояване на стойност на индексатора:
-
С [Shift-F11] прескачаме останалата част от блока и преминаваме с [F10] през другите присвоявания докато достигнем до цикъла, който прочита стойностите от масива:
-
В този момент при натискане на [F11] се изпълнява кодът за прочитане на стойността от масива. Забелязваме, че механизмът на изпълнение на индексаторите е същият, както на свойствата.
-
С помощта на инструмента IL DASM можем да си обясним приликите между свойства и индексатори. Когато разгледаме генерирания за приложението MSIL код виждаме, че индексаторите, както свойствата, се реализират от двойка методи с имена get_Item и set_Item:
Индексатори с няколко параметъра
В .NET Framework се допуска дефинирането на индексатори, приемащи повече от един параметър. Примерно обръщение към такъв индексатор е конструкцията personInfo["Бай Иван", 68]. Възможно е в един тип да се дефинират и няколко индексатора с различен набор от параметри. Индексаторите не могат да бъдат статични, тъй като реализират индексиране в рамките на дадена инстанция.
Ето още един пример за индексатор, който приема два параметъра от тип символен низ и връща целочислена стойност:
class DistanceCalculator
{
public int this[string aTown1, string aTown2]
{
get
{
if (aTown1.Equals("София") && aTown2.Equals("Варна"))
return 470;
else
throw new ApplicationException("Unknown distance!");
}
}
}
class DistanceTest
{
static void Main()
{
DistanceCalculator dc = new DistanceCalculator();
Console.WriteLine("Разстоянието между {0} и {1} е {2} " +
"километра.", "София", "Варна", dc["София", "Варна"]);
}
}
|
В примера е реализиран клас, който по дадени имена на два град връща разстоянието между тях. Разбира се, тази функционалност не е реализирана напълно, но целта на примера е да се илюстрира работата с индексатори, а не да се даде завършен проект, който работи.
Сподели с приятели: |