Иногда бывает удобно создавать классы, вложенные в другие классы.
Например, в ситуации, когда какой-либо вспомогательный класс
используется только одним классом, можно сделать этот вспомогательный
класс вложенным. Доступ к вложенному классу регулируется теми же
правилами, которые были описаны для членов классов, т.е. зависит от
используемых модификаторов. Модификатор, применяемый к вложенному
классу, определяет уровень доступа к этому вложенному классу. Подобно
тому, как закрытый член класса доступен только внутри своего класса,
закрытый вложенный класс доступен только внутри того класса, который его
содержит.
В следующем примере класс Parser использует класс Token. Без
использования вложенных классов такой код можно написать так:
public class Parser { Token[] tokens; }
public class Token { string name; }
В этом примере классы Parser и Token оба являются открытыми, что редко
бывает оптимальным решением. Этот подход неудобен не только потому, что
Token занимает дополнительное место в списке классов. Такую схему в
целом нельзя назвать эффективной, если класс Token является полезным
только для класса Parser. В связи с этим следует сделать класс Token
вложенным, при этом можно будет объявить его с модификатором private,
спрятав его от всех остальных классов кроме класса Parser.
Перепишем код следующим образом:
public class Parser { Token[] tokens; private class Token { string name; } }
Теперь класс Token доступен только классу Parser. Есть также
возможность сделать класс Token внутренним, чтобы он не был доступен за
пределами сборки. Однако при этом Token будет доступен любому классу из
данной сборки.
Использование вложенных классов имеет одно важное преимущество -
облегчение восприятие кода. Например, легко заметить, что при чтении
кода класс Token может быть проигнорирован, если содержимое класса
Parser в данный момент не имеет значения. Если подобная организация
классов применяется в сборке повсеместно, это позволяет значительно
упростить код.
Кроме того, вложенные классы упрощают организацию кода. Например, если
класс Parser используется в пространстве имен Language, Вам может
потребоваться отдельное пространство имен Parser, в которое будет удобно
поместить классы, связанные с классом Parser. В этом пространстве имен
будут находиться классы Parser, Token и, возможно, другие классы. Однако
если класс Token сделать вложенным, класс Parser можно будет оставить в
пространстве имен Language.
Примечание:
вложенными могут быть не только классы. Интерфейсы, структуры и
перечисления также могут быть вложенными в какой-либо класс.
Создание, инициализация и
уничтожение
В любой объектно-ориентированной системе большую роль играют созданием,
инициализация и уничтожение объектов. В .NET Runtime программист не
может управлять уничтожением объектов, но нужно знать о тех вещах,
которые можно контролировать.
Конструкторы
В C# для объектов не создаются конструкторы по умолчанию. При
необходимости можно написать конструктор по умолчанию (например,
конструктор без параметров) для класса.
Конструктор может вызывать конструктор базового класса с помощью
ключевого слова base:
public class BaseClass { public BaseClass(int x) { this.x = x; } public int X { get { return(x); } } int x; }
public class Derived: BaseClass { public Derived(int x): base(x) { } }
class Test { public static void Main() { Derived d = new Derived(15); Console.WriteLine("X = {0}", d.X); } }
В этом примере конструктор класса Derived для создания объекта просто
вызывает конструктор базового класса BaseClass.
Иногда удобно сделать так, чтобы один конструктор вызывал другой
конструктор того же самого класса:
class MyObject { public MyObject(int x) { this.x = x; } public MyObject(int x, int y): this(x) { this.y = y; } public int X { get { return(x); } } public int Y { get { return(y); } } int x; int y; }
class Test { public static void Main() { MyObject my = new MyObject(10, 20); Console.WriteLine("x = {0}, y = {1}", my.X, my.Y); } }
Инициализация
Если значение, задаваемое полю по умолчанию, Вас не устраивает, то
можно присвоить полю другое значение с помощью конструктора. Если класс
содержит несколько конструкторов, то будет удобнее присвоить полю
значение в инициализаторе (initializer) вместо того, чтобы устанавливать
нужное значение в каждом конструкторе.
Приведем пример использования инициализации:
public class Parser { public Parser(int number) { this.number = number; } int number; }
class MyClass { public int counter = 100; public string heading = "Top"; private Parser parser = new Parser(100); }
Этот способ очень удобен: начальное значение поля можно задавать при
его объявлении. При этом также облегчается восприятие кода, поскольку
становится сразу видно, какое начальное значение присваивается полю. Примечание:
как правило, если поле может иметь различные значения в зависимости от
того, какой конструктор используется, то этому полю следует присваивать
значение с помощью конструктора. Если используется инициализатор, то при
чтении кода становится неочевидным, что значение поля может измениться
после вызова конструктора.
Деструкторы
Строго говоря, в C# нет деструкторов, по крайней мере в привычном
смысле этого слова, т.е. деструкторов, которые вызываются при удалении
объекта.
То, что в C# называется деструктором, в некоторых других языках носит
название finalizer. Эта функция вызывается сборщиком мусора (garbage
collector), когда он удаляет объект из памяти. Таким образом, у
программиста нет возможности узнать, в какой момент будет вызван
деструктор. Поэтому деструкторы в C# менее удобны и полезны, чем,
например, в С++. Если в деструкторе совершаются действия, которые
необходимо выполнить после завершения работы с объектом, следует создать
еще один метод, который будет выполнять те же самые действия и к
которому программист сможет иметь непосредственный доступ.
Перегрузка и сокрытие имен
В классах языка C# (и в Common Language Runtime в целом) перегрузка
методов основана на количестве и/или типе параметров. Различные типы
возвращаемого значения не могут служить основанием для перегрузки.
// error class MyObject { public string GetNextValue(int value) { return((value + 1).ToString()); } public int GetNextValue(int value) { return(value + 1); } }
Этот код не будет скомпилирован, потому что две функции GetNextValue() различаются только
типом возвращаемого значения. В силу этого все дальнейшие вызовы функции GetNextValue() будут неоднозначными.
Сокрытие имен
В C# при сокрытии имен методов учитывается только само имя метода, а не
его сигнатура. Рассмотрим следующий пример:
// error public class Base { public int Process(int value) { Console.WriteLine("Base.Process: {0}", value); } } public class Derived: Base { public int Process(string value) { Console.WriteLine("Derived.Process: {0}", value); } }
class Test { public static void Main() { Derived d = new Derived(); d.Process("Hello"); d.Process(12); // error ((Base) d).Process(12); // okay } }
Если бы оба метода Process()
находились в одном классе, эта ситуация рассматривалась бы как
перегрузка метода и, таким образом, оба метода Process() были бы доступны. Однако в
нашем примере методы Process()
находятся в разных классах, а определение метода Process() в наследующем классе
скрывает все использования того
же имени метода в базовом классе.
Чтобы получить доступ к обоим методам, необходимо в классе Derived
переопределить метод Process(),
содержащийся в базовом классе. Причем переопределенный метод может
просто вызывать реализацию этого метода из базового класса.
Статические поля
Существует возможность объявлять члены класса, которые связаны не с
конкретным экземпляром этого класса, а с самим классом. Такие члены
класса называются статическими.
Статическое поле является простейшим примером статического члена
класса. Чтобы объявить статическое поле, достаточно просто использовать
модификатор static перед объявлением переменной.
Например, получить информацию о количестве созданных экземпляров класса
можно следующим образом:
class MyClass { public MyClass() { instanceCount++; } public static int instanceCount = 0; }
class Test { public static void Main() { MyClass my = new MyClass(); Console.WriteLine(MyClass.instanceCount); MyClass my2 = new MyClass(); Console.WriteLine(MyClass.instanceCount); } }
Конструктор объекта увеличивает счетчик экземпляров instanceCount, и по значению этого
счетчика можно определить, сколько экземпляров уже было создано. Доступ
к статическому полю осуществляется через имя класса, а не через имя
конкретного экземпляра класса. Это правило выполняется для любого
статического члена класса. Примечание:
доступ к статическим членам класса несколько отличается в С++ и в C#,
поскольку в С++ можно получить доступ к статическому члену класса как
через имя класса, так и через имя объекта. Однако в С++ это иногда
приводит к усложнению восприятия кода.
Статические функции-члены
В предыдущем примере разрешается доступ к внутреннему полю класса.
Однако обычно этого следует избегать. Код предыдущего примера можно
переписать, используя для получения значения счетчика статическую
функцию-член вместо статического поля:
class MyClass { public MyClass() { instanceCount++; } public static int GetInstanceCount() { return(instanceCount); } static int instanceCount = 0; }
class Test { public static void Main() { MyClass my = new MyClass(); Console.WriteLine(MyClass.GetInstanceCount()); } }
Этот код работает корректно, и пользователи больше не имеют прямого
доступа к полю класса, что делает код более гибким. Поскольку метод GetInstanceCount() является
статическим, его следует вызывать, используя имя класса, а не имя
объекта.
Вообще говоря, в этом примере было бы удобнее использовать статическое
свойство (property), но о свойствах классов мы расскажем позже.
Статические конструкторы
Статическими могут быть не только члены класса, но и конструкторы.
Статический конструктор вызывается до того, как будет создан первый
экземпляр класса. Такой конструктор бывает полезен для выполнения
каких-либо подготовительных действий, которые должны быть выполнены
только один раз.
Примечание:
как и в отношении многих вещей в .NET Runtime, пользователь не может
знать заранее, в какой момент будет вызван статический конструктор. .NET
Runtime гарантирует только то, что он будет вызван где-то в промежутке
между моментом запуска программы и созданием первого объекта данного
класса. Это, в частности, означает, что в статическом конструкторе
нельзя предполагать, что объект будет создан в самое ближайшее время.
Чтобы объявить статический конструктор, достаточно просто добавить
модификатор static перед объявлением конструктора. Статический
конструктор не может иметь параметров.
class MyClass { static MyClass() { //... } }
Для деструкторов никаких статических аналогов не существует.
Константы
В C# переменные могут быть объявлены как константы. Константами могут
быть только переменные встроенных типов (built-in types). Чтобы объявить
константу, достаточно добавить ключевое слово const перед
объявлением переменной. Ключевое слово const означает, что
значение переменной не может быть изменено.
Приведем несколько примеров констант:
enum MyEnum { Jet } class LotsOLiterals { // const items can't be changed. // const implies static. public const int value1 = 33; public const string value2 = "Hello"; public const MyEnum value3 = MyEnum.Jet; } class Test { public static void Main() { Console.WriteLine("{0} {1} {2}", LotsOLiterals.value1, LotsOLiterals.value2, LotsOLiterals.value3); } }
Поля только для чтения
(readonly Fields)
Поскольку переменные многих типов не могут быть константами, в
некоторых ситуациях использовать ключевое слово const невозможно.
Например, в класс Color было бы удобно поместить константные значения
для наиболее распространенных цветов. Если бы не было ограничений на
применение ключевого слова const, можно было бы написать такой
код:
// error class Color { public Color(int red, int green, int blue) { this.red = red; this.green = green; this.blue = blue; } int red; int green; int blue; // call to new can't be used with static public static const Color Red = new Color(255, 0, 0); public static const Color Green = new Color(0, 255, 0); public static const Color Blue = new Color(0, 0, 255); } class Test { static void Main() { Color background = Color.Red; } }
Этот код не будет работать, т.к. статические поля Red, Green и Blue не могут быть вычислены во
время компиляции и, следовательно, не могут быть константами. Однако
сделать их обычными открытыми полями также было бы нежелательно,
поскольку в этом случае любой пользователь мог бы заменить красный цвет,
например, на серый.
Модификатор readonly был создан специально для таких ситуаций.
Если поле объявлено как readonly, его значение задается в
конструкторе или инициализаторе (initializer), но после этого не может
быть изменено.
Поскольку значения цветов принадлежат самому классу, а не отдельному
его экземпляру, они инициализируются в статическом конструкторе:
class Color { public Color(int red, int green, int blue) { this.red = red; this.green = green; this.blue = blue; } int red; int green; int blue; public static readonly Color Red; public static readonly Color Green; public static readonly Color Blue; // static constructor static Color() { Red = new Color(255, 0, 0); Green = new Color(0, 255, 0); Blue = new Color(0, 0, 255); } } class Test { static void Main() { Color background = Color.Red; } }
Этот код будет работать правильно.
Если статических членов много или их создание обходится дорого (в
смысле затрат времени или памяти), то разумнее сделать их свойствами
только для чтения (readonly properties), чтобы можно было создавать
члены класса на лету, если это необходимо.
С другой стороны, возможно было бы проще определить перечисление с
различными названиями цветов и при необходимости возвращать экземпляры
значений:
class Color { public Color(int red, int green, int blue) { this.red = red; this.green = green; this.blue = blue; } public enum PredefinedEnum { Red, Blue, Green } public static Color GetPredefinedColor(PredefinedEnum pre) { switch (pre) { case PredefinedEnum.Red: return(new Color(255, 0, 0)); case PredefinedEnum.Green: return(new Color(0, 255, 0)); case PredefinedEnum.Blue: return(new Color(0, 0, 255)); default: return(new Color(0, 0, 0)); } } int red; int blue; int green; }
class Test { static void Main() { Color background = Color.GetPredefinedColor(Color.PredefinedEnum.Blue); } }
При использовании перечисления приходится немного больше печатать, но
зато не будет задержки при запуске или большого количества объектов,
занимающих память. Этот способ также позволяет сделать интерфейс класса
более простым и понятным для пользователя, особенно если в программе
используется большое количество цветов. Примечание:
Опытные программисты C++ вероятно не одобрили бы последний пример.
Здесь реализована одна из классических проблем, возникающих в связи со
способом, каким в С++ осуществляется управление памятью. Если функция
возвращает объект, созданный с помощью оператора new (т.е. объект
для которого выделяется память в куче), это означает, что вызывающая
функция должна уничтожить этот объект и освободить память. Но
пользователь класса вполне может забыть уничтожить объект или потерять
указатель на него, что приведет к утечке памяти. Однако C# такой
проблемы не существует, поскольку динамически созданные объекты
уничтожаются сборщиком мусора.
Закрытые конструкторы
Поскольку в C# не существует глобальных переменных или констант, все
объявления должны быть помещены внутри класса. Иногда это приводит к
возникновению классов, которые полностью состоят из статических членов.
В этом случае нет необходимости когда-либо создавать объект такого
класса. Для того, чтобы запретить создание объектов класса, можно
поместить в класс закрытый конструктор.
// Error class PiHolder { private PiHolder() {} static double Pi = 3.1415926535; } class Test { PiHolder pi = new PiHolder(); // error }
Методы с переменным числом
параметров
Иногда бывает удобно объявить метод с переменным числом параметров
(хороший пример - метод WriteLine()). C# позволяет сделать это
достаточно просто:
class Port { // version with a single object parameter public void Write(string label, object arg) { WriteString(label); WriteString(arg.ToString()); } // version with an array of object parameters public void Write(string label, params object[] args)) { WriteString(label); for (int index = 0; index < args.GetLength(0); index++) { WriteString(args[index].ToString()); } } void WriteString(string str) { // writes string to the port here Console.WriteLine("Port debug: {0}", str); } }
class Test { public static void Main() { Port port = new Port(); port.Write("Single Test", "Port ok"); port.Write("Port Test: ", "a", "b", 12, 14.2); object[] arr = new object[4]; arr[0] = "The"; arr[1] = "answer"; arr[2] = "is"; arr[3] = 42; port.Write("What is the answer?", arr); } }
Ключевое слово params, используемое в качестве модификатора
последнего параметра, влияет на обработку функции компилятором. Когда в
коде встречается вызов функции, компилятор сначала ищет соответствующее
ей объявление (match for the function). Например, первому вызову функции
из приведенного примера соответствует объявление:
public void Write(string, object arg)
Точно так же в третьем вызове функция получает в качестве параметров
строку и массив, что соответствует объявлению:
public void Write(string label, params object[] args)
Наиболее интересным является второй вызов функции, который не имеет
соответствующего объявления.
Если компилятор не находит подходящего объявления функции, он обращает
внимание на наличие ключевого слова params и затем пытается
согласовать список параметров, рассматривая дополнительные параметры как
элементы массива, который имеет модификатор params. Если удается
согласовать список параметров, компилятор пишет код создания массива.
Другими словами, строка
С модификатором params может быть объявлен массив элементов
любого типа, а не только типа object, как в данном примере.
Любой метод может иметь только один параметр с модификатором params,
причем этот параметр всегда должен быть последним в списке параметров
метода.
Параметр с модификатором params может принимать любое число
аргументов (даже ноль).
В дополнение к версии функции, которая получает массив params в
качестве аргумента, следует создавать более специфические версии той же
функции. Это полезно и для эффективности (поскольку не всегда будет
создаваться объектный массив), и при взаимодействии с языками, которые
не поддерживают синтаксис params. Перегрузка функции с
использованием одного, двух и трех параметров, плюс создание версии,
которая принимает массив params, является признаком хорошего
стиля программирования.