Выражения и операторы
ЧАСТЬ III
НАПИСАНИЕ ПРОГРАММ
ГЛАВА 10
Выражения и операторы
В этой главе мы рассмотрим основу любого языка программирования — его способность выполнять присваивания и сравнения с помощью операторов. Мы увидим, какие операторы есть в С# и каково их старшинство, а затем углубимся в отдельные категории выражений для выполнения арифметических операций, присваивания значений и сравнения операндов.
Оператор — это символ, указывающий операцию, выполняемую над одним или несколькими аргументами. При выполнении оператора получается результат. Синтаксис применения операторов несколько отличен от вызова методов, и формат выражений, содержащих операторы в С#, вы должны знать как свои пять пальцев. Как и в большинстве других языков, семантика операторов в С# соответствует правилам и нотациям, знакомым нам со школьной скамьи. Базовые операторы в С# включают умножение (*), деление (/), сложение и унарный плюс (+), вычитание и унарный минус (—), модуль (%) и присваивание (=).
Операторы служат для того, чтобы получить новое значение из значений, над которыми производится операция. Эти исходные значения называются операндами. Результат операции должен быть сохранен в памяти. Иногда он сохраняется в переменной, содержащей один из исходных операндов. Компилятор С# генерирует сообщение об ошибке, если при использовании оператора не определяется или не сохраняется новое значение. Приведенный ниже код значений не меняет. Компилятор выдаст сообщение об ошибке, так как арифметическое выражение, не приводящее к изменению хоть одного значения, обычно рассматривается как ошибочное.
class NoResultApp
{
public static void Main()
{
int i; int j;
i + j; // Ошибка, поскольку результат ничему не присваивается. } >
Большинство операторов работает только с числовыми типами данных, такими как Byte, Short, Long, Integer, Single, Double и Decimal. Исключение — операторы сравнения (== и !=). Кроме того, в С# можно применять операторы + и — для класса String и даже применять операторы инкремента (++) и (—) для таких необычных языковых конструкций как делегаты. О последних я расскажу в главе 14.
Когда в одном выражении несколько операторов, компилятор должен определить порядок их выполнения. При этом компилятор руководствуется правилами, которые называются старшинством операторов. Понимание старшинства операторов нужно для правильного написания выражений — иногда результат может не соответствовать ожидаемому.
Рассмотрим выражение 42 + 6 * 10. Если сложить 42 и 6, а затем сумму умножить на 10, получится 480. Если же умножить 6 на 10 и к результату прибавить 42, получится 102. При компиляции кода специальный компонент компилятора — лексический анализатор — отвечает за порядок чтения этого кода. Именно лексический анализатор определяет относительное старшинство разнородных операторов в одном выражении. Для этого он использует некоторое значение — приоритет — каждого поддерживаемого оператора. Более приоритетные операторы разрешаются в первую очередь. В нашем примере оператор * имеет старшинство над оператором +, так как * поглощает (сейчас поясню этот термин) свои операнды до того, как это сделает +. Объяснение кроется в общих арифметических правилах: умножение и деление всегда имеют более высокий приоритет, чем сложение и вычитание. Вернемся к примеру: говорят, что число 6 поглощено оператором * и в 42 + 6 * 10 и в 42 * 6 + 10, так что эти выражения эквивалентны 42 + (6 * 10) и (42 * 6) + 10.
Теперь посмотрим, как старшинство операторов определяется в С#. Ниже операторы перечислены в порядке убывания приоритета (табл. 10-1). Дальше я подробней расскажу о различных категориях операторов, поддерживаемых в С#.
Табл. 10-1. Старшинство операторов в С#.
Категория оператора |
Операторы |
Простой |
(х), х.у, f(x), а[х], х++, х — , new, typeof, sizeof,
checked, unchecked |
Унарный |
+ , -, !, ++x, — х, (Т)х |
Мультипликативный |
*,/, % |
Аддитивный |
+, - |
Сдвиг |
«, » |
Отношение |
<, >, <=, >=, is |
Равенство |
== |
Логическое И (AND) |
& |
Логическое исключающее ИЛИ (XOR) |
^ |
Логическое ИЛИ (OR) |
1 |
Условное И (AND) |
&& |
Условное ИЛ И (OR) |
II |
Условие |
9- |
Присваивание |
= *= /= % = , + = , -= « = , » = , &=, ^ =
, = |
Ассоциативность определяет, какая часть выражения должна быть вычислена первой. Например, результатом приведенного выражения может быть 21 или 33 в зависимости от того, какая ассоциативность будет применяться для оператора «—»: левая или правая.
42-15-6
Оператор — имеет левую ассоциативность, т. е. сначала вычисляется 42—15, а затем из результата вычитается 6. Если бы он имел правую ассоциативность, сначала вычислялась бы правая часть выражения (15—6), а затем результат вычитался бы из 42.
Все бинарные операторы (операторы с двумя операндами), кроме операторов присваивания, — лево-ассоциативные, т. е. они обрабатывают выражения слева направо. Таким образом, а + Ь + с — то же, что и (а + Ь) + с, где сначала вычисляется а + Ъ, а затем к сумме прибавляется с. Операторы присваивания и условные операторы — право-ассоциативные, т. е. обрабатывают выражения справа налево. Иначе говоря, а=Ъ=с эквивалентно а = (Ь = с). Многие на этом спотыкаются, когда хотят поместить в одну строку несколько операторов присваивания, так что давайте рассмотрим такой код:
using System;
class RightAssocApp {
public static void Main() {
int a = 1; int b = 2; int с = 3;
Console.WriteLine("a={0} b={1} c={2>", a, b, c); a = b = c;
Console.WriteLine("После "a=b=c - : a={0} b={1} c={2}", a, b, c); }}
Результат выполнения этого примера таков:
а=1 b=2 с=3
После 'а=Ь=с': а=3 b=3 о=3
Поначалу вычисление выражений справа налево может сбивать с толку, но давайте подойдем к этому так: если бы оператор присваивания был лево-ассоциативным, компилятор сначала должен был бы вычислить а = Ь, после чего а было бы равно 2, а затем b = с и в результате b было бы равно 3. Конечный результат был бы а=2 Ь=3 с=3. Очевидно, что мы ожидаем не этого, когда пишем а = b = с, и именно поэтому операторы присваивания и условные операторы право-ассоциативные.
Ничто не доставляет столько хлопот, как поиск ошибки, допущенной только потому, что разработчик не знал правил старшинства и ассоциативности. Мне попадались сообщения в почтовых конференциях, в которых разумные вроде бы люди предлагали своего рода механизм самодокументирования — использование пробелов для указания операторов, которые, по их мнению, имеют старшинство. Например, поскольку мы знаем, что оператор умножения приоритетней оператора сложения, нам следовало бы написать примерно такой код, в котором пробелы указывают подразумеваемое старшинство:
а = b*c + d;
Такой подход в корне неверен: компилятор не может проводить корректный синтаксический анализ кода, если не определен конкретный синтаксис. Компилятор анализирует код согласно правилам, определенным разработчиками компилятора. С другой стороны, существуют круглые скобки, используемые для явного указания старшинства и ассоциативности. Например, выражение а = b * с + d можно переписать как а =( b * с) + d или как а = b * (с + d) и компилятор будет сначала вычислять выражение в скобках. Если есть несколько пар скобок, компилятор сначала вычислит выражения в скобках, а затем все выражение, основываясь на описанных правилах старшинства и ассоциативности.
Я твердо убежден, что следует всегда применять скобки, если в выражении несколько операторов. Рекомендую делать это, даже если вам понятен порядок вычислений, ведь люди, которые будут сопровождать ваш код, могут быть не такими грамотными.
Правильней всего рассматривать операторы по старшинству. Ниже я опишу наиболее распространенные операторы.
Первая категория — простые операторы. Поскольку большинство из них элементарны, я их просто перечислю и кратко опишу их функции. Затем я опишу другие, менее понятные.
- (х) Это разновидность оператора «скобки» для управления порядком вычислений как в математических операциях, так и при вызове методов.
- х.у Оператор «точка» используется для указания члена класса или структуры. Здесь х представляет сущность, содержащую в себе член у.
- f(x) Такая разновидность оператора «скобки» применяется для перечисления аргументов методов.
- а[х] Квадратные скобки используются для индексации массива. Эти скобки также применяются совместно с индексаторами, когда объекты могут рассматриваться как массив. Об индексаторах см. главу 7.
- х++ Об операторе инкремента мы поговорим отдельно в разделе «Операторы инкремента и декремента».
- х— Оператор декремента мы тоже рассмотрим позднее.
- new Этот оператор используется для создания экземпляров объектов на основании определения класса.
typeof
Отражение (reflection) — это способность получать информацию о типе в период исполнения. Эта информация включает имена типов, классов и элементы структур. В .NET Framework эта функциональность связана с классом System. Type. Этот класс — корень всех операторов отражения и может быть получен с помощью оператора typeof. Сейчас мы не будем вникать в подробности отражения (этим мы займемся в главе 16), но вот простой пример, иллюстрирующий простоту применения оператора typeof 'для получения практически любой информации о типе или объекте во время исполнения программы:
using System;
using System.Reflection;
public class Apple {
public int nSeeds;
public void Ripen()
{
}}
public class TypeOfApp {
public static void Main() {
Type t = typeof(Apple);
string className = t.ToStringO;
Console.ИгШипе("\пИнформация 0 классе {О}", className);
Console.WriteLine("\nMeroflH {0}", className);
Console. WriteLine("————————"); Methodlnfo[] methods = t.GetMethodsO;
foreach (Methodlnfo method in methods)
{
Console.WriteLine(method.ToSt ring());
}
Console.WriteLine("\nBce члены {О}", className);
Console. Writel_ine("————————"); Memberlnfo[] allMembers = t.GetMembersO;
foreach (Memberlnfo member in allMembers)
{
Console. WriteLine(member.ToStringO);
} } }
В этой программе содержится класс Apple, у которого всего два члена: поле nSeeds и метод Ripen. Сначала, используя оператор typeof и имя класса, я получаю объект System. Type, который затем сохраняется в переменной t. С этого момента я могу использовать объект System. Type для получения всех методов и членов класса Apple. Это делается с помощью методов GetMethods и GetMembers соответственно. Результаты выполнения этих методов выводятся на стандартное устройство вывода следующим образом:
Информация о классе Apple Методы Apple
Int32 GetHashCode()
Boolean Equals(System.Object)
System.String ToStringQ
Void RipenO
System.Type GetTypeO
Все члены Apple
Int32 nSeeds
Int32 GetHashCodeO
Boolean Equals(System.Object)
System.String ToStringO
Void RipenO
System.Type GetTypeO
Void .ctorO
Прежде чем двигаться дальше, хочу сделать два замечания. Во-первых, обратите внимание, что выводятся и унаследованные члены класса. Так как класс не порожден явно из другого класса, мы знаем, что все члены, не определенные в классе Apple наследуются от неявного базового класса System.Object. Во-вторых, объект System.Type можно получить методом GetType. Этот наследуемый от System.Object метод позволяет работать с объектами, а не с классами. Любой из двух приведенных далее фрагментов можно использовать для получения объекта System. Type.
// Получение объекта System.Type на основе определения класса. Type t1 = typeof(Apple);
// Получение объекта System.Type из объекта. Apple apple = new AppleQ; Type t2 = apple.GetTypeO;
sizeof
Оператор sizeof применяется для получения размера указанного типа в байтах. При этом помните о двух исключительно важных факторах. Во-первых, sizeof можно применять только к размерным типам. Следовательно, хотя его можно использовать для членов классов, для классов как таковых его применять нельзя. Во-вторых, sizeof можно применять только в методах или блоках кода, помеченных как unsafe. С кодом такого рода мы познакомимся в главе 17. Вот пример использования оператора sizeof в методе класса, помеченного как unsafe:
using System;
class BasicTypes {
//ПРИМЕЧАНИЕ: Код, использующий оператор sizeof,
// должен быть помечен как unsafe, static unsafe public void ShowSizesQ {
Console.WriteLine("\nPa3Mephi основных типов");
Console.WriteLine("Pa3Mep short = {0}", sizeof(short));
Console.WriteLine("Pa3Mep int = {0}", sizeof(int));
Console.Writel_ine("Pa3Mep long = {0}", sizeof(long));
Console.WriteLine("Pa3Mep bool = {0}", sizeof(bool)); } }
class UnsafeUpp
{
unsafe public static void Main()
{
BasicTypes.ShowSizes();
} }
Вот результат выполнения этого приложения:
Размеры основных типов Размер short = 2 Размер int = 4 Размер long = 8 Размер bool = 1
Оператор sizeof можно использовать для определения размеров не только простых встроенных типов, но и пользовательских размерных типов, таких как структуры. Однако при этом результаты sizeof могут быть неочевидны:
// Использование оператора sizeof. using System;
struct StructWithNoMembers
{
}
struct StructWithMembers
{
short s;
int i;
long 1;
bool b; }
struct CompositeStruct
{
StructWithNoMembers a; StructWithMembers b;
StructWithNoMembers c; }
class UnSafe2App {
unsafe public static void Main()
{
Console.WriteLine("\nPa3Mep StructWithNoMembers structure = {0}",
sizeof(StructWithNoMembers));
Console.WriteLine("\nPa3Mep StructWithMembers structure = {0}",
sizeof(StructWithMembers)); Console.WriteLine("\nPa3Mep CompositeStruct structure = {0}",
sizeof(CompositeStruct)); }
}
Хотя можно предположить, что это приложение выведет 0 для структуры без членов (StructWithNoMembers), 15 для структуры с четырьмя членами базовых типов (StructWithMembers) и 15 для структуры, агрегирующей две предыдущие (CompositeStruct), в действительности результат будет таким:
Размер StructWithNoMembers structure = 1 Размер StructWithMembers structure = 16
Размер CompositeStruct structure = 24
Объяснение этому — способ сохранения конструкции struct компилятором в выходном файле, при котором компилятор применяет выравнивание и дополнение пробелами. Например, если структура имеет размер 3 байта и установлено выравнивание по 4-байтным границам, компилятор автоматически добавит в структуру 1 байт, и оператор sizeof укажет, что размер структуры — 4 байта. Не забывайте это учитывать при определении размера структур в С#.
checked и unchecked
Эти два оператора управляют проверкой переполнения при выполнении математических операций.
С#, как и большинство других языков, поддерживает основные математические операторы: умножение (*), деление (/), сложение (+), вычитание (—) и модуль (%). Назначение первых четырех операторов понятно из их названий; оператор модуля формирует остаток от целочисленного деления. Вот код, иллюстрирующий применение математических операторов:
using System;
class MathOpsApp
{
public static void Main()
{
// Класс System.Random является частью библиотеки классов
// .NET Framework. В его конструкторе по умолчанию
// метод Next использует текущую дату/время в качестве
// начального значения. Random rand = new RandomO; int a, b, c;
a = rand.Next() % 100;
// Предельное значение 99. b = rand.NextO % 100;
// Предельное значение 99.
Console.WriteLine("a={0} b={1}", a, b);
с = a * b;
Console.WriteLineC'a * b = {0}", c);
// Заметьте, что здесь используются целые числа.
// Следовательно, если а меньше Ь, результат всегда
// будет 0. Для получения более точного результата
// нужно применять переменные типа double или float, с = а / b;
Console.WriteLineC'a / b = {0}", с);
с = a + b;
Console.WriteLineC'a + b = {0}", c);
с = a - b;
Console.WriteLineC'a - b = {0}", c);
с = a X b;
Console.WriteLineC'a X b = {0}", c); > >
Унарные операторы
Унарных операторов два: плюс и минус. Оператор унарного минуса указывает компилятору, что число отрицательное. Таким образом, в следующем коде а будет равно —42:
using System; using System;
class UnarylApp {
public static void Main()
{
int a = 0;
a = -42;
Console.WriteLine("{0}", a); } }
Однако в этом коде появляется неопределенность: using System;
class Unary2App
{
public static void Main() {
int a; int b = 2; int с = 42;
a = b * -с;
Console.WriteLine("{0}", a); }}
Выражение a = b * -с не совсем понятно. Снова повторю, что использование скобок прояснит это выражение:
// При использовании скобок очевидно, что мы // умножаем b на отрицательное число с. а = b * (-с);
Если унарный минус возвращает отрицательное значение операнда, можно подумать, что унарный плюс возвращает положительное. Однако унарный плюс лишь возвращает операнд в его первоначальной форме и больше ничего не делает, т. е. не влияет на операнд. Например, выполнение этого кода приведет к выводу значения -84:
using System;
class UnarySApp {
public static void Main() {
int a; int b = 2; int с = -42;
a = b * (+c);
Console.WriteLine("{0}", a); } }
Для получения положительного значения служит функция Math.Abs. Этот код выведет значение 84:
using System;
class Unary4App
{
public static void Main()
{
int a; int b = 2; int с = -42;
a = b * Math.Abs(c); Console.Writel_ine("{0}", a); } }
Последний унарный оператор, который я упоминал, — это Т(х). Это разновидность оператора «скобки», позволяющая приводить один тип к другому. Поскольку его можно перегрузить посредством создания пользовательского преобразования, мы обсудим его в главе 13.
Составные операторы присваивания
Составной оператор присваивания — это комбинация бинарного оператора и оператора присваивания (=). Синтаксис этих операторов таков:
хор=у
где ор — это оператор. Заметьте, что при этом левое значение (lvalue) не заменяется правым (rvalue), составной оператор оказывает такой же эффект, как:
х = х ор у
и lvalue используется как база для результата операции.
Заметьте, я сказал «оказывает такой же эффект». Компилятор не переводит выражение наподобие х += 5 в х = х + 5, он действует логически. С особым вниманием нужно отнестись к случаям, когда lvalue является методом. Рассмотрим код:
using System;
class CompoundAssignmentlApp {
protected lnt[] elements;
public int[] GetArrayElementO
{
return elements;
}
CompoundAssignment1App() {
elements = new int[1];
elements[0] = 42;
}
public static void Main() {
CompoundAssignmentlApp app = new CompoundAsslgnment1App();
Console.WrlteLine("{0}", app.GetArrayElement()[0]);
app.GetArrayElement()[0] = app.GetArrayElement()[0] + 5;
Console.WriteLine("{0}", app.GetArrayElement()[0]); }. }
Обратите внимание на выделенную строку — вызов метода Compound-AssignmentlApp.GetArrayElement и последующее изменение первого элемента — здесь я использовал синтаксис:
х = х ор у
Вот какой MSIL-код будет сгенерирован: // Неэффективная методика: х = х ор у.
.method public hldebyslg static void Main() 11 managed
{
.entrypolnt
// Размер кода 79 (Ox4f) .maxstack 4
.locals (class CompoundAssignmentlApp V_0)
IL_0000: newobj instance void CompoundAssignmentlApp::.ctor()
IL_0005: stloc.O
IL_0006: Idstr "{ОГ
IL_OOOb: ldloc.0
ILJJOOc: call instance int32[]
CompoundAssignmentlApp:: GetArrayElementO
IL_0011: ldc.14.0
IL_0012: Idelema ['mscorlib']System.Int32
IL_0017: box [ 1 mscorlib']System.Int32
IL_001c: call void ['mscorlib 1 ]System.Console
::WriteLine (class System.String, class System.Object)
IL_0021: ldloc.0
IL_0022: call instance int32[] CompoundAssignmentlApp::GetArrayElementO
IL_0027: Idc.i4.0
IL_0028: ldloc.0
IL_0029: call instance int32[] CompoundAssignmentlApp: :GetArrayElementO
IL_002e: Idc.i4.0
IL_002f: ldelem.14
IL_0030: ldc.14.5
IL_0031: add
IL_0032: stelem.14
IL_0033: Idstr "{0}"
IL_0038: ldloc.0
IL_0039: call
instance int32[] CompoundAssignmentlApp::GetArrayElement() IL_003e: ldc.14.0
IL_003f: Idelema ['mscorlib']Systera.Int32
IL_0044: box ['msoorlib']System.Int32 IL_0049: call void ['mscorlib']System.Console::WriteLine
(class System.String, class System.Object) IL_004e: ret
} // конец метода 'CompoundAssignmentlApp::Main
Посмотрите на вьщеленные строки: метод CompoundAssignmentlApp.Get-ArrayElement на самом деле вызывается дважды! Это по меньшей мере неэффективно, а возможно, и пагубно в зависимости от того, что еще делает этот метод.
Теперь рассмотрим другой код, в котором применен синтаксис составного оператора присваивания:
using System;
class CompoundAssignment2App {
protected int[] elements;
public int[] GetArrayElementO
{
return elements;
}
CompoundAssignment2App() {
elements = new int[1];
elements[0] = 42;
}
public static void Main() {
CompoundAssignment2App app = new CompoundAssignment2App();
Console.WriteLine("{0}", app.GetArrayElement()[0]);
app.GetArrayElement()[0] += 5; Console.WriteLine("{0}", app.GetArrayElement()[0]); } }
Использование составного оператора присваивания приведет к созданию гораздо более эффективного MSIL-кода:
// Более эффективная методика: х ор= у.
.method public hidebysig static void Main() il managed
\ {
\ .entrypoint
I // Размер кода 76 (Ox4c) \ .maxstack 4
.locals (class CompoundAssignmentlApp V_0, int32[] V_1)
\ IL_0000: newobj instance void CompoundAssignmentlApp::.ctor()
\ IL_0005: stloc.O 1 IL_0006: Idstr "{0}" 1
IL_OOOb: ldloc.0 IL_OOOc: call instance int32[]
CompoundAssignmentlApp:: GetArrayElementO
IL_0011: Idc.i4.0
IL_0012: Idelema [•mscorlib']System.Int32
IL_0017: box [] mscorlib - ]System.Int32 lL_001c: call void ['mscorlib']System.Console::WriteLine
(class System.String, class System.Object)
IL_0021: ldloc.0 IL_0022: call instance int32[]
CompoundAssignmentlApp::GetArrayElement()
IL_0027: dup
IL_0028: stloc.1
IL_0029: Idc.i4.0
IL_002a: ldloc.1
IL_002b: Idc.i4.0
IL_002c: ldelem.14
IL_002d: ldc.14.5
IL_002e: add
IL_002f: stelem.14
IL_0030: Idstr "{0}"
IL_0035: ldloc.0
IL_0036: call instance int32[]
CompoundAssignmentlApp:: GetArrayElementO IL_003b: Idc.i4.0
IL_003c: Idelema ['mscorlib']System.Int32
IL_0041: box [•mscorlib']System.Int32
IL_0046: call void ['mscorlib']System.Console::WriteLine
(class System.String, class System.Object)
IL_004b: ret } // конец метода 'CompoundAssignmentlApp::Main
Вы видите, что использована команда MSIL dup. Она дублирует верхний элемент в стеке, создавая таким образом копию значения, возвра-щенного методом CompoundAssignmentlApp.Get Array Element. I
Из этого видно, что хотя по сути х +=у эквивалентно х = х + у, MSIL- код в обоих случаях разный. Это отличие должно заставить вас задуматься, какой синтаксис использовать в каждом отдельном случае. Эмпирическое правило и моя рекомендация: всегда и везде, где можно, применять составные операторы присваивания.
Операторы инкремента и декремента
Появившиеся в языке С и перенесенные в C++ и Java операторы инк 1 -ремента и декремента позволяют лаконично выразить, что вы хотите увеличить или уменьшить числовое значение на 1. То есть /++ равносильно добавлению 1 к текущему значению /'.
Наличие двух форм операторов инкремента и декремента иногда приводит к путанице. Префиксный и постфиксный типы этих операторов отличаются тем, в какой момент производится изменение значения. В префиксной версии операторов инкремента и декремента (++аи — а соответственно) сначала выполняется операция, а затем создается значение. В постфиксной версии (а++ и а—) сначала создается значение, а затем выполняется операция. Рассмотрим пример:
using System;
class IncDecApp {
public static void Foo(int j)
{
Console.WriteLine("IncDecApp.Foo j = {0}", j);
>
public static void Main() {
int i = 1;
Console.WriteLineC'flo обращения к Foo(i++) = {0}", i);
Foo(i++);
Console.WriteLine("После обращения к Foo(i++) = {0}", i);
Console.WriteLine("\n");
\ Console.WriteLineC'flo обращения к Foo(++i) = {0}", i);
\ Foo(++l);
\ Console.WrlteLine("После обращения к Foo(++i) = {0}", i);
Результат выполнения будет таким:
До обращения к Foo(i++) = 1
IncDecApp.Foo j = 1
После обращения к Foo(i++) = 2
До обращения к Foo(-n-i) = 2
IncDecApp.Foo j = 3
После обращения к Foo(++i) = 3
Разница в том, когда создается значение и модифицируется операнд. При вызове Foo(i++) значение /' передается (без изменений) в метод Foo и после возврата из метода / увеличивается на 1. Посмотрите на приведенный MSIL-код: команда add выполняется после помещения значения в стек.
IL.0013: ldloc.0
IL.0014: dup
IL_0015: Idc.i4.1
IL_0016: add
IL_0017: stloc.O
IL_0018: call void IncDecApp::Foo(int32)
Теперь посмотрим на префиксную форму оператора, используемую в вызове Foo(++a). В этом случае MSIL-код будет другой. При этом команда add выполняется до того, как значение помещается в стек для последующего вызова метода Foo.
IL.0049: ldloc.0
IL_004a: Idc.i4.1
IL_004b: add
IL_004c: dup
IL_004d: stloc.O
IL_004e: call void IncDecApp::Foo(int32)
Большинство операторов возвращает числовые значения. Что касается операторов отношения, они генерируют булевский результат. Вместо / того чтобы выполнять математические операции с набором операндов, / операторы отношения анализируют соотношение между операндами и возвращают значение true, если соотношение истинно, false — если/ ложно.
К операторам отношения, называемым операторами сравнения, относят)-ся «меньше» (<), «меньше или равно» (<=), «больше» (>), «больше или равно» (>=), «равно» (==) и «не равно» (!=). Применение этих операторов к числам понятно, но при использовании с объектами их выполнение не так очевидно. Вот пример:
using System;
class NumericTest {
public NumericTest(int 1)
{
this.i = i;
>
protected int 1; }
class RelationalOpslApp {
public static void Main() {
NumericTest testl = new NumericTest(42);
NumericTest test2 = new NumericTest(42);
Console.WriteLine("{0}", testl == test2); } }
Если вы программируете на Java, вы знаете что здесь должно произойти. Однако С++-программисты будут скорей всего удивлены, увидев результат false. Напомню: создавая экземпляр объекта, вы получаете ссылку на него. Значит, встречая оператор отношения, сравнивающий два объекта, компилятор С# сравнивает не содержимое объектов, а их адреса. Чтобы лучше в этом разобраться, рассмотрим MSIL-код:
\ .method public hldebysig static void Main() il managed
\ .entrypoint
\ // Размер кода 39 (0x27)
1 .maxstack 3
.locals (class NumericTest V_0, \ class NumericTest V_1, 1 bool V_2)
ILJJOOO: Idc.i4.s 42
1L_0002: newobj instance void NumericTest::.ctor(int32)
IL_0007: stloc.O
IL_0008: Idc.i4.s 42
IL_OOOa: newobj instance void NumericTest::.ctor(int32)
IL_OOOf: stloc.1
IL_0010: Idstr "{0}"
IL_0015: ldloc.0
IL_0016: ldloc.1
IL_0017: eeq
IL_0019: stloc.2
IL_001a: Idloca.s V_2
IL_001c: box ['mscorlib']System.Boolean
IL_0021: call void ['mscorlib']System.Console::WriteLine
(class System.String,class System.Object)
IL_0026: ret } // конец метода 'RelationalOpslApp::Main
Посмотрите на строку .locals. Компилятор указывает, что у метода Main три локальных переменных. Первые две — объекты NumericTest, a третья — переменная булевского типа. Теперь перейдем к строкам IL_0002 и IL_0007. Здесь создается экземпляр объекта testl, и ссылка на него с помощью stloc сохраняется в первой локальной переменной. При этом важно, что MSIL сохраняет адрес вновь созданного объекта. Затем в строках IL_OOOa и IL_OOOf вы видите коды MSIL для создания объекта test2 и сохранения возвращаемой ссылки во второй локальной переменной. Наконец, в строках 1LJ)015 и IL_0016 локальные переменные помещаются в стек командой Idloc, а в строке IL_0017 команда сед сравнивает два значения в вершине стека (т. е. ссылки на объекты testl и testl). Возвращаемое значение сохраняется в третьей локальной переменной и далее выводится методом System.Console. WriteLine.
Но как же сравнивать члены двух объектов? Ответ — в использовании неявного базового класса всех объектов .NET Framework. Именно , для этих целей у класса System.Object есть метод Equals. Например, еле- / дующий код выполняет сравнение содержимого объектов и выводит, как / и следовало ожидать, true: I
using System;
class RelationalOps2App
{
public static void Main() {
Decimal testl = new Decimal(42);
Decimal test2 = new Decimal(42);
Console.WriteLine("{0}", testl.Equals(test2));
}
}
В примере RelationalOpslApp используется «самодельный» класс (Nu-mericTest), а во втором примере — .NET-класс (Decimal). Дело в том, что метод System.Object.Equals нужно переопределить, чтобы выполнить реальное сравнение членов. Следовательно, метод Equals с классом Nume-ricTest работать не будет, так как мы не переопределили метод. А вот класс Decimal переопределяет наследуемый им метод Equals, и в этом случае все будет работать.
Другой способ сравнения объектов — использование перегрузки операторов (operator overloading). Перегрузка операторов определяет операции, выполняемые над объектами конкретного типа. Например, для объектов string оператор + не выполняет сложение, а конкатенирует строки. Перегрузку операторов мы рассмотрим в главе 13.
Значение в левой части оператора присваивания называется lvalue, а в правой части — rvalue. В качестве rvalue может быть любая константа, переменная, число или выражение, результат которого совместим с lvalue. Между тем lvalue должно быть переменной определенного типа. Дело в том, что значение копируется из правой части в левую. Таким образом, для нового значения должно быть выделено физическое адресное пространство. Например, можно написать /' = 4, поскольку для / есть место в памяти — в стеке или в куче — в зависимости от типа переменной /. А вот оператор 4 = 1 выполнить нельзя, так как 4 — это значение, а не переменная, содержимое которой в памяти можно изменить. Замечу кстати, что в С# в качестве lvalue может быть переменная, свойство или индексатор. Подробнее о свойствах и индексаторах см. главу 7. В этой главе я для простоты использую переменные. Если с присваиванием числовых значений все достаточно понятно, с объектами дело сложнее. Напомню, что когда вы имеете дело с объектами, вы манипулируете не элементами стека, которые легко копировать и перемещать. В случае объектов у вас на самом деле есть лишь ссылки на некоторые сущности, для которых динамически выделена память. Следовательно, когда вы пытаетесь присвоить переменной объект (или любой ссылочный тип) копируются не данные, как это происходит в случае размерных типов, а ссылки.
Скажем, у вас два объекта: testl и test2. Если вы укажете testl = test2, testl не будет копией test2. Они будут совпадать! Объект testl указывает на ту же память, что и test2, и любые изменения объекта testl приведут к изменениям test2. Вот программа, которая это иллюстрирует:
using System;
class Foo {
public int i; }
class RefTestlApp {
public static void MainO {
Foo testl = new Foo(); testl.i = 1;
Foo test2 = new Foo(); test2.i = 2;
Console.WriteLine("До назначения объектов");
Console.WriteLine("test1.i={0>", testl.i);
Console.WriteLine("test2.i={0}", test2.i);
Console.WriteLine("\n");
testl = test2;
Console.Writel_ine("После назначения объектов");
Console.WriteLine("test1.i={0}", testl.i);
Console.WriteLine("test2.i={0}", test2.i);
Console.WriteLine("\n");
testl.i = 42; ;'
Console.WriteLine("Пocлe изменения только члена TEST1");
Console.WriteLine("test1.i={0}", testl.i);
Console.WriteLine("test2.i={0}", test2.i);
Console.WriteLine("\n"); } }
Выполнив этот код, вы увидите:
До назначения объекта
test1.i=1
test2.i=2
После назначения объекта
testt.i=2
test2.i=2
После изменения только члена TEST1
test1.i=42
test2.i=42
Посмотрим, что происходит на каждом этапе выполнения этого примера. Foo — это простой к класс с единственным членом, /. В методе Main создаются два экземпляра этого класса: testl и test2 — и их члены i устанавливаются в 1 и 2 соответственно. Затем эти значения выводятся, и, как и ожидалось, testl.i равен 1, a test2.i — 2. И тут начинается самое интересное! В следующей строке объекту testl присваивается test2. Читатели, программирующие на Java, знают, что будет дальше. Однако большинство программистов на C++ будут ожидать, что член / объекта testl теперь равен члену объекта test2 (если исходить из предположения, что при компиляции такого приложения будет выполнена некая разновидность оператора копирования членов объектов). Выводимый результат это вроде подтверждает. Однако на самом деле связь между объектами теперь гораздо глубже. Присвоим значение 42 члену testl.i и снова выведем результат. И?! При изменении объекта testl изменился и testZ Это произошло из-за того, что объекта testl больше нет. После присваивания ему test2 объект testl утерян, так как приложение на него больше не ссылается и в результате он «вычищается» сборщиком мусора (garbage collector, GC). Теперь testl и test2 указывают на одну и ту же память в куче. Следовательно, при изменении одной переменной пользователь увидит изменение и другой.
Обратите внимание на две последние выводимые строки: хотя в коде изменялось только значение testl.i, значение test2.i также изменилось. Еще раз: обе переменные теперь указывают на одно место в памяти — такое поведение и ожидали программисты на Java. Однако это совершенно не соответствует ожиданиям разработчиков на C++, поскольку в этом языке производится именно копирование объектов: каждая переменная имеет свою уникальную копию членов и изменения одного объекта не влияют на другой. Поскольку это ключ к пониманию работы объектов в С#, сделаем небольшое отступление и посмотрим, что будет происходить при передаче объекта методу:
using System;
class Foo {
public int i; }
class RefTest2App {
public void ChangeValue(Foo f)
{
f.i = 42;
}
public static void Main() {
RefTest2App app = new RefTest2App();
Foo test = new Foo(); test.i = 6;
Console.WriteLine("До вызова метода");
Console.WriteLine("test.i={0}", test.i);
Console.WriteLine("\n");
app.ChangeValue(test);
Console.WriteLine("После вызова метода");
Console.WriteLine("test.i={0}", test.i);
Console.WriteLine("\n"); > }
В большинстве языков, кроме Java, этот код будет копировать созданный объект test в локальный стек метода RefTest2App.ChangeValue. В таком случае объект test, созданный в методе Main, никогда не увидит изменений объекта/, производимых в методе ChangeValue. Однако еще раз повторю, что метод Main передает ссылку на выделенный в куче объект test. Когда метод ChangeValue манипулирует своей локальной переменной //, он так же напрямую манипулирует объектом test метода Main.
Подведем итоги
Главное в любом языке программирования — способ выполнения присваивания, математических, логических операций и операций отношения — всего, что требуется для работы реальных приложений. В коде эти операции представлены операторами. К факторам, влияющим на выполнение операторов, относятся старшинство и ассоциативность (правая и левая) операторов. Мощный набор предопределенных операторов в С# можно расширять реализациями, определенными пользователем, о чем мы поговорим в главе 13.