Глава 14. Дефиниране на класове
В тази тема...
В настоящата тема ще разберем как можем да дефинираме собствени класове и кои са елементите на класовете. Ще се научим да декларираме полета, конструктори и свойства в класовете. Ще припомним какво е метод и ще разширим знанията си за модификатори и нива на достъп до полетата и методите на класовете. Ще разгледаме особеностите на конструкторите и подробно ще обясним как обектите се съхраняват в динамичната памет и как се инициализират полетата им. Накрая ще обясним какво представляват статичните елементи на класа – полета (включително константи), свойства и методи и как да ги ползваме.
Съдържание
- Видео
- Презентация
- Мисловни карти
- В тази тема...
- Собствени класове
- Използване на класове и обекти
- Съхранение на собствени класове
- Модификатори и нива на достъп (видимост)
- Деклариране на класове
- Ключовата дума this
- Полета
- Методи
- Достъп до нестатичните данни на класа
- Припокриване на полета с локални променливи
- Видимост на полета и методи
- Конструктори
- Свойства (Properties)
- Статични класове (static classes) и статични членове на класа (static members)
- Изброени типове
- Вътрешни класове (nested classes)
- Шаблонни типове и типизиране (generics)
- Упражнения
- Решения и упътвания
- Демонстрации (сорс код)
- Дискусионен форум
Видео
Презентация
Мисловни карти
Собствени класове
"... Всеки модел представя някакъв аспект от реалността или някаква интересна идея. Моделът е опростяване. Той интерпретира реалността, като се фокусира върху аспектите от нея, свързани с решаването на проблема и игнорира излишните детайли." [Evans]
Целта на всяка една програма, която създаваме, е да реши даден проблем или да реализира някаква идея. За да измислим решението, ние първо създаваме опростен модел на реалността, който не отразява всички факти от нея, а се фокусира само върху тези, които имат значение за намирането на решение на нашата задача. След това, използвайки модела, намираме решение (т.е. създаваме алгоритъма) на нашия проблем и това решение го описваме чрез средствата на даден език за програмиране.
В днешно време най-често използваният тип езици за програмиране са обектно-ориентираните. И тъй като обектно-ориентираното програмиране (ООП) е близко до начина на мислене на човека, то ни дава възможността с лекота да описваме модели на заобикалящата ни среда. Една от причините за това е, че ООП ни предоставя средство, за описание на съвкупността от понятия, които описват обектите във всеки модел. Това средство се нарича клас (class). Понятието клас и дефинирането на собствени класове, различни от системните, е вградена възможност на езика C# и целта на настоящата глава е да се запознаем с него.
Да си припомним: какво са класовете и обектите?
Клас (class) в ООП наричаме описание (спецификация) на даден клас обекти от реалността. Класът представлява шаблон, който описва видовете състояния и поведението на конкретните обекти (екземплярите), които биват създавани от този клас (шаблон).
Обект (object) наричаме екземпляр създаден по дефиницията (описанието) на даден клас. Когато един обект е създаден по описанието, което един клас дефинира, казваме, че обектът е от тип "името на този клас".
Например, ако имаме клас Dog, описващ някакви характеристики на куче от реалния свят, казваме, че обектите, които са създадени по описанието на този клас (например кученцата "Шаро" и "Рекс") са от тип класa Dog. Това означение е същото, както когато казваме, че низът "some string" е от класа String. Разликата е, че обектът от тип Dog е екземпляр от клас, който не е част от библиотеката с класове на .NET Framework, а е дефиниран от самите нас.
Какво съдържа един клас?
Всеки клас съдържа дефиниция на това какви данни трябва да се съдържат в един обект, за да се опише състоянието му. Обектът (конкретния екземпляр от този клас) съдържа самите данни. Тези данни дефинират състоянието му.
Освен състоянието, в класа също се описва и поведението на обектите. Поведението се изразява в действията, които могат да бъдат извършвани от обектите. Средството на ООП, чрез което можем да описваме поведението на обектите от даден клас, е декларирането на методи в класа.
Елементи на класа
Сега ще изброим основните елементи на един клас, а по-късно ще разгледаме подробно всеки един от тях.
Основните елементи на класовете в C# са следните:
- Декларация на класа (class declaration) – това е редът, на който декларираме името на класа. Например:
public class Dog |
- Тяло на клас – по подобие на методите, класовете също имат част, която следва декларацията им, оградена с фигурни скоби – "{" и "}" между които се намира съдържанието на класа. Тя се нарича тяло на класа. Елементите на класа, които се описват в тялото му са изброени в следващите точки.
public class Dog { // ... The body of the class comes here ... } |
- Конструктор (constructor) – това е псевдометод, който се използва за създаване на нови обекти. Така изглежда един конструктор:
public Dog() { // ... Some code ... } |
- Полета (fields) – те са променливи, декларирани в класа (някъде в литературата се срещат като член-променливи). В тях се пазят данни, които отразяват състоянието на обекта и са нужни за работата на методите на класа. Стойността, която се пази в полетата, отразява конкретното състояние на дадения обект, но съществуват и такива полета, наречени статични, които са общи за всички обекти.
// Field definition private string name; |
- Свойства (properties) – така наричаме характеристиките на даден клас. Обикновено стойността на тези характеристики се пази в полета. Подобно на полетата, свойствата могат да бъдат притежавани само от конкретен обект или да са споделени между всички обекти от тип даден клас.
// Property definition private string Name { get; set; } |
- Методи (methods) – от главата "Методи", знаем, че методите представляват именувани блокове програмен код. Те извършват някакви действия и чрез тях реализират поведението на обектите от този клас. В методите се изпълняват алгоритмите и се обработват данните на обекта.
Ето как изглежда един клас, който сме дефинирали сами и който притежава елементите, които описахме току-що:
// Class declaration public class Dog { // Opening brace of the class body
// Field declaration private string name;
// Constructor declaration public Dog() { this.name = "Balkan"; }
// Another constructor declaration public Dog(string name) { this.name = name; }
// Property declaration public string Name { get { return name; } set { name = value; } }
// Method declaration public void Bark() { Console.WriteLine("{0} said: Wow-wow!", name); } } // Closing brace of the class body |
За момента няма да обясняваме в по-големи детайли изложения код, тъй като подробна информация ще бъде дадена при обяснението как се декларира всеки един от елементите на класа.
Използване на класове и обекти
В главата "Създаване и използване на обекти" видяхме подробно как се създават нови обекти от даден клас и как могат да се използват. Сега накратко ще си припомним как ставаше това.
Как да използваме дефиниран от нас клас?
За да можем да използваме някой клас, първо трябва да създадем обект от него. За целта използваме ключовата дума new в комбинация с някой от конструкторите на класа. Това ще създаде обект от дадения клас (тип).
За да можем да манипулираме новосъздадения обект, ще трябва да го присвоим на променлива от типа на неговия клас. По този начин в тази променлива ще бъде запазена връзка (референция) към него.
Чрез променливата, използвайки точкова нотация, можем да извикваме методите, свойствата на обекта, както и да достъпваме полетата (член-променливите) и свойствата му.
Пример – кучешка среща
Нека вземем примера от предходната секция на тази глава, където дефинирахме класа Dog, който описва куче, и добавим метод Main() към него. В него ще онагледим казаното току-що:
static void Main() { string firstDogName = null; Console.WriteLine("Write first dog name: "); firstDogName = Console.ReadLine();
// Using a constructor to create a dog with specified name Dog firstDog = new Dog(firstDogName);
// Using a constructor to create a dog wit a default name Dog secondDog = new Dog();
Console.WriteLine("Write second dog name: "); string secondDogName = Console.ReadLine();
// Using property to set the name of the dog secondDog.Name = secondDogName;
// Creating a dog with a default name Dog thirdDog = new Dog();
Dog[] dogs = new Dog[] { firstDog, secondDog, thirdDog };
foreach (Dog dog in dogs) { dog.Bark(); } } |
Съответно изходът от изпълнението ще бъде следният:
Write first dog name: Bobcho Write second dog name: Walcho Bobcho said: Wow-wow! Walcho said: Wow-wow! Balkan said: Wow-wow! |
В примерната програма, с помощта на Console.ReadLine(), получаваме имената на обектите от тип куче, които потребителят трябва да въведе от конзолата.
Присвояваме първия въведен низ на променливата firstDogName. След това използваме тази променлива при създаването на първия обект от тип Dog – firstDog, като я подаваме като параметър на конструктора.
Създаваме втория обект от тип Dog, без да подаваме низ за името на кучето на конструктора му. След това, чрез Console.ReadLine(), въвеждаме името на второто куче и получената стойност директно подаваме на свойството Name. Извикването му става чрез точкова нотация, приложена към променливата, която пази референция към втория създаден обект от тип Dog – secondDog.Name.
Когато създаваме третия обект от тип Dog, не подаваме име на кучето на конструктора, нито след това модифицираме подразбиращата се стойност "Balkan".
След това създаваме масив от тип Dog, като го инициализираме с трите обекта, които току-що създадохме.
Накрая, използваме цикъл, за да обходим масива от обекти от тип Dog. На всеки елемент от масива, отново използвайки точкова нотация, извикваме метода Bark() за съответния обект чрез dog.Bark().
Природа на обектите
Нека припомним, че когато в .NET създадем един обект, той се състои от две части – същинска част от обекта, която съдържа неговите данни и се намира в частта от оперативната памет, наречена динамична памет (heap) и референция към този обект, която се намира в друга част от оперативната памет, където се държат локалните променливи и параметрите на методите, наречена стек (stack).
Например, нека имаме клас Dog, на който характеристиките му са име (name), порода (kind) и възраст (age). Създаваме променлива dog от този клас. Тази променлива се явява референция (указател) към обекта в динамичната памет (heap).
Референцията е променливата, чрез която достъпваме обекта. На схемата по-долу примерната референция, която има връзка към реалния обект в хийпа, е с името dog. В нея, за разлика от променливите от примитивен тип, не се съдържа самата стойност (т.е. данните на самия обект), а адреса, на който те се намират в хийпа:
Когато декларираме една променлива от тип някакъв клас, но не искаме тя да е инициализирана с връзка към конкретен обект, тогава трябва да й присвоим стойност null. Ключовата дума null в езика C# означава, че една променлива не сочи към нито един обект (липса на стойност):
Съхранение на собствени класове
В C# единственото ограничение относно съхранението на наши собствени класове е те да са във файлове с разширение .cs. В един такъв файл може да има няколко класа, структури и други типове. Въпреки че компилаторът не го изисква, е препоръчително всеки клас да се съхранява в отделен файл, който съответства на името му, т.е. класът Dog трябва да е записан във файл с име Dog.cs.
Вътрешна организация на класовете
Както знаем от темата "Създаване и използване на обекти", пространствата от имена (namespaces) в C# представляват именувани групи класове, които са логически свързани, без да има специално изискване как да бъдат разположени във файловата система.
Ако искаме да включим в кода си пространствата от имена нужни за работата на класовете, декларирани в даден файл или няколко файла, това трябва да стане чрез т.нар. using директиви. Те не са задължителни, но ако ги има, трябва да ги поставим на първите редове от файла, преди декларациите на класове или други типове. В следващите параграфи ще разберем за какво по-точно служат те.
След включването на използваните пространства от имена, следва декларирането на пространството от имена на класовете във файла. Както вече знаем, не сме задължени да дефинираме класовете си в пространство от имена, но е добра практика да го правим, тъй като разпределянето в пространства от имена помага за по-добрата организация на кода и разграничаването на класовете с еднакви имена.
Пространствата от имена съдържат декларации на класове, структури, интерфейси и други типове данни, както и други пространства от имена. Пример за вложени пространства от имена е пространството от имена System, което съдържа пространството от имена Data. Името на вложеното пространство е System.Data.
Пълното име на класа в .NET Framework е името на класа, предшествано от името на пространството от имена, в което той е деклариран: <namespace_name>.<class_name>. Чрез using директивите можем да използваме типовете от дадено пространство от имена, без да уточняваме пълното му име. Например:
using System; … DateTime date; |
вместо
System.DateTime date; |
Ето типичната последователност на декларациите, която трябва да следваме, когато създаваме собствени .cs файлове:
// Using directives - optional using <namespace1>; using <namespace2>;
// Namespace definition - optional namespace <namespace_name> { // Class declaration class <first_class_name> { // ... Class body ... }
// Class declaration class <second_class_name> { // ... Class body ... }
// ...
// Class declaration class <n-th_class_name> { // ... Class body ... } } |
Декларирането на пространство от имена и съответно включването на пространства от имена са вече обяснени в главата "Създаване и използване на обекти" и затова няма да ги дискутираме отново.
Преди да продължим, да обърнем внимание на първия ред от горната схема. Вместо включвания на пространства от имена той съдържа коментар. Това не е проблем, тъй като по време на компилация, коментарите се "изчистват" от кода и на първи ред от файла остава включване на пространство от имена.
Кодиране на файловете. Четене на кирилица и Unicode
Когато създаваме .cs файл, в който да дефинираме класовете си, е добре да помислим за кодирането при съхраняването му във файловата система.
Вътрешно в .NET Framework компилираният код се представя в Unicode кодиране и затова няма проблеми, ако във файла използваме символи, които са от азбуки, различни от латинската, например на кирилица:
using System;
public class EncodingTest { // Тестов коментар static int години = 4;
static void Main() { Console.WriteLine("години: " + години); } } |
Този код ще се компилира и изпълни без проблем, но за да запазим символите четими в редактора на Visual Studio, трябва да осигурим подходящото кодиране на файла.
За да направим това или ако искаме да използваме различно кодиране от Unicode, трябва да асоциираме съответното кодиране с файла. При отваряне на файлове това става по следния начин:
1. От File менюто избираме Open и след това File.
2. В прозореца Open File натискаме стрелката, съседна на бутона Open и избираме Open With.
3. От списъка на прозореца Open With избираме Editor с encoding support, например CSharp Editor with Encoding.
4. Натискаме [OK].
5. В прозореца Encoding избираме съответното кодиране от падащото меню Encoding.
6. Натискаме [OK].
За запаметяване на файлове във файловата система в определено кодиране стъпките са следните:
1. От менюто File избираме Save As.
2. В прозореца Save File As натискаме стрелката, съседна на бутона Save и избираме Save with Encoding.
3. В Advanced Save Options избираме желаното кодиране от списъка Encoding (за предпочитане е универсалното кодиране UTF-8).
4. От Line Endings избираме желания вид за край на реда.
Въпреки че имаме възможността да използваме символи от други азбуки, в .cs файловете, e препоръчително да пишем всички идентификатори и коментари на английски език, за да може кодът ни да е разбираем за повече хора по света.
Представете си само, ако ви се наложи да дописвате код, писан от виетнамец, където имената на променливите и коментарите са на виетнамски език. Не искате да ви се случва, нали? Тогава се замислете как ще се почувства един виетнамец, ако види променливи и коментари на български език.
Модификатори и нива на достъп (видимост)
Нека си припомним, от главата "Методи", че модификатор наричаме ключова дума с помощта, на която даваме допълнителна информация на компилатора за кода, за който се отнася модификаторът.
В C# има четири модификатора за достъп. Те са public, private, protected и internal. Модификатори за достъп могат да се използват само пред следните елементи на класа: декларация, полета, свойства и методи на класа.
Модификатори и нива на достъп
Както обяснихме, в C# има четири модификатора за достъп – public, private, protected и internal. С тях ние ограничаваме или позволяваме достъпа (видимостта) до елементите на класа, пред които те са поставени. Нивата на достъп в .NET биват public, protected, internal, protected internal и private. В тази глава ще се занимаем подробно само с public, private и internal. Повече за protected и protected internal ще научим в главата "Принципи на обектно-ориентираното програмиране".
Ниво на достъп public
Използвайки модификатора public, ние указваме на компилатора, че елементът, пред който е поставен, може да бъде достъпен от всеки друг клас, независимо дали е от текущия проект, от текущото пространство от имена или извън тях. Нивото на достъп public определя липса на ограничения върху видимостта, най-малко рестриктивното от всички нива на достъп в C#.
Ниво на достъп private
Нивото на достъп private, което налага най-голяма рестрикция на видимостта на класа и елементите му. Модификаторът private служи за индикация, че елементът, за който се отнася, не може да бъде достъпван от никой друг клас (освен от класа, в който е дефиниран), дори този клас да се намира в същото пространство от имена. Това ниво на достъп се използва по подразбиране, т.е. се прилага, когато липсва модификатор за достъп пред съответния елемент на класа.
Ниво на достъп internal
Модификаторът internal се използва, за да се ограничи достъпът до елемента само от файлове от същото асембли, т.е. същия проект във Visual Studio. Когато във Visual Studio направим няколко проекта, класовете от тях ще се компилират в различни асемблита.
Асембли (assembly)
Асембли (assembly) е колекция от типове и ресурси, която формира логическа единица функционалност. Всички типове в C# и изобщо в .NET Framework могат да съществуват само в асемблита. При всяка компилация на .NET приложение се създава асембли. То се съхранява като файл с разширение .exe или .dll.
Деклариране на класове
Декларирането на клас следва строго определени правила (синтаксис):
[<access_modifier>] class <class_name> |
Когато декларираме клас, задължително трябва да използваме ключовата дума class. След нея трябва да стои името на класа <class_name>.
Освен ключовата дума class и името на класа, в декларацията на класа могат да бъдат използвани някои модификатори, например разгледаните вече модификатори за достъп.
Видимост на класа
Нека имаме два класа – А и В. Казваме, че класът А, има достъп до класа В, ако може да прави едно от следните неща:
1. Създава обект (инстанция) от тип класа В.
1. Достъпва определени методи и член-променливи (полета) в класа В, в зависимост от нивото на достъп на съответните методи и полета.
Има и трета операция, която може да бъде извършвана с класове, когато видимостта им позволява, наречена наследяване на клас, но на нея ще се спрем по-късно, в главата "Принципи на обектно-ориентираното програмиране".
Както разбрахме, ниво достъп означава "видимост". Ако класът А не може да "види" класа В, нивото на достъп на методите и полетата в класа В нямат значение.
Нивата на достъп, които един невложен клас може да има, са само public и internal.
Ниво на достъп public
Ако декларираме един клас с модификатор за достъп public, ще можем да го достъпваме от всеки един клас и от всичко едно пространство от имена, независимо къде се намират те. Това означава, че всеки друг клас ще може да създава обекти от тип този клас и да има достъп до методите и полетата на класа (стига тези полета да имат подходящо ниво на достъп).
Не трябва да забравяме, че ако искаме да използваме клас с ниво на достъп public от друго пространство от имена, различно от текущото, трябва да използваме конструкцията за включване на пространства от имена using или всеки път да изписваме пълното име на класа.
Ниво на достъп internal
Ако декларираме един клас с модификатор за достъп internal, той ще бъде достъпен само от същото асембли. Това означава, че само класовете от същото асембли ще могат да създават обекти от тип този клас и да имат достъп до методите и полетата (с подходящо ниво на достъп) на класа. Това ниво на достъп се подразбира, когато не е използван никакъв модификатор за достъп при декларацията на класа.
Ако във Visual Studio имаме два проекта в общ solution и искаме от единия проект да използваме клас, дефиниран в другия проект, то реферираният клас трябва задължително да е public.
Ниво на достъп private
За да сме изчерпателни, трябва да споменем, че като модификатор за достъп до клас, може да се използва модификаторът за видимост private, но това е свързано с понятието "вътрешен клас" (nested class), което ще разгледаме в секцията "Вътрешни класове".
Тяло на класа
По подобие на методите, след декларацията на класа следва неговото тяло, т.е. частта от класа, в която се съдържа програмния код:
[<access_modifier>] class <class_name> { // ... Class body – the code of the class goes here ... } |
Тялото на класа започва с отваряща фигурна скоба "{" и завършва със затваряща – "}". Класът винаги трябва да има тяло.
Правила за именуване на класовете
По подобие на декларирането на име на метод, за създаването на име на клас съществува следния общоприет стандарт:
1. Имената на класовете започват с главна буква, а останалите букви са малки. Ако името е съставено от няколко думи, всяка дума започва с главна буква, без да се използват разделители между думите.
2. За имена на класове обикновено се използват съществителни имена.
3. Името на класовете е препоръчително да бъде на английски език.
Ето няколко примера за имена на класове, които са правилно именувани:
Account Car BufferedReader |
Повече за имената на класовете ще научите в главата "Качествен програмен код".
Ключовата дума this
Ключовата дума this в C# дава достъп до референцията към текущия обект, когато се използва от метод в даден клас. Това е обектът, чийто метод или конструктор бива извикван. Можем да я разглеждаме като указател (референция), дадена ни априори от създателите на езика, с която да достъпваме елементите (полета, методи, конструктори) на собствения ни клас:
this.myField; // access a field in the class this.DoMyMethod(); // access a method in the class this(3, 4); // access a constructor with two int parameters |
За момента няма да обясняваме изложения код. Разяснения ще дадем по-късно, в местата от секциите на тази глава, посветени на елементите на класа (полета, методи, конструктори) и засягащи ключовата дума this.
Полета
Както стана дума в началото на главата, когато декларираме клас, описваме обект от реалния свят. За описанието на този обект, се фокусираме само върху характеристиките му, които имат отношение към проблема, който ще решава нашата програма.
Тези характеристики на реалния обект ги интерпретираме в декларацията на класа, като декларираме набор от специален тип променливи, наречени полета, в които пазим данните за отделните характеристики. Когато създадем обект по описанието на нашия клас, стойностите на полетата, ще съдържат конкретните характеристики, с които даден екземпляр от класа (обект) се отличава от всички останали обекти от същия клас.
Деклариране на полета в даден клас
До момента сме се сблъсквали само с два типа променливи (вж. главата "Методи"), в зависимост от това къде са декларирани:
1. Локални променливи – това са променливите, които са дефинирани в тялото на някой метод (или блок).
2. Параметри – това са променливите в списъка с параметри, които един метод може да има.
В C# съществува и трети вид променливи, наречени полета (fields) или член-променливи на класа (instance variables).
Те се декларират в тялото на класа, но извън тялото на блок, метод или конструктор (какво е конструктор ще разгледаме подробно след малко).
Полетата се декларират в тялото на класа, но извън тялото на метод, конструктор или блок. |
Ето един примерен код, в който се декларират няколко полета:
class SampleClass { int age; long distance; string[] names; Dog myDog; } |
Формално, декларацията на полетата става по следния начин:
[<modifiers>] <field_type> <field_name>; |
Частта <field_type> определя типа на даденото поле. Той може да бъде както примитивен тип (byte, short, char и т.н.) или масив, така и от тип някакъв клас (например Dog или string).
Частта <field_name> е името на даденото поле. Както при имената на обикновените променливи, когато именуваме една член-променлива, трябва да спазваме правилата за именуване на идентификатори в C# (вж. главата "Примитивни типове и променливи").
Частта <modifiers> е понятие, с което сме означили както модификаторите за достъп, така и други модификатори. Те не са задължителна част от декларацията на едно поле.
Модификаторите и нивата на достъп, позволени в декларацията на едно поле, са обяснени в секцията "Видимост на полета и методи".
В тази глава, от другите модификатори, които не са за достъп, и могат да се използват при декларирането на полета на класа, ще обърнем внимание още на static, const и readonly.
Област на действие (scope)
Трябва да знаем, че областта на действие (scope) на едно поле е от реда, на който е декларирано, до затварящата фигурна скоба на тялото на класа.
Инициализация по време на деклариране
Когато декларираме едно поле е възможно едновременно с неговата декларация да му дадем първоначална стойност. Начинът, по който става това, е същият както при инициализацията (даването на стойност) на обикновена локална променлива:
[<modifiers>] <field_type> <field_name> = <initial_value>; |
Разбира се, трябва <initial_value> да бъде от типа на полето или някой съвместим с него тип. Например:
class SampleClass { int age = 5; long distance = 234; // The literal 234 is of integer type
string[] names = new string[] { "Pencho", "Marincho" }; Dog myDog = new Dog();
// ... Other code ... } |
Стойности по подразбиране на полетата
Всеки път, когато създаваме нов обект от даден клас, се заделя област в динамичната памет за всяко поле от класа. След като бъде заделена, тази памет се инициализира автоматично с подразбиращи стойности за конкретния тип поле (занулява се). Полетата, които на се инициализират изрично при декларацията на полето или в някой от конструкторите, се зануляват.
При създаване на обект всички негови полета се инициализират с подразбиращите се стойности за типа им, освен ако изрично не бъдат инициализирани. |
В някои езици (като C и C++) новозаделените обекти не се инициализират автоматично с нулеви стойности и това създава условия за допускане на трудни за откриване грешки. Появява се синдромът "ама това вчера работеше" – непредвидимо поведение, при което програмата понякога работи коректно (когато заделената памет съдържа по случайност благоприятни стойности), а понякога не работи (когато заделената памет съдържа неблагоприятни стойности. В C# и въобще в .NET платформата този проблем е решен чрез автоматичното зануляване на полетата.
Стойността по подразбиране за всички типове е 0 или неин еквивалент. За най-често използваните типове подразбиращите се стойности са както следва:
Тип на поле |
Стойност по подразбиране |
bool |
false |
byte |
0 |
char |
'\0' |
decimal |
0.0M |
double |
0.0D |
float |
0.0F |
int |
0 |
референция към обект |
null |
За по-изчерпателна информация може да погледнете темата "Примитивни типове и променливи", секция "Типове данни", подсекция "Видове", където има пълен списък с всички примитивни типове данни в C# и подразбиращите се стойности за всеки един от тях.
Например, ако създадем клас Dog и за него дефинираме полета име (name), възраст (age), дължина (length) и дали кучето е от мъжки пол (isMale), без да ги инициализираме по време на декларацията им, те ще бъдат автоматично занулени при създаването на обект от този клас:
public class Dog { string name; int age; int length; bool isMale;
static void Main() { Dog dog = new Dog();
Console.WriteLine("Dog's name is: " + dog.name); Console.WriteLine("Dog's age is: " + dog.age); Console.WriteLine("Dog's length is: " + dog.length); Console.WriteLine("Dog is male: " + dog.isMale); } } |
Съответно при стартиране на примера като резултат ще получим:
Dog's name is: Dog's age is: 0 Dog's length is: 0 Dog is male: False |
Автоматична инициализация на локални променливи и полета
Ако дефинираме дадена локална променлива в един метод, без да я инициализираме, и веднага след това се опитаме да я използваме (примерно като отпечатаме стойността й), това ще предизвика грешка при компилация, тъй като локалните променливи не се инициализират с подразбиращи се стойности по време на тяхното деклариране.
За разлика от полетата, локалните променливи, не биват инициализирани с подразбираща се стойност при тяхното деклариране. |
Нека разгледаме един пример:
static void Main() { int notInitializedLocalVariable; Console.WriteLine(notInitializedLocalVariable); } |
Ако се опитаме да компилираме горния код, ще получим следното съобщение за грешка:
Use of unassigned local variable 'notInitializedLocalVariable' |
Собствени стойности по подразбиране
Добър стил на програмиране е обаче, когато декларираме полетата на класа си, изрично да ги инициализираме с някаква подразбираща се стойност, дори ако тя е нула. Въпреки, че C# ще занули всяко едно от полетата, ако ги инициализираме изрично, ще направим кода по-ясен и по-лесен за възприемане.
Пример за такова инициализиране може да дадем като модифицираме класът SampleClass от предходната секция "Инициализация по време на деклариране":
class SampleClass { int age = 0; long distance = 0; string[] names = null; Dog myDog = null;
// ... Other code ... } |
Модификатори const и readonly
Както споменахме в началото на тази секция, в декларацията на едно поле е позволено да се използват модификаторите const и readonly. Те не са модификатори за достъп, а се използват за еднократно инициализиране на полета. Полета, декларирани като const или readonly се наричат константи. Използват се когато дадена стойност се повтаря на няколко места в програмата. В такива стойността се изнася като константа и се дефинира само веднъж. Пример за константи от .NET Framework са математическите константи Math.PI и Math.E, както и константите String.Empty и Int32.MaxValue.
Константи, декларирани с const
Полетата, имащи модификатор const в декларацията си, трябва да бъдат инициализирани при декларацията си и след това стойността им не може да се променя. Те могат да бъдат достъпвани без да има инстанция на класа, тъй като са споделени между всичко обекти на класа. Нещо повече, при компилация на всички места в кода, където се реферират const полета, те се заместват със стойността им, сякаш тя е зададена директно, а не чрез константа. По тази причина const полетата се наричат още compile-time константи, защото се заместват със стойността им по време на компилация.
Константи, декларирани с readonly
Модификаторът readonly задава полета, чиято стойността не може да се променя след като веднъж е зададена. Полетата, декларирани с readonly, позволяват еднократна инициализация или в момента на декларирането им или в конструкторите на класа. По-късно те не могат да се променят. По тази причина readonly полетата се наричат още run-time константи – константи, защото стойността им не може да се променя след като се зададе първоначално и run-time, защото стойността им се извлича по време на работа на програмата, както при всички останали полета в класа.
Нека онагледим казаното с пример:
public class ConstReadonlyModifiersTest { public const double PI = 3.1415926535897932385; public readonly double size;
public ConstReadonlyModifiersTest(int size) { this.size = size; // Cannot be further modified! }
static void Main() { Console.WriteLine(PI); Console.WriteLine(ConstReadonlyModifiersTest.PI); ConstReadonlyModifiersTest t = new ConstReadonlyModifiersTest(5); Console.WriteLine(t.size);
// This will cause compile-time error Console.WriteLine(ConstReadonlyModifiersTest.size); } } |
Методи
В главата "Методи" подробно се запознахме с това как да декларираме и използваме метод. В тази секция накратко ще припомним казаното там и ще се фокусираме върху някои допълнителни особености при декларирането и създаването на методи.
Деклариране на методи в даден клас
Декларирането на методи, както знаем, става по следния начин:
// Method definition [<modifiers>] [<return_type>] <method_name>([<parameters_list>]) { // ... Method’s body ... [<return_statement>]; } |
Задължителните елементи при декларирането на метода са типът на връщаната стойност <return_type>, името на метода <method_name> и отварящата и затварящата кръгли скоби – "(" и ")".
Списъкът от параметри <params_list> не е задължителен. Използваме го да подаваме някакви данни на метода, който декларираме, ако той има нужда.
Знаем, че ако типът на връщаната стойност <return_type> е void, тогава <return_statement> може да участва само с оператора return без аргумент, с цел прекратяване действието на метода. Ако <return_type> е различен от void, методът задължително трябва да връща резултат чрез ключовата дума return с аргумент, който е от тип <return_type> или съвместим с него.
Работата, която методът трябва да свърши, се намира в тялото му, заградена от фигурни скоби – "{" и "}".
Макар че разгледахме някои от модификаторите за достъп, позволени да се използват при декларирането на един метод, в секцията "Видимост на полета и методи" ще разгледаме по-подробно тази тема.
Ще разгледаме модификатора static в секцията "Статични класове (Static classes) и статични членове на класа (static members) на тази глава.
Пример – деклариране на метод
Нека погледнем декларирането на един метод за намиране сбор на две цели числа:
int Add(int number1, int number2) { int result = number1 + number2; return result; } |
Името, с което сме го декларирали, е Add, а типът на връщаната му стойност е int. Списъкът му от параметри се състои от два елемента – променливите number1 и number2. Съответно, връщаме стойността на сбора от двете числа като резултат.
Достъп до нестатичните данни на класа
В главата "Създаване и използване на обекти", разгледахме как чрез оператора точка, можем да достъпим полетата и да извикаме методите на един клас. Нека припомним как можем да достъпваме полета и да извикваме методи на даден клас, които не са статични, т.е. нямат модификатор static, в декларацията си.
Например, нека имаме клас Dog, с поле за възраст – age. За да отпечатаме стойността на това поле, е нужно да създадем обект от клас Dog и да достъпим полето на този обект чрез точкова нотация:
public class Dog { int age = 2;
public static void Main() { Dog dog = new Dog(); Console.WriteLine("Dog's age is: " + dog.age); } } |
Съответно резултатът ще бъде:
Dog's age is: 2 |
Достъп до нестатичните полетата на класа от нестатичен метод
Достъпът до стойността на едно поле може да се осъществява не директно чрез оператора точка (както бе в последния пример dog.age), а чрез метод или свойство. Нека в класа Dog си създадем метод, който връща стойността на полето age:
public int GetAge() { return this.age; } |
Както виждаме, за да достъпим стойността на полето за възрастта, вътре, от самия клас, използваме ключовата дума this. Знаем, че ключовата дума this е референция към текущия обект, към който се извиква метода. Следователно, в нашия пример, с "return this.age", ние казваме "от текущия обект (this) вземи (използването на оператора точка) стойността на полето age и го върни като резултат от метода (чрез ключовата дума return)". Тогава, вместо в метода Main() да достъпваме стойността на полето age на обекта dog, ние просто ще извикаме метода GetAge():
static void Main() { Dog dog = new Dog(); Console.WriteLine("Dog's age is: " + dog.GetAge()); } |
Резултатът след тази промяна ще бъде отново същият.
Формално, декларацията за достъп до поле в рамките на класа, е следната:
this.<field_name> |
Нека подчертаем, че този достъп е възможен само от нестатичен код, т.е. метод или блок, който няма модификатор static.
Освен за извличане на стойността на едно поле, можем да използваме ключовата дума this и за модифициране на полето.
Например, нека декларираме метод MakeOlder(), който извикваме всяка година на датата на рождения ден на нашия домашен любимец и който, увеличава възрастта му с една година:
public void MakeOlder() { this.age++; } |
За да проверим дали това, което написахме работи коректно, в края на метода Main() добавяме следните два реда:
// One year later, on the birthday date... dog.MakeOlder(); Console.WriteLine("After one year dog's age is: " + dog.age); |
След изпълнението, резултатът е следният:
Dog's age is: 2 After one year dog's age is: 3 |
Извикване нестатичните методи на класа от нестатичен метод
По подобие на полетата, които нямат static в декларацията си, методите, които също не са статични, могат да бъдат извиквани в тялото на класа чрез ключовата дума this. Това става, след като към нея, чрез точкова нотация добавим метода, който ни е необходим заедно с аргументите му (ако има параметри):
this.<method_name>(…) |
Например, нека създадем метод PrintAge(), който отпечатва възрастта на обекта от тип Dog, като за целта извиква метода GetAge():
public void PrintAge() { int myAge = this.GetAge(); Console.WriteLine("My age is: " + myAge); } |
На първия ред от примера указваме, че искаме да получим възрастта (стойността на полето age) на текущия обект, използвайки метода GetAge() на текущия обект. Това става чрез ключовата дума this.
Достъпването на нестатичните елементи на класа (полета и методи) се осъществява чрез ключовата дума this и оператора за достъп – точка. |
Достъп до нестатични данни на класа без използване на this
Когато достъпваме полетата на класа или извикваме нестатичните му методи, е възможно, да го направим без ключовата дума this. Тогава двата метода, които декларирахме могат да бъдат записани по следния начин:
public int GetAge() { return age; // The same like this.age }
public void MakeOlder() { age++; // The same like this.age++ } |
Ключовата дума this се използва, за да укаже изрично, че трябва да се осъществи достъп до нестатично поле на даден клас или извикваме негов нестатичен метод. Когато това изрично уточнение не е необходимо, може да бъде пропускана и директно да се достъпва елемента на класа.
Когато не е нужно изрично да се укаже, че се осъществява достъп до елемент на класа, ключовата дума this може да бъде пропусната. |
Въпреки, че се подразбира, ключовата дума this често се използва при достъп до полетата на класа, защото прави кода по-лесен за четене и разбиране, като изрично уточнява, че трябва да се направи достъп до член на класа, а не до локална променлива.
Припокриване на полета с локални променливи
От секцията "Деклариране на полета в даден клас" по-горе, знаем, че областта на действие на едно поле е от реда, на който е декларирано полето, до затварящата скоба на тялото на класа. Например:
public class OverlappingScopeTest { int myValue = 3;
void PrintMyValue() { Console.WriteLine("My value is: " + myValue); }
static void Main() { OverlappingScopeTest instance = new OverlappingScopeTest(); instance.PrintMyValue(); } } |
Този код ще изведе в конзолата като резултат:
My value is: 3 |
От друга страна, когато имплементираме тялото на един метод, ни се налага да дефинираме локални променливи, които да използваме по време на изпълнение на метода. Както знаем, областта на действие на тези локални променливи започва от реда, на който са декларирани и продължава до затварящата фигурна скоба на тялото на метода. Например, нека добавим този метод в току-що декларирания клас OverlappingScopeTest:
int CalculateNewValue(int newValue) { int result = myValue + newValue; return result; } |
В този случай, локалната променлива, която използваме, за да изчислим новата стойност, е result.
Понякога обаче, може името на някоя локална променлива да съвпадне с името на някое поле. Тогава настъпва колизия.
Нека първо погледнем един пример, преди да обясним за какво става въпрос. Нека модифицираме метода PrintMyValue() по следния начин:
void PrintMyValue() { int myValue = 5; Console.WriteLine("My value is: " + myValue); } |
Ако декларираме така метода, дали той ще се компилира? А ако се компилира, дали ще се изпълни? Ако се изпълни коя стойност ще бъде отпечатана – тази на полето или тази на локалната променлива?
Така деклариран, след като бъде изпълнен методът Main(), резултатът, който ще бъде отпечатан, ще бъде:
My value is: 5 |
Това е така, тъй като C# позволява да се дефинират локални променливи, чиито имена съвпадат с някое поле на класа. Ако това се случи, казваме, че областта на действие на локалната променлива припокрива областта на действие на полето (scope overlapping).
Точно затова областта на действие на локалната променлива myValue със стойност 5 препокри областта на действие на полето със същото име. Тогава, при отпечатването на стойността, бе използвана стойността на локалната променлива.
Въпреки това, понякога се налага при колизия на имената да бъде използвано полето, а не локалната променлива със същото име. В този случай, за да извлечем стойността на полето, използваме ключовата дума this. За целта достъпваме полето чрез оператора точка, приложен към this. По този начин еднозначно указваме, че искаме да използваме стойността на полето, не на локалната променлива със същото име.
Нека разгледаме отново нашия пример с извеждането на стойността на полето myValue:
void PrintMyValue() { int myValue = 5; Console.WriteLine("My value is: " + this.myValue); } |
Този път, резултатът от извикването на метода е:
My value is: 3 |
Видимост на полета и методи
В началото на главата разгледахме общите положения с модификаторите и нивата на достъп на елементите на един клас в C#. По-късно се запознахме подробно с нивата на достъп при декларирането на един клас.
Сега ще разгледаме нивата на видимост на полетата и методите в класа. Тъй като полетата и методите са елементи (членове) на класа и имат едни и същи правила при определяне на нивото им на достъп, ще изложим тези правила едновременно.
За разлика от декларацията на клас, при декларирането на полета и методи на класа, могат да бъдат използвани и четирите нива на достъп – public, protected, internal и private. Нивото на видимост protected няма да бъде разглеждано в тази глава, тъй като е обвързано с наследяването на класове и е обяснено подробно в главата "Принципи на обектно-ориентираното програмиране".
Преди да продължим, нека припомним, че ако един клас A, не е видим (няма достъп) от друг клас B, тогава нито един елемент (поле или метод) на класа A, не може да бъде достъпен от класа B.
Ако два класа не са видими един за друг, то елементите им (полета и методи) не са видими също, независимо с какви нива на достъп са декларирани самите те. |
В следващите подсекции, към обясненията, ще разглеждаме примери, в които имаме два класа (Dog и Kid), които са видими един за друг, т.е. всеки един от класовете може да създава обекти от тип – другия клас и да достъпва елементите му в зависимост от нивото на достъп, с което са декларирани. Ето как изглежда първия клас Dog:
public class Dog { private string name = "Sharo";
public string Name { get { return this.name; } }
public void Bark() { Console.WriteLine("wow-wow"); }
public void DoSth() { this.Bark(); } } |
В освен полета и методи се използва и свойство Name, което просто връща полето name. Ще разгледаме свойствата след малко, така че за момента се фокусирайте върху останалото.
Кодът на класа Kid има следния вид:
public class Kid { public void CallTheDog(Dog dog) { Console.WriteLine("Come, " + dog.Name); }
public void WagTheDog(Dog dog) { dog.Bark(); } } |
В момента, всички елементи (полета и методи) на двата класа са декларирани с модификатор за достъп public, но при обяснението на различните нива на достъп, ще го променяме съответно. Това, което ще ни интересува, е как промяната в нивото на достъп на елементите (полета и методи) на класа Dog и ще рефлектира върху достъпа до тези елементи, когато този достъп се извършва от:
- Самото тяло на класа Dog.
- Тялото на класа Kid, съответно вземайки предвид дали Kid е в пространството от имена (или асембли), в което се намира класа Dog или не.
Ниво на достъп public
Когато метод или променлива на класа са декларирани с модификатор за достъп public, те могат да бъдат достъпвани от други класове, независимо дали другите класове са декларирани в същото пространство от имена, в същото асембли или извън него.
Нека разгледаме двата типа достъп до член на класа, които се срещат в нашите класове Dog и Kid:
Достъп до член на класа осъществен в самата декларация на класа. |
|
Достъп до член на класа осъществен, чрез референция към обект, създаден в тялото на друг клас |
Когато членовете на двата класа са public, се получава следното:
Dog.cs |
|
|
class Dog { public string name = "Sharo";
public string Name { get { return this.name; } }
public void Bark() { Console.WriteLine("wow-wow"); }
public void DoSth() { this.Bark(); } } |
Kid.cs |
|
|
class Kid { public void CallTheDog(Dog dog) { Console.WriteLine("Come, " + dog.name); }
public void WagTheDog(Dog dog) { dog.Bark(); } } |
Както виждаме, без проблем осъществяваме, достъп до полето name и до метода Bark() в класа Dog от тялото на самия клас. Независимо дали класът Kid е в пространството от имена на класа Dog, можем от тялото му, да достъпим полето name и съответно да извикаме метода Bark() чрез оператора точка, приложен към референцията dog към обект от тип Dog.
Ниво на достъп internal
Когато член на някой клас бъде деклариран с ниво на достъп internal, тогава този елемент на класа може да бъде достъпван от всеки клас в същото асембли (т.е. в същия проект във Visual Studio), но не и от класовете извън него (т.е. от друг проект във Visual Studio):
Dog.cs |
|
|
class Dog { internal string name = "Sharo";
public string Name { get { return this.name; } }
internal void Bark() { Console.WriteLine("wow-wow"); }
public void DoSth() { this.Bark(); } } |
Съответно, за класа Kid, разглеждаме двата случая:
- Когато е в същото асембли, достъпът до елементите на класа Dog, ще бъде позволен, независимо дали двата класа са в едно и също пространство от имена или в различни:
Kid.cs |
|
|
class Kid { public void CallTheDog(Dog dog) { Console.WriteLine("Come, " + dog.name); }
public void WagTheDog(Dog dog) { dog.Bark(); } } |
- Когато класът Kid е външен за асемблито, в което е деклариран класът Dog, тогава достъпът до полето name и метода Bark() ще е невъзможен:
Kid.cs |
|
|
class Kid { public void CallTheDog(Dog dog) { Console.WriteLine("Come, " + dog.name); }
public void WagTheDog(Dog dog) { dog.Bark(); } } |
Всъщност достъпът до internal членовете на класа Dog е невъзможен по две причини: недостатъчна видимост на класа и недостатъчна видимост на членовете му. За да се позволи достъп от друго асембли до класа Dog, той, е необходимо той да е деклариран като public и едновременно с това въпросните му членове да са декларирани като public. Ако или класът или членовете му имат по-ниска видимост, достъпът до тях е невъзможен от други асемблита (други Visual Studio проекти).
Ако се опитаме да компилираме класа Kid, когато е външен за асемблито, в което се намира класа Dog, ще получим грешки при компилация.
Ниво на достъп private
Нивото на достъп, което налага най-много ограничения е private. Елементите на класа, които са декларирани с модификатор за достъп private (или са декларирани без модификатор за достъп, защото тогава private се подразбира), не могат да бъдат достъпвани от никой друг клас, освен от класа, в който са декларирани.
Следователно, ако декларираме полето name и метода Bark() на класа Dog, с модификатори private, няма проблем да ги достъпваме вътрешно от самия клас Dog, но достъп от други класове не е позволен, дори ако са от същото асембли:
Dog.cs |
|
|
class Dog { private string name = "Sharo";
public string Name { get { return this.name; } }
private void Bark() { Console.WriteLine("wow-wow"); }
public void DoSth() { this.Bark(); } } |
Kid.cs |
|
|
class Kid { public void CallTheDog(Dog dog) { Console.WriteLine("Come, " + dog.name); }
public void WagTheDog(Dog dog) { dog.Bark(); } } |
Трябва да знаем, че когато задаваме модификатор за достъп за дадено поле, той най-често трябва да бъде private, тъй като така даваме възможно най-висока защита на достъпа до стойността на полето. Съответно, достъпът и модификацията на тази стойност от други класове (ако са необходими) ще се осъществяват единствено чрез свойства или методи. Повече за тази техника ще научим в секцията "Капсулация" на главата "Принципи на обектно-ориентираното програмиране".
Как се определя нивото на достъп на елементите на класа?
Преди да приключим със секцията за видимостта на елементите на един клас, нека направим един експеримент. Нека в класа Dog полето name и метода Bark() са декларирани с модификатор за достъп private. Нека също така, декларираме метод Main(), със следното съдържание:
public class Dog { private string name = "Sharo";
// ...
private void Bark() { Console.WriteLine("wow-wow"); }
// ...
public static void Main() { Dog myDog = new Dog(); Console.WriteLine("My dog's name is " + myDog.name); myDog.Bark(); } } |
Въпросът, който стои пред нас е, ще се компилира ли класът Dog, при положение, че сме декларирали елементите на класа с модификатор за достъп private, а в същото време ги извикваме с точкова нотация, приложена към променливата myDog, в метода Main()?
Стартираме компилацията и тя минава успешно. Съответно, резултатът от изпълнението на метода Main(), който декларирахме в класа Dog ще бъде следният:
My dog’s name is Sharo Wow-wow |
Всичко се компилира и работи, тъй като модификаторите за достъп до елементите на класа се прилагат на ниво клас, а не на ниво обекти. Тъй като променливата myDog е дефинирана в тялото на класа Dog (където е разположен и Main() метода на програмата), можем да достъпваме елементите му (полета и методи) чрез точкова нотация, независимо че са декларирани с ниво на достъп private. Ако обаче се опитаме да направим същото от тялото на класа Kid, това няма да е възможно, тъй като достъпът до private полетата от външен клас не е разрешено.
Конструктори
В обектно-ориентираното програмиране, когато създаваме обект от даден клас, е необходимо да извикаме елемент от класа, наречен конструктор.
Какво е конструктор?
Конструктор на даден клас, наричаме псевдометод, който няма тип на връщана стойност, носи името на класа и който се извиква чрез ключовата дума new. Задачата на конструктора е да инициализира заделената за обекта памет, в която ще се съхраняват неговите полетата (тези, които не са static).
Извикване на конструктор
Единственият начин да извикаме един конструктор в C# е чрез ключовата дума new. Тя заделя памет за новия обект (в стека или в хийпа според това дали обектът е стойностен или референтен тип), занулява полетата му, извиква конструктора му (или веригата конструктори, образувана при наследяване) и накрая връща референция към новозаделения обект.
Нека разгледаме един пример, от който ще стане ясно как работи конструкторът. От главата "Създаване и използване на обекти", знаем как се създава обект:
Dog myDog = new Dog(); |
В случая, чрез ключовата дума new, извикваме конструктора на класа Dog, при което се заделя паметта необходима за новосъздадения обект от тип Dog. Когато става дума за класове, те се заделят в динамичната памет (хийпа). Нека проследим как протича този процес стъпка по стъпка. Първо се заделя памет за обекта:
След това се инициализират полетата му (ако има такива) с подразбиращите се стойности за съответните им типове:
Ако създаването на новия обект е завършило успешно, конструкторът връща референция към него, която се присвоява на променливата myDog, от тип класа Dog:
Деклариране на конструктор
Ако имаме класа Dog, ето как би изглеждал неговия най-опростен конструктор:
public Dog() { } |
Формално, декларацията на конструктора изглежда по следния начин:
[<modifiers>] <class_name>([<parameters_list>]) |
Както вече знаем, конструкторите приличат на методи, но нямат тип на връщана стойност (затова ги нарекохме псевдометоди).
Име на конструктора
В C# задължително името на всеки конструктор съвпада с името на класа, в който го декларираме – <class_name>. В примера по-горе, името на конструктора е същото, каквото е името на класа – Dog. Трябва да знаем, че както при методите, името на конструктора винаги е следвано от кръгли скоби – "(" и ")".
В C# не е позволено, да се декларира метод, който притежава име, което съвпада с името на класа (следователно и с името на конструкторите). Ако въпреки всичко бъде деклариран метод с името на класа, това ще доведе до грешка при компилация.
public class IllegalMethodExample { // Legal constructor public IllegalMethodExample () { }
// Illegal method private string IllegalMethodExample() { return "I am illegal method!"; } } |
При опит за компилация на този клас, компилаторът ще изведе следното съобщение за грешка:
SampleClass: member names cannot be the same as their enclosing type |
Списък с параметри
По подобие на методите, ако за създаването на обекта са необходими допълнителни данни, конструкторът ги получава чрез списък от параметри – <parameters_list>. В примерния конструктор на класа Dog няма нужда от допълнителни данни за създаване на обект от такъв тип и затова няма деклариран списък от параметри. Повече за списъка от параметри ще разгледаме в една от следващите секции – "Деклариране на конструктор с параметри".
Разбира се след декларацията на конструктора, следва неговото тяло, което е като тялото на всеки един метод в C#, но по принцип съдържа предимно инициализационна логика, т.е. задава начални стойности на полетата на класа.
Модификатори
Забелязваме, че в декларацията на конструктора, може да се добавят модификатори – <modifiers>. За модификаторите, които познаваме и които не са модификатори за достъп, т.е. const и static, трябва да знаем, че само const не е позволен за употреба при декларирането на конструктори. По-късно в тази глава, в секцията "Статични конструктори" ще научим повече подробности за конструктори декларирани с модификатор static.
Видимост на конструкторите
По подобие на полетата и методите на класа, конструкторите, могат да бъдат декларирани с нива на видимост public, protected, internal, protected internal и private. Нивата на достъп protected и protected internal ще бъдат обяснени в главата "Принципи на обектно-ориентираното програмиране". Останалите нива на достъп имат същото значение и поведение като при полетата и методите.
Инициализация на полета в конструктора
Както обяснихме по-рано, при създаването на нов обект и извикването на конструктор, се заделя памет за нестатичните полетата на обекта от дадения клас и те се инициализират със стойностите по подразбиране за техния тип (вж. секция "Извикване на конструктор").
Освен това, чрез конструкторите най-често инициализираме полетата на класа, със стойности зададени от нас, а не с подразбиращите се за типа.
Например, в примерите, които разглеждахме до момента, винаги полето name на обекта от тип Dog, го инициализирахме по време на неговата декларация:
string name = "Sharo"; |
Вместо да правим това по време на декларацията на полето, по-добър стил на програмиране е да му дадем стойност в конструктора:
public class Dog { private string name;
public Dog() { this.name = "Sharo"; }
// ... The rest of the class body ... } |
В някои книги се препоръчва, въпреки че инициализираме полетата в конструктора, изрично да присвояваме подразбиращите се за типа им стойности по време на инициализация, с цел да се подобри четимостта на кода, но това е въпрос на личен избор:
public class Dog { private string name = null;
public Dog() { this.name = "Sharo"; }
// ... The rest of the class body ... } |
Инициализация на полета в конструктора – представяне в паметта
Нека разгледаме подробно, какво прави конструкторът след като бъде извикан и в тялото му инициализираме полетата на класа. Знаем, че при извикване, той ще задели памет за всяко поле и тази памет ще бъде инициализирана със стойността по подразбиране.
Ако полетата са от примитивен тип, тогава след подразбиращите се стойности, ще бъдат присвоени новите, които ние подаваме.
В случая, когато полетата са от референтен тип, например нашето поле name, конструкторът ще ги инициализира с null. След това ще създаде обекта от съответния тип, в случая низа "Sharo" и накрая ще се присвои референция към новия обект в съответното поле, при нас – полето name.
Същото ще се получи, ако имаме и други полета, които не са примитивни типове и ги инициализираме в конструктора. Например, нека имаме клас, който описва каишка – Collar:
public class Collar { private int size;
public Collar() { } } |
Нека съответно нашият клас Dog, има поле collar, което е от тип Collar и което инициализираме в конструктора на класа:
public class Dog { private string name; private int age; private double length; private Collar collar;
public Dog() { this.name = "Sharo"; this.age = 3; this.length = 0.5; this.collar = new Collar(); }
public static void Main() { Dog myDog = new Dog(); } } |
Нека проследим стъпките, през които минава конструкторът, след като бъде извикан в Main() метода. Както знаем, той ще задели памет в хийпа за всички полета, и ще ги инициализира със съответните им подразбиращи се стойности:
След това, конструкторът ще трябва да се погрижи за създаването на обекта за полето name (т.е. ще извика конструктора на класа string, който ще свърши работата по създаването на низа):
След това нашия конструктор ще запази референция към новия низ в полето name:
След това идва ред на създаването на обекта от тип Collar. Нашият конструктор (на класа Dog), извиква конструктора на класа Collar, който заделя памет за новия обект:
След това я инициализира с подразбиращата се стойност за съответния тип:
След това референцията към новосъздадения обект, която конструкторът на класа Collar връща като резултат от изпълнението си, се записва в полето collar:
Накрая, референцията към новия обект от тип Dog се присвоява на локалната променлива myDog в метода Main():
Помним, че локалните променливи винаги се съхраняват в областта от оперативната памет, наречена стек, а обектите – в частта, наречена хийп.
Последователност на инициализиране на полетата на класа
За да няма обърквания, нека разясним последователността, в която се инициализират полетата на един клас, независимо от това дали сме им дали стойност по време на декларация и/или сме ги инициализирали в конструктора.
Първо се заделя памет за съответното поле в хийпа и тази памет се инициализира със стойността по подразбиране на типа на полето. Например, нека разгледаме отново нашия клас Dog:
public class Dog { private string name;
public Dog() { Console.WriteLine( "this.name has value of: \"" + this.name + "\""); // ... No other code here ... } // ... Rest of the class body ... } |
При опит да създадем нов обект от тип нашия клас, в конзолата ще бъде отпечатано съответно:
this.name has value of: "" |
Втората стъпка на CLR, след инициализирането на полетата със стойността по подразбиране за съответния тип е, ако е зададена стойност при декларацията на полето, тя да му се присвои.
Така, ако променим реда от класа Dog, на който декларираме полето name, то първоначално ще бъде инициализирано със стойност null и след това ще му бъде присвоена стойността "Rex".
private string name = "Rex"; |
Съответно, при всяко създаване на обект от нашия клас:
public static void Main() { Dog dog = new Dog(); } |
Ще бъде извеждано:
this.name has value of: "Rex" |
Едва след тези две стъпки на инициализация на полетата на класа (инициализиране със стойностите по подразбиране и евентуално стойността зададена от програмиста по време на декларация на полето), се извиква конструкторът на класа. Едва тогава, полетата получават стойностите, с които са им дадени в тялото на конструктора.
Деклариране на конструктор с параметри
В предната секция, видяхме как можем да дадем стойности на полетата, различни от стойностите по подразбиране. Много често, обаче, по време на декларирането на конструктора не знаем какви стойности ще приемат различните полета. За да се справим с този проблем, по подобие на методите с параметри, нужната информация, която трябва за работата на конструктора, му се подава чрез списъка с параметри. Например:
public Dog(string dogName, int dogAge, double dogLength) { name = dogName; age = dogAge; length = dogLength; collar = new Collar(); } |
Съответно, извикването на конструктор с параметри, става по същия начин както извикването на метод с параметри – нужните стойности ги подаваме в списък, чийто елементи са разделени със запетайки:
public static void Main() { Dog myDog = new Dog("Bobi", 2, 0.4); // Passing parameters
Console.WriteLine("My dog " + myDog.name + " is " + myDog.age + " year(s) old. " + " and it has length: " + myDog.length + " m."); } |
Резултатът от изпълнението на този Main() метод е следния:
My dog Bobi is 2 year(s) old. It has length: 0.4 m. |
В C# нямаме ограничение за броя на конструкторите, които можем да създадем. Единственото условие е те да се различават по сигнатурата си (какво е сигнатура обяснихме в главата "Методи").
Област на действие на параметрите на конструктора
По аналогия на областта на действие на променливите в списъка с параметри на един метод, променливите в списъка с параметри на един конструктор имат област на действие от отварящата скоба на конструктора до затварящата такава, т.е. в цялото тяло на конструктора.
Много често, когато декларираме конструктор с параметри, е възможно да именуваме променливите от списъка му с параметри, със същите имена, като имената на полетата, които ще бъдат инициализирани. Нека за пример вземем отново конструктора, който декларирахме в предходната секция:
public Dog(string name, int age, double length) { name = name; age = age; length = length; collar = new Collar(); } |
Нека компилираме и изпълним съответно Main() метода, който също използвахме в предходната секция. Ето какъв е резултатът от изпълнението му:
My dog is 0 year(s) old. It has length: 0 m |
Странен резултат, нали? Всъщност се оказва, че не е толкова странен. Обяснението е следното – областта, в която действат променливите от списъка с параметри на конструктора, припокрива областта на действие на полетата, които имат същите имена в конструктора. По този начин не даваме никаква стойност на полетата, тъй като на практика ние не ги достъпваме. Например, вместо на полето age, ние присвояваме стойността на променливата age на самата нея:
age = age; |
Както видяхме в секцията "Припокриване на полета с локални променливи", за да избегнем това разминаване, трябва да достъпим полето, на което искаме да присвоим стойност, но чието име съвпада с името на променлива от списъка с параметри, използвайки ключовата дума this:
public Dog(string name, int age, double length) { this.name = name; this.age = age; this.length = length; this.collar = new Collar(); } |
Сега, ако изпълним отново Main() метода:
public static void Main() { Dog myDog = new Dog("Bobi", 2, 0.4);
Console.WriteLine("My dog " + myDog.name + " is " + myDog.age + " year(s) old. " + " and it has length: " + myDog.length + " m"); } |
Резултатът ще бъде точно какъвто очакваме да бъде:
My dog Bobi is 2 year(s) old. It has length: 0.4 m |
Конструктор с променлив брой аргументи
Подобно на методите с променлив брой аргументи, които разгледахме в главата "Методи", конструкторите също могат да бъдат декларирани с параметър за променлив брой аргументи. Правилата за декларация и извикване на конструктори с променлив брой аргументи са същите като тези, които описахме за декларацията и извикването при методи:
- Когато декларираме конструктор с променлив брой параметри, трябва да използваме запазената дума params, след което поставяме типа на параметрите, следван от квадратни скоби. Накрая, следва името на масива, в който ще се съхраняват подадените при извикване на метода аргументи. Например за целочислени аргументи ползваме params int[] numbers.
- Позволено е, конструкторът с променлив брой параметри да има и други параметри в списъка си от параметри.
- Параметърът за променлив брой аргументи трябва да е последен в списъка от параметри на конструктора.
Нека разгледаме примерна декларация на конструктор на клас, който описва лекция:
public Lecture(string subject, params string[] studentsNames) { // ... Initialization of the instance variables ... } |
Първият параметър в декларацията е името на предмета, по който е лекцията, а следващия параметър е за променлив брой аргументи – имената на студентите. Ето как би изглеждало примерното създаване на обект от този клас:
Lecture lecture = new Lecture("Biology", "Pencho", "Mincho", "Stancho"); |
Съответно, като първи параметър сме подали името на предмета – "Biology", а за всички оставащи аргументи – имената на присъстващите студенти.
Варианти на конструкторите (overloading)
Както видяхме, можем да декларираме конструктори с параметри. Това ни дава възможност да декларираме конструктори с различна сигнатура (брой и подредба на параметрите), с цел да предоставим удобство на тези, които ще създават обекти от нашия клас. Създаването на конструктори с различна сигнатура се нарича създаване на варианти на конструкторите (constructors overloading).
Нека вземем за пример класа Dog. Можем да декларираме различни конструктори:
// No parameters public Dog() { this.name = "Sharo"; this.age = 1; this.length = 0.3; this.collar = new Collar(); }
// One parameter public Dog(string name) { this.name = name; this.age = 1; this.length = 0.3; this.collar = new Collar(); }
// Two parameters public Dog(string name, int age) { this.name = name; this.age = age; this.length = 0.3; this.collar = new Collar(); }
// Three parameters public Dog(string name, int age, double length) { this.name = name; this.age = age; this.length = length; this.collar = new Collar(); }
// Four parameters public Dog(string name, int age, double length, Collar collar) { this.name = name; this.age = age; this.length = length; this.collar = collar; } |
Преизползване на конструкторите
В последния пример, който дадохме, видяхме, че в зависимост от нуждите за създаване на обекти от нашия клас може да декларираме различни варианти на конструктори. Лесно се забелязва, че голяма част от кода на тези конструктори се повтаря. Това ни кара да се замислим, дали няма начин един конструктор, който вече извършва дадена инициализация, да бъде преизползван от другите, които трябва да изпълнят същата инициализация. От друга страна, в началото на главата споменахме, че един конструктор не може да бъде извикан по начина, по който се извикват методите, а само чрез ключовата дума new. Би трябвало да има някакъв начин, иначе много код ще се повтаря излишно.
В C#, съществува механизъм, чрез който един конструктор може да извиква друг конструктор деклариран в същия клас. Това става отново с ключовата дума this, но използвана в друга синтактична конструкция при декларацията на конструкторите:
[<modifiers>] <class_name>([<parameters_list_1>]) : this([<parameters_list_2>]) |
Към познатата ни форма за деклариране на конструктор (първия ред от декларацията показана по-горе), можем да добавим двоеточие, следвано от ключовата дума this, следвана от скоби. Ако конструкторът, който искаме да извикаме е с параметри, в скобите трябва да добавим списък от параметри parameters_list_2, които да му подадем.
Ето как би изглеждал кодът от предходната секция, в който вместо да повтаряме инициализацията на всяко едно от полетата, извикваме конструктори, декларирани в същия клас:
// Nо parameters public Dog() : this("Sharo") // Constructor call { // More code could be added here }
// One parameter public Dog(string name) : this(name, 1) // Constructor call { }
// Two parameters public Dog(string name, int age) : this(name, age, 0.3) // Constructor call { }
// Three parameters public Dog(string name, int age, double length) : this(name, age, length, new Collar()) // Constructor call { }
// Four parameters public Dog(string name, int age, double length, Collar collar) { this.name = name; this.age = age; this.length = length; this.collar = collar; } |
Както е указано чрез коментар в първия конструктор от примера по-горе, ако е необходимо, в допълнение към извикването на някой от другите конструктори с определени параметри всеки конструктор може в тялото си да добави код, който прави допълнителни инициализации или други действия.
Конструктор по подразбиране
Нека разгледаме следния въпрос – какво става, ако не декларираме конструктор в нашия клас? Как ще създадем обекти от този тип?
Тъй като често се случва даден клас да няма нито един конструктор, този въпрос е решен в езика C#. Когато не декларираме нито един конструктор, компилаторът ще създаде един за нас и той ще се използва при създаването на обекти от типа на нашия клас. Този конструктор се нарича конструктор по подразбиране (default implicit constructor), който няма да има параметри и ще бъде празен (т.е. няма да прави нищо в допълнение към подразбиращото се зануляване на полетата на обекта).
Когато не дефинираме нито един конструктор в даден клас, компилаторът ще създаде един, наречен конструктор по подразбиране. |
Например, нека декларираме класа Collar, без да декларираме никакъв конструктор в него:
public class Collar { private int size;
public int Size { get { return size; } } } |
Въпреки, че нямаме изрично деклариран конструктор без параметри, ще можем да създадем обекти от този клас по следния начин:
Collar collar = new Collar(); |
Конструкторът по подразбиране изглежда по следния начин:
<access_level> <class_name>() { } |
Трябва да знаем, че конструкторът по подразбиране винаги носи името на класа <class_name> и винаги списъкът му с параметри е празен и неговото тяло е празно. Той просто се "подпъхва" от компилатора, ако в класа няма нито един конструктор. Подразбиращият се конструктор обикновено е public (с изключение на някои много специфични ситуации, при които е protected).
Конструкторът по подразбиране е винаги без параметри. |
За да се уверим, че конструкторът по подразбиране винаги е без параметри, нека направим опит да извикаме подразбиращия се конструктор, като му подадем параметри:
Collar collar = new Collar(5); |
Компилаторът ще изведе следното съобщение за грешка:
'Collar' does not contain a constructor that takes 1 arguments |
Работа на конструктора по подразбиране
Както се досещаме, единственото, което конструкторът по подразбиране ще направи при създаването на обекти от нашия клас, е да занули полетата на класа. Например, ако в класа Collar не сме декларирали нито един конструктор и създадем обект от него и се опитаме да отпечатаме стойността в полето size:
public static void Main() { Collar collar = new Collar(); Console.WriteLine("Collar's size is: " + collar.Size); } |
Резултатът ще бъде:
Collar's size is: 0 |
Виждаме, че стойността, която е запазена в полето size на обекта collar, е точно стойността по подразбиране за целочисления тип int.
Кога няма да се създаде конструктор по подразбиране?
Трябва да знаем, че ако декларираме поне един конструктор в даден клас, тогава компилаторът няма да създаде конструктор по подразбиране.
За да проверим това, нека разгледаме следния пример:
public Collar(int size) : this() { this.size = size; } |
Нека това е единственият конструктор на класа Collar. В него се опитваме да извикаме конструктор без параметри, надявайки се, че компилаторът ще е създал конструктор по подразбиране за нас (който знаем, че е без параметри). След като се опитаме да компилираме, ще разберем, че това, което се опитваме да направим, е невъзможно:
'Collar' does not contain a constructor that takes 0 arguments |
Ако сме декларирали дори един единствен конструктор в даден клас, компилаторът няма да създаде конструктор по подразбиране за нас.
Ако декларираме поне един конструктор в даден клас, компилаторът няма да създаде конструктор по подразбиране за нас. |
Разлика между конструктор по подразбиране и конструктор без параметри
Преди да приключим със секцията за конструкторите, нека поясним нещо много важно:
Въпреки че конструкторът по подразбиране и този, без параметри, си приличат по сигнатура, те са напълно различни. |
Разликата се състои в това, че конструкторът по подразбиране (default implicit constructor) се създава от компилатора, ако не декларираме нито един конструктор в нашия клас, а конструкторът без параметри (default constructor) го декларираме ние.
Освен това, както обяснихме по-рано, конструкторът по подразбиране винаги ще има ниво на достъп protected или public, в зависимост от модификатора на достъп на класа, докато нивото на достъп на конструктора без параметри изцяло зависи от нас – ние го определяме.
Свойства (Properties)
В света на обектно-ориентираното програмиране съществува елемент на класовете, наречен свойство (property), който е нещо средно между поле и метод и служи за по-добра защита на състоянието в класа. В някои езици за обектно-ориентирано програмиране, като С#, Delphi / Free Pascal, Visual Basic, JavaScript, D, Python и др., свойствата са част от езика, т.е. за тях съществува специален механизъм, чрез който се декларират и използват. Други езици, като например Java, не подържат концепцията за свойства и за целта програмистите, трябва да декларират двойка методи (за четене и модификация на свойството), за да се предостави тази функционалност.
Свойствата в С# – представяне чрез пример
Използването на свойства е доказано добра практика и важна част от концепциите на обектно-ориентираното програмиране. Създаването на свойство в програмирането става чрез деклариране на два метода – един за достъп (четене) и един за модификация (записване) на стойността на съответното свойство.
Нека разгледаме един пример. Да си представим, че имаме отново клас Dog, който описва куче. Характерно свойство за едно куче е, например, цвета му (color). Достъпът до свойството "цвят" на едно куче и съответната му модификация може да осъществим по следния начин:
// Getting (reading) a property string colorName = dogInstance.Color;
// Setting (modifying) a property dogInstance.Color = "black"; |
Свойства – капсулация на достъпа до полетата
Основната цел на свойствата е да осигуряват капсулация на състоянието на класа, в който са декларирани, т.е. да го защитят от попадане в невалидни състояния.
Капсулация (encapsulation) наричаме скриването на физическото представяне на данните в един клас, така че, ако в последствие променим това представяне, това да не рефлектира върху останалите класове, които използват този клас.
Чрез синтаксиса на C#, това се реализира като декларираме полета (физическото представяне на данните) с възможно най-ограничено ниво на видимост (най-често с модификатор private) и декларираме достъпът до тези полета (четене и модифициране) да може да се осъществява единствено чрез специални методи за достъп (accessor methods).
Капсулация – пример
За да онагледим какво представлява капсулацията, която предоставят свойствата на един клас, както и какво представляват самите свойства, ще разгледаме един пример.
Нека имаме клас, който представя точка от двумерното пространство със свойства координатите (x, y). Ето как би изглеждал той, ако декларираме всяка една от координатите, като поле:
Point.cs |
using System;
class Point { private double x; private double y;
public Point(int x, int y) { this.x = x; this.y = y; }
public double X { get { return x; } set { x = value; } }
public double Y { get { return y; } set { y = value; } } } |
Полетата на обектите от нашия клас (т.е. координатите на точките) са декларирани като private и не могат да бъдат достъпвани чрез точкова нотация. Ако създадем обект от клас Point, можем да модифицираме и четем свойствата (координатите) на точката, единствено чрез свойствата за достъп до тях:
PointTest.cs |
using System;
class PointTest { static void Main() { Point myPoint = new Point(2, 3);
double myPointXCoordinate = myPoint.X; // Access a property double myPointYCoordinate = myPoint.Y; // Access a property
Console.WriteLine("The X coordinate is: " + myPointXCoordinate); Console.WriteLine("The Y coordinate is: " + myPointYCoordinate); } } |
Резултатът от изпълнението на този Main() метод ще бъде следният:
The X coordinate is: 2 The Y coordinate is: 3 |
Ако обаче решим, да променим вътрешното представяне на свойствата на точката, например вместо две полета, ги декларираме като едномерен масив с два елемента, можем да го направим, без това да повлияе по някакъв начин на останалите класове от нашия проект:
Point.cs |
using System;
class Point { private double[] coordinates;
public Point(int xCoord, int yCoord) { this.coordinates = new double[2];
// Initializing the x coordinate coordinates[0] = xCoord;
// Initializing the y coordinate coordinates[1] = yCoord; }
public double XCoord { get { return coordinates[0]; } set { coordinates[0] = value; } }
public double YCoord { get { return coordinates[1]; } set { coordinates[1] = value; } } } |
Резултатът от изпълнението на Main() метода няма да се промени и резултатът ще бъде същият, без да променяме дори символ в кода на класа PointTest.
Демонстрираното е добър пример за добра капсулация на данните на един обект предоставена от механизма на свойствата. Чрез тях скриваме вътрешното представяне на информацията, като декларираме свойства / методи за достъп до него и ако в последствие настъпи промяна в представянето, това няма да се отрази на другите класове, които използват нашия клас, тъй като те ползват само свойствата му и не знаят как е представена информацията "зад кулисите".
Разбира се, разгледаният пример демонстрира само една от ползите да се опаковат (обвиват) полетата на класа в свойства. Свойствата позволяват още контрол над данните в класа и могат да проверяват дали присвояваните стойности свойства са коректни по някакви критерии. Например ако имаме свойство максимална скорост на клас Car, може чрез свойства да наложим изискването стойността й да е в диапазона между 1 и 300 км/ч.
Още за физическото представяне на свойствата в класа
Както видяхме по-горе, свойствата могат да имат различно представяне в един клас на физическо ниво. В нашия пример, информацията за свойствата на класа Point първоначално беше съхранена в две полета, а след това в едно поле-масив.
Ако обаче решим, вместо да пазим информацията за свойствата на точката в полета, можем да я запазим във файл или в база данни и всеки път, когато се наложи да достъпваме съответното свойство, можем да четем / пишем от файла или базата вместо както в предходните примери да използваме полета на класа. Тъй като свойствата се достъпват чрез специални методи (наречени методи за достъп и модификация или accessor methods), които ще разгледаме след малко, за класовете, които ще използват нашия клас, това как се съхранява информацията няма да има значение (заради добрата капсулация!).
В най-честия случай обаче, информацията за свойствата на класа се пази в поле на класа, което има възможно най-стриктното ниво на видимост private.
Няма значение по какъв начин физически ще бъде пазена информацията за свойствата в един C# клас, но обикновено това става чрез поле на класа с максимално ограничено ниво на достъп (private). |
Представяне на свойство без декларация на поле
Нека разгледаме един пример, в който свойството не се пази нито в поле, нито някъде другаде, а се преизчислява при опит за достъп до него.
Нека имаме клас Rectangle, който представя геометричната фигура правоъгълник. Съответно този клас има две полета – за ширина width и дължина height. Нека нашия клас има и още едно свойство – лице (area). Тъй като винаги чрез дължината и ширината на правоъгълника можем да намерим стойността на свойството "лице", не е нужно да имаме отделно поле в класа, за да пазим тази стойност. По тази причина, можем да си декларираме просто един метод за получаване на лицето, в който пресмятаме формулата за лице на правоъгълник:
Rectangle.cs |
using System;
class Rectangle { private float height; private float width;
public Rectangle(float height, float width) { this.height = height; this.width = width; }
// Obtaining the value of the property area public float Area { get { return this.height * this.width; } } } |
Както ще видим след малко, не е задължително едно свойство да има едновременно методи за модификация и за четене на стойността. Затова е позволено да декларираме само метод за четене на свойството Area на правоъгълника. Няма смисъл от метод, който модифицира стойността на лицето на един правоъгълник, тъй като то е винаги едно и също при определена дължина на страните.
Деклариране на свойства в C#
За да декларираме едно свойство в C#, трябва да декларираме методи за достъп (за четене и промяна) на съответното свойство и да решим по какъв начин ще съхраняваме информацията за това свойство в класа.
Преди да декларираме методите обаче, трябва да декларираме самото свойството в класа. Формално декларацията на свойствата изглежда по следния начин:
[<modifiers>] <property_type> <property_name> |
С <modifiers> сме означили, както модификаторите за достъп, така и други модификатори (например static, който ще разгледаме в следващата секция на главата). Те не са задължителна част от декларацията на едно поле.
Типа на свойството <property_type> задава типа на стойностите на свойството. Може да бъде както примитивен тип (например int), така и референтен (например масив).
Съответно, <property_name> е името на свойството. То трябва да започва с главна буква и да удовлетворява правилото PascalCase, т.е. всяка нова дума, която се долепя в задната част на името на свойството, започва с главна буква. Ето няколко примера за правилно именувани свойства:
// MyValue property public int MyValue { get; set; }
// Color property public string Color { get; set; }
// X-coordinate property public double X { get; set; } |
Тяло на свойство
Подобно на класа и методите, свойствата в С# имат тяло, където се декларират методите за достъп до свойството (accessors).
[<modifiers>] <property_type> <property_name> { // ... Property's accessors methods go here } |
Тялото на свойството започва с отваряща фигурна скоба "{" и завършва със затваряща – "}". Свойствата винаги трябва да имат тяло.
Метод за четене на стойността на свойство (getter)
Както обяснихме, декларацията на метод за четене на стойността на едно свойство (в литературата наричан още getter) се прави в тялото на свойството, като за целта трябва да се спазва следния синтаксис:
get { <accessor_body> } |
Съдържанието на блока ограден от фигурните скоби (<accessor_body>) е подобно на съдържанието на произволен метод. В него се декларират действията, които трябва да се извършат за връщане на резултата от метода.
Методът за четене на стойността на едно свойство трябва да завършва с return или throw операция. Типът на стойността, която се връща като резултат от този метод, трябва да е същият както типa <property_type> описан в декларацията на свойството.
Въпреки, че по-рано в тази секция срещнахме доста примери на декларирани свойства с метод за четене на стойността им, нека разгледаме още един пример за свойството "възраст" (Age), което е от тип int и е декларирано чрез поле в същия клас:
private int age; // Field declaration
public string Age // Property declaration { get { return this.age; } // Getter declaration } |
Извикване на метод за четене на стойността на свойство
Ако допуснем, че свойството Age от последния пример е декларирано в клас от тип Dog, извикването на метода за четене на стойността на свойството, става чрез точкова нотация, приложена към променлива от типа, в чийто клас е декларирано свойството:
Dog dogInstance = new Dog(); // ... int dogAge = dogInstance.Age; // Getter invocation Console.WriteLine(dogInstance.Age); // Getter invocation |
Последните два реда от примера показват, че достъпвайки чрез точкова нотация името на свойството, автоматично се извиква неговият getter метод (методът за четене на стойността му).
Метод за промяна на стойността на свойство (setter)
По подобие на метода за четене на стойността на едно свойство, може да се декларира и метод за промяна (модификация) на стойността на едно свойство (в литературата наричан още setter). Той се декларира в тялото на свойството с тип на връщана стойност void и в него подадената при присвояването стойност е достъпна през неявен параметър value.
Декларацията се прави в тялото на свойството, като за целта трябва да се спазва следнияt синтаксис:
set { <accessor_body> } |
Съдържанието на блока ограден от фигурните скоби (<accessor_body>) е подобно на съдържанието, на произволен метод. В него се декларират действията, които трябва да се извършат за промяна на стойността на свойството. Този метод използва неявен параметър, наречен value, който е предоставен от С# по подразбиране и който съдържа новата стойност на свойството. Той е от същия тип, от който е свойството.
Нека допълним примера за свойството "възраст" (Age) в класа Dog, за да онагледим казаното дотук:
private int age; // Field declaration
public string Age // Property declaration { get{ return this.age; } set{ this.age = value; } // Setter declaration } |
Извикване на метод за промяна на стойността на свойство
Извикването на метода за модификация на стойността на свойството става чрез точкова нотация, приложена към променлива от типа, в чийто клас е декларирано свойството:
Dog dogInstance = new Dog(); // ... dogInstance.Age = 3; // Setter invocation |
На последния ред при присвояването на стойността 3 се извиква setter методът на свойството Age, с което тази стойност се записва в параметъра value и се подава на setter метода на свойството Age. Съответно в нашия пример, стойността на променливата value се присвоява на полето age oт класа Dog, но в общия случай може да се обработи по по-сложен начин.
Проверка на входните данни на метода за промяна на стойността на свойство
В процеса на програмиране е добра практика данните, които се подават на setter метода за модификация на свойство да бъдат проверявани дали са валидни и в случай че не са да се вземат необходимите "мерки". Най-често при некоректни данни се предизвиква изключение.
Да вземем отново примера с възрастта на кучето. Както знаем, тя трябва да бъде положително число. За да предотвратим възможността някой да присвои на свойството Age стойност, която е отрицателно число или нула, добавяме следната проверка в началото на setter метода:
public int Age { get { return this.age; } set { // Take precaution: пerform check for correctness if (value <= 0) { throw new ArgumentException( "Invalid argument: Age should be a negative number."); } // Assign the new correct value this.age = value; } } |
В случай, че някой се опита да присвои стойност на свойството Age, която е отрицателно число или 0, ще бъде хвърлено изключение от тип ArgumentException с подробна информация какъв е проблемът.
За да се предпази от невалидни данни един клас трябва да проверява подадените му стойности в setter методите на всички свойства и във всички конструктори, както и във всички методи, които могат да променят някое поле на класа. Практиката класовете да се предпазват от невалидни данни и невалидни вътрешни състояния се използва широко в програмирането и е част от концепцията "Защитно програмиране", която ще разгледаме в главата "Качествен програмен код".
Видове свойства
В зависимост от особеностите им, можем да класифицираме свойствата по следния начин:
1. Само за четене (read-only), т.е. тези свойства имат само get метод, както в примера с лицето на правоъгълник.
2. Само за модифициране (write-only), т.е. тези свойства имат само set метод, но не и метод за четене на стойността на свойството.
3. И най-честият случай е read-write, когато свойството има методи както за четене, така и за промяна на стойността.
Алтернативен похват за работа със свойства
Преди да приключим секцията ще отбележим още нещо за свойствата в един клас, а именно – как можем да декларираме свойства в С#, без да използваме стандартния синтаксис, разгледан до момента.
В езици за програмиране като Java, в които няма концепция (и съответно синтактични средства) за работа със свойства, свойствата се декларират чрез двойка методи, отново наречени getter и setter, по подобие на тези, които разгледахме по-горе.
Тези методи трябва да отговарят на следните изисквания:
1. Методът за четене на стойността на свойство е метод без параметри, който трябва да връща резултат от тип, идентичен с типа на свойството и името му да е образувано от името на свойството с представка Get:
[<modifiers>] <property_type> Get<property_name> |
2. Методът за модификация на стойността на свойство трябва да има тип на връщаната стойност void, името му да е образувано от името на свойството с представка Set и типа на единствения аргумент на метода да бъде идентичен с този на свойството:
[<modifiers>] void Set<property_name>(<property_type> par_name) |
Ако представим свойството Age на класа Dog в примера, който използвахме в предходните секции чрез двойка методи, то декларацията на свойството би изглеждала по следния начин:
private int age; // Field declaration
public int GetAge() // Getter declaration { return this.age; }
public void SetAge(int age) // Setter declaration { this.age = age; } |
Съответно, четенето и модификацията на свойството Age, ще се извършва чрез извикване на декларираните методи:
Dog dogInstance = new Dog();
// ...
// Getter invocations int dogAge = dogInstance.GetAge(); Console.WriteLine(dogInstance.GetAge());
// Setter invocation dogInstance.SetAge(3); |
Въпреки че представихме тази алтернатива за декларация на свойства, единствената ни цел бе да бъдем изчерпателни и да направим съпоставка с други езици като Java. Лесно се забелязва, че този начин за декларация на свойствата е по-трудно четим и по-неестествен в сравнение с първия, който изложихме. Затова е препоръчително да се използват вградените средства на езика С# за декларация и използване на свойства.
При работа със свойства е препоръчително да се използва стандартният механизъм, който С# предлага, а не алтернативният, който се използва в някои други езици. |
Статични класове (static classes) и статични членове на класа (static members)
Когато един елемент на класа е деклариран с модификатор static, го наричаме статичен. В С# като статични могат да бъдат декларирани полетата, методите, свойствата, конструкторите и класовете.
По-долу първо ще разгледаме статичните елементи на класа, или с други думи полетата, методите, свойствата и конструкторите му и едва тогава ще се запознаем и с концепцията за статичен клас.
За какво се използват статичните елементи?
Преди да разберем принципа, на който работят статичните елементи на класа, нека се запознаем с причините, поради които се налага използването им.
Метод за сбор на две числа
Нека си представим, че имаме клас, в който един метод винаги работи по един и същ начин. Например, нека неговата задача е да получи две числа чрез списъка му от параметри и да върне като резултат сбора им. При такъв сценарий няма да има никакво значение кой обект от този клас ще изпълни този метод, тъй като той винаги ще се държи по един и същ начин – ще събира две числа, независими от извикващия обект. Реално поведението на метода не зависи от състоянието на обекта (стойностите в полетата на обекта). Тогава защо е нужно да създаваме обект, за да изпълним този метод, при положение че методът не зависи от никой от обектите от този клас? Защо просто не накараме класа да изпълни този метод?
Брояч на инстанциите от даден клас
Нека разгледаме и друг сценарий. Да кажем, че искаме да пазим в програмата ни текущия брой на обектите, които са били създадени от даден клас. Как ще съхраним тази променлива, която ще пази броя на създадените обекти?
Както знаем, няма да е възможно да я пазим като поле на класа, тъй като при всяко създаване на обект, ще се създава ново копие на това поле за всеки обект, и то ще бъде инициализирано със стойността по подразбиране. Всеки обект ще пази свое поле за индикация на броя на обектите и обектите няма да могат да споделят информацията по между си. Изглежда броячът не трябва да е поле в класа, а някак си да бъде извън него. В следващите подсекции ще разберем как да се справим и с този проблем.
Какво е статичен член?
Формално погледнато, статичен член (static member) на класа наричаме всяко поле, свойство, метод или друг член, който има модификатор static в декларацията си. Това означава, че полета, методи и свойства маркирани като статични, принадлежат на самия клас, а не на някой конкретен обект от дадения клас.
Следователно, когато маркираме поле, метод или свойство като статични, можем да ги използваме, без да създаваме нито един обект от дадения клас. Единственото, от което се нуждаем е да имаме достъп (видимост) до класа, за да можем да извикваме статичните му методи, или да достъпваме статичните му полета и свойства.
Статичните елементи на класа могат да се използват без да се създава обект от дадения клас. |
От друга страна, ако имаме създадени обекти от дадения клас, тогава статичните полета и свойства ще бъдат общи (споделени) за тях и ще има само едно копие на статичното поле или свойство, което се споделя от всички обекти от дадения клас. По тази причина в езика VB.NET вместо ключовата дума static със същото значение се ползва ключовата дума Shared.
Статични полета
Когато създаваме обекти от даден клас, всеки един от тях има различни стойности в полетата си. Например, нека разгледаме отново класа Dog:
public class Dog { // Instance variables private string name; private int age; } |
Той има две полета съответно за име – name и възраст – age. Във всеки обект, всяко едно от тези полета има собствена стойност, която се съхранява на различно място в паметта за всеки обект.
Понякога обаче, искаме да имаме полета, които са общи за всички обекти от даден клас. За да постигнем това, трябва в декларацията на тези полета да използваме модификатора static. Както вече обяснихме, такива полета се наричат статични полета (static fields). В литературата се срещат, също и като променливи на класа.
Казваме, че статичните полета са асоциирани с класа, вместо с който и да е обект от този клас. Това означава, че всички обекти, създадени по описанието на един клас споделят статичните полета на класа.
Всички обекти, създадени по описанието на един клас споделят статичните полета на класа. |
Декларация на статични полета
Статичните полета декларираме по същия начин, както се декларира поле на клас, като след модификатора за достъп (ако има такъв), добавяме ключовата дума static:
[<access_modifier>] static <field_type> <field_name> |
Ето как би изглеждало едно поле dogCount, което пази информация за броя на създадените обекти от клас Dog:
Dog.cs |
public class Dog { // Static (class) variable static int dogCount;
// Instance variables private string name; private int age; } |
Статичните полета се създават, когато за първи път се опитаме да ги достъпим (прочетем / модифицираме). След създаването си, по подобие на обикновените полета в класа, те се инициализират с подразбиращата се стойност за типа си.
Инициализация по време на декларация
Ако по време на декларация на статичното поле, сме задали стойност за инициализация, тя се присвоява на съответното статично поле. Тази инициализация се изпълнява само веднъж – при първото достъпване на полето, веднага след като приключи присвояването на стойността по подразбиране. При последващо достъпване на полето, тази инициализация на статичното поле няма да се изпълни.
В горния пример можем да добавим инициализация на статичното поле:
// Static variable - declaration and initialization static int dogCount = 0; |
Тази инициализация ще се извърши при първото обръщение към статичното поле. Когато се опитаме да достъпим някое статично поле на класа, ще се задели памет за него и то ще се инициализира със стойностите по подразбиране. След това, ако полето има инициализация по време на декларацията си (както е в нашия случай с полето dogCount), тази инициализация ще се извърши. В последствие обаче, когато се опитваме да достъпим полето от други части на програмата ни, този процес няма да се повтори, тъй като статичното поле вече съществува и е инициализирано.
Достъп до статични полета
За разлика от обикновените (нестатични) полета на класа, статичните полета, бидейки асоциирани с класа, а не с конкретен обект, могат да бъдат достъпвани от външен клас като към името на класа, чрез точкова нотация, достъпим името на съответното статично поле:
<class_name>.<static_field_name> |
Например, ако искаме да отпечатаме стойността на статичното поле, което пази броя на създадените обекти от нашия клас Dog, това ще стане по следния начин:
public static void Main() { // Аccess to the static variable through class name Console.WriteLine("Dog count is now " + Dog.dogCount); } |
Съответно, резултатът от изпълнението на този Main() метод е:
Dog count is now 0 |
В C# статичните полета не могат да се достъпват през обект на класа (за разлика от други обектноориентирани езици за програмиране).
Когато даден метод се намира в класа, в който е дефинирано дадено статично поле, то може да бъде достъпено директно без да се задава името на класа, защото то се подразбира:
<static_field_name> |
Модификация на стойностите на статичните полета
Както вече стана дума по-горе, статичните променливи на класа, са споделени от всички обекти и не принадлежат на нито един обект от класа. Съответно, това дава възможност, всеки един от обектите на класа да променя стойностите на статичните полета, като по този начин останалите обекти ще могат да "видят" модифицираната стойност.
Ето защо, например, за да отчетем броя на създадените обекти от клас Dog, е удобно да използваме статично поле, което увеличаваме с единица, при всяко извикване на конструктора на класа, т.е. всеки път, когато създаваме обект от нашия клас:
public Dog(string name, int age) { this.name = name; this.age = age;
// Modifying the static counter in the constructor Dog.dogCount += 1; } |
Тъй като осъществяваме достъп до статично поле на класа Dog от него самия, можем да си спестим уточняването на името на класа и да ползваме следния код за достъп до полето dogCount:
public Dog(string name, int age) { this.name = name; this.age = age;
// Modifying the static counter in the constructor dogCount += 1; } |
За препоръчване е, разбира се, първият начин, при който е очевидно, че полето е статично в класа Dog. При него кодът е по-лесно четим.
Съответно, за да проверим дали това, което написахме е вярно, ще създадем няколко обекта от нашия клас Dog и ще отпечатаме броя им. Това ще стане по следния начин:
public static void Main() { Dog dog1 = new Dog("Karaman", 1); Dog dog2 = new Dog("Bobi", 2); Dog dog3 = new Dog("Sharo", 3);
// Access to the static variable Console.WriteLine("Dog count is now " + Dog.dogCount); } |
Съответно изходът от изпълнението на примера е:
Dog count is now 3 |
Константи (constants)
Преди да приключим с темата за статичните полета, трябва да се запознаем с един по-особен вид статични полета.
По подобие на константите от математиката, в C#, могат да се създадат специални полета на класа, наречени константи. Декларирани и инициализирани веднъж константите, винаги притежават една и съща стойност за всички обекти от даден тип.
В C# константите биват два вида:
1. Константи, чиято стойност се извлича по време на компилация на програмата (compile-time константи).
2. Константи, чиято стойност се извлича по време на изпълнение на програмата (run-time константи).
Константи инициализирани по време на компилация (compile-time constants)
Константите, които се изчисляват по време на компилация се декларират по следния начин, използвайки модификатора const:
[<access_modifiers>] const <type> <name>; |
Константите, декларирани със запазената дума const, са статични полета. Въпреки това, в декларацията им не се изисква (нито е позволена от компилатора) употребата на модификатора static:
Въпреки, че константите декларирани с модификатор const са статични полета, в декларацията им не трябва и не може да се използва модификаторът static. |
Например, ако искаме да декларираме като константа числото "пи", познато ни от математиката, това може да стане по следния начин:
public const double PI = 3.141592653589793; |
Стойността, която присвояваме на дадена константа може да бъде израз, който трябва да бъде изчислим от компилатора по време на компилация. Например, както знаем от математиката, константата "пи" може да бъде представена като приблизителен резултат от делението на числата 22 и 7:
public const double PI = 22d / 7; |
При опит за отпечатване на стойността на константата:
public static void Main() { Console.WriteLine("The value of PI is: " + PI); } |
в командния ред ще бъде изписано:
The value of PI is: 3.14285714285714 |
Ако не дадем стойност на дадена константа по време на декларацията й, а по-късно, ще получим грешка при компилация. Например, ако в примера с константата PI, първо декларираме константата, и по-късно се опитаме да й дадем стойност:
public const double PI;
// ... Some code ...
public void SetPiValue() { // Attempting to initialize the constant PI PI = 3.141592653589793; } |
Компилаторът ще изведе грешка подобна на следната, указвайки ни реда, на който е декларирана константата:
A const field requires a value to be provided |
Нека обърнем внимание отново:
Константите декларирани с модификатор const задължително се инициализират в момента на тяхната декларация. |
Тип на константите инициализирани по време на компилация
След като научихме как се декларират константи, които се инициализират по време на компилация, нека разгледаме следния пример: Искаме да създадем клас за цвят (Color). Ще използваме т.нар. Red-Green-Blue (RGB) цветови модел, съгласно който всеки цвят е представен чрез смесване на трите основни цвята – червен, зелен и син. Тези три основни цвята са представени като три цели числа в интервала от 0 до 255. Например черното е представено като (0, 0, 0), бялото като (255, 255, 255), синьото – (0, 0, 255) и т.н.
В нашия клас декларираме три целочислени полета за червена, зелена и синя светлина и конструктор, който приема стойности за всяко едно от тях:
Color.cs |
class Color { private int red; private int green; private int blue;
public Color(int red, int green, int blue) { this.red = red; this.green = green; this.blue = blue; } } |
Тъй като някои цветове се използват по-често от други (например черно и бяло) можем да декларираме константи за тях, с идеята потребителите на нашия клас да ги използват наготово вместо всеки път да създават свои собствени обекти за въпросните цветове. За целта модифицираме кода на нашия клас по следния начин, добавяйки декларацията на съответните цветове-константи:
Color.cs |
class Color { public const Color Black = new Color(0, 0, 0); public const Color White = new Color(255, 255, 255);
private int red; private int green; private int blue; |