Основы языка C#
Цель лекции: познакомиться с основными отличительными особенностями языка C#, рассмотреть примеры использования новых средств и операторов языка, типов данных и их преобразований в объеме, необходимом для дальнейшего изучения материала.
Основные операторы языка C#
Состав и синтаксис операторов C# унаследован от языка С++. Тем не менее существует ряд отличий, которые улучшают некоторые характеристики C++, делая его более легким в использовании. Предполагая, что читатель уже знаком с основными операторами языка C++ и имеет минимальный опыт программирования, остановимся на рассмотрении только наиболее значимых операторов C#, а также операторов, специфических для данного языка программирования.
Цикл foreach
Новым видом цикла, который появился в C# и отсутствует в C++, является цикл foreach. Он удобен при работе с массивами, коллекциями и другими контейнерами данных. Синтаксис оператора выглядит следующим образом:
foreach (тип идентификатор in контейнер) оператор
Тело цикла выполняется для каждого идентификатора в контейнере. Тип идентификатора должен быть согласован с типом элементов, хранящихся в контейнере. На каждом шаге цикла идентификатор, задающий текущий элемент контейнера, получает значение очередного элемента в соответствии с порядком, установленным на элементах контейнера. С использованием этого текущего элемента и выполняется тело цикла. Количество шагов цикла равно количеству элементов, находящихся в контейнере. Таким образом, цикл заканчивается в тот момент, когда были перебраны все элементы контейнера.
Важной особенностью этого цикла является то, что в теле цикла элементы контейнера доступны только для чтения. Поэтому заполнять коллекции с использованием цикла foreach нельзя, необходимо пользоваться другими видами циклов.
В следующем примере демонстрируется работа с двумерным массивом, который в начале заполняется случайными числами с помощью цикла for, затем с помощью цикла foreach подсчитывается сумма всех элементов массива, а также находятся минимальный и максимальный элементы.
public void SumMinMax() { int[,] myArray = new int[10, 10]; Random rnd = new Random(); for (int i = 0; i < 10; i++) { for (int j = 0; j < 10; j++) { myArray[i, j] = rnd.Next(100); Response.Write(myArray[i, j] + " "); } Response.Write("<br/>"); } long sum = 0; int min = myArray[0, 0]; int max = myArray[0, 0]; foreach (int i in myArray) { sum += i; if (i > max) max = i; if (i < min) min = i; } Response.Write("Sum=" + sum.ToString() + " Min=" + min.ToString() + " Max=" + max.ToString()); }
Типы данных. Преобразования типов
Типы данных принято разделять на простые и сложные в зависимости от того, как устроены их данные. У простых (скалярных) типов возможные значения данных едины и неделимы. Сложные типы характеризуются способом структуризации данных — одно значение сложного типа состоит из множества значений данных, организующих сложный тип.
Типы данных разделяются также на статические и динамические. Для данных статического типа память отводится в момент объявления, требуемый размер данных (памяти) известен при их объявлении. Для данных динамического типа размер данных в момент объявления обычно неизвестен и память им выделяется динамически по запросу в процессе выполнения программы.
Еще одна важная классификация типов — это их деление на значимые и ссылочные. Для значимых типов значение переменной (объекта) является неотъемлемой собственностью переменной (точнее, собственностью является память, отводимая значению, а само значение может изменяться). Для ссылочных типов значением служит ссылка на некоторый объект в памяти, расположенный обычно в динамической памяти - " куче ". Объект, на который указывает ссылка, может быть разделяемым. Это означает, что несколько ссылочных переменных могут указывать на один и тот же объект и разделять его значения. Значимый тип принято называть развернутым, подчеркивая тем самым, что значение объекта развернуто непосредственно в памяти, отводимой объекту.
Для большинства процедурных языков, реально используемых программистами - Паскаль, C++, Java, Visual Basic, C#, — система встроенных типов более или менее одинакова. Всегда в языке присутствуют арифметический, логический (булев), символьный типы. Арифметический тип всегда разбивается на подтипы. Всегда допускается организация данных в виде массивов и записей (структур). Внутри арифметического типа всегда допускаются преобразования, всегда есть функции, преобразующие строку в число и обратно.
Поскольку язык C# является непосредственным потомком языка C++, то и системы типов этих двух языков близки и совпадают вплоть до названий типов и областей их определения. Но отличия, в том числе принципиального характера, есть и здесь.
Система типов
Давайте рассмотрим, как устроена система типов в языке C#, но вначале для сравнения приведем классификацию типов в стандарте языка C++.
Стандарт языка C++ включает следующий набор фундаментальных типов.
- Логический тип — bool.
- Символьный тип — char.
- Целые типы. Целые типы могут быть одного из трех размеров: short, int, long, сопровождаемые описателем signed или unsigned, который указывает, как интерпретируется значение — со знаком или без оного.
- Типы с плавающей точкой. Эти типы также могут быть одного из трех размеров — float, double, long double.
- Тип void, используемый для указания на отсутствие информации.
- Указатели (например, int* — типизированный указатель на переменную типа int ).
- Ссылки (например, double& — типизированная ссылка на переменную типа double ).
- Массивы (например, char[] — массив элементов типа char ).
- Перечислимые типы enum для представления значений из конкретного множества.
- Структуры — struct.
- Классы.
Первые три вида типов называются интегральными, или счетными. Значения их перечислимы и упорядочены. Целые типы и типы с плавающей точкой относятся к арифметическому типу. Типы подразделяются также на встроенные и типы, определенные пользователем.
Эта схема типов сохранена и в языке C#. Однако здесь на верхнем уровне используется и другая классификация, имеющая для C# принципиальный характер. Согласно этой классификации, все типы можно разделить на четыре категории:
- Типы-значения — value, или значимые типы.
- Ссылочные — reference.
- Указатели — pointer.
- Тип void.
Эта классификация основана на том, где и как хранятся значения типов. Для ссылочного типа значение задает ссылку на область памяти в " куче ", где расположен соответствующий объект. Для значимого типа используется прямая адресация, значение хранит собственно данные, и память для них отводится, как правило, в стеке.
В отдельную категорию выделены указатели, что подчеркивает их особую роль в языке. Указатели имеют ограниченную область действия и могут использоваться только в небезопасных блоках, помеченных как unsafe.
Особый статус имеет и тип void, указывающий на отсутствие какого-либо значения.
В языке C# жестко определено, какие типы относятся к ссылочным, а какие к значимым. К значимым типам относятся: логический, арифметический, структуры, перечисление. Массивы, строки и классы относятся к ссылочным типам. На первый взгляд, такая классификация может вызывать некоторое недоумение: почему это структуры, которые в C++ близки к классам, относятся к значимым типам, а массивы и строки — к ссылочным. Однако ничего удивительного здесь нет. В C# массивы рассматриваются как динамические, их размер может определяться на этапе вычислений, а не в момент трансляции. Строки в C# также рассматриваются как динамические переменные, длина которых может изменяться. Поэтому строки и массивы относятся к ссылочным типам, требующим распределения памяти в " куче ".
Со структурами дело обстоит иначе. Структуры C# представляют частный случай класса. Определив свой класс как структуру, программист получает возможность отнести класс к значимым типам, что иногда бывает крайне полезно. В C# только благодаря структурам появляется возможность управлять отнесением класса к значимым или ссылочным типам. Правда, это неполноценное средство, поскольку на структуры накладываются дополнительные ограничения по сравнению с обычными классами.
Согласно принятой классификации все типы делятся на встроенные и определенные пользователем. Все встроенные типы C# однозначно отображаются, а фактически совпадают с системными типами каркаса .NET Framework, размещенными в пространстве имен System. Поэтому всюду, где можно использовать имя, например int, с тем же успехом можно использовать и имя System.Int32.
Система встроенных типов языка C# не только содержит практически все встроенные типы (за исключением long double ) стандарта языка C++, но и перекрывает его разумным образом. В частности, тип String является встроенным в язык, что вполне естественно. В области совпадения сохранены имена типов, принятые в C++, что облегчает жизнь тем, кто привык работать на C++, но собирается по тем или иным причинам перейти на язык C#.
Язык C# в большей степени, чем язык C++, является языком объектного программирования. В языке C# сглажено различие между типом и классом. Все типы — встроенные и пользовательские — одновременно являются классами, связанными отношением наследования. Родительским, базовым классом является класс Object. Все остальные типы или, точнее, классы являются его потомками, наследуя методы этого класса. У класса Object есть четыре наследуемых метода:
- bool Equals (object obj) — проверяет эквивалентность текущего объекта и объекта, переданного в качестве аргумента;
- System.Type GetType() — возвращает системный тип текущего объекта;
- String ToString() — возвращает строку, связанную с объектом. Для арифметических типов возвращается значение, преобразованное в строку;
- int GetHashCode() — служит как хэш-функция в соответствующих алгоритмах поиска по ключу при хранении данных в хэш-таблицах.
Естественно, что все встроенные типы нужным образом переопределяют методы родителя и добавляют собственные методы и свойства. Учитывая, что и типы, создаваемые пользователем, также являются потомками класса Object, для них необходимо переопределить методы родителя, если предполагается использование этих методов ; реализация родителя, предоставляемая по умолчанию, не обеспечивает нужного эффекта.
Рассмотрим несколько примеров. Начнем с вполне корректного в языке C# примера объявления переменных и присваивания им значений:
int x=11; int v = new Int32(); v = 007; String s1 = "Agent"; s1 = s1 + v.ToString() +x.ToString();
В этом примере переменная x объявляется как обычная переменная типа int. В то же время для объявления переменной v того же типа int используется стиль, принятый для объектов. В объявлении применяется конструкция new и вызов конструктора класса. В операторе присваивания, записанном в последней строке фрагмента, для обеих переменных вызывается метод ToString, как это делается при работе с объектами. Этот метод, наследуемый от родительского класса Object и переопределенный в классе int, возвращает строку с записью целого. Отметим еще, что класс int не только наследует методы родителя - класса Object, — но и дополнительно определяет метод CompareTo, выполняющий сравнение целых, и метод GetTypeCode, возвращающий системный код типа. Для класса Int определены также статические методы и поля, о которых поговорим чуть позже.
Так что же такое после этого int, спросите вы: тип или класс? Ведь ранее говорилось, что int относится к value-типам, следовательно, он хранит в стеке значения своих переменных, в то время как объекты должны задаваться ссылками. С другой стороны, создание экземпляра с помощью конструктора, вызов методов, наконец, существование родительского класса Object, — все это указывает на то, что int — это настоящий класс. Правильный ответ состоит в том, что int — это и тип, и класс. В зависимости от контекста x может восприниматься как переменная типа int или как объект класса int. Это же верно и для всех остальных значимых типов. Стоит отметить, что все значимые типы фактически реализованы как структуры, представляющие частный случай класса.
Такая двойственность в языке C# обусловлена тем, что значимые типы эффективнее в реализации, им проще отводить память, так что именно соображения эффективности реализации заставили авторов языка сохранить значимые типы. Более важно, что зачастую необходимо оперировать значениями, а не ссылками на них, хотя бы из-за различий в семантике присваивания для переменных ссылочных и значимых типов.
С другой стороны, в определенном контексте крайне полезно рассматривать переменные типа int как настоящие объекты и обращаться с ними как с объектами. В частности, полезно иметь возможность создавать и работать со списками, чьи элементы являются разнородными объектами, в том числе и принадлежащими к значимым типам.
Семантика присваивания
Рассмотрим присваивание: x = e.
Чтобы присваивание было допустимым, типы переменной x и выражения e должны быть согласованными. Пусть сущность x согласно объявлению принадлежит классу T. Будем говорить, что тип T основан на классе T и является базовым типом x, так что базовый тип определяется классом объявления. Пусть теперь в рассматриваемом нами присваивании выражение e связано с объектом типа T1.
Определение: тип T1 согласован по присваиванию с базовым типом T переменной x, если класс T1 является потомком класса T.
Присваивание допустимо, если и только если имеет место согласование типов. Так как все классы в языке C# — встроенные и определенные пользователем — по определению являются потомками класса Object, то отсюда и следует наш частный случай: переменным класса Object можно присваивать выражения любого типа.
Несмотря на то, что обстоятельный разговор о наследовании, родителях и потомках нам еще предстоит, лучше с самого начала понимать отношения между родительским классом и классом-потомком, отношения между объектами этих классов. Класс-потомок при создании наследует все свойства и методы родителя. Родительский класс не имеет возможности наследовать свойства и методы, создаваемые его потомками. Наследование — это односторонняя операция от родителя к потомку. Ситуация с присваиванием — симметричная. Объекту родительского класса присваивается объект класса-потомка. Объекту класса-потомка не может быть присвоен объект родительского класса. Присваивание — это односторонняя операция от потомка к родителю. Одностороннее присваивание реально означает, что ссылочная переменная родительского класса может быть связана с любыми объектами, имеющими тип потомков родительского класса.
Например, пусть задан некоторый класс Parent, а класс Child — его потомок, объявленный следующим образом:
class Child:Parent {...}
Пусть теперь в некотором классе, являющемся клиентом классов Parent и Child, объявлены переменные этих классов и созданы связанные с ними объекты:
Parent p1 = new Parent(), p2 = new Parent(); Child ch1 = new Child(), ch2 = new Child();
Тогда допустимы присваивания р1 = p2; p2= p1; ch1=ch2; ch2 = ch1 p1 = ch1; p1 = ch2
Но недопустимы присваивания ch1 = p1; ch2 = p1; ch2 = p2;
Заметьте, ситуация не столь удручающая — сын может вернуть себе переданный родителю объект, задав явное преобразование. Так что следующие присваивания допустимы:
p1 = ch1; ... ch1 = (Child)p1;
Семантика присваивания справедлива и для другого важного случая - при рассмотрении соответствия между формальными и фактическими аргументами процедур и функций. Если формальный аргумент согласно объявлению имеет тип T, а выражение, задающее фактический аргумент, имеет тип T1, то имеет место согласование типов формального и фактического аргументов, если и только если класс T1 является потомком класса T. Отсюда незамедлительно следует, что если формальный параметр процедуры принадлежит классу Object, то фактический аргумент может быть выражением любого типа.
Преобразование к типу object
Рассмотрим частный случай присваивания x = e ; когда x имеет тип object. В этом случае гарантируется полная согласованность по присваиванию — выражение нe может иметь любой тип. В результате присваивания значением переменной x становится ссылка на объект, заданный выражением e. Заметьте, текущим типом x становится тип объекта, заданного выражением e. Уже здесь проявляется одно из важных различий между классом и типом. Переменная, лучше сказать — сущность x, согласно объявлению принадлежит классу Object, но ее тип — тип того объекта, с которым она связана в текущий момент, — может динамически изменяться.