Неформальное введение в объектно-ориентированное программирование на платформе .NET Framework 4
Неформальное введение в объектно-ориентированное программирование на платформе .NET Framework
Продолжение. Начало в КГ №№ 13, 14, 16 .
Прежде чем мы начнем создавать базовые классы "сущностей", задействованных в нашем алгоритме, нам предварительно необходимо разобраться с тем, какие возможности для этого нам предоставляет язык C#. Поэтому задвиньте пока на дальнюю полку мой пример с варкой кофе. В ближайшие несколько статей мы к нему не вернемся. Вместо этого мы с вами совершим небольшую экскурсию, осмотрев с высоты птичьего полета реализацию объектно-ориентированной парадигмы в языке C#. Пример же с варкой кофе нам снова понадобится по окончании этой обзорной экскурсии. К этому моменту мы с вами сможем, уже во всеоружии, решить поставленные его алгоритмом задачи.
Важное примечание! Прежде чем вы приступите к набивке текстов примеров из этой или последующих моих статей, учтите, что язык C# является регистрозависимым. Console.WriteLine и coNsoLe.wRiteLIne для него — совершенно разные команды. Набирая текст примеров, всегда обращайте внимание на правильность регистра в названиях классов и методов.
Рассказывая вам о классовой модели C#, я предполагаю, что вы уже знакомы с базовыми операторами C-образных языков. Если это не так, поищите где-нибудь книжку по этой теме. Если я сейчас начну вам подробно рассказывать о том, как работают операторы for(;;;){} или if(){}, я никогда не доберусь до конца этого цикла статей. В C# эти операторы описываются и функционируют точно так же, как и в других распространенных языках, за некоторыми исключениями и дополнениями.
Изменения в языке
Операторы цикла for, while и foreach
Операторы цикла for и while унаследованы от C-подобных языков без каких-либо заметных изменений. Вы можете использовать все те "выкрутасы", к которым привыкли и в C, и в C++ . Из нововведений появился новый оператор foreach(X in Y), пришедший в язык из Visual Basic. Он предназначен для перебора всех объектов в какой-либо коллекции (списке). Что такое "коллекция", я пока не смогу вам объяснить, ведь мы с вами еще даже не изучили, что такое класс . Пока я вам лишь замечу, что коллекция — это набор из некоторого количества объектов одного или разных типов, и foreach перебирает их все по одному. Одним из примеров такого набора является обычный массив.
Оператор выбора if
В реализации if появилось одно весьма существенное нововведение. Код вида int x=0; if(x){}else{} больше не работает и приводит к ошибке времени компиляции. Для того чтобы все заработало, необходимо писать так: int x=0; if(x!=0){}else{} . Говоря другими словами, 0 теперь совсем не равняется false, а неравенство выражения нулю совсем не означает true . Компилятор отслеживает эту ошибку в тексте ваших программ и сразу на нее ругается.
Оператор выбора switch
Логика работы оператора switch также подверглась модификации. В С# вам не удатся провалиться сквозь метки выбора в операторе switch{case:;} . Вполне приемлемый для C++ код:
int a =3;
switch (a){
case 1:
x=true;
case 2:
y=false;
break;
}
вызовет в C# ошибку, так как каждый case должен заканчиваться своим оператором break . Исключение сделано лишь для секций case, не имеющих тела. То есть код:
int a =3;
switch (a){
case 1:
case 2:
y=false;
break;
}
вполне допустим.
На самом деле C# по сравнению с C++ имеет еще довольно много нюансов. Мы с ними познакомимся по мере рассмотрения примеров в этом цикле статей.
Типы данных
Теперь давайте поговорим о типах данных. В номере 12 (25 марта 2003 г.) нашей газеты уже публиковалась статья, озаглавленная "Linux и C#. Типы данных". Первоначально я хотел сослаться на нее, затрагивая эту тему. Но, внимательно изучив статью и ничего не поняв из прочитанного там, я решил своими словами изложить ту информацию, которую пытался донести до читателя ее автор.
Говоря о типах данных в C#, следует сразу учесть тот нюанс, что язык поддерживает параллельно два разных набора типов данных. Во-первых, это набор типов данных самой среды Net Framework, а во-вторых — набор синонимов этих типов, записанных в нотации, привычной программистам на C или С++ .
Потребовалось это, по всей видимости, вот зачем. Как я и писал в первой статье цикла, Net Framework — это единая среда выполнения для большого количества языков программирования. На данный момент под нее написано уже с добрый десяток компиляторов разных языков. Все они используют типы данных, естественные для программистов на этом конкретном языке. Во время компиляции программы эти нативные для конкретного языка типы данных приводятся к типам Net Framework. Получается, что и овцы целы, и волки сыты. Программистам не нужно изучать новые типы данных, а среда Net Framework замечательно работает со всеми языками одновременно, не заморачиваясь поддержкой всевозможных языковых фенечек на манер типа данных Real, принятого в Pascal, или его аналога Real16, пришедшего ему на смену в Delphi.
Язык С # позволяет работать как с естественными для C++-программиста типами данных, так и напрямую с типами, объявленными в Net Framework. Вы наверняка спросите меня, а какой из двух наборов типов данных лучше использовать в ваших программах? Да какой хотите! Я сам использую обе разновидности вперемешку, тяготея при этом к использованию типов Net Framework. Мне так проще переключаться в коде между VB.Net и С#. Нативные типы данных у этих двух языков разные, а типы Net Framework присутствуют в обоих языках.
Ниже я приведу вам небольшую табличку типов данных C# c указанием соответствующего им типа данных Net Framework.
Тип С# Тип Net Framework Назначение
Byte System.Byte 8-битное значения без знака
short System.Int16 16-битное значение со знаком
int System.Int32 32-битное значение со знаком
long System.Int64 64-битное значение со знаком
char System.Char Один 16-битный символ Unicode
float System.Single 32-битное значение с плавающей запятой
double System.Double 64-битное значение с плавающей запятой
bool System.Boolean Значение True или False (истина или ложь)
decimal System.Decimal 128-битное число с плавающей точкой
string System.String Cтрока Unicode символов
object System.Object Базовый тип для любых типов
Примечание: На самом деле в этой таблице я упустил еще несколько типов данных. Сделал я это намеренно. Дело в том, что эти типы данных применимы лишь к программам, написанным на C#. Их следует по возможности избегать, так как они делают невозможным использование вашего кода программистами, скажем, на VB.Net. Мне не хотелось бы приучать вас в своих статьях к неправильному стилю программирования. Если вы захотите с ними разобраться, то самостоятельно найдите дополнительную литературу. А это моя статья: о чем хочу — о том и рассказываю. Замечу лишь, что к таким "неправильным" типам относятся sbyte, ushort, uint, ulong . То есть "знаковые" варианты типов данных без знака и "беззнаковые" аналоги типов данных со знаком. Особого смысла в их использовании я не вижу.
Вернемся к моей таблице. Если вы уже знакомы с каким-либо языком программирования, то понимание назначения перечисленных в ней типов данных не должно вызвать у вас каких-либо трудностей. Отдельного пояснения требуют, пожалуй, только последние три типа данных — decimal, string и object .
decimal — это дробное число. Его следует использовать в тех случаях, когда для вас критична возможная потеря точности за счет ошибок округления. В Delphi имеется такой тип данных, как Currency. decimal — это еще более "точное" его подобие.
string — это привычная всем программистам строка, обладающая, однако, одним неочевидным свойством. Дело в том, что переменную типа string невозможно изменить! Например, вот совершенно банальный пример кода:
string s ="" ;// объявляем пустую строку
s="One" ;// присваиваем ей значение "One"
s+=" and Two";// добавляем в хвост строке " and Two"
Так вот, этот код сначала создает строку s c пустым значением, затем уничтожает ее и на ее месте создает строку c содержимым "One". Затем снова ее уничтожает и на ее месте создает строку с содержимым "One and Two"! Почему так сделано, можно гадать очень долго, а вот незнание этого нюанса приводит к тому, что ваши программы по обработке строк очень долго выполняются и поедают много памяти.
Поэтому, если в вашей программе используется строка, которая очень часто модифицируется, задействуйте для ее обработки объект System.Text.StringBuilder . Он специально предназначен для операций с часто модифицирующейся строкой. Описание методов объекта System.Text.StringBuilder выходит за рамки этой статьи (их довольно много). Посмотрите самостоятельно их описание в MSDN или прилагаемой к Net Framework документации.
У нас остался, пожалуй, самый интересный тип данных — object . В таблице я написал, что этот тип данных является базовым для всех других типов данных. На самом деле так оно и есть. Все встроенные типы данных в C# на самом деле являются объектами. Даже когда вы используете константу в своем коде, она также будет являться объектом, производным от System.Object . Попробуйте из любопытства откомпилировать такой код:
using System;
class App{
static void Main(){
Console.WriteLine(2.ToString());
}
}
Эта программа благополучно откомпилируется и выполнится, несмотря на довольно странно выглядящий вызов функции 2.ToString() . Дело в том, что константа 2 в моем примере — это полноценный объект, обладающий набором методов. Один из них — Object.ToString() — мы с вами и вызвали. И тип данных int, и тип данных string, и все остальные встроенные типы данных восходят в конце концов к типу object . Более того: даже когда вы создаете свой собственный тип данных (класс), он все равно будет иметь в предках object, хотите вы этого или нет. object — это основа для любого типа данных, созданного в среде Net Framework. Каким образом происходит это наследование, вы поймете, когда мы с вами разберемся с тем, как создаются и наследуются классы в C#. Вот, блин, извечная проблема "Курицы и яйца"! Все зависит от всего, и очень трудно подступиться к описанию языка "с самого начала". Все связано со всем, и неизвестно, с какой точки начать повествование, чтобы постоянно не забегать вперед.
Ну ладно, мы отвлеклись. Итак, класс в C# описывается с помощью следующей лексической конструкции:
class имя_класса
{
}
Используя эту конструкцию, мы закладываем черты поведения будущих объектов этого типа. Обратите внимание: описание класса не создает никаких реальных объектов! Вы просто рассказываете программе, как должен выглядеть ваш будущий объект. После того как вы опишете класс, вы сможете впоследствии создать сколько угодно экземпляров этого объекта. Для создания новых объектов из имеющихся описаний классов используется команда языка C# new . Например, если мы с вами описали класс по имени FirstClass :
class FirstClass
{
}
то впоследствии новый экземпляр класса FirstClass с именем fs мы можем создать с помощью следующего кода:
FirstClass fs = new FirstClass();
Сначала идет название описания класса (его тип), затем — название переменной. Затем с помощью команды new создается новый экземпляр. Для этого вызывается конструктор объекта, выглядящий как название типа, после которого идут круглые скобки. То есть используется привычный всем синтаксис C++.
Создаем свои объекты
Для более подробного изучения понятия класса в C# давайте возьмем пример, хорошо понятный любому жителю бывшего СССР. Давайте создадим класс по имени MVD_Worker, описывающий некоего абстрактного "работника министерства внутренних дел". Соответствующее описание класса может выглядеть примерно таким образом:
class MVD_Worker
{
}
Сначала идет кодовое слово class, говорящее компилятору, что дальше следует описание нового класса, за ним идет название типа будущего класса. Именно его мы потом и будем использовать создавая новые экземпляры нашего нового класса. Далее идут фигурные скобки, очерчивающие границы нашего класса. Все то, что мы впоследствии опишем внутри этих скобок, принадлежит этому классу. То, что описано снаружи этих скобок, к нашему классу не относится. Для закрепления материала давайте я вам приведу пример кода, создающего экземпляр нашего класса MVD_Worker по имени omon .
MVD_Worker omon = new MVD_Worker();
Думая о работнике MVD, кто-то из вас наверняка представил себе гаишника, кто-то ОМОНовца, а кто-то — соседа дядю Васю, живущего в квартире напротив вас. Непорядок: нам этот разнобой совершенно ни к чему! Подумайте, а что имеется общего у всех вышеназванных работников уважаемого ведомства? Давайте отбросим пока их общечеловеческие признаки типа того, что у них всех имеется по паре ног, две руки и два глаза. Ну как? Что скажете?
Я сам, пораскинув мозгами пару минут, вывел такой общий для них всех признак — "у них всех есть соответствующее служебное удостоверение". Это общее свойство мы и заложим как базовый признак работника соответствующего ведомства и назовем его hasDocument .
Так как это свойство может иметь лишь два значения: "документ есть" и "документа нет", — то для его хранения мы используем тип данных bool . Напомню: этот тип данных может принимать только два возможных значения: true, означающее "да", и false — означающее "нет".
По правилам языка C# оформляется все вышесказанное вот таким образом:
// Пример 1
class MVD_Worker
{
public bool HasDocument=true;
}
На первый взгляд, у нас все получилось хорошо. Создав экземпляр класса, мы легко можем считывать или изменять значение переменной из своего кода. Например, вот так:
MVD_Worker omon = new MVD_Worker();
omon.hasDocument = true;
if (omon.hasDocument){...}
Но представим себе на минутку, что наш свежеиспеченный класс MVD_Worker когда-нибудь, в коде будущей программы, встретится с неким классом, описывающим "нарушителя". Так как переменная hasDocument доступна для модификации любому встречному классу, наш гипотетический "нарушитель" запросто может "отобрать" у нашего сотрудника MVD удостоверение. Это не есть правильно. Первое, что приходит в голову, — объявить вместо переменной константу — например, вот таким образом:
public const bool HasDocument=true;
Тогда при попытке присвоить переменной какое-либо значение будет возникать ошибка времени компиляции. Но в этом случае у нас возникает другая проблема. После такого объявления hasDocument нашего гипотетического сотрудника не сможет уволить не только его собственный отдел кадров, но и даже сам президент. Произойдет это потому, что переменную, объявленную как константа, нельзя изменить из кода программы.
Общепринятым решением этой проблемы является объявление специальной формы переменной, называемой "свойством". Любое свойство фактически состоит из двух функций: первая предназначена для установки значения, вторая — для чтения этого значения. С точки зрения внешнего кода свойство ведет себя, как обычная переменная. Если внешний код пытается присвоить свойству какое-либо значение, срабатывает метод set{}, если же код пытается считать значение свойства, вызывается метод get{} .
Оба этих вызова методов проходят для внешнего кода совершенно незаметно, создавая иллюзию работы с обычной переменной. В C# свойство описывается следующим образом:
// Пример 2
class MVD_Worker {
private bool internalHasDocument=true;
public bool hasDocument
{
get{return this.internalHasDocument;}
set{ this.internalHasDocument =value;}
}
}
// А вот примеры обращения к свойству из внешнего кода
...
MVD_Worker omon = new MVD_Worker();
...
omon.hasDocument=false;
...
if(omon.hasDocument){omon.hasDocument=false;}
...
Давайте посмотрим на текст примера повнимательнее. Обратите внимание на использование слова this при обращении к переменной. Это слово означает дословно "тут". Вы указываете компилятору, что переменная, к которой вы хотите обратиться, находится в _этом_ классе. Говоря другими словами, this — это ссылка внутри класса на сам этот класс. На данном этапе использование этой записи покажется вам странным, но когда мы перейдем к вопросам наследования, вы поймете, зачем эта ссылка нужна. Пока привыкайте обращаться к переменным класса изнутри его кода именно таким образом. Теперь давайте посмотрим, каким еще изменениям подвергся наш класс.
Во-первых, наша переменная переименовалась и называется теперь internalHasDocument . Переименовать переменную нам пришлось из-за того, что теперь у нас в классе появилась другая конструкция языка, называющаяся hasDocument . Эта конструкция и есть "свойство". Объявляется свойство примерно так же, как и переменная, за исключением того, что после его имени в фигурных скобках идет описание одного или двух методов. Эти методы имеют предопределенные имена get и set . Создавать методы с другими именами внутри блока свойства нельзя.
Первый метод get{} предназначен для получения значения свойства. В нашем примере метод get просто возвращает значение переменной internalHasDocument . В принципе, get{} — это обычная функция. В ее теле вы можете записать любые допустимые команды языка. По крупному счету, требование к реализации функции get только одно: get{} обязан вернуть с помощью return значение, соответствующее по типу тому, что вы ранее объявили в "шапке" свойства. То есть, если вы сказали, что свойство имеет тип bool, значит, будьте добры вернуть bool . Если сказали, что свойство имеет тип int, должны вернуть int . Откуда вы возьмете это значение, это ваше личное дело. Это может быть константа или результат работы какой-либо другой функции. Вы можете записать метод get нашего свойства hasDocument примерно таким образом: get{return true;}, то есть вообще не пользоваться содержащимся в internalHasDocument значением. И это будет работать.
Второй метод, set{}, как вы уже, наверно, догадались, предназначен для установки значения свойства. Подобно методу get{}, он точно так же является функцией и оставляет вам как разработчику полную свободу своей реализации. Методу неявно передается переменная, называющаяся value. В ней хранится значение, которое внешний относительно вашего класса код пытается присвоить вашему свойству.
Подразумевается, что вы сохраните это значение в какой-либо внутренней переменной или тем или иным образом измените внутреннее состояние своего объекта. Компилятор никак не проверяет, сделали вы это или нет, поэтому вполне допустим метод set{} с пустым телом . Если вы, скажем, только создаете класс и на данном этапе работы понятия не имеете, как именно будет реализовываться этот метод set, просто объявите его и оставьте тело метода set{} пустым.
...
get{return true;}
set{}
...
Внимательный читатель наверняка обратил внимание на мою фразу о том, что методов может быть или два, или один. Если вы опустите в описании метод set, то получите свойство, которое можно только читать (read-only), а установить нельзя. Если вы опустите метод get, то получите свойство, которому можно только присваивать значение, а получить его обратно нельзя (write-only).
Теперь, когда мы более ли менее разобрались с понятием свойства, давайте попробуем применить его к нашей задаче с классом MVD_Worker . Итак, нам необходимо, чтобы наш класс показывал всем желающим свое удостоверение, но в руки его не давал. Для этого нам необходимо задать read-only-свойство по имени hasDocument, базирующееся в реальности на переменной internalHasDocument . Так как нам нужно read-only-свойство, мы опускаем аксессор set{}. Новая версия нашего класса MVD_Worker выглядит так:
// Пример 3
class MVD_Worker {
private bool internalHasDocument=true;
public bool hasDocument {
get{return this.internalHasDocument;}
}
}
Теперь приведу пример работы внешней программы с нашим классом MVD_Worker . Для разнообразия (а также для того, чтобы не заставлять понапрасну скрежетать зубами над текстами моих примеров Linux-программистов, что их, мол, обделили с реализацией WinForms ) сделаем на этот раз консольное приложение. Сделаем мы консольное приложение только в этом примере, поэтому желающие и дальше компилировать примеры моих статей — велком на Microsoft Net Framework.
Итак, привожу полный текст консольного приложения:
// Пример 4
using System;
class MVD_Worker {
private bool internalHasDocument=true;
public bool hasDocument {
get{return this.internalHasDocument;}
}
}
class App
{
static void Main(){
MVD_Worker omon = new MVD_Worker();
Console.WriteLine( omon.hasDocument);
}
}
Я сам с помощью клипборда перебросил текст этого примера из Microsoft Word, в котором я пишу эту статью, в редактор FAR. Сохранил полученный файл под именем mvd.cs и скомпилировал его, вызвав из командной строки FAR компилятор C# с помощью команды:
csc mvd.cs
После того как компилятор отработал, у меня рядышком с mvd.cs оказался файл mvd.exe. Запускаете его из FAR, и, если у вас все набрано верно, на экране появится слово True .
На этом этапе у вас наверняка возник вопрос: а что случится, если внешний код обратится не к любезно предоставленному нами свойству hasDocument, а напрямую к переменной internalHasDocument ? Не сможет ли он таким образом изменить значение нашего свойства в обход всей нашей защиты?
Нет, не сможет. Видите, перед объявлением переменной internalHasDocument стоит слово private ? Это так называемый модификатор доступа. Модификаторы нужны для того, чтобы определять, кто имеет право работать с этой переменной, а кто нет.
Модификатор private означает, что к объявленной таким образом конструкции может иметь доступ только код, работающий внутри этого класса. Что такое "внутри"? Наиболее просто это можно пояснить так. Внутри — это значит, внутри фигурных скобок, ограничивающих объявление класса. Посмотрите пример 1 в этой статье. Видите там пару фигурных скобок {} ? Если вы внимательно проанализируете код остальных примеров, то заметите, что и в них эти скобки везде присутствуют. Мы вставляем новые конструкции или внутри них, или снаружи. Эти скобки являются чем-то вроде границ класса. Все, что лежит внутри них, относится к этому классу, то, что снаружи их, к этому классу не относится. Впрочем, я вам об этом уже рассказывал выше.
В нашем примере 4 к переменной internalHasDocument может обращаться только код изнутри методов get и set. Для методов снаружи класса (к примеру, кода в методе Main класса App ) она недоступна. Попробуйте изменить метод Main вот таким образом:
// Пример 5
static void Main(){
MVD_Worker omon = new MVD_Worker();
omon.internalHasDocument=false;
Console.WriteLine( omon.hasDocument);
}
а затем перекомпилировать проект. Еще на этапе компиляции компилятор вас обругает:
error CS0122: 'MVD_Worker.internalHasDocument' is inaccessible due to its protection level
Так что ничего у нас не выйдет. Уберите неправильную строчку и больше так не поступайте.
Противоположным по значению смыслом обладает модификатор доступа public. Если вы напишете его перед какой-либо лексической конструкцией, она станет доступна коду, находящемуся вне класса. Пример такого использования модификатора вы можете увидеть в объявлении свойства public bool hasDocument . Попробуйте удалить слово public и скомпилировать пример. Вы получите уже знакомую вам ошибку:
error CS0122: 'MVD_Worker.hasDocument' is inaccessible due to its protection level
Без модификатора public наше свойство точно так же недоступно внешнему коду, как и переменная internalHasDocument .
Позвольте, — воскликнете вы. Я же просто стер public, я не указывал взамен private ! Все правильно. Если вы вообще не указали никакого модификатора доступа, C# самостоятельно его ставит за вас, основываясь на типе объекта. Для переменных, методов и свойств таким модификатором является private. Вы можете сделать вывод, что ставить модификатор private вообще необязательно: мол, все равно компилятор это сделает за вас. Я настоятельно рекомендую вам так не поступать. Во-первых, это противоречит хорошему тону программирования, а во-вторых — приводит к досадным ошибкам. Я сам недавно в одном из форумов долго распинался по поводу того, как классно организовывается в С# наследование. Мол, взял чужой DLL-файл c классом — и наследуй… А чуть позже — сидел и краснел, так как не смог унаследовать даже свой собственный класс, объявленный в другом модуле. Причем не мог я это сделать только из-за того, что, когда его создавал, упустил модификатор public в объявлении класса. С тех пор я всегда пишу public перед объявлением класса, так как заранее не знаю, придет мне когда-нибудь в голову унаследовать этот класс или нет.
Класс, описанный как class SomeName{}, объявляется с умалчиваемым модификатором internal, то есть он доступен только классам внутри одного с ним бинарного файла. Если вы скомпилировали свой класс в отдельную DLL, то другим классам, из основного приложения (другого бинарного файла), он будет недоступен. Правильнее его было бы объявить как public class SomeName{} . Тогда вы сможете спокойно от него наследовать и в такой ситуации.
Другой вопрос, который у вас наверняка возникнет: ну, создали мы read-only-свойство, ну, а как мы ему будем присваивать значение? Если задавать его прямо в коде, как это сделано в предыдущих примерах, то чем оно тогда отличается от обычной константы?
Так как мы с вами эмулируем реальную жизнь, то давайте подумаем, когда наш работник получает свое удостоверение. Правильно, при приеме на работу. Тут мы с вами плавно подошли к такому понятию, как конструктор объекта. Конструктор вызывается каждый раз, когда вы создаете новый объект. Он предназначен для приведения объекта в некое исходное состояние перед тем, как его свойства и методы начнут взаимодействовать с остальным кодом вашего приложения. Так как нам с вами перед использованием класса MVD_Worker необходимо выдать (или не выдавать) ему удостоверение, мы и впишем эту процедуру в конструктор. По правилам, принятым в C#, наш пример с добавлением конструктора будет выглядеть так:
// Пример 6
public class MVD_Worker {
...
public MVD_Worker(bool WorkerHasDocument)
{
internalHasDocument=WorkerHasDocument;
}
}
Рассмотрев приведенный код, легко можно сделать следующие выводы.
Конструктор — это обычный метод, объявленный с модификатором public (на самом деле это не факт). Конструктору, как и любому методу, могут быть переданы параметры. У конструктора, в отличие от метода, не указывается тип возвращаемого значения. Имя конструктора должно совпадать с именем класса. Собственно говоря, именно это совпадение имен и делает метод конструктором.
Теперь давайте посмотрим, как создается объект, имеющий конструктор с параметрами:
// Пример 7
using System;
public class MVD_Worker {
private bool internalHasDocument=true;
public bool hasDocument {
get{return this.internalHasDocument;}
}
public MVD_Worker(bool WorkerHasDocument)
{
internalHasDocument=WorkerHasDocument;
}
}
public class App
{
static void Main(){
MVD_Worker omon = new MVD_Worker(true);
Console.WriteLine("Omon has document :" + omon.hasDocument);
MVD_Worker pisez = new MVD_Worker(false);
Console.WriteLine( "Pisez has document :"+pisez.hasDocument);
}
}
После того как вы скомпилируете и запустите на выполнение этот пример, он выведет на экран:
Omon has document :True
Pisez has document :False
Таким образом, два разных экземпляра одного и того же класса возвращают нам разное значение в зависимости от того, как именно мы их инициализировали в момент создания. Происходит это потому, что объект, в отличие от процедуры с локальными переменными, хранит свое внутреннее состояние. Каждый экземпляр класса, который вы создали, имеет ссылку на общие для всех экземпляров методы, но переменные (поля) у каждого экземпляра свои.
К слову, в нашем приложении мы с вами уже совершили одну неочевидную ошибку. Помните, как мы раньше создавали объект, вызывая конструктор класса без параметров? Ну-у-у MVD_Worker omon = new MVD_Worker(); Давайте попробуем вcтавить в наш пример номер 7 подобную строчку создания нового объекта:
...
MVD_Worker omon = new MVD_Worker(true);
MVD_Worker omon1 = new MVD_Worker();
Console.WriteLine("Omon has document :" + omon.hasDocument);
...
Оп-ля!
error CS1501: No overload for method 'MVD_Worker' takes '0' arguments
Прикол тут вот в чем. Пока мы не объявляли в классе своих собственных конструкторов с параметрами, в нем неявно отрабатывал его собственный (унаследованный) конструктор без параметров. Как только мы создали свой конструктор (любой), этот "дефолтовый" конструктор работать перестает. Поэтому нам необходимо помимо реально нужных нам конструкторов дополнительно описать и этот "дефолтовый".
В нашем случае он будет выглядеть примерно так:
public MVD_Worker()
{
internalHasDocument=false; // документа по умолчанию нет.
}
Если теперь вы взглянете внимательно на код, то подметите еще одну особенность использования конструкторов при создании объектов. Конструкторов у объекта может быть несколько. Все они имеют одинаковое имя, совпадающее с именем объекта, и обязательно разное количество или разный тип параметров. На самом деле это правило касается не только конструкторов, но и вообще любых методов. Вы можете объявлять внутри класса любое количество методов с одинаковыми именами до тех пор, пока их параметры имеют разный тип или отличаются общим количеством. По-умному эта фича называется перегрузка методов. Суть ее сводится вот к чему. Если вы объявите несколько методов с одинаковым именем, но разными параметрами и впоследствии вызовете этот метод из внешнего кода с параметром определенного типа, компилятор сам подберет метод, наиболее подходящий к тем параметрам, которые вы передали. На самом деле эту фичу проще продемонстрировать, чем о ней рассказать. Давайте напишем небольшое тестовое приложение:
// Пример 8
using System;
public class Test
{
public void Out(string s){
Console.WriteLine("I,m string :"+s);
}
public void Out(int i){
Console.WriteLine("I,m integer:"+i);
}
public void Out(double d){
Console.WriteLine("I,m double :"+d);
}
public void Out(object o){
Console.WriteLine("I'm can't write this "+o.ToString());
}
}
class App
{
public static void Main(){
float f = 14f;
Test t = new Test();
t.Out("Hi!");
t.Out(1);
t.Out(3.14);
t.Out(null);
t.Out(f);
t.Out(true);
}
}
Скомпилируйте этот пример и выполните полученное приложение. Оно выведет на ваш экран следующее:
I,m string :Hi!
I,m integer:1
I,m double :3,14
I,m string :
I,m double :14
I'm can't write this True
То есть для каждого типа данных вызывается свой собственный метод. Обратите внимание на три последних вызова Out в Main . В них я намеренно попытался запутать наш класс.
Если вы передадите методу в качестве параметра null, отрабатывает первый же встреченный метод. Происходит это потому, что в C# null по своему типу подходит к любому другому типу объектов.
Если вы передадите методу в качестве параметра тип, для которого у нас не описан соответствующий перегруженный метод, компилятор попытается подобрать наиболее подходящий по типу параметра метод. Так, в случае параметра float компилятор выбрал метод с параметром типа double . Тип double перекрывает по диапазону возможных значений тип float . А вот если бы у нас был описан метод для float, а мы передали бы параметр double, этот номер бы уже не прошел. Попробуйте поэкспериментировать самостоятельно и сами объяснить, почему получается именно так, а не иначе.
Когда же мы передали методу параметр типа bool ( true ), компилятор не смог найти подходящий метод и вызвал метод c параметром object, так как object — это общий предок для всех типов данных и подобно null совместим с любым типом C#.
Если бы этого метода не было, вы получили бы ошибку времени компиляции, сообщающую о том, что компилятор не смог подобрать подходящего метода для t.Out(true) . Я от себя рекомендую, используя перегрузку методов, всегда заводить метод с параметром object .
Ну что ж, вуаля! Мы с вами научились пользоваться методами и свойствами, а вместе с ними разобрались, по крупному счету, с сутью одной из трех священных коров ООП программирования, называющейся инкапсуляция . Этим мудреным словом описывается всего лишь механизм, который позволяет запрятать для внешнего кода нюансы конкретной реализации объекта. Так, внешний для нашего класса код понятия не имеет о существовании переменной internalHasDocument. Причем это незнание не мешает ему успешно работать с нашим классом. Если впоследствии вы решите изменить механизм обработки этого свойства — к примеру, банально переименовать internalHasDocument, скажем, в havePassport, вам для этого нужно лишь подправить в вашем классе метод get{} . Внешний код при этом исправлять не нужно! Он как работал себе через свойство hasDocument, так и продолжает себе работать. Помимо подобной маскировки инкапсуляция обеспечивает еще одно полезное свойство вашей программы. Если у вас создан экземпляр класса Omonovez по имени boez, то при работе с таким объектом только через его публичные методы и свойства вы можете, отобрав у него удостоверение, попутно лишить его автомата и каски.
// Пример 9
using System;
class Omonovez{
private bool internalHasDocument = false;
private bool internalHasKalashnik = false;
private bool internalHasKaska = false;
public bool hasKalashnik{
get{return internalHasKalashnik;}
set{hasDocument=value;}
}
public bool hasKaska{
get{return internalHasKaska;}
set{hasDocument=value;}
}
public bool hasDocument {
get{return internalHasDocument;}
set{
internalHasDocument = value;
internalHasKalashnik = value;
internalHasKaska = value;
}
}
public void OutState(){
Console.WriteLine();
Console.WriteLine("Document - "+ this.internalHasDocument);
Console.WriteLine("Kalashnik - "+ this.internalHasKalashnik);
Console.WriteLine("Kaska - "+ this.internalHasKaska);
}
}
class App{
public static void Main(){
Omonovez boez = new Omonovez();
boez.hasDocument=true;
boez.OutState();
boez.hasDocument=false;
boez.OutState();
boez.hasKaska=false;
boez.OutState();
}
}
В этом примере объявляются три закрытые переменные и соответственно три открытых свойства, управляющих их состоянием. Помимо свойств я включил в объект еще и метод, выводящий в консоль текущее состояние внутренних переменных.
Попытка изменить любое из трех свойств приведет к изменению всех трех переменных. Причем, заметьте, в коде присутствует всего один метод, который реально меняет все три переменных ( get в hasDocument ). Если бы мы с вами оставили переменные класса открытыми, такой номер у нас бы не прошел. Более того: любой внешний класс смог бы, поодиночке меняя эти переменные, запутать нам весь алгоритм. Оформив же таким образом свойства, мы можем внутри своего кода вообще не проверять наличие каски. Достаточно убедиться, что у бойца есть документы.
Пример, конечно, дурацкий, но он неплохо комментирует то, о чем я вам хотел рассказать. Попробуйте самостоятельно его модифицировать так, чтобы он отражал более осмысленную житейскую логику.
Ну что ж, счетчик количества символов в моем MSWord подсказывает мне, что пора закругляться, и я, подобно Шахерезаде, заканчиваю на сегодня дозволенные речи…
Герман Иванов
Продолжение. Начало в КГ №№ 13, 14, 16 .
Прежде чем мы начнем создавать базовые классы "сущностей", задействованных в нашем алгоритме, нам предварительно необходимо разобраться с тем, какие возможности для этого нам предоставляет язык C#. Поэтому задвиньте пока на дальнюю полку мой пример с варкой кофе. В ближайшие несколько статей мы к нему не вернемся. Вместо этого мы с вами совершим небольшую экскурсию, осмотрев с высоты птичьего полета реализацию объектно-ориентированной парадигмы в языке C#. Пример же с варкой кофе нам снова понадобится по окончании этой обзорной экскурсии. К этому моменту мы с вами сможем, уже во всеоружии, решить поставленные его алгоритмом задачи.
Важное примечание! Прежде чем вы приступите к набивке текстов примеров из этой или последующих моих статей, учтите, что язык C# является регистрозависимым. Console.WriteLine и coNsoLe.wRiteLIne для него — совершенно разные команды. Набирая текст примеров, всегда обращайте внимание на правильность регистра в названиях классов и методов.
Рассказывая вам о классовой модели C#, я предполагаю, что вы уже знакомы с базовыми операторами C-образных языков. Если это не так, поищите где-нибудь книжку по этой теме. Если я сейчас начну вам подробно рассказывать о том, как работают операторы for(;;;){} или if(){}, я никогда не доберусь до конца этого цикла статей. В C# эти операторы описываются и функционируют точно так же, как и в других распространенных языках, за некоторыми исключениями и дополнениями.
Изменения в языке
Операторы цикла for, while и foreach
Операторы цикла for и while унаследованы от C-подобных языков без каких-либо заметных изменений. Вы можете использовать все те "выкрутасы", к которым привыкли и в C, и в C++ . Из нововведений появился новый оператор foreach(X in Y), пришедший в язык из Visual Basic. Он предназначен для перебора всех объектов в какой-либо коллекции (списке). Что такое "коллекция", я пока не смогу вам объяснить, ведь мы с вами еще даже не изучили, что такое класс . Пока я вам лишь замечу, что коллекция — это набор из некоторого количества объектов одного или разных типов, и foreach перебирает их все по одному. Одним из примеров такого набора является обычный массив.
Оператор выбора if
В реализации if появилось одно весьма существенное нововведение. Код вида int x=0; if(x){}else{} больше не работает и приводит к ошибке времени компиляции. Для того чтобы все заработало, необходимо писать так: int x=0; if(x!=0){}else{} . Говоря другими словами, 0 теперь совсем не равняется false, а неравенство выражения нулю совсем не означает true . Компилятор отслеживает эту ошибку в тексте ваших программ и сразу на нее ругается.
Оператор выбора switch
Логика работы оператора switch также подверглась модификации. В С# вам не удатся провалиться сквозь метки выбора в операторе switch{case:;} . Вполне приемлемый для C++ код:
int a =3;
switch (a){
case 1:
x=true;
case 2:
y=false;
break;
}
вызовет в C# ошибку, так как каждый case должен заканчиваться своим оператором break . Исключение сделано лишь для секций case, не имеющих тела. То есть код:
int a =3;
switch (a){
case 1:
case 2:
y=false;
break;
}
вполне допустим.
На самом деле C# по сравнению с C++ имеет еще довольно много нюансов. Мы с ними познакомимся по мере рассмотрения примеров в этом цикле статей.
Типы данных
Теперь давайте поговорим о типах данных. В номере 12 (25 марта 2003 г.) нашей газеты уже публиковалась статья, озаглавленная "Linux и C#. Типы данных". Первоначально я хотел сослаться на нее, затрагивая эту тему. Но, внимательно изучив статью и ничего не поняв из прочитанного там, я решил своими словами изложить ту информацию, которую пытался донести до читателя ее автор.
Говоря о типах данных в C#, следует сразу учесть тот нюанс, что язык поддерживает параллельно два разных набора типов данных. Во-первых, это набор типов данных самой среды Net Framework, а во-вторых — набор синонимов этих типов, записанных в нотации, привычной программистам на C или С++ .
Потребовалось это, по всей видимости, вот зачем. Как я и писал в первой статье цикла, Net Framework — это единая среда выполнения для большого количества языков программирования. На данный момент под нее написано уже с добрый десяток компиляторов разных языков. Все они используют типы данных, естественные для программистов на этом конкретном языке. Во время компиляции программы эти нативные для конкретного языка типы данных приводятся к типам Net Framework. Получается, что и овцы целы, и волки сыты. Программистам не нужно изучать новые типы данных, а среда Net Framework замечательно работает со всеми языками одновременно, не заморачиваясь поддержкой всевозможных языковых фенечек на манер типа данных Real, принятого в Pascal, или его аналога Real16, пришедшего ему на смену в Delphi.
Язык С # позволяет работать как с естественными для C++-программиста типами данных, так и напрямую с типами, объявленными в Net Framework. Вы наверняка спросите меня, а какой из двух наборов типов данных лучше использовать в ваших программах? Да какой хотите! Я сам использую обе разновидности вперемешку, тяготея при этом к использованию типов Net Framework. Мне так проще переключаться в коде между VB.Net и С#. Нативные типы данных у этих двух языков разные, а типы Net Framework присутствуют в обоих языках.
Ниже я приведу вам небольшую табличку типов данных C# c указанием соответствующего им типа данных Net Framework.
Тип С# Тип Net Framework Назначение
Byte System.Byte 8-битное значения без знака
short System.Int16 16-битное значение со знаком
int System.Int32 32-битное значение со знаком
long System.Int64 64-битное значение со знаком
char System.Char Один 16-битный символ Unicode
float System.Single 32-битное значение с плавающей запятой
double System.Double 64-битное значение с плавающей запятой
bool System.Boolean Значение True или False (истина или ложь)
decimal System.Decimal 128-битное число с плавающей точкой
string System.String Cтрока Unicode символов
object System.Object Базовый тип для любых типов
Примечание: На самом деле в этой таблице я упустил еще несколько типов данных. Сделал я это намеренно. Дело в том, что эти типы данных применимы лишь к программам, написанным на C#. Их следует по возможности избегать, так как они делают невозможным использование вашего кода программистами, скажем, на VB.Net. Мне не хотелось бы приучать вас в своих статьях к неправильному стилю программирования. Если вы захотите с ними разобраться, то самостоятельно найдите дополнительную литературу. А это моя статья: о чем хочу — о том и рассказываю. Замечу лишь, что к таким "неправильным" типам относятся sbyte, ushort, uint, ulong . То есть "знаковые" варианты типов данных без знака и "беззнаковые" аналоги типов данных со знаком. Особого смысла в их использовании я не вижу.
Вернемся к моей таблице. Если вы уже знакомы с каким-либо языком программирования, то понимание назначения перечисленных в ней типов данных не должно вызвать у вас каких-либо трудностей. Отдельного пояснения требуют, пожалуй, только последние три типа данных — decimal, string и object .
decimal — это дробное число. Его следует использовать в тех случаях, когда для вас критична возможная потеря точности за счет ошибок округления. В Delphi имеется такой тип данных, как Currency. decimal — это еще более "точное" его подобие.
string — это привычная всем программистам строка, обладающая, однако, одним неочевидным свойством. Дело в том, что переменную типа string невозможно изменить! Например, вот совершенно банальный пример кода:
string s ="" ;// объявляем пустую строку
s="One" ;// присваиваем ей значение "One"
s+=" and Two";// добавляем в хвост строке " and Two"
Так вот, этот код сначала создает строку s c пустым значением, затем уничтожает ее и на ее месте создает строку c содержимым "One". Затем снова ее уничтожает и на ее месте создает строку с содержимым "One and Two"! Почему так сделано, можно гадать очень долго, а вот незнание этого нюанса приводит к тому, что ваши программы по обработке строк очень долго выполняются и поедают много памяти.
Поэтому, если в вашей программе используется строка, которая очень часто модифицируется, задействуйте для ее обработки объект System.Text.StringBuilder . Он специально предназначен для операций с часто модифицирующейся строкой. Описание методов объекта System.Text.StringBuilder выходит за рамки этой статьи (их довольно много). Посмотрите самостоятельно их описание в MSDN или прилагаемой к Net Framework документации.
У нас остался, пожалуй, самый интересный тип данных — object . В таблице я написал, что этот тип данных является базовым для всех других типов данных. На самом деле так оно и есть. Все встроенные типы данных в C# на самом деле являются объектами. Даже когда вы используете константу в своем коде, она также будет являться объектом, производным от System.Object . Попробуйте из любопытства откомпилировать такой код:
using System;
class App{
static void Main(){
Console.WriteLine(2.ToString());
}
}
Эта программа благополучно откомпилируется и выполнится, несмотря на довольно странно выглядящий вызов функции 2.ToString() . Дело в том, что константа 2 в моем примере — это полноценный объект, обладающий набором методов. Один из них — Object.ToString() — мы с вами и вызвали. И тип данных int, и тип данных string, и все остальные встроенные типы данных восходят в конце концов к типу object . Более того: даже когда вы создаете свой собственный тип данных (класс), он все равно будет иметь в предках object, хотите вы этого или нет. object — это основа для любого типа данных, созданного в среде Net Framework. Каким образом происходит это наследование, вы поймете, когда мы с вами разберемся с тем, как создаются и наследуются классы в C#. Вот, блин, извечная проблема "Курицы и яйца"! Все зависит от всего, и очень трудно подступиться к описанию языка "с самого начала". Все связано со всем, и неизвестно, с какой точки начать повествование, чтобы постоянно не забегать вперед.
Ну ладно, мы отвлеклись. Итак, класс в C# описывается с помощью следующей лексической конструкции:
class имя_класса
{
}
Используя эту конструкцию, мы закладываем черты поведения будущих объектов этого типа. Обратите внимание: описание класса не создает никаких реальных объектов! Вы просто рассказываете программе, как должен выглядеть ваш будущий объект. После того как вы опишете класс, вы сможете впоследствии создать сколько угодно экземпляров этого объекта. Для создания новых объектов из имеющихся описаний классов используется команда языка C# new . Например, если мы с вами описали класс по имени FirstClass :
class FirstClass
{
}
то впоследствии новый экземпляр класса FirstClass с именем fs мы можем создать с помощью следующего кода:
FirstClass fs = new FirstClass();
Сначала идет название описания класса (его тип), затем — название переменной. Затем с помощью команды new создается новый экземпляр. Для этого вызывается конструктор объекта, выглядящий как название типа, после которого идут круглые скобки. То есть используется привычный всем синтаксис C++.
Создаем свои объекты
Для более подробного изучения понятия класса в C# давайте возьмем пример, хорошо понятный любому жителю бывшего СССР. Давайте создадим класс по имени MVD_Worker, описывающий некоего абстрактного "работника министерства внутренних дел". Соответствующее описание класса может выглядеть примерно таким образом:
class MVD_Worker
{
}
Сначала идет кодовое слово class, говорящее компилятору, что дальше следует описание нового класса, за ним идет название типа будущего класса. Именно его мы потом и будем использовать создавая новые экземпляры нашего нового класса. Далее идут фигурные скобки, очерчивающие границы нашего класса. Все то, что мы впоследствии опишем внутри этих скобок, принадлежит этому классу. То, что описано снаружи этих скобок, к нашему классу не относится. Для закрепления материала давайте я вам приведу пример кода, создающего экземпляр нашего класса MVD_Worker по имени omon .
MVD_Worker omon = new MVD_Worker();
Думая о работнике MVD, кто-то из вас наверняка представил себе гаишника, кто-то ОМОНовца, а кто-то — соседа дядю Васю, живущего в квартире напротив вас. Непорядок: нам этот разнобой совершенно ни к чему! Подумайте, а что имеется общего у всех вышеназванных работников уважаемого ведомства? Давайте отбросим пока их общечеловеческие признаки типа того, что у них всех имеется по паре ног, две руки и два глаза. Ну как? Что скажете?
Я сам, пораскинув мозгами пару минут, вывел такой общий для них всех признак — "у них всех есть соответствующее служебное удостоверение". Это общее свойство мы и заложим как базовый признак работника соответствующего ведомства и назовем его hasDocument .
Так как это свойство может иметь лишь два значения: "документ есть" и "документа нет", — то для его хранения мы используем тип данных bool . Напомню: этот тип данных может принимать только два возможных значения: true, означающее "да", и false — означающее "нет".
По правилам языка C# оформляется все вышесказанное вот таким образом:
// Пример 1
class MVD_Worker
{
public bool HasDocument=true;
}
На первый взгляд, у нас все получилось хорошо. Создав экземпляр класса, мы легко можем считывать или изменять значение переменной из своего кода. Например, вот так:
MVD_Worker omon = new MVD_Worker();
omon.hasDocument = true;
if (omon.hasDocument){...}
Но представим себе на минутку, что наш свежеиспеченный класс MVD_Worker когда-нибудь, в коде будущей программы, встретится с неким классом, описывающим "нарушителя". Так как переменная hasDocument доступна для модификации любому встречному классу, наш гипотетический "нарушитель" запросто может "отобрать" у нашего сотрудника MVD удостоверение. Это не есть правильно. Первое, что приходит в голову, — объявить вместо переменной константу — например, вот таким образом:
public const bool HasDocument=true;
Тогда при попытке присвоить переменной какое-либо значение будет возникать ошибка времени компиляции. Но в этом случае у нас возникает другая проблема. После такого объявления hasDocument нашего гипотетического сотрудника не сможет уволить не только его собственный отдел кадров, но и даже сам президент. Произойдет это потому, что переменную, объявленную как константа, нельзя изменить из кода программы.
Общепринятым решением этой проблемы является объявление специальной формы переменной, называемой "свойством". Любое свойство фактически состоит из двух функций: первая предназначена для установки значения, вторая — для чтения этого значения. С точки зрения внешнего кода свойство ведет себя, как обычная переменная. Если внешний код пытается присвоить свойству какое-либо значение, срабатывает метод set{}, если же код пытается считать значение свойства, вызывается метод get{} .
Оба этих вызова методов проходят для внешнего кода совершенно незаметно, создавая иллюзию работы с обычной переменной. В C# свойство описывается следующим образом:
// Пример 2
class MVD_Worker {
private bool internalHasDocument=true;
public bool hasDocument
{
get{return this.internalHasDocument;}
set{ this.internalHasDocument =value;}
}
}
// А вот примеры обращения к свойству из внешнего кода
...
MVD_Worker omon = new MVD_Worker();
...
omon.hasDocument=false;
...
if(omon.hasDocument){omon.hasDocument=false;}
...
Давайте посмотрим на текст примера повнимательнее. Обратите внимание на использование слова this при обращении к переменной. Это слово означает дословно "тут". Вы указываете компилятору, что переменная, к которой вы хотите обратиться, находится в _этом_ классе. Говоря другими словами, this — это ссылка внутри класса на сам этот класс. На данном этапе использование этой записи покажется вам странным, но когда мы перейдем к вопросам наследования, вы поймете, зачем эта ссылка нужна. Пока привыкайте обращаться к переменным класса изнутри его кода именно таким образом. Теперь давайте посмотрим, каким еще изменениям подвергся наш класс.
Во-первых, наша переменная переименовалась и называется теперь internalHasDocument . Переименовать переменную нам пришлось из-за того, что теперь у нас в классе появилась другая конструкция языка, называющаяся hasDocument . Эта конструкция и есть "свойство". Объявляется свойство примерно так же, как и переменная, за исключением того, что после его имени в фигурных скобках идет описание одного или двух методов. Эти методы имеют предопределенные имена get и set . Создавать методы с другими именами внутри блока свойства нельзя.
Первый метод get{} предназначен для получения значения свойства. В нашем примере метод get просто возвращает значение переменной internalHasDocument . В принципе, get{} — это обычная функция. В ее теле вы можете записать любые допустимые команды языка. По крупному счету, требование к реализации функции get только одно: get{} обязан вернуть с помощью return значение, соответствующее по типу тому, что вы ранее объявили в "шапке" свойства. То есть, если вы сказали, что свойство имеет тип bool, значит, будьте добры вернуть bool . Если сказали, что свойство имеет тип int, должны вернуть int . Откуда вы возьмете это значение, это ваше личное дело. Это может быть константа или результат работы какой-либо другой функции. Вы можете записать метод get нашего свойства hasDocument примерно таким образом: get{return true;}, то есть вообще не пользоваться содержащимся в internalHasDocument значением. И это будет работать.
Второй метод, set{}, как вы уже, наверно, догадались, предназначен для установки значения свойства. Подобно методу get{}, он точно так же является функцией и оставляет вам как разработчику полную свободу своей реализации. Методу неявно передается переменная, называющаяся value. В ней хранится значение, которое внешний относительно вашего класса код пытается присвоить вашему свойству.
Подразумевается, что вы сохраните это значение в какой-либо внутренней переменной или тем или иным образом измените внутреннее состояние своего объекта. Компилятор никак не проверяет, сделали вы это или нет, поэтому вполне допустим метод set{} с пустым телом . Если вы, скажем, только создаете класс и на данном этапе работы понятия не имеете, как именно будет реализовываться этот метод set, просто объявите его и оставьте тело метода set{} пустым.
...
get{return true;}
set{}
...
Внимательный читатель наверняка обратил внимание на мою фразу о том, что методов может быть или два, или один. Если вы опустите в описании метод set, то получите свойство, которое можно только читать (read-only), а установить нельзя. Если вы опустите метод get, то получите свойство, которому можно только присваивать значение, а получить его обратно нельзя (write-only).
Теперь, когда мы более ли менее разобрались с понятием свойства, давайте попробуем применить его к нашей задаче с классом MVD_Worker . Итак, нам необходимо, чтобы наш класс показывал всем желающим свое удостоверение, но в руки его не давал. Для этого нам необходимо задать read-only-свойство по имени hasDocument, базирующееся в реальности на переменной internalHasDocument . Так как нам нужно read-only-свойство, мы опускаем аксессор set{}. Новая версия нашего класса MVD_Worker выглядит так:
// Пример 3
class MVD_Worker {
private bool internalHasDocument=true;
public bool hasDocument {
get{return this.internalHasDocument;}
}
}
Теперь приведу пример работы внешней программы с нашим классом MVD_Worker . Для разнообразия (а также для того, чтобы не заставлять понапрасну скрежетать зубами над текстами моих примеров Linux-программистов, что их, мол, обделили с реализацией WinForms ) сделаем на этот раз консольное приложение. Сделаем мы консольное приложение только в этом примере, поэтому желающие и дальше компилировать примеры моих статей — велком на Microsoft Net Framework.
Итак, привожу полный текст консольного приложения:
// Пример 4
using System;
class MVD_Worker {
private bool internalHasDocument=true;
public bool hasDocument {
get{return this.internalHasDocument;}
}
}
class App
{
static void Main(){
MVD_Worker omon = new MVD_Worker();
Console.WriteLine( omon.hasDocument);
}
}
Я сам с помощью клипборда перебросил текст этого примера из Microsoft Word, в котором я пишу эту статью, в редактор FAR. Сохранил полученный файл под именем mvd.cs и скомпилировал его, вызвав из командной строки FAR компилятор C# с помощью команды:
csc mvd.cs
После того как компилятор отработал, у меня рядышком с mvd.cs оказался файл mvd.exe. Запускаете его из FAR, и, если у вас все набрано верно, на экране появится слово True .
На этом этапе у вас наверняка возник вопрос: а что случится, если внешний код обратится не к любезно предоставленному нами свойству hasDocument, а напрямую к переменной internalHasDocument ? Не сможет ли он таким образом изменить значение нашего свойства в обход всей нашей защиты?
Нет, не сможет. Видите, перед объявлением переменной internalHasDocument стоит слово private ? Это так называемый модификатор доступа. Модификаторы нужны для того, чтобы определять, кто имеет право работать с этой переменной, а кто нет.
Модификатор private означает, что к объявленной таким образом конструкции может иметь доступ только код, работающий внутри этого класса. Что такое "внутри"? Наиболее просто это можно пояснить так. Внутри — это значит, внутри фигурных скобок, ограничивающих объявление класса. Посмотрите пример 1 в этой статье. Видите там пару фигурных скобок {} ? Если вы внимательно проанализируете код остальных примеров, то заметите, что и в них эти скобки везде присутствуют. Мы вставляем новые конструкции или внутри них, или снаружи. Эти скобки являются чем-то вроде границ класса. Все, что лежит внутри них, относится к этому классу, то, что снаружи их, к этому классу не относится. Впрочем, я вам об этом уже рассказывал выше.
В нашем примере 4 к переменной internalHasDocument может обращаться только код изнутри методов get и set. Для методов снаружи класса (к примеру, кода в методе Main класса App ) она недоступна. Попробуйте изменить метод Main вот таким образом:
// Пример 5
static void Main(){
MVD_Worker omon = new MVD_Worker();
omon.internalHasDocument=false;
Console.WriteLine( omon.hasDocument);
}
а затем перекомпилировать проект. Еще на этапе компиляции компилятор вас обругает:
error CS0122: 'MVD_Worker.internalHasDocument' is inaccessible due to its protection level
Так что ничего у нас не выйдет. Уберите неправильную строчку и больше так не поступайте.
Противоположным по значению смыслом обладает модификатор доступа public. Если вы напишете его перед какой-либо лексической конструкцией, она станет доступна коду, находящемуся вне класса. Пример такого использования модификатора вы можете увидеть в объявлении свойства public bool hasDocument . Попробуйте удалить слово public и скомпилировать пример. Вы получите уже знакомую вам ошибку:
error CS0122: 'MVD_Worker.hasDocument' is inaccessible due to its protection level
Без модификатора public наше свойство точно так же недоступно внешнему коду, как и переменная internalHasDocument .
Позвольте, — воскликнете вы. Я же просто стер public, я не указывал взамен private ! Все правильно. Если вы вообще не указали никакого модификатора доступа, C# самостоятельно его ставит за вас, основываясь на типе объекта. Для переменных, методов и свойств таким модификатором является private. Вы можете сделать вывод, что ставить модификатор private вообще необязательно: мол, все равно компилятор это сделает за вас. Я настоятельно рекомендую вам так не поступать. Во-первых, это противоречит хорошему тону программирования, а во-вторых — приводит к досадным ошибкам. Я сам недавно в одном из форумов долго распинался по поводу того, как классно организовывается в С# наследование. Мол, взял чужой DLL-файл c классом — и наследуй… А чуть позже — сидел и краснел, так как не смог унаследовать даже свой собственный класс, объявленный в другом модуле. Причем не мог я это сделать только из-за того, что, когда его создавал, упустил модификатор public в объявлении класса. С тех пор я всегда пишу public перед объявлением класса, так как заранее не знаю, придет мне когда-нибудь в голову унаследовать этот класс или нет.
Класс, описанный как class SomeName{}, объявляется с умалчиваемым модификатором internal, то есть он доступен только классам внутри одного с ним бинарного файла. Если вы скомпилировали свой класс в отдельную DLL, то другим классам, из основного приложения (другого бинарного файла), он будет недоступен. Правильнее его было бы объявить как public class SomeName{} . Тогда вы сможете спокойно от него наследовать и в такой ситуации.
Другой вопрос, который у вас наверняка возникнет: ну, создали мы read-only-свойство, ну, а как мы ему будем присваивать значение? Если задавать его прямо в коде, как это сделано в предыдущих примерах, то чем оно тогда отличается от обычной константы?
Так как мы с вами эмулируем реальную жизнь, то давайте подумаем, когда наш работник получает свое удостоверение. Правильно, при приеме на работу. Тут мы с вами плавно подошли к такому понятию, как конструктор объекта. Конструктор вызывается каждый раз, когда вы создаете новый объект. Он предназначен для приведения объекта в некое исходное состояние перед тем, как его свойства и методы начнут взаимодействовать с остальным кодом вашего приложения. Так как нам с вами перед использованием класса MVD_Worker необходимо выдать (или не выдавать) ему удостоверение, мы и впишем эту процедуру в конструктор. По правилам, принятым в C#, наш пример с добавлением конструктора будет выглядеть так:
// Пример 6
public class MVD_Worker {
...
public MVD_Worker(bool WorkerHasDocument)
{
internalHasDocument=WorkerHasDocument;
}
}
Рассмотрев приведенный код, легко можно сделать следующие выводы.
Конструктор — это обычный метод, объявленный с модификатором public (на самом деле это не факт). Конструктору, как и любому методу, могут быть переданы параметры. У конструктора, в отличие от метода, не указывается тип возвращаемого значения. Имя конструктора должно совпадать с именем класса. Собственно говоря, именно это совпадение имен и делает метод конструктором.
Теперь давайте посмотрим, как создается объект, имеющий конструктор с параметрами:
// Пример 7
using System;
public class MVD_Worker {
private bool internalHasDocument=true;
public bool hasDocument {
get{return this.internalHasDocument;}
}
public MVD_Worker(bool WorkerHasDocument)
{
internalHasDocument=WorkerHasDocument;
}
}
public class App
{
static void Main(){
MVD_Worker omon = new MVD_Worker(true);
Console.WriteLine("Omon has document :" + omon.hasDocument);
MVD_Worker pisez = new MVD_Worker(false);
Console.WriteLine( "Pisez has document :"+pisez.hasDocument);
}
}
После того как вы скомпилируете и запустите на выполнение этот пример, он выведет на экран:
Omon has document :True
Pisez has document :False
Таким образом, два разных экземпляра одного и того же класса возвращают нам разное значение в зависимости от того, как именно мы их инициализировали в момент создания. Происходит это потому, что объект, в отличие от процедуры с локальными переменными, хранит свое внутреннее состояние. Каждый экземпляр класса, который вы создали, имеет ссылку на общие для всех экземпляров методы, но переменные (поля) у каждого экземпляра свои.
К слову, в нашем приложении мы с вами уже совершили одну неочевидную ошибку. Помните, как мы раньше создавали объект, вызывая конструктор класса без параметров? Ну-у-у MVD_Worker omon = new MVD_Worker(); Давайте попробуем вcтавить в наш пример номер 7 подобную строчку создания нового объекта:
...
MVD_Worker omon = new MVD_Worker(true);
MVD_Worker omon1 = new MVD_Worker();
Console.WriteLine("Omon has document :" + omon.hasDocument);
...
Оп-ля!
error CS1501: No overload for method 'MVD_Worker' takes '0' arguments
Прикол тут вот в чем. Пока мы не объявляли в классе своих собственных конструкторов с параметрами, в нем неявно отрабатывал его собственный (унаследованный) конструктор без параметров. Как только мы создали свой конструктор (любой), этот "дефолтовый" конструктор работать перестает. Поэтому нам необходимо помимо реально нужных нам конструкторов дополнительно описать и этот "дефолтовый".
В нашем случае он будет выглядеть примерно так:
public MVD_Worker()
{
internalHasDocument=false; // документа по умолчанию нет.
}
Если теперь вы взглянете внимательно на код, то подметите еще одну особенность использования конструкторов при создании объектов. Конструкторов у объекта может быть несколько. Все они имеют одинаковое имя, совпадающее с именем объекта, и обязательно разное количество или разный тип параметров. На самом деле это правило касается не только конструкторов, но и вообще любых методов. Вы можете объявлять внутри класса любое количество методов с одинаковыми именами до тех пор, пока их параметры имеют разный тип или отличаются общим количеством. По-умному эта фича называется перегрузка методов. Суть ее сводится вот к чему. Если вы объявите несколько методов с одинаковым именем, но разными параметрами и впоследствии вызовете этот метод из внешнего кода с параметром определенного типа, компилятор сам подберет метод, наиболее подходящий к тем параметрам, которые вы передали. На самом деле эту фичу проще продемонстрировать, чем о ней рассказать. Давайте напишем небольшое тестовое приложение:
// Пример 8
using System;
public class Test
{
public void Out(string s){
Console.WriteLine("I,m string :"+s);
}
public void Out(int i){
Console.WriteLine("I,m integer:"+i);
}
public void Out(double d){
Console.WriteLine("I,m double :"+d);
}
public void Out(object o){
Console.WriteLine("I'm can't write this "+o.ToString());
}
}
class App
{
public static void Main(){
float f = 14f;
Test t = new Test();
t.Out("Hi!");
t.Out(1);
t.Out(3.14);
t.Out(null);
t.Out(f);
t.Out(true);
}
}
Скомпилируйте этот пример и выполните полученное приложение. Оно выведет на ваш экран следующее:
I,m string :Hi!
I,m integer:1
I,m double :3,14
I,m string :
I,m double :14
I'm can't write this True
То есть для каждого типа данных вызывается свой собственный метод. Обратите внимание на три последних вызова Out в Main . В них я намеренно попытался запутать наш класс.
Если вы передадите методу в качестве параметра null, отрабатывает первый же встреченный метод. Происходит это потому, что в C# null по своему типу подходит к любому другому типу объектов.
Если вы передадите методу в качестве параметра тип, для которого у нас не описан соответствующий перегруженный метод, компилятор попытается подобрать наиболее подходящий по типу параметра метод. Так, в случае параметра float компилятор выбрал метод с параметром типа double . Тип double перекрывает по диапазону возможных значений тип float . А вот если бы у нас был описан метод для float, а мы передали бы параметр double, этот номер бы уже не прошел. Попробуйте поэкспериментировать самостоятельно и сами объяснить, почему получается именно так, а не иначе.
Когда же мы передали методу параметр типа bool ( true ), компилятор не смог найти подходящий метод и вызвал метод c параметром object, так как object — это общий предок для всех типов данных и подобно null совместим с любым типом C#.
Если бы этого метода не было, вы получили бы ошибку времени компиляции, сообщающую о том, что компилятор не смог подобрать подходящего метода для t.Out(true) . Я от себя рекомендую, используя перегрузку методов, всегда заводить метод с параметром object .
Ну что ж, вуаля! Мы с вами научились пользоваться методами и свойствами, а вместе с ними разобрались, по крупному счету, с сутью одной из трех священных коров ООП программирования, называющейся инкапсуляция . Этим мудреным словом описывается всего лишь механизм, который позволяет запрятать для внешнего кода нюансы конкретной реализации объекта. Так, внешний для нашего класса код понятия не имеет о существовании переменной internalHasDocument. Причем это незнание не мешает ему успешно работать с нашим классом. Если впоследствии вы решите изменить механизм обработки этого свойства — к примеру, банально переименовать internalHasDocument, скажем, в havePassport, вам для этого нужно лишь подправить в вашем классе метод get{} . Внешний код при этом исправлять не нужно! Он как работал себе через свойство hasDocument, так и продолжает себе работать. Помимо подобной маскировки инкапсуляция обеспечивает еще одно полезное свойство вашей программы. Если у вас создан экземпляр класса Omonovez по имени boez, то при работе с таким объектом только через его публичные методы и свойства вы можете, отобрав у него удостоверение, попутно лишить его автомата и каски.
// Пример 9
using System;
class Omonovez{
private bool internalHasDocument = false;
private bool internalHasKalashnik = false;
private bool internalHasKaska = false;
public bool hasKalashnik{
get{return internalHasKalashnik;}
set{hasDocument=value;}
}
public bool hasKaska{
get{return internalHasKaska;}
set{hasDocument=value;}
}
public bool hasDocument {
get{return internalHasDocument;}
set{
internalHasDocument = value;
internalHasKalashnik = value;
internalHasKaska = value;
}
}
public void OutState(){
Console.WriteLine();
Console.WriteLine("Document - "+ this.internalHasDocument);
Console.WriteLine("Kalashnik - "+ this.internalHasKalashnik);
Console.WriteLine("Kaska - "+ this.internalHasKaska);
}
}
class App{
public static void Main(){
Omonovez boez = new Omonovez();
boez.hasDocument=true;
boez.OutState();
boez.hasDocument=false;
boez.OutState();
boez.hasKaska=false;
boez.OutState();
}
}
В этом примере объявляются три закрытые переменные и соответственно три открытых свойства, управляющих их состоянием. Помимо свойств я включил в объект еще и метод, выводящий в консоль текущее состояние внутренних переменных.
Попытка изменить любое из трех свойств приведет к изменению всех трех переменных. Причем, заметьте, в коде присутствует всего один метод, который реально меняет все три переменных ( get в hasDocument ). Если бы мы с вами оставили переменные класса открытыми, такой номер у нас бы не прошел. Более того: любой внешний класс смог бы, поодиночке меняя эти переменные, запутать нам весь алгоритм. Оформив же таким образом свойства, мы можем внутри своего кода вообще не проверять наличие каски. Достаточно убедиться, что у бойца есть документы.
Пример, конечно, дурацкий, но он неплохо комментирует то, о чем я вам хотел рассказать. Попробуйте самостоятельно его модифицировать так, чтобы он отражал более осмысленную житейскую логику.
Ну что ж, счетчик количества символов в моем MSWord подсказывает мне, что пора закругляться, и я, подобно Шахерезаде, заканчиваю на сегодня дозволенные речи…
Герман Иванов
Компьютерная газета. Статья была опубликована в номере 17 за 2003 год в рубрике программирование :: разное