В настоящей главе начинается формальное изучение языка программирования C# за счет представления набора отдельных тем, которые необходимо знать для освоения инфраструктуры .NET Core. В первую очередь мы разберемся, каким образом строить объект приложения, и выясним структуру точки входа исполняемой программы, т.е. метода
Main()
, а также новое средство C# 9.0 — операторы верхнего уровня. Затем мы исследуем фундаментальные типы данных C# (и их эквиваленты в пространстве имен System
), в том числе классы System.String
и System.Text.StringBuilder
.
После ознакомления с деталями фундаментальных типов данных .NET Core мы рассмотрим несколько приемов преобразования типов данных, включая сужающие и расширяющие операции, а также использование ключевых слов
checked
и unchecked
.
Кроме того, в главе будет описана роль ключевого слова var языка С#, которое позволяет неявно определять локальную переменную. Как будет показано далее в книге, неявная типизация чрезвычайно удобна (а порой и обязательна) при работе с набором технологий LINQ. Глава завершается кратким обзором ключевых слов и операций С#, которые дают возможность управлять последовательностью выполняемых в приложении действий с применением разнообразных конструкций циклов и принятия решений.
Язык C# требует, чтобы вся логика программы содержалась внутри определения типа (вспомните из главы 1, что тип — это общий термин, относящийся к любому члену из множества {класс, интерфейс, структура, перечисление, делегат}). В отличие от многих других языков программирования создавать глобальные функции или глобальные элементы данных в C# невозможно. Взамен все данные-члены и все методы должны находиться внутри определения типа. Первым делом создадим новое пустое решение под названием
Chapter3_AllProject.sln
, которое содержит проект консольного приложения по имени SimpleCSharpApp
.
Выберите в Visual Studio шаблон Blank Solution (Пустое решение) в диалоговом окне Create a new project (Создание нового проекта). После открытия решения щелкните правой кнопкой мыши на имени решения в окне Solution Explorer (Проводник решений) и выберите в контекстном меню пункт Add►New Project (Добавить►Новый проект). Выберите шаблон ConsoleАрр (.NET Core) (Консольное приложение (.NET Core)) на языке С#, назначьте ему имя
SimpleCSharpApp
и щелкните на кнопке Create (Создать). Не забудьте выбрать в раскрывающемся списке Target framework (Целевая инфраструктура) вариант .NET 5.0.
Введите в окне командной строки следующие команды:
dotnet new sin -n Chapter3_AllProjects
dotnet new console -lang c# -n SimpleCSharpApp
-o .\SimpleCSharpApp -f net5.0
dotnet sin .\Chapter3_AllProjects.sin add .\SimpleCSharpApp
Наверняка вы согласитесь с тем, что код в первоначальном файле
Program.cs
не особо примечателен:
using System;
namespace SimpleCSharpApp
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}
Теперь модифицируем метод
Main()
класса Program
следующим образом:
class Program
{
static void Main(string[] args)
{
// Вывести пользователю простое сообщение.
Console.WriteLine("***** My First C# App *****);
Console.WriteLine("Hello World!");
Console.WriteLine();
// Ожидать нажатия клавиши , прежде чем завершить работу.
Console.ReadLine();
}
}
На заметку! Язык программирования C# чувствителен к регистру. Следовательно,
Main
— не то же, что main
, a Readline
— не то же, что ReadLine
. Запомните, что все ключевые слова C# вводятся в нижнем регистре (например, public
, lock
, class
, dynamic
), в то время как названия пространств имен, типов и членов (по соглашению) начинаются с заглавной буквы и имеют заглавные буквы в любых содержащихся внутри словах (скажем, Console.WriteLine
, System.Windows.MessageBox
, System.Data.SqlClient
). Как правило, каждый раз, когда вы получаете от компилятора сообщение об ошибке, касающееся неопределенных символов, то в первую очередь должны проверить правильность написания и регистр.
Предыдущий код содержит определение типа класса, который поддерживает единственный метод по имени
Main()
. По умолчанию среда Visual Studio назначает классу, определяющему метод Main()
, имя Program;
однако при желании его можно изменить. До выхода версии C# 9.0 каждое исполняемое приложение C# (консольная программа, настольная программа Windows или Windows-служба) должно было содержать класс, определяющий метод Main()
, который использовался для обозначения точки входа в приложение.
Выражаясь формально, класс, в котором определен метод
Main()
, называется объектом приложения. В одном исполняемом приложении допускается иметь несколько объектов приложений (что может быть удобно при модульном тестировании), но тогда вы обязаны проинформировать компилятор о том, какой из методов Main()
должен применяться в качестве точки входа. Это можно делать через элемент
в файле проекта или посредством раскрывающегося списка Startup Object (Объект запуска) на вкладке Application (Приложение) окна свойств проекта в Visual Studio.
Обратите внимание, что сигнатура метода
Main()
снабжена ключевым словом static
, которое подробно объясняется в главе 5. Пока достаточно знать, что статические члены имеют область видимости уровня класса (а не уровня объекта) и потому могут вызываться без предварительного создания нового экземпляра класса.
Помимо наличия ключевого слова
static
метод Main()
принимает единственный параметр, который представляет собой массив строк (string[] args
). Несмотря на то что в текущий момент данный массив никак не обрабатывается, параметр args
может содержать любое количество входных аргументов командной строки (доступ к ним будет вскоре описан). Наконец, метод Main()
в примере был определен с возвращаемым значением void
, т.е. перед выходом из области видимости метода мы не устанавливаем явным образом возвращаемое значение с использованием ключевого слова return
.
Внутри метода
Main()
содержится логика класса Program
. Здесь мы работаем с классом Console
, который определен в пространстве имен System
. В состав его членов входит статический метод WriteLine()
, который отправляет текстовую строку и символ возврата каретки в стандартный поток вывода. Кроме того, мы производим вызов метода Console.ReadLine()
, чтобы окно командной строки, открываемое IDE-средой Visual Studio, оставалось видимым. Когда консольные приложения .NET Core запускаются в IDE-среде Visual Studio (в режиме отладки или выпуска), то окно консоли остается видимым по умолчанию. Такое поведение можно изменить, установив флажок Automatically close the console when debugging stops (Автоматически закрывать окно консоли при останове отладки) в диалоговом окне, которое доступно через пункт меню Tools►Options►Debugging (Сервис►Параметры►Отладка). Вызов Console.ReadLine()
здесь оставляет окно открытым, если программа выполняется из проводника Windows двойным щелчком на имени файла *.ехе
. Класс System.Console
более подробно рассматривается далее в главе.
По умолчанию Visual Studio будет генерировать метод
Main()
с возвращаемым значением void
и одним входным параметром в виде массива строк. Тем не менее, это не единственно возможная форма метода Main()
. Точку входа в приложение разрешено создавать с использованием любой из приведенных ниже сигнатур (предполагая, что они содержатся внутри определения класса или структуры С#):
// Возвращаемый тип int, массив строк в качестве параметра.
static int Main(string[] args)
{
// Перед выходом должен возвращать значение!
return 0;
}
// Нет возвращаемого типа, нет параметров.
static void Main()
{
}
// Возвращаемый тип int, нет параметров.
static int Main()
{
// Перед выходом должен возвращать значение!
return 0;
}
С выходом версии С# 7.1 метод
Main()
может быть асинхронным. Асинхронное программирование раскрывается в главе 15, но теперь важно помнить о существовании четырех дополнительных сигнатур:
static Task Main()
static Task Main()
static Task Main(string[])
static Task Main(string[])
На заметку! Метод
Main()
может быть также определен как открытый в противоположность закрытому, что подразумевается, если конкретный модификатор доступа не указан. Среда Visual Studio определяет метод Main()
как неявно закрытый. Модификаторы доступа подробно раскрываются в главе 5.
Очевидно, что выбор способа создания метода
Main()
зависит от ответов на три вопроса. Первый вопрос: нужно ли возвращать значение системе, когда метод Main()
заканчивается и работа программы завершается? Если да, тогда необходимо возвращать тип данных int
, а не void
. Второй вопрос: требуется ли обрабатывать любые предоставляемые пользователем параметры командной строки? Если да, то они будут сохранены в массиве строк. Наконец, третий вопрос: есть ли необходимость вызывать асинхронный код в методе Main()
? Ниже мы более подробно обсудим первые два варианта, а исследование третьего отложим до главы 15.
Хотя и верно то, что до выхода версии C# 9.0 все приложения .NET Core на языке C# обязаны были иметь метод
Main()
, в C# 9.0 появились операторы верхнего уровня, которые устраняют необходимость в большей части формальностей, связанных с точкой входа в приложение С#. Вы можете избавиться как от класса (Program
), так и от метода Main()
. Чтобы взглянуть на это в действии, приведите содержимое файла Program.cs
к следующему виду:
using System;
// Отобразить пользователю простое сообщение.
Console.WriteLine(***** Му First C# Арр *****);
Console.WriteLine("Hello World!");
Console.WriteLine();
// Ожидать нажатия клавиши , прежде чем завершить работу.
Console.ReadLine();
Запустив программу, вы увидите, что получается тот же самый результат! Существует несколько правил применения операторов верхнего уровня.
• Операторы верхнего уровня можно использовать только в одном файле внутри приложения.
• В случае применения операторов верхнего уровня программа не может иметь объявленную точку входа.
• Операторы верхнего уровня нельзя помещать в пространство имен.
• Операторы верхнего уровня по-прежнему имеют доступ к строковому массиву аргументов.
• Операторы верхнего уровня возвращают код завершения приложения (как объясняется в следующем разделе) с использованием
return
.
• Функции, которые объявлялись в классе
Program
, становятся локальными функциями для операторов верхнего уровня. (Локальные функции раскрываются в главе 4.)
• Дополнительные типы можно объявлять после всех операторов верхнего уровня. Объявление любых типов до окончания операторов верхнего уровня приводит к ошибке на этапе компиляции.
"За кулисами" компилятор заполняет пробелы. Исследуя сгенерированный код IL для обновленного кода, вы заметите такое определение
TypeDef
для точки входа в приложение:
// TypeDef #1 (02000002)
// -------------------------------------------------------
// TypDefName: $ (02000002)
// Flags : [NotPublic] [AutoLayout] [Class] [Abstract] [Sealed] [AnsiClass]
[BeforeFieldInit] (00100180)
// Extends : 0100000D [TypeRef] System.Object
// Method #1 (06000001) [ENTRYPOINT]
// -------------------------------------------------------
// MethodName: $ (06000001)
Сравните его с определением
TypeDef
для точки входа в главе 1:
// -------------------------------------------------------
// TypDefName: CalculatorExamples.Program (02000002)
// Flags : [NotPublic] [AutoLayout] [Class] [AnsiClass]
[BeforeFieldInit] (00100000)
// Extends : 0100000C [TypeRef] System.Object
// Method #1 (06000001) [ENTRYPOINT]
// -------------------------------------------------------
// MethodName: Main (06000001)
В примере из главы 1 обратите внимание, что значение
TypDefName
представлено как пространство имен (CalculatorExamples
) плюс имя класса (Program
), а значением MethodName
является Main
. В обновленном примере, использующем операторы верхнего уровня, компилятор заполняется значение $
для TypDefName
и значение $
для имени метода.
Хотя в подавляющем большинстве случаев методы
Main()
или операторы верхнего уровня будут иметь void
в качестве возвращаемого значения, возможность возвращения int
(или Task
) сохраняет согласованность C# с другими языками, основанными на С. По соглашению возврат значения 0
указывает на то, что программа завершилась успешно, тогда как любое другое значение (вроде -1
) представляет условие ошибки (имейте в виду, что значение 0
автоматически возвращается даже в случае, если метод Main()
прототипирован как возвращающий void
).
При использовании операторов верхнего уровня (следовательно, в отсутствие метода
Main()
) в случае, если исполняемый код возвращает целое число, то оно и будет кодом возврата. Если же явно ничего не возвращается, тогда все равно обеспечивается возвращение значения 0
, как при явном применении метода Main()
.
В ОС Windows возвращаемое приложением значение сохраняется в переменной среды по имени
%ERRORLEVEL%
. Если создается приложение, которое программно запускает другой исполняемый файл (тема, рассматриваемая в главе 19), тогда получить значение %ERRORLEVEL%
можно с применением свойства ExitCode
запущенного процесса.
Поскольку возвращаемое значение передается системе в момент завершения работы приложения, вполне очевидно, что получить и отобразить финальный код ошибки во время выполнения приложения невозможно. Однако мы покажем, как просмотреть код ошибки по завершении программы, изменив операторы верхнего уровня следующим образом:
// Обратите внимание, что теперь возвращается int, а не void.
// Вывести сообщение и ожидать нажатия клавиши .
Console.WriteLine("***** My First C# App *****");
Console.WriteLine("Hello World!");
Console.WriteLine();
Console.ReadLine();
// Возвратить произвольный код ошибки.
return -1;
Если программа в качестве точки входа по-прежнему использует метод
Main()
, то вот как изменить сигнатуру метода, чтобы возвращать int
вместо void
:
static int Main()
{
…
}
Теперь давайте захватим возвращаемое значение программы с помощью пакетного файла. Используя проводник Windows, перейдите в папку, где находится файл решения (например,
С:\SimpleCSharpApp
), и создайте в ней новый текстовый файл (по имени SimpleCSharpApp.cmd
). Поместите в файл приведенные далее инструкции (если раньше вам не приходилось создавать файлы *.cmd
, то можете не беспокоиться о деталях):
@echo off
rem Пакетный файл для приложения SimpleCSharpApp.exe,
rem в котором захватывается возвращаемое им значение.
dotnet run
@if "%ERRORLEVEL%" == "0" goto success
:fail
echo This application has failed!
echo return value = %ERRORLEVEL%
goto end
:success
echo This application has succeeded!
echo return value = %ERRORLEVEL%
goto end
:end
echo All Done.
Откройте окно командной подсказки (или терминал VSC) и перейдите в папку, содержащую новый файл
*.cmd
. Запустите его, набрав имя и нажав <Enter>. Вы должны получить показанный ниже вывод, учитывая, что операторы верхнего уровня или метод Main()
возвращает -1
. Если бы возвращалось значение 0
, то вы увидели бы в окне консоли сообщение This application has succeeded!
***** My First C# App *****
Hello World!
This application has failed!
return value = -1
All Done.
Ниже приведен сценарий
PowerShell
, который эквивалентен предыдущему сценарию в файле *.cmd
:
dotnet run
if ($LastExitCode -eq 0) {
Write-Host "This application has succeeded!"
} else
{
Write-Host "This application has failed!"
}
Write-Host "All Done."
Введите
PowerShell
в терминале VSC и запустите сценарий посредством следующей команды:
.\SimpleCSharpApp.psl
Вот что вы увидите в терминальном окне:
***** My First C# App *****
Hello World!
This application has failed!
All Done.
В подавляющем большинстве приложений C# (если только не во всех) в качестве возвращаемого значения будет применяться
void
, что подразумевает неявное возвращение нулевого кода ошибки. Таким образом, все методы Main()
или операторы верхнего уровня в этой книге (кроме текущего примера) будут возвращать void
.
Теперь, когда вы лучше понимаете, что собой представляет возвращаемое значение метода
Main()
или операторов верхнего уровня, давайте посмотрим на входной массив строковых данных. Предположим, что приложение необходимо модифицировать для обработки любых возможных параметров командной строки. Один из способов предусматривает применение цикла for
языка С#. (Все итерационные конструкции языка C# более подробно рассматриваются в конце главы.)
// Вывести сообщение и ожидать нажатия клавиши .
Console.WriteLine("***** My First C# App *****");
Console.WriteLine("Hello World!");
Console.WriteLine();
// Обработать любые входные аргументы.
for (int i = 0; i < args.Length; i++)
{
Console.WriteLine("Arg: {0}", args[i]);
}
Console.ReadLine();
// Возвратить произвольный код ошибки,
return 0;
На заметку! В этом примере применяются операторы верхнего уровня, т.е. метод
Main()
не задействован. Вскоре будет показано, как обновить метод Main()
, чтобы он принимал параметр args
.
Снова загляните в код IL, который сгенерирован для программы, использующей операторы верхнего уровня. Обратите внимание, что метод
$
принимает строковый массив по имени args
, как видно ниже (для экономии пространства код приведен с сокращениями):
.class private abstract auto ansi sealed beforefieldinit '$'
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.
CompilerGeneratedAttribute::.ctor()=
( 01 00 00 00 )
.method private hidebysig static
void '$'(string[] args) cil managed
{
.entrypoint
...
} // end of method '$'::'$'
} // end of class '$'
Если в программе в качестве точки входа по-прежнему применяется метод
Main()
, тогда обеспечьте, чтобы сигнатура метода принимала строковый массив по имени args
:
static int Main(string[] args)
{
...
}
Здесь с использованием свойства
Length
класса System.Array
производится проверка, есть ли элементы в массиве строк. Как будет показано в главе 4, все массивы C# фактически являются псевдонимом класса System.Array
и потому разделяют общий набор членов. По мере прохода в цикле по элементам массива их значения выводятся на консоль. Предоставить аргументы в командной строке в равной степени просто:
C:\SimpleCSharpApp>dotnet run /arg1 -arg2
***** My First C# App *****
Hello World!
Arg: /arg1
Arg: -arg2
Вместо стандартного цикла
for
для реализации прохода по входному строковому массиву можно также применять ключевое слово foreach
. Вот пример использования foreach
(особенности конструкций циклов обсуждаются далее в главе):
// Обратите внимание, что в случае применения foreach
// отпадает необходимость в проверке размера массива.
foreach(string arg in args)
{
Console.WriteLine("Arg: {0}", arg);
}
Console.ReadLine();
return 0;
Наконец, доступ к аргументам командной строки можно также получать с помощью статического метода
GetCommandLineArgs()
типа System.Environment
. Данный метод возвращает массив элементов string
. Первый элемент содержит имя самого приложения, а остальные — индивидуальные аргументы командной строки. Обратите внимание, что при таком подходе больше не обязательно определять метод Main()
как принимающий массив string
во входном параметре, хотя никакого вреда от этого не будет.
// Получить аргументы с использованием System.Environment.
string[] theArgs = Environment.GetCommandLineArgs();
foreach(string arg in theArgs)
{
Console.WriteLine("Arg: {0}", arg);
}
Console.ReadLine();
return 0;
На заметку! Метод
GetCommandLineArgs()
не получает аргументы для приложения через метод Main()
и не полагается на параметр string[] args
.
Разумеется, именно на вас возлагается решение о том, на какие аргументы командной строки должна реагировать программа (если они вообще будут предусмотрены), и как они должны быть сформатированы (например, с префиксом
-
или /
). В показанном выше коде мы просто передаем последовательность аргументов, которые выводятся прямо в окно командной строки. Однако предположим, что создается новое игровое приложение, запрограммированное на обработку параметра вида -godmode
. Когда пользователь запускает приложение с таким флагом, в отношении него можно было бы предпринять соответствующие действия.
В реальности конечный пользователь при запуске программы имеет возможность предоставлять аргументы командной строки. Тем не менее, указывать допустимые флаги командной строки также может требоваться во время разработки в целях тестирования программы. Чтобы сделать это в Visual Studio, щелкните правой кнопкой на имени проекта в окне Solution Explorer, выберите в контекстном меню пункт Properties (Свойства), в открывшемся окне свойств перейдите на вкладку Debug (Отладка) в левой части окна, введите желаемые аргументы в текстовом поле Application arguments (Аргументы приложения) и сохраните изменения (рис. 3.1).
Указанные аргументы командной строки будут автоматически передаваться методу
Main()
во время отладки или запуска приложения внутри IDE-среды Visual Studio.
Помимо
GetCommandLineArgs()
класс Environment
открывает доступ к ряду других чрезвычайно полезных методов. В частности, с помощью разнообразных статических членов этот класс позволяет получать детальные сведения, касающиеся операционной системы, под управлением которой в текущий момент функционирует ваше приложение .NET 5. Для оценки полезности класса System.Environment
измените свой код, добавив вызов локального метода по имени ShowEnvironmentDetails()
:
// Локальный метод внутри операторов верхнего уровня.
ShowEnvironmentDetails();
Console.ReadLine();
return -1;
}
Реализуйте метод
ShowEnvironmentDetails()
после операторов верхнего уровня, обращаясь в нем к разным членам типа Environment
:
static void ShowEnvironmentDetails()
{
// Вывести информацию о дисковых устройствах
// данной машины и другие интересные детали.
foreach (string drive in Environment.GetLogicalDrives())
{
Console.WriteLine("Drive: {0}", drive); // Логические устройства
}
Console.WriteLine("OS: {0}", Environment.OSVersion);
// Версия операционной системы
Console.WriteLine("Number of processors: {0}",
Environment.ProcessorCount); // Количество процессоров
Console.WriteLine(".NET Core Version: {0}",
Environment.Version); // Версия платформы .NET Core
}
Ниже показан возможный вывод, полученный в результате тестового запуска данного метода:
***** My First C# App *****
Hello World!
Drive: C:\
OS: Microsoft Windows NT 10.0.19042.0
Number of processors: 16
.NET Core Version: 5.0.0
В типе
Environment
определены и другие члены кроме тех, что задействованы в предыдущем примере. В табл. 3.1 описаны некоторые интересные дополнительные свойства; полные сведения о них можно найти в онлайновой документации.
Почти во всех примерах приложений, создаваемых в начальных главах книги, будет интенсивно применяться класс
System.Console
. Справедливо отметить, что консольный пользовательский интерфейс может выглядеть не настолько привлекательно, как графический пользовательский интерфейс либо интерфейс веб-приложения. Однако ограничение первоначальных примеров консольными программами позволяет сосредоточиться на синтаксисе C# и ключевых аспектах платформы .NET 5, не отвлекаясь на сложности, которыми сопровождается построение настольных графических пользовательских интерфейсов или веб-сайтов.
Класс
Console
инкапсулирует средства манипулирования потоками ввода, вывода и ошибок для консольных приложений. В табл. 3.2 перечислены некоторые (но определенно не все) интересные его члены. Как видите, в классе Console
имеется ряд членов, которые оживляют простые приложения командной строки, позволяя, например, изменять цвета фона и переднего плана и выдавать звуковые сигналы (еще и различной частоты).
Дополнительно к членам, описанным в табл. 3.2, в классе
Console
определен набор методов для захвата ввода и вывода; все они являются статическими и потому вызываются с префиксом в виде имени класса (Console
). Как вы уже видели, метод WriteLine()
помещает в поток вывода строку текста (включая символ возврата каретки). Метод Write()
помещает в поток вывода текст без символа возврата каретки. Метод ReadLine()
позволяет получить информацию из потока ввода вплоть до нажатия клавиши <Enter>. Метод Read()
используется для захвата одиночного символа из потока ввода.
Чтобы реализовать базовый ввод-вывод с применением класса
Console
, создайте новый проект консольного приложения по имени BasicConsoleIO
и добавьте его в свое решение, используя следующие команды:
dotnet new console -lang c# -n BasicConsoleIO -o .\BasicConsoleIO -f net5.0
dotnet sln .\Chapter3_AllProjects.sln add .\BasicConsoleIO
Замените код
Program.cs
, как показано ниже:
using System;
Console.WriteLine("***** Basic Console I/O *****");
GetUserData();
Console.ReadLine();
static void GetUserData()
{
}
На заметку! В Visual Studio и Visual Studio Code поддерживается несколько "фрагментов кода", которые после своей активизации вставляют код. Фрагмент кода
cw
очень полезен в начальных главах книги, т.к. он автоматически разворачивается в вызов метода Console.WriteLine()
. Чтобы удостовериться в этом, введите cw
где-нибудь в своем коде и нажмите клавишу <ТаЬ>. Имейте в виду, что в Visual Studio Code клавишу <Tab> необходимо нажать один раз, а в Visual Studio — два раза.
Теперь поместите после операторов верхнего уровня реализацию метода
GetUserData()
с логикой, которая приглашает пользователя ввести некоторые сведения и затем дублирует их в стандартный поток вывода. Скажем, мы могли бы запросить у пользователя его имя и возраст (который для простоты будет трактоваться как текстовое значение, а не привычное числовое):
static void GetUserData()
{
// Получить информацию об имени и возрасте.
Console.Write("Please enter your name: "); // Предложить ввести имя
string userName = Console.ReadLine();
Console.Write("Please enter your age: "); // Предложить ввести возраст
string userAge = Console.ReadLine();
// Просто ради забавы изменить цвет переднего плана.
ConsoleColor prevColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Yellow;
// Вывести полученную информацию на консоль.
Console.WriteLine("Hello {0}! You are {1} years old.",
userName, userAge);
// Восстановить предыдущий цвет переднего плана.
Console.ForegroundColor = prevColor;
}
После запуска приложения входные данные будут совершенно предсказуемо выводиться в окно консоли (с использованием указанного специального цвета).
В ходе изучения нескольких начальных глав вы могли заметить, что внутри различных строковых литералов часто встречались такие конструкции, как
{0}
и {1}
. Платформа .NET 5 поддерживает стиль форматирования строк, который немного напоминает стиль, применяемый в операторе printf()
языка С. Попросту говоря, когда вы определяете строковый литерал, содержащий сегменты данных, значения которых остаются неизвестными до этапа выполнения, то имеете возможность указывать заполнитель, используя синтаксис с фигурными скобками. Во время выполнения все заполнители замещаются значениями, передаваемыми методу Console.WriteLine()
.
Первый параметр метода
WriteLine()
представляет строковый литерал, который содержит заполнители, определяемые с помощью {0}
, {1}
, {2}
и т.д. Запомните, что порядковые числа заполнителей в фигурных скобках всегда начинаются с 0
. Остальные параметры WriteLine()
— это просто значения, подлежащие вставке вместо соответствующих заполнителей.
На заметку! Если уникально нумерованных заполнителей больше, чем заполняющих аргументов, тогда во время выполнения будет сгенерировано исключение, связанное с форматом. Однако если количество заполняющих аргументов превышает число заполнителей, то лишние аргументы просто игнорируются.
Отдельный заполнитель допускается повторять внутри заданной строки. Например, если вы битломан и хотите построить строку
"9, Number 9, Number 9"
, тогда могли бы написать такой код:
// Джон говорит...
Console.WriteLine("{0}, Number {0}, Number {0}", 9);
Также вы должны знать о возможности помещения каждого заполнителя в любую позицию внутри строкового литерала. К тому же вовсе не обязательно, чтобы заполнители следовали в возрастающем порядке своих номеров, например:
// Выводит: 20, 10, 30
Console.WriteLine("{1}, {0}, {2}", 10, 20, 30);
Строки можно также форматировать с использованием интерполяции строк, которая рассматривается позже в главе.
Если для числовых данных требуется более сложное форматирование, то каждый заполнитель может дополнительно содержать разнообразные символы форматирования, наиболее распространенные из которых описаны в табл. 3.3.
Символы форматирования добавляются к заполнителям в виде суффиксов после двоеточия (например,
{0:С}
, {1:d}
, {2:X}
). В целях иллюстрации измените метод Main()
для вызова нового вспомогательного метода по имени FormatNumericalData()
, реализация которого в классе Program
форматирует фиксированное числовое значение несколькими способами.
// Демонстрация применения некоторых дескрипторов формата,
static void FormatNumericalData()
{
Console.WriteLine("The value 99999 in various formats:");
Console.WriteLine("c format: {0:c}", 99999);
Console.WriteLine("d9 format: {0:d9}", 99999);
Console.WriteLine("f3 format: {0:f3}", 99999);
Console.WriteLine("n format: {0:n}", 99999);
// Обратите внимание, что использование для символа
// шестнадцатеричного формата верхнего или нижнего регистра
// определяет регистр отображаемых символов.
Console.WriteLine("E format: {0:E}", 99999);
Console.WriteLine("e format: {0:e}", 99999);
Console.WriteLine("X format: {0:X}", 99999);
Console.WriteLine("x format: {0:x}", 99999);
}
Ниже показан вывод, получаемый в результате вызова метода
FormatNumericalData()
.
The value 99999 in various formats:
c format: $99,999.00
d9 format: 000099999
f3 format: 99999.000
n format: 99,999.00
E format: 9.999900E+004
e format: 9.999900e+004
X format: 1869F
x format: 1869f
В дальнейшем будут встречаться и другие примеры форматирования; если вас интересуют дополнительные сведения о форматировании строк, тогда обратитесь в документацию по .NET Core (
https://docs.microsoft.com/ru-ru/dotnet/standard/base-types/formatting-types
).
Напоследок следует отметить, что применение символов форматирования строк не ограничено консольными приложениями. Тот же самый синтаксис форматирования может быть использован при вызове статического метода
string.Format()
. Прием удобен, когда необходимо формировать выходные текстовые данные во время выполнения в приложении любого типа (например, в настольном приложении с графическим пользовательским интерфейсом, веб-приложении ASP.NET Core и т.д.).
Метод
string.Format()
возвращает новый объект string
, который форматируется согласно предоставляемым флагам. Приведенный ниже код форматирует строку с шестнадцатеричным представлением числа:
// Использование string.Format() для форматирования строкового литерала.
string userMessage = string.Format("100000 in hex is {0:x}", 100000);
Подобно любому языку программирования для фундаментальных типов данных в C# определены ключевые слова, которые используются при представлении локальных переменных, переменных-членов данных в классах, возвращаемых значений и параметров методов. Тем не менее, в отличие от других языков программирования такие ключевые слова в C# являются чем-то большим, нежели просто лексемами, распознаваемыми компилятором. В действительности они представляют собой сокращенные обозначения полноценных типов из пространства имен
System
. В табл. 3.4 перечислены системные типы данных вместе с их диапазонами значений, соответствующими ключевыми словами C# и сведениями о совместимости с общеязыковой спецификацией (CLS). Все системные типы находятся в пространстве имен System, которое ради удобства чтения не указывается.
На заметку! Вспомните из главы 1, что совместимый с CLS код .NET Core может быть задействован в любом другом управляемом языке программирования .NET Core. Если в программах открыт доступ к данным, не совместимым с CLS, тогда другие языки .NET Core могут быть не в состоянии их использовать.
Для объявления локальной переменой (например, переменной внутри области видимости члена) необходимо указать тип данных, за которым следует имя переменной. Создайте новый проект консольного приложения по имени
BasicDataTypes
и добавьте его в свое решение с применением следующих команд:
dotnet new console -lang c# -n BasicDataTypes -o .\BasicDataTypes -f net5.0
dotnet sln .\Chapter3_AllProjects.sln add .\BasicDataTypes
Обновите код, как показано ниже:
using System;
using System.Numerics;
Console.WriteLine("***** Fun with Basic Data Types *****\n");
Теперь добавьте статическую локальную функцию
LocalVarDeclarations()
и вызовите ее в операторах верхнего уровня:
static void LocalVarDeclarations()
{
Console.WriteLine("=> Data Declarations:");
// Локальные переменные объявляются так:
// типДанных имяПеременной;
int myInt;
string myString;
Console.WriteLine();
}
Имейте в виду, что использование локальной переменной до присваивания ей начального значения приведет к ошибке на этапе компиляции. Таким образом, рекомендуется присваивать начальные значения локальным переменным непосредственно при их объявлении, что можно делать в одной строке или разносить объявление и присваивание на два отдельных оператора кода.
static void LocalVarDeclarations()
{
Console.WriteLine("=> Data Declarations:");
// Локальные переменные объявляются и инициализируются так:
// типДанных имяПеременной = начальноеЗначение;
int myInt = 0;
// Объявлять и присваивать можно также в двух отдельных строках.
string myString;
myString = "This is my character data";
Console.WriteLine();
}
Кроме того, разрешено объявлять несколько переменных того же самого типа в одной строке кода, как в случае следующих трех переменных
bool
:
static void LocalVarDeclarations()
{
Console.WriteLine("=> Data Declarations:");
int myInt = 0;
string myString;
myString = "This is my character data";
// Объявить три переменных типа bool в одной строке.
bool b1 = true, b2 = false, b3 = b1;
Console.WriteLine();
}
Поскольку ключевое слово
bool
в C# — просто сокращенное обозначение структуры System.Boolean
, то любой тип данных можно указывать с применением его полного имени (естественно, то же самое касается всех остальных ключевых слов С#, представляющих типы данных). Ниже приведена окончательная реализация метода LocalVarDeclarations()
, в которой демонстрируются разнообразные способы объявления локальных переменных:
static void LocalVarDeclarations()
{
Console.WriteLine("=> Data Declarations:");
// Локальные переменные объявляются и инициализируются так:
// типДанных имяПеременной = начальноеЗначение; int myInt = 0;
string myString;
myString = "This is my character data";
// Объявить три переменных типа bool в одной строке,
bool b1 = true, b2 = false, b3 = b1;
// Использовать тип данных System.Boolean для объявления булевской переменной.
System.Boolean b4 = false;
Console.WriteLine("Your data: {0}, {1}, {2}, {3}, {4}, {5}",
myInt, myString, b1, b2, b3, b4);
Console.WriteLine();
}
Литерал
default
позволяет присваивать переменной стандартное значение ее типа данных. Литерал default
работает для стандартных типов данных, а также для специальных классов (см. главу 5) и обобщенных типов (см. главу 10). Создайте новый метод по имени DefaultDeclarations()
, поместив в него следующий код:
static void DefaultDeclarations()
{
Console.WriteLine("=> Default Declarations:");
int myInt = default;
}
Все внутренние типы данных поддерживают так называемый стандартный конструктор (см. главу 5). Это средство позволяет создавать переменную, используя ключевое слово
new
, что автоматически устанавливает переменную в ее стандартное значение:
• переменные типа
bool
устанавливаются в false
;
• переменные числовых типов устанавливаются в
0
(или в 0.0
для типов с плавающей точкой);
• переменные типа
char
устанавливаются в пустой символ;
• переменные типа
BigInteger
устанавливаются в 0
;
• переменные типа
DateTime
устанавливаются в 1/1/0001 12:00:00 AM
;
• объектные ссылки (включая переменные типа
string
) устанавливаются в null
.
На заметку! Тип данных
BigIntege
r, упомянутый в приведенном выше списке, будет описан чуть позже.
Применение ключевого слова
new
при создании переменных базовых типов дает более громоздкий, но синтаксически корректный код С#:
static void NewingDataTypes()
{
Console.WriteLine("=> Using new to create variables:");
bool b = new bool(); // Устанавливается в false
int i = new int(); // Устанавливается в 0
double d = new double(); // Устанавливается в 0.0
DateTime dt = new DateTime(); // Устанавливается в 1/1/0001 12:00:00 AM
Console.WriteLine("{0}, {1}, {2}, {3}", b, i, d, dt);
Console.WriteLine();
}
В версии C# 9.0 появился сокращенный способ создания экземпляров переменных, предусматривающий применение ключевого слова
new()
без типа данных. Вот как выглядит обновленная версия предыдущего метода NewingDataTypes
):
static void NewingDataTypesWith9()
{
Console.WriteLine("=> Using new to create variables:");
bool b = new(); // Устанавливается в false
int i = new(); // Устанавливается в 0
double d = new(); // Устанавливается в 0.0
DateTime dt = new(); // Устанавливается в 1/1/0001 12:00:00 AM
Console.WriteLine("{0}, {1}, {2}, {3}", b, i, d, dt);
Console.WriteLine();
}
Интересно отметить, что даже элементарные типы данных в.NET Core организованы в иерархию классов. Если вы не знакомы с концепцией наследования, тогда найдете все необходимые сведения в главе 6. А до тех пор просто знайте, что типы, находящиеся в верхней части иерархии классов, предоставляют определенное стандартное поведение, которое передается производным типам. На рис. 3.2 показаны отношения между основными системными типами.
Обратите внимание, что каждый тип в конечном итоге оказывается производным от класса
System.Object
, в котором определен набор методов (например, ToString()
, Equals()
, GetHashCode()
), общих для всех типов из библиотек базовых классов .NET Core (упомянутые методы подробно рассматриваются в главе 6).
Также важно отметить, что многие числовые типы данных являются производными от класса
System.ValueType
. Потомки ValueType автоматически размещаются в стеке и по этой причине имеют предсказуемое время жизни и довольно эффективны. С другой стороны, типы, в цепочке наследования которых класс System.ValueType
отсутствует (такие как System.Type
, System.String
, System.Array
, System.Exception
и System.Delegate
), размещаются не в стеке, а в куче с автоматической сборкой мусора. (Более подробно такое различие обсуждается в главе 4.)
Не вдаваясь глубоко в детали классов
System.Object
и System.ValueType
, важно уяснить, что поскольку любое ключевое слово C# (скажем, int
) представляет собой просто сокращенное обозначение соответствующего системного типа (в данном случае System.Int32
), то приведенный ниже синтаксис совершенно законен. Дело в том, что тип System.Int32
(int
в С#) в конечном итоге является производным от класса System.Object
и, следовательно, может обращаться к любому из его открытых членов, как продемонстрировано в еще одной вспомогательной функции:
static void ObjectFunctionality()
{
Console.WriteLine("=> System.Object Functionality:");
// Ключевое слово int языка C# - это в действительности сокращение для
// типа System.Int32, который наследует от System.Object следующие члены:
Console.WriteLine("12.GetHashCode() = {0}", 12.GetHashCode());
Console.WriteLine("12.Equals(23) = {0}", 12.Equals(23));
Console.WriteLine("12.ToString() = {0}", 12.ToString());
Console.WriteLine("12.GetType() = {0}", 12.GetType());
Console.WriteLine();
}
Вызов метода
ObjectFunctionality()
внутри Main()
дает такой вывод:
=> System.Object Functionality:
12.GetHashCode() = 12
12.Equals(23) = False
12.ToString() = 12
12.GetType() = System.Int32
Продолжая эксперименты со встроенными типами данных С#, следует отметить, что числовые типы .NET Core поддерживают свойства
MaxValue
и MinValue
, предоставляющие информацию о диапазоне значений, которые способен хранить конкретный тип. В дополнение к свойствам MinValue
и MaxValue
каждый числовой тип может определять собственные полезные члены. Например, тип System.Double
позволяет получать значения для бесконечно малой (эпсилон) и бесконечно большой величин (которые интересны тем, кто занимается решением математических задач). В целях иллюстрации рассмотрим следующую вспомогательную функцию:
static void DataTypeFunctionality()
{
Console.WriteLine("=> Data type Functionality:");
Console.WriteLine("Max of int: {0}", int.MaxValue);
Console.WriteLine("Min of int: {0}", int.MinValue);
Console.WriteLine("Max of double: {0}", double.MaxValue);
Console.WriteLine("Min of double: {0}", double.MinValue);
Console.WriteLine("double.Epsilon: {0}", double.Epsilon);
Console.WriteLine("double.PositiveInfinity: {0}",
double.PositiveInfinity);
Console.WriteLine("double.NegativeInfinity: {0}",
double.NegativeInfinity);
Console.WriteLine();
}
В случае определения литерального целого числа (наподобие
500
) исполняющая среда по умолчанию назначит ему тип данных int
. Аналогично литеральное число с плавающей точкой (такое как 55.333
) по умолчанию получит тип double
. Чтобы установить тип данных в long
, используйте суффикс l
или L
(4L
). Для объявления переменной типа float
применяйте с числовым значением суффикс f
или F
(5.3F
), а для объявления десятичного числа используйте со значением с плавающей точкой суффикс m
или М
(300.5М
). Это станет более важным при неявном объявлении переменных, как будет показано позже в главе.
Рассмотрим тип данных
System.Boolean
. К допустимым значениям, которые могут присваиваться типу bool
в С#, относятся только true
и false
. С учетом этого должно быть понятно, что System.Boolean
не поддерживает свойства MinValue
и MaxValue
, но вместо них определяет свойства TrueString
и FalseString
(которые выдают, соответственно, строки "True"
и "False"
).
Вот пример:
Console.WriteLine("bool.FalseString: {0}", bool.FalseString);
Console.WriteLine("bool.TrueString: {0}", bool.TrueString);
Текстовые данные в C# представляются посредством ключевых слов
string
и char
, которые являются сокращенными обозначениями для типов System.String
и System.Char
(оба основаны на Unicode). Как вам уже может быть известно, string
представляет непрерывное множество символов (например, "Hello"
), a char
— одиночную ячейку в string
(например, 'Н'
).
Помимо возможности хранения одиночного элемента символьных данных тип
System.Char
предлагает немало другой функциональности. Используя статические методы System.Char
, можно выяснять, является данный символ цифрой, буквой, знаком пунктуации или чем-то еще. Взгляните на следующий метод:
static void CharFunctionality()
{
Console.WriteLine("=> char type Functionality:");
char myChar = 'a';
Console.WriteLine("char.IsDigit('a'): {0}", char.IsDigit(myChar));
Console.WriteLine("char.IsLetter('a'): {0}", char.IsLetter(myChar));
Console.WriteLine("char.IsWhiteSpace('Hello There', 5): {0}",
char.IsWhiteSpace("Hello There", 5));
Console.WriteLine("char.IsWhiteSpace('Hello There', 6): {0}",
char.IsWhiteSpace("Hello There", 6));
Console.WriteLine("char.IsPunctuation('?'): {0}",
char.IsPunctuation('?'));
Console.WriteLine();
}
В методе
CharFunctionality()
было показано, что для многих членов System.Char
предусмотрены два соглашения о вызове: одиночный символ или строка с числовым индексом, указывающим позицию проверяемого символа.
Типы данных .NET Core предоставляют возможность генерировать переменную лежащего в основе типа, имея текстовый эквивалент (например, путем выполнения разбора) Такой прием может оказаться исключительно удобным, когда вы хотите преобразовывать в числовые значения некоторые вводимые пользователем данные (вроде элемента, выбранного в раскрывающемся списке внутри графического пользовательского интерфейса) Ниже приведен пример метода
ParseFromStrings()
, содержащий логику разбора:
static void ParseFromStrings()
{
Console.WriteLine("=> Data type parsing:");
bool b = bool.Parse("True");
Console.WriteLine("Value of b: {0}", b); // Вывод значения b
double d = double.Parse("99.884");
Console.WriteLine("Value of d: {0}", d); // Вывод значения d
int i = int.Parse("8");
Console.WriteLine("Value of i: {0}", i); // Вывод значения i
char c = Char.Parse("w");
Console.WriteLine("Value of c: {0}", c); // Вывод значения с
Console.WriteLine();
}
Проблема с предыдущим кодом связана с тем, что если строка не может быть аккуратно преобразована в корректный тип данных, то сгенерируется исключение. Например, следующий код потерпит неудачу во время выполнения:
bool b = bool.Parse("Hello");
Решение предусматривает помещение каждого вызова
Parse()
в блок try-catch
(обработка исключений подробно раскрывается в главе 7), что добавит много кода, или применение метода TryParse()
. Метод TryParse()
принимает параметр out
(модификатор out
рассматривается в главе 4) и возвращает значение bool
, которое указывает, успешно ли прошел разбор. Создайте новый метод по имени ParseFromStringWithTryParse()
и поместите в него такой код:
static void ParseFromStringsWithTryParse()
{
Console.WriteLine("=> Data type parsing with TryParse:");
if (bool.TryParse("True", out bool b))
{
Console.WriteLine("Value of b: {0}", b); // Вывод значения b
}
else
{
Console.WriteLine("Default value of b: {0}", b);
// Вывод стандартного значения b
}
string value = "Hello";
if (double.TryParse(value, out double d))
{
Console.WriteLine("Value of d: {0}", d);
}
else
{
// Преобразование входного значения в double потерпело неудачу
// и переменной было присвоено стандартное значение.
Console.WriteLine("Failed to convert the input ({0}) to a double
and
the variable was
assigned the default {1}", value,d);
}
Console.WriteLine();
}
Если вы только начали осваивать программирование и не знаете, как работают операторы
if/else
, то они подробно рассматриваются позже в главе. В приведенном выше примере важно отметить, что когда строка может быть преобразована в запрошенный тип данных, метод TryParse()
возвращает true
и присваивает разобранное значение переменной, переданной методу. В случае невозможности разбора значения переменной присваивается стандартное значение, а метод TryParse()
возвращает false
.
В пространстве имен
System
определено несколько полезных типов данных, для которых отсутствуют ключевые слова языка С#, в том числе структуры DateTime
и TimeSpan
. (При желании можете самостоятельно ознакомиться с типом System.Void
, показанным на рис. 3.2.)
Тип
DateTime
содержит данные, представляющие специфичное значение даты (месяц, день, год) и времени, которые могут форматироваться разнообразными способами с применением членов этого типа. Структура TimeSpan
позволяет легко определять и трансформировать единицы времени, используя различные ее члены.
static void UseDatesAndTimes()
{
Console.WriteLine("=> Dates and Times:");
// Этот конструктор принимает год, месяц и день.
DateTime dt = new DateTime(2015, 10, 17);
// Какой это день месяца?
Console.WriteLine("The day of {0} is {1}", dt.Date, dt.DayOfWeek);
// Сейчас месяц декабрь.
dt = dt.AddMonths(2);
Console.WriteLine("Daylight savings: {0}", dt.IsDaylightSavingTime());
// Этот конструктор принимает часы, минуты и секунды.
TimeSpan ts = new TimeSpan(4, 30, 0);
Console.WriteLine(ts);
// Вычесть 15 минут из текущего значения TimeSpan и вывести результат.
Console.WriteLine(ts.Subtract(new TimeSpan(0, 15, 0)));
}
В пространстве имен
System.Numerics
определена структура по имени BigInteger
. Тип данных BigInteger
может применяться для представления огромных числовых значений, которые не ограничены фиксированным верхним или нижним пределом.
На заметку! В пространстве имен
System.Numerics
также определена вторая структура по имени Complex
, которая позволяет моделировать математически сложные числовые данные (например, мнимые единицы, вещественные данные, гиперболические тангенсы). Дополнительные сведения о структуре Complex
можно найти в документации по .NET Core.
Несмотря на то что во многих приложениях .NET Core потребность в структуре
BigInteger
может никогда не возникать, если все-таки необходимо определить большое числовое значение, то в первую очередь понадобится добавить в файл показанную ниже директиву using
:
// Здесь определен тип BigInteger:
using System.Numerics;
Теперь с применением операции
new
можно создать переменную BigInteger
. Внутри конструктора можно указать числовое значение, включая данные с плавающей точкой. Однако компилятор C# неявно типизирует числа не с плавающей точкой как int
, а числа с плавающей точкой — как double
. Как же тогда установить для BigInteger
большое значение, не переполнив стандартные типы данных, которые задействуются для неформатированных числовых значений?
Простейший подход предусматривает определение большого числового значения в виде текстового литерала, который затем может быть преобразован в переменную
BigInteger
посредством статического метода Parse()
. При желании можно также передавать байтовый массив непосредственно конструктору класса BigInteger
.
На заметку! После того как переменной
BigInteger
присвоено значение, модифицировать ее больше нельзя, т.к. это неизменяемые данные. Тем не менее, в классе BigInteger
определено несколько членов, которые возвращают новые объекты BigInteger
на основе модификаций данных (такие как статический метод Multiply()
, используемый в следующем примере кода).
В любом случае после определения переменной
BigInteger
вы обнаружите, что в этом классе определены члены, похожие на члены в других внутренних типах данных C# (например, float
либо int
). Вдобавок в классе BigInteger
определен ряд статических членов, которые позволяют применять к переменным BigInteger
базовые математические операции (наподобие сложения и умножения). Взгляните на пример работы с классом BigInteger
:
static void UseBigInteger()
{
Console.WriteLine("=> Use BigInteger:");
BigInteger biggy =
BigInteger.Parse("9999999999999999999999999999999999999999999999");
Console.WriteLine("Value of biggy is {0}", biggy);
Console.WriteLine("Is biggy an even value?: {0}", biggy.IsEven);
Console.WriteLine("Is biggy a power of two?: {0}", biggy.IsPowerOfTwo);
BigInteger reallyBig = BigInteger.Multiply(biggy,
BigInteger.Parse("8888888888888888888888888888888888888888888"));
Console.WriteLine("Value of reallyBig is {0}", reallyBig);
}
Важно отметить, что тип данных
BigInteger
реагирует на внутренние математические операции С#, такие как +
, -
и *
. Следовательно, вместо вызова метода BigInteger.Multiply()
для перемножения двух больших чисел можно использовать такой код:
BigInteger reallyBig2 = biggy * reallyBig;
К настоящему моменту вы должны понимать, что ключевые слова С#, представляющие базовые типы данных, имеют соответствующие типы в библиотеках базовых классов .NET Core, каждый из которых предлагает фиксированную функциональность. Хотя абсолютно все члены этих типов данных в книге подробно не рассматриваются, имеет смысл изучить их самостоятельно. Подробные описания разнообразных типов данных .NET Core можно найти в документации по .NET Core — скорее всего, вы будете удивлены объемом их встроенной функциональности.
Временами при присваивании числовой переменной крупных чисел цифр оказывается больше, чем способен отслеживать глаз. В версии C# 7.0 был введен разделитель групп цифр в виде символа подчеркивания (
_
) для данных int
, long
, decimal
, double
или шестнадцатеричных типов. Версия C# 7.2 позволяет шестнадцатеричным значениям (и рассматриваемым далее новым двоичным литералам) после открывающего объявления начинаться с символа подчеркивания. Ниже представлен пример применения нового разделителя групп цифр:
static void DigitSeparators()
{
Console.WriteLine("=> Use Digit Separators:");
Console.Write("Integer:"); // Целое
Console.WriteLine(123_456);
Console.Write("Long:"); // Длинное целое
Console.WriteLine(123_456_789L);
Console.Write("Float:"); // С плавающей точкой
Console.WriteLine(123_456.1234F);
Console.Write("Double:"); // С плавающей точкой двойной точности
Console.WriteLine(123_456.12);
Console.Write("Decimal:"); // Десятичное
Console.WriteLine(123_456.12M);
// Обновление в версии 7.2: шестнадцатеричное значение
// может начинаться с символа _
Console.Write("Hex:");
Console.WriteLine(0x_00_00_FF); // Шестнадцатеричное
}
В версии C# 7.0 появился новый литерал для двоичных значений, которые представляют, скажем, битовые маски. Новый разделитель групп цифр работает с двоичными литералами, а в версии C# 7.2 разрешено начинать двоичные и шестнадцатеричные числа начинать с символа подчеркивания. Теперь двоичные числа можно записывать ожидаемым образом, например:
0b_0001_0000
Вот метод, в котором иллюстрируется использование новых литералов с разделителем групп цифр:
static void BinaryLiterals()
{
// Обновление в версии 7.2: двоичное значение может начинаться с символа _
Console.WriteLine("=> Use Binary Literals:");
Console.WriteLine("Sixteen: {0}",0b_0001_0000); // 16
Console.WriteLine("Thirty Two: {0}",0b_0010_0000); // 32
Console.WriteLine("Sixty Four: {0}",0b_0100_0000); // 64
}
Класс
System.String
предоставляет набор членов, вполне ожидаемый от служебного класса такого рода, например, члены для возвращения длины символьных данных, поиска подстрок в текущей строке и преобразования символов между верхним и нижним регистрами. В табл. 3.5 перечислены некоторые интересные члены этого класса.
Работа с членами
System.String
выглядит вполне ожидаемо. Просто объявите переменную string
и задействуйте предлагаемую типом функциональность через операцию точки. Не следует забывать, что несколько членов System.String
являются статическими и потому должны вызываться на уровне класса (а не объекта).
Создайте новый проект консольного приложения по имени
FunWithStrings
и добавьте его в свое решение. Замените существующий код следующим кодом:
using System;
using System.Text;
BasicStringFunctionality();
static void BasicStringFunctionality()
{
Console.WriteLine("=> Basic String functionality:");
string firstName = "Freddy";
// Вывод значения firstName.
Console.WriteLine("Value of firstName: {0}", firstName);
// Вывод длины firstname.
Console.WriteLine("firstName has {0} characters.", firstName.Length);
// Вывод firstName в верхнем регистре.
Console.WriteLine("firstName in uppercase: {0}", firstName.ToUpper());
// Вывод firstName в нижнем регистре.
Console.WriteLine("firstName in lowercase: {0}", firstName.ToLower());
// Содержит ли firstName букву у?
Console.WriteLine("firstName contains the letter y?: {0}",
firstName.Contains("y"));
// Вывод firstName после замены.
Console.WriteLine("New first name: {0}", firstName.Replace("dy", ""));
Console.WriteLine();
}
Здесь объяснять особо нечего: метод просто вызывает разнообразные члены, такие как
ToUpper()
и Contains()
, на локальной переменной string
, чтобы получить разные форматы и трансформации. Ниже приведен вывод:
***** Fun with Strings *****
=> Basic String functionality:
Value of firstName: Freddy
firstName has 6 characters.
firstName in uppercase: FREDDY
firstName in lowercase: freddy
firstName contains the letter y?: True
firstName after replace: Fred
Несмотря на то что вывод не выглядит особо неожиданным, вывод, полученный в результате вызова метода
Replace()
, может вводить в заблуждение. В действительности переменная firstName
вообще не изменяется; взамен получается новая переменная string
в модифицированном формате. Чуть позже мы еще вернемся к обсуждению неизменяемой природы строк.
Переменные
string
могут соединяться вместе для построения строк большего размера с помощью операции +
языка С#. Как вам должно быть известно, такой прием формально называется конкатенацией строк. Рассмотрим следующую вспомогательную функцию:
static void StringConcatenation()
{
Console.WriteLine("=> String concatenation:");
string s1 = "Programming the ";
string s2 = "PsychoDrill (PTP)";
string s3 = s1 + s2;
Console.WriteLine(s3);
Console.WriteLine();
}
Интересно отметить, что при обработке символа
+
компилятор C# выпускает вызов статического метода String.Concat()
. В результате конкатенацию строк можно также выполнять, вызывая метод String.Concat()
напрямую (хотя фактически это не дает никаких преимуществ, а лишь увеличивает объем набираемого кода):
static void StringConcatenation()
{
Console.WriteLine("=> String concatenation:");
string s1 = "Programming the ";
string s2 = "PsychoDrill (PTP)";
string s3 = String.Concat(s1, s2);
Console.WriteLine(s3);
Console.WriteLine();
}
Подобно другим языкам, основанным на С, строковые литералы C# могут содержать разнообразные управляющие последовательности, которые позволяют уточнять то, как символьные данные должны быть представлены в потоке вывода. Каждая управляющая последовательность начинается с символа обратной косой черты, за которым следует специфический знак. В табл. 3.6 перечислены наиболее распространенные управляющие последовательности.
Например, чтобы вывести строку, которая содержит символ табуляции после каждого слова, можно задействовать управляющую последовательность
\t
. Или предположим, что нужно создать один строковый литерал с символами кавычек внутри, второй — с определением пути к каталогу и третий — со вставкой трех пустых строк после вывода символьных данных. Для этого можно применять управляющие последовательности \"
, \\
и \n
. Кроме того, ниже приведен еще один пример, в котором для привлечения внимания каждый строковый литерал сопровождается звуковым сигналом:
static void EscapeChars()
{
Console.WriteLine("=> Escape characters:\a");
string strWithTabs = "Model\tColor\tSpeed\tPet Name\a ";
Console.WriteLine(strWithTabs);
Console.WriteLine("Everyone loves \"Hello World\"\a ");
Console.WriteLine("C:\\MyApp\\bin\\Debug\a ");
// Добавить четыре пустых строки и снова выдать звуковой сигнал.
Console.WriteLine("All finished.\n\n\n\a ");
Console.WriteLine();
}
Синтаксис с фигурными скобками, продемонстрированный ранее в главе
({0}, {1}
и т.д.), существовал в рамках платформы .NET еще со времен версии 1.0. Начиная с выхода версии C# 6, при построении строковых литералов, содержащих заполнители для переменных, программисты на C# могут использовать альтернативный синтаксис. Формально он называется интерполяцией строк. Несмотря на то что выходные данные операции идентичны выходным данным, получаемым с помощью традиционного синтаксиса форматирования строк, новый подход позволяет напрямую внедрять сами переменные, а не помещать их в список с разделителями-запятыми.
Взгляните на показанный ниже дополнительный метод в нашем классе
Program(StringInterpolation()
), который строит переменную типа string
с применением обоих подходов:
static void StringInterpolation()
{
Console.WriteLine("=> String interpolation:\a");
// Некоторые локальные переменные будут включены в крупную строку.
int age = 4;
string name = "Soren";
// Использование синтаксиса с фигурными скобками.
string greeting = string.Format("Hello {0} you are {1} years old.",
name, age);
Console.WriteLine(greeting);
// Использование интерполяции строк.
string greeting2 = $"Hello {name} you are {age} years old.";
Console.WriteLine(greeting2);
}
В переменной
greeting2
легко заметить, что конструируемая строка начинается с префикса $
. Кроме того, фигурные скобки по-прежнему используются для пометки заполнителя под переменную; тем не менее, вместо применения числовой метки имеется возможность указывать непосредственно переменную. Предполагаемое преимущество заключается в том, что новый синтаксис несколько легче читать в линейной манере (слева направо) с учетом того, что не требуется "перескакивать в конец" для просмотра списка значений, подлежащих вставке во время выполнения.
С новым синтаксисом связан еще один интересный аспект: фигурные скобки, используемые в интерполяции строк, обозначают допустимую область видимости. Таким образом, с переменными можно применять операцию точки, чтобы изменять их состояние. Рассмотрим модификацию кода присваивания переменных
greeting
и greeting2
:
string greeting = string.Format("Hello {0} you are {1} years old.",
name.ToUpper(), age);
string greeting2 = $"Hello {name.ToUpper()} you are {age} years old.";
Здесь посредством вызова
ToUpper()
производится преобразование значения name в верхний регистр. Обратите внимание, что при подходе с интерполяцией строк завершающая пара круглых скобок к вызову данного метода не добавляется. Учитывая это, использовать область видимости, определяемую фигурными скобками, как полноценную область видимости метода, которая содержит многочисленные строки исполняемого кода, невозможно. Взамен допускается только вызывать одиночный метод на объекте с применением операции точки, а также определять простое общее выражение наподобие {age += 1}
.
Полезно также отметить, что в рамках нового синтаксиса внутри строкового литерала по-прежнему можно использовать управляющие последовательности. Таким образом, для вставки символа табуляции необходимо применять последовательность
\t
:
string greeting = string.Format("\tHello {0} you are {1} years old.",
name.ToUpper(), age);
string greeting2 = $"\tHello {name.ToUpper()} you are {age} years old.";
Когда вы добавляете к строковому литералу префикс
@
, то создаете так называемую дословную строку. Используя дословные строки, вы отключаете обработку управляющих последовательностей в литералах и заставляете выводить значения string
в том виде, как есть. Такая возможность наиболее полезна при работе со строками, представляющими пути к каталогам и сетевым ресурсам. Таким образом, вместо применения управляющей последовательности \\
можно поступить следующим образом:
// Следующая строка воспроизводится дословно,
// так что отображаются все управляющие символы.
Console.WriteLine(@"C:\MyApp\bin\Debug");
Также обратите внимание, что дословные строки могут использоваться для предохранения пробельных символов в строках, разнесенных по нескольким строкам вывода:
// При использовании дословных строк пробельные символы предохраняются.
string myLongString = @"This is a very
very
very
long string";
Console.WriteLine(myLongString);
Применяя дословные строки, в литеральную строку можно также напрямую вставлять символы двойной кавычки, просто дублируя знак
"
:
Console.WriteLine(@"Cerebus said ""Darrr! Pret-ty sun-sets""");
Дословные строки также могут быть интерполированными строками за счет указания операций интерполяции (
$
) и дословности (@
):
string interp = "interpolation";
string myLongString2 = $@"This is a very
very
long string with {interp}";
Нововведением в версии C# 8 является то, что порядок следования этих операций не имеет значения. Работать будет либо
$@
либо @$
.
Как будет подробно объясняться в главе 4, ссылочный тип — это объект, размещаемый в управляемой куче со сборкой мусора. По умолчанию при выполнении проверки на предмет равенства ссылочных типов (с помощью операций
==
и ! =
языка С#) значение true
будет возвращаться в случае, если обе ссылки указывают на один и тот же объект в памяти. Однако, несмотря на то, что тип string
в действительности является ссылочным, операции равенства для него были переопределены так, чтобы можно было сравнивать значения объектов string
, а не ссылки на объекты в памяти.
static void StringEquality()
{
Console.WriteLine("=> String equality:");
string s1 = "Hello!";
string s2 = "Yo!";
Console.WriteLine("s1 = {0}", s1);
Console.WriteLine("s2 = {0}", s2);
Console.WriteLine();
// Проверить строки на равенство.
Console.WriteLine("s1 == s2: {0}", s1 == s2);
Console.WriteLine("s1 == Hello!: {0}", s1 == "Hello!");
Console.WriteLine("s1 == HELLO!: {0}", s1 == "HELLO!");
Console.WriteLine("s1 == hello!: {0}", s1 == "hello!");
Console.WriteLine("s1.Equals(s2): {0}", s1.Equals(s2));
Console.WriteLine("Yo!.Equals(s2): {0}", "Yo!".Equals(s2));
Console.WriteLine();
}
Операции равенства C# выполняют в отношении объектов
string
посимвольную проверку равенства с учетом регистра и нечувствительную к культуре. Следовательно, строка "Hello!"
не равна строке "HELLO!"
и также отличается от строки "hello!"
. Кроме того, памятуя о связи между string
и System.String
, проверку на предмет равенства можно осуществлять с использованием метода Equals()
класса String
и других поддерживаемых им операций равенства. Наконец, поскольку каждый строковый литерал (такой как "Yo!"
) является допустимым экземпляром System.String
, доступ к функциональности, ориентированной на работу со строками, можно получать для фиксированной последовательности символов.
Как уже упоминалось, операции равенства строк (
Compare()
, Equals()
и ==
), а также функция IndexOf()
по умолчанию чувствительны к регистру символов и нечувствительны к культуре. Если ваша программа не заботится о регистре символов, тогда может возникнуть проблема. Один из способов ее преодоления предполагает преобразование строк в верхний или нижний регистр с последующим их сравнением:
if (firstString.ToUpper() == secondString.ToUpper())
{
// Делать что-то
}
Здесь создается копия каждой строки со всеми символами верхнего регистра. В большинстве ситуаций это не проблема, но в случае очень крупных строк может пострадать производительность. И дело даже не производительности — написание каждый раз такого кода преобразования становится утомительным. А что, если вы забудете вызвать
ToUpper()
? Результатом будет трудная в обнаружении ошибка.
Гораздо лучший прием предусматривает применение перегруженных версий перечисленных ранее методов, которые принимают значение перечисления
StringComparison
, управляющего выполнением сравнения. Значения StringComparison
описаны в табл. 3.7.
Чтобы взглянуть на результаты применения
StringComparison
, создайте новый метод по имени StringEqualitySpecifyingCompareRules()
со следующим кодом:
static void StringEqualitySpecifyingCompareRules()
{
Console.WriteLine("=> String equality (Case Insensitive:");
string s1 = "Hello!";
string s2 = "HELLO!";
Console.WriteLine("s1 = {0}", s1);
Console.WriteLine("s2 = {0}", s2);
Console.WriteLine();
// Проверить результаты изменения стандартных правил сравнения.
Console.WriteLine("Default rules: s1={0},s2={1}s1.Equals(s2): {2}",
s1, s2,
s1.Equals(s2));
Console.WriteLine("Ignore case: s1.Equals(s2,
StringComparison.OrdinalIgnoreCase): {0}",
s1.Equals(s2, StringComparison.OrdinalIgnoreCase));
Console.WriteLine("Ignore case, Invariant Culture: s1.Equals(s2,
StringComparison.
InvariantCultureIgnoreCase): {0}",
s1.Equals(s2, StringComparison.InvariantCultureIgnoreCase));
Console.WriteLine();
Console.WriteLine("Default rules: s1={0},s2={1} s1.IndexOf(\"E\"): {2}",
s1, s2,
s1.IndexOf("E"));
Console.WriteLine("Ignore case: s1.IndexOf(\"E\",
StringComparison.OrdinalIgnoreCase):
{0}", s1.IndexOf("E",
StringComparison.OrdinalIgnoreCase));
Console.WriteLine("Ignore case, Invariant Culture: s1.IndexOf(\"E\",
StringComparison.
InvariantCultureIgnoreCase): {0}",
s1.IndexOf("E", StringComparison.InvariantCultureIgnoreCase));
Console.WriteLine();
}
В то время как приведенные здесь примеры просты и используют те же самые буквы в большинстве культур, если ваше приложение должно принимать во внимание разные наборы культур, тогда применение перечисления
StringComparison
становится обязательным.
Один из интересных аспектов класса
System.String
связан с тем, что после присваивания объекту string начального значения символьные данные не могут быть изменены. На первый взгляд это может показаться противоречащим действительности, ведь строкам постоянно присваиваются новые значения, а в классе System.String
доступен набор методов, которые, похоже, только то и делают, что изменяют символьные данные тем или иным образом (скажем, преобразуя их в верхний или нижний регистр). Тем не менее, присмотревшись внимательнее к тому, что происходит "за кулисами", вы заметите, что методы типа string
на самом деле возвращают новый объект string
в модифицированном виде:
static void StringsAreImmutable()
{
Console.WriteLine("=> Immutable Strings:\a");
// Установить начальное значение для строки.
string s1 = "This is my string.";
Console.WriteLine("s1 = {0}", s1);
// Преобразована ли строка si в верхний регистр?
string upperString = s1.ToUpper();
Console.WriteLine("upperString = {0}", upperString);
// Нет! Строка si осталась в том же виде!
Console.WriteLine("s1 = {0}", s1);
}
Просмотрев показанный далее вывод, можно убедиться, что в результате вызова метода
ToUpper()
исходный объект string(s1)
не преобразовывался в верхний регистр. Взамен была возвращена копия переменной типа string
в измененном формате.
s1 = This is my string.
upperString = THIS IS MY STRING.
s1 = This is my string.
Тот же самый закон неизменяемости строк действует и в случае применения операции присваивания С#. Чтобы проиллюстрировать, реализуем следующий метод
StringsAreImmutable2()
:
static void StringsAreImmutable2()
{
Console.WriteLine("=> Immutable Strings 2:\a");
string s2 = "My other string";
s2 = "New string value";
}
Скомпилируйте приложение и запустите
ildasm.exe
(см. главу 1). Ниже приведен код CIL, который будет сгенерирован для метода StringsAreImmutable2()
:
.method private hidebysig static void StringsAreImmutable2() cil managed
{
// Code size 21 (0x15)
.maxstack 1
.locals init (string V_0)
IL_0000: nop
IL_0001: ldstr "My other string"
IL_0006: stloc.0
IL_0007: ldstr "New string value" /* 70000B3B */
IL_000c: stloc.0
IL_000d: ldloc.0
IL_0013: nop
IL_0014: ret
} // end of method Program::StringsAreImmutable2
Хотя низкоуровневые детали языка CIL пока подробно не рассматривались, обратите внимание на многочисленные вызовы кода операции
ldstr
("load string" — "загрузить строку"). Попросту говоря, код операции ldstr
языка CIL загружает новый объект string
в управляемую кучу. Предыдущий объект string
, который содержал значение "Му other string"
, будет со временем удален сборщиком мусора.
Так что же в точности из всего этого следует? Выражаясь кратко, класс
string
может стать неэффективным и при неправильном употреблении приводить к "разбуханию" кода, особенно при выполнении конкатенации строк или при работе с большими объемами текстовых данных. Но если необходимо представлять элементарные символьные данные, такие как номер карточки социального страхования, имя и фамилия или простые фрагменты текста, используемые внутри приложения, тогда тип string
будет идеальным вариантом.
Однако когда строится приложение, в котором текстовые данные будут часто изменяться (подобное текстовому процессору), то представление обрабатываемых текстовых данных с применением объектов
string
будет неудачным решением, т.к. оно практически наверняка (и часто косвенно) приведет к созданию излишних копий строковых данных. Тогда каким образом должен поступить программист? Ответ на этот вопрос вы найдете ниже.
С учетом того, что тип
string
может оказаться неэффективным при необдуманном использовании, библиотеки базовых классов .NET Core предоставляют пространство имен System.Text
. Внутри этого (относительно небольшого) пространства имен находится класс StringBuilder
. Как и System.String
, класс StringBuilder
определяет методы, которые позволяют, например, заменять или форматировать сегменты. Для применения класса StringBuilder
в файлах кода C# первым делом понадобится импортировать следующее пространство имен в файл кода (что в случае нового проекта Visual Studio уже должно быть сделано):
// Здесь определен класс StringBuilder:
using System.Text;
Уникальность класса
StringBuilder
в том, что при вызове его членов производится прямое изменение внутренних символьных данных объекта (делая его более эффективным) без получения копии данных в модифицированном формате. При создании экземпляра StringBuilder
начальные значения объекта могут быть заданы через один из множества конструкторов. Если вы не знакомы с понятием конструктора, тогда пока достаточно знать только то, что конструкторы позволяют создавать объект с начальным состоянием при использовании ключевого слова new
. Взгляните на следующий пример применения StringBuilder
:
static void FunWithStringBuilder()
{
Console.WriteLine("=> Using the StringBuilder:");
StringBuilder sb = new StringBuilder("**** Fantastic Games ****");
sb.Append("\n");
sb.AppendLine("Half Life");
sb.AppendLine("Morrowind");
sb.AppendLine("Deus Ex" + "2");
sb.AppendLine("System Shock");
Console.WriteLine(sb.ToString());
sb.Replace("2", " Invisible War");
Console.WriteLine(sb.ToString());
Console.WriteLine("sb has {0} chars.", sb.Length);
Console.WriteLine();
}
Здесь создается объект
StringBuilder
с начальным значением "**** Fantastic Games ****"
. Как видите, можно добавлять строки в конец внутреннего буфера, а также заменять или удалять любые символы. По умолчанию StringBuilder
способен хранить строку только длиной 16 символов или меньше (но при необходимости будет автоматически расширяться): однако стандартное начальное значение длины можно изменить посредством дополнительного аргумента конструктора:
// Создать экземпляр StringBuilder с исходным размером в 256 символов.
StringBuilder sb = new StringBuilder("**** Fantastic Games ****", 256);
При добавлении большего количества символов, чем в указанном лимите, объект
StringBuilder
скопирует свои данные в новый экземпляр и увеличит размер буфера на заданный лимит.
Теперь, когда вы понимаете, как работать с внутренними типами данных С#, давайте рассмотрим связанную тему преобразования типов данных. Создайте новый проект консольного приложения по имени
TypeConversions
и добавьте его в свое решение. Приведите код к следующему виду:
using System;
Console.WriteLine("***** Fun with type conversions *****");
// Сложить две переменные типа short и вывести результат.
short numb1 = 9, numb2 = 10;
Console.WriteLine("{0} + {1} = {2}",
numb1, numb2, Add(numb1, numb2));
Console.ReadLine();
static int Add(int x, int y)
{
return x + y;
}
Легко заметить, что метод
Add()
ожидает передачи двух параметров int
. Тем не менее, в вызывающем коде ему на самом деле передаются две переменные типа short
. Хотя это может выглядеть похожим на несоответствие типов данных, программа компилируется и выполняется без ошибок, возвращая ожидаемый результат 19.
Причина, по которой компилятор считает такой код синтаксически корректным, связана с тем, что потеря данных в нем невозможна. Из-за того, что максимальное значение для типа
short
(32 767) гораздо меньше максимального значения для типа int
(2 147 483 647), компилятор неявно расширяет каждое значение short
до типа int
. Формально термин расширение используется для определения неявного восходящего приведения которое не вызывает потерю данных.
На заметку! Разрешенные расширяющие и сужающие (обсуждаются далее) преобразования, поддерживаемые для каждого типа данных С#, описаны в разделе "Type Conversion Tables in .NET" ("Таблицы преобразования типов в .NET") документации по .NET Core.
Несмотря на то что неявное расширение типов благоприятствовало в предыдущем примере, в других ситуациях оно может стать источником ошибок на этапе компиляции. Например, пусть для переменных
numb1
и numb2
установлены значения, которые (при их сложении) превышают максимальное значение типа short
. Кроме того, предположим, что возвращаемое значение метода Add()
сохраняется в новой локальной переменной short
, а не напрямую выводится на консоль.
static void Main(string[] args)
{
Console.WriteLine("***** Fun with type conversions *****");
// Следующий код вызовет ошибку на этапе компиляции!
short numb1 = 30000, numb2 = 30000;
short answer = Add(numb1, numb2);
Console.WriteLine("{0} + {1} = {2}",
numb1, numb2, answer);
Console.ReadLine();
}
В данном случае компилятор сообщит об ошибке:
Cannot implicitly convert type 'int' to 'short'. An explicit conversion exists (are you missing a cast?)
He удается неявно преобразовать тип int в short. Существует явное преобразование (возможно, пропущено приведение)
Проблема в том, что хотя метод
Add()
способен возвратить значение int
, равное 60 000 (которое умещается в допустимый диапазон для System.Int32
), это значение не может быть сохранено в переменной short,
потому что выходит за пределы диапазона допустимых значений для типа short
. Выражаясь формально, среде CoreCLR не удалось применить сужающую операцию. Нетрудно догадаться, что сужающая операция является логической противоположностью расширяющей операции, поскольку предусматривает сохранение большего значения внутри переменной типа данных с меньшим диапазоном допустимых значений.
Важно отметить, что все сужающие преобразования приводят к ошибкам на этапе компиляции, даже когда есть основание полагать, что такое преобразование должно пройти успешно. Например, следующий код также вызовет ошибку при компиляции:
// Снова ошибка на этапе компиляции!
static void NarrowingAttempt()
{
byte myByte = 0;
int myInt = 200;
myByte = myInt;
Console.WriteLine("Value of myByte: {0}", myByte);
}
Здесь значение, содержащееся в переменной типа
int(myInt)
, благополучно умещается в диапазон допустимых значений для типа byte
; следовательно, можно было бы ожидать, что сужающая операция не должна привести к ошибке во время выполнения. Однако из-за того, что язык C# создавался с расчетом на безопасность в отношении типов, все-таки будет получена ошибка на этапе компиляции.
Если нужно проинформировать компилятор о том, что вы готовы мириться с возможной потерей данных из-за сужающей операции, тогда потребуется применить явное приведение, используя операцию приведения
()
языка С#. Взгляните на показанную далее модификацию класса Program
:
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***** Fun with type conversions *****");
short numb1 = 30000, numb2 = 30000;
// Явно привести int к short (и разрешить потерю данных).
short answer = (short)Add(numb1, numb2);
Console.WriteLine("{0} + {1} = {2}",
numb1, numb2, answer);
NarrowingAttempt();
Console.ReadLine();
}
static int Add(int x, int y)
{
return x + y;
}
static void NarrowingAttempt()
{
byte myByte = 0;
int myInt = 200;
// Явно привести int к byte (без потери данных).
myByte = (byte)myInt;
Console.WriteLine("Value of myByte: {0}", myByte);
}
}
Теперь компиляция кода проходит успешно, но результат сложения оказывается совершенно неправильным:
***** Fun with type conversions *****
30000 + 30000 = -5536
Value of myByte: 200
Как вы только что удостоверились, явное приведение заставляет компилятор применить сужающее преобразование, даже когда оно может вызвать потерю данных. В случае метода
NarrowingAttempt()
это не было проблемой, т.к. значение 200 умещалось в диапазон допустимых значений для типа byte
. Тем не менее, в ситуации со сложением двух значений типа short
внутри Main()
конечный результат получился полностью неприемлемым (30000 + 30000 = -5536?).
Для построения приложений, в которых потеря данных не допускается, язык C# предлагает ключевые слова
checked
и unchecked
, которые позволяют гарантировать, что потеря данных не останется необнаруженной.
Давайте начнем с выяснения роли ключевого слова
checked
. Предположим, что в класс Program
добавлен новый метод, который пытается просуммировать две переменные типа byte
, причем каждой из них было присвоено значение, не превышающее допустимый максимум (255). По идее после сложения значений этих двух переменных (с приведением результата int
к типу byte
) должна быть получена точная сумма.
static void ProcessBytes()
{
byte b1 = 100;
byte b2 = 250;
byte sum = (byte)Add(b1, b2);
// В sum должно содержаться значение 350.
// Однако там оказывается значение 94!
Console.WriteLine("sum = {0}", sum);
}
Удивительно, но при просмотре вывода приложения обнаруживается, что в переменной sum содержится значение 94 (а не 350, как ожидалось). Причина проста. Учитывая, что
System.Byte
может хранить только значение в диапазоне от 0 до 255 включительно, в sum
будет помещено значение переполнения (350-256 = 94). По умолчанию, если не предпринимаются никакие корректирующие действия, то условия переполнения и потери значимости происходят без выдачи сообщений об ошибках.
Для обработки условий переполнения и потери значимости в приложении доступны два способа. Это можно делать вручную, полагаясь на свои знания и навыки в области программирования. Недостаток такого подхода произрастает из того факта, что мы всего лишь люди, и даже приложив максимум усилий, все равно можем попросту упустить из виду какие-то ошибки.
К счастью, язык C# предоставляет ключевое слово
checked
. Когда оператор (или блок операторов) помещен в контекст checked
, компилятор C# выпускает дополнительные инструкции CIL, обеспечивающие проверку условий переполнения, которые могут возникать при сложении, умножении, вычитании или делении двух значений числовых типов.
Если происходит переполнение, тогда во время выполнения генерируется исключение
System.OverflowException
. В главе 7 будут предложены подробные сведения о структурированной обработке исключений, а также об использовании ключевых слов try
и catch
. Не вдаваясь пока в детали, взгляните на следующий модифицированный код:
static void ProcessBytes()
{
byte b1 = 100;
byte b2 = 250;
// На этот раз сообщить компилятору о необходимости добавления
// кода CIL, необходимого для генерации исключения, если возникает
// переполнение или потеря значимости.
try
{
byte sum = checked((byte)Add(b1, b2));
Console.WriteLine("sum = {0}", sum);
}
catch (OverflowException ex)
{
Console.WriteLine(ex.Message);
}
}
Обратите внимание, что возвращаемое значение метода
Add()
помещено в контекст ключевого слова checked
. Поскольку значение sum
выходит за пределы допустимого диапазона для типа byte
, генерируется исключение времени выполнения. Сообщение об ошибке выводится посредством свойства Message
:
Arithmetic operation resulted in an overflow.
Арифметическая операция привела к переполнению.
Чтобы обеспечить принудительную проверку переполнения для целого блока операторов, контекст
checked
можно определить так:
try
{
checked
{
byte sum = (byte)Add(b1, b2);
Console.WriteLine("sum = {0}", sum);
}
}
catch (OverflowException ex)
{
Console.WriteLine(ex.Message);
}
В любом случае интересующий код будет автоматически оцениваться на предмет возможных условий переполнения, и если они обнаружатся, то сгенерируется исключение, связанное с переполнением.
Если создается приложение, в котором никогда не должно возникать молчаливое переполнение, то может обнаружиться, что в контекст ключевого слова
checked
приходится помещать слишком много строк кода. В качестве альтернативы компилятор C# поддерживает флаг /checked
. Когда он указан, все присутствующие в коде арифметические операции будут оцениваться на предмет переполнения, не требуя применения ключевого слова checked
. Если переполнение было обнаружено, тогда сгенерируется исключение времени выполнения. Чтобы установить его для всего проекта, добавьте в файл проекта следующий код:
true
Для активизации флага
/checked
в Visual Studio откройте окно свойств проекта. В раскрывающемся списке Configuration (Конфигурация) выберите вариант All Configurations (Все конфигурации), перейдите на вкладку Build (Сборка) и щелкните на кнопке Advanced (Дополнительно). В открывшемся диалоговом окне отметьте флажок Check for arithmetic overflow (Проверять арифметическое переполнение), как показано на рис. 3.3. Включить эту настройку может быть удобно при создании отладочной версии сборки. После устранения всех условий переполнения в кодовой базе флаг /checked
можно отключить для последующих построений (что приведет к увеличению производительности приложения).
На заметку! Если вы не выберете в списке вариант All Configurations, тогда настройка будет применена только к конфигурации, выбранной в текущий момент (т.е Debug (Отладка) или Release (Выпуск)).
А теперь предположим, что проверка переполнения и потери значимости включена в масштабах проекта, но есть блок кода, в котором потеря данных приемлема. Как с ним быть? Учитывая, что действие флага
/checked
распространяется на всю арифметическую логику, в языке C# имеется ключевое слово unchecked
, которое предназначено для отмены генерации исключений, связанных с переполнением, в отдельных случаях. Ключевое слово unchecked
используется аналогично checked
, т.е. его можно применять как к единственному оператору, так и к блоку операторов:
// Предполагая, что флаг /checked активизирован, этот блок
// не будет генерировать исключение времени выполнения.
unchecked
{
byte sum = (byte)(b1 + b2);
Console.WriteLine("sum = {0} ", sum);
}
Подводя итоги по ключевым словам
checked
и unchecked
в С#, следует отметить, что стандартное поведение исполняющей среды .NET Core предусматривает игнорирование арифметического переполнения и потери значимости. Когда необходимо обрабатывать избранные операторы, должно использоваться ключевое слово checked
. Если нужно перехватывать ошибки переполнения по всему приложению, то придется активизировать флаг /checked
. Наконец, ключевое слово unchecked
может применяться при наличии блока кода, в котором переполнение приемлемо (и, следовательно, не должно приводить к генерации исключения времени выполнения).
Вплоть до этого места в главе при объявлении каждой локальной переменной явно указывался ее тип данных:
static void DeclareExplicitVars()
{
// Явно типизированные локальные переменные
// объявляются следующим образом:
// типДанных имяПеременной = начальноеЗначение;
int myInt = 0;
bool myBool = true;
string myString = "Time, marches on...";
}
В то время как многие согласятся с тем, что явное указание типа данных для каждой переменной является рекомендуемой практикой, язык C# поддерживает возможность неявной типизации локальных переменных с использованием ключевого слова
var
. Ключевое слово var
может применяться вместо указания конкретного типа данных (такого как int
, bool
или string
). Когда вы поступаете подобным образом, компилятор будет автоматически выводить лежащий в основе тип данных на основе начального значения, используемого для инициализации локального элемента данных.
Чтобы прояснить роль неявной типизации, создайте новый проект консольного приложения по имени
ImplicitlyTypedLocalVars
и добавьте его в свое решение. Обновите код в Program.cs
, как показано ниже:
using System;
using System.Linq;
Console.WriteLine("***** Fun with Implicit Typing *****");
Добавьте следующую функцию, которая демонстрирует неявные объявления:
static void DeclareImplicitVars ()
{
// Неявно типизированные локальные переменные
// объявляются следующим образом:
// var имяПеременной = начальноеЗначение;
var myInt = 0;
var myBool = true;
var myString = "Time, marches on...";
}
На заметку! Строго говоря,
var
не является ключевым словом языка С#. Вполне допустимо объявлять переменные, параметры и поля по имени var
, не получая ошибок на этапе компиляции. Однако когда лексема var
применяется в качестве типа данных, то в таком контексте она трактуется компилятором как ключевое слово.
В таком случае, основываясь на первоначально присвоенных значениях, компилятор способен вывести для переменной
myInt
тип System.Int32
, для переменной myBool
— тип System.Boolean
, а для переменной myString
— тип System.String
. В сказанном легко убедиться за счет вывода на консоль имен типов с помощью рефлексии. Как будет показано в главе 17, рефлексия представляет собой действие по определению состава типа во время выполнения. Например, с помощью рефлексии можно определить тип данных неявно типизированной локальной переменной. Модифицируйте метод DeclareImplicitVars()
:
static void DeclareImplicitVars()
{
// Неявно типизированные локальные переменные,
var myInt = 0;
var myBool = true;
var myString = "Time, marches on...";
// Вывести имена лежащих в основе типов.
Console.WriteLine("myInt is a: {0}", myInt.GetType().Name);
// Вывод типа myInt
Console.WriteLine("myBool is a: {0}", myBool.GetType().Name);
// Вывод типа myBool
Console.WriteLine("myString is a: {0}", myString.GetType().Name);
// Вывод типа myString
}
На заметку! Имейте в виду, что такую неявную типизацию можно использовать для любых типов, включая массивы, обобщенные типы (см. главу 10) и собственные специальные типы. В дальнейшем вы увидите и другие примеры неявной типизации. Вызов метода
DeclareImplicitVars()
в операторах верхнего уровня дает следующий вывод:
***** Fun with Implicit Typing *****
myInt is a: Int32
myBool is a: Boolean
myString is a: String
Неявное объявление Как утверждалось ранее, целые числа по умолчанию получают тип
int
, а числа с плавающей точкой — тип double
. Создайте новый метод по имени DeclareImplicitNumerics
и поместите в него показанный ниже код, в котором демонстрируется неявное объявление чисел:
static void DeclareImplicitNumerics ( )
{
// Неявно типизированные числовые переменные.
var myUInt = 0u;
var myInt = 0;
var myLong = 0L;
var myDouble = 0.5;
var myFloat = 0.5F;
var myDecimal = 0.5M;
// Вывод лежащего в основе типа.
Console.WriteLine("myUInt is a: {0}", myUInt.GetType().Name);
Console.WriteLine("myInt is a: {0}", myInt.GetType().Name);
Console.WriteLine("myLong is a: {0}", myLong.GetType().Name);
Console.WriteLine("myDouble is a: {0}", myDouble.GetType().Name);
Console.WriteLine("myFloat is a: {0}", myFloat.GetType().Name);
Console.WriteLine("myDecimal is a: {0}", myDecimal.GetType().Name);
}
С использованием ключевого слова
var
связаны разнообразные ограничения. Прежде всего, неявная типизация применима только к локальным переменным внутри области видимости метода или свойства. Использовать ключевое слово var
для определения возвращаемых значений, параметров или данных полей в специальном типе не допускается. Например, показанное ниже определение класса приведет к выдаче различных сообщений об ошибках на этапе компиляции:
class ThisWillNeverCompile
{
// Ошибка! Ключевое слово var не может применяться к полям!
private var myInt = 10;
// Ошибка! Ключевое слово var не может применяться
// к возвращаемому значению или типу параметра!
public var MyMethod(var x, var y){}
}
Кроме того, локальным переменным, которые объявлены с ключевым словом
var
, обязано присваиваться начальное значение в самом объявлении, причем присваивать null
в качестве начального значения невозможно. Последнее ограничение должно быть рациональным, потому что на основании только null
компилятору не удастся вывести тип, на который бы указывала переменная.
// Ошибка! Должно быть присвоено значение!
var myData;
// Ошибка! Значение должно присваиваться в самом объявлении!
var myInt;
myInt = 0;
// Ошибка! Нельзя присваивать null в качестве начального значения!
var myObj = null;
Тем не менее, присваивать
null
локальной переменной, тип которой выведен в результате начального присваивания, разрешено (при условии, что это ссылочный тип):
// Допустимо, если SportsCar имеет ссылочный тип!
var myCar = new SportsCar();
myCar = null;
Вдобавок значение неявно типизированной локальной переменной допускается присваивать другим переменным, которые типизированы как неявно, так и явно:
// Также нормально!
var myInt = 0;
var anotherlnt = myInt;
string myString = "Wake up!";
var myData = myString;
Кроме того, неявно типизированную локальную переменную разрешено возвращать вызывающему коду при условии, что возвращаемый тип метода и выведенный тип переменной, определенной посредством
var
, совпадают:
static int GetAnlntO
{
var retVal = 9;
return retVal;
}
Имейте в виду, что неявная типизация локальных переменных дает в результате строго типизированные данные. Таким образом, применение ключевого слова
var
в языке C# — не тот же самый прием, который используется в сценарных языках (вроде JavaScript или Perl). Кроме того, ключевое слово var
— это не тип данных Variant
в СОМ, когда переменная на протяжении своего времени жизни может хранить значения разных типов (что часто называют динамической типизацией).
На заметку! В C# поддерживается возможность динамической типизации с применением ключевого слова
dynamic
. Вы узнаете о таком аспекте языка в главе 18.
Взамен средство выведения типов сохраняет аспект строгой типизации языка C# и воздействует только на объявление переменных при компиляции. Затем данные трактуются, как если бы они были объявлены с выведенным типом; присваивание такой переменной значения другого типа будет приводить к ошибке на этапе компиляции.
static void ImplicitTypingIsStrongTyping()
{
// Компилятору известно, что s имеет тип System.String.
var s = "This variable can only hold string data!";
s = "This is fine...";
// Можно обращаться к любому члену лежащего в основе типа.
string upper = s.ToUpper();
// Ошибка! Присваивание числовых данных строке не допускается!
s = 44;
}
Теперь, когда вы видели синтаксис, используемый для объявления неявно типизируемых локальных переменных, вас наверняка интересует, в каких ситуациях его следует применять. Прежде всего, использование
var
для объявления локальных переменных просто ради интереса особой пользы не принесет. Такой подход может вызвать путаницу у тех, кто будет изучать код, поскольку лишает возможности быстро определить лежащий в основе тип данных и, следовательно, затрудняет понимание общего назначения переменной. Поэтому если вы знаете, что переменная должна относиться к типу int
, то сразу и объявляйте ее с типом int
!
Однако, как будет показано в начале главы 13, в наборе технологий LINQ применяются выражения запросов, которые могут выдавать динамически создаваемые результирующие наборы, основанные на формате самого запроса. В таких случаях неявная типизация исключительно удобна, потому что вам не придется явно определять тип, который запрос может возвращать, а в ряде ситуаций это вообще невозможно. Посмотрите, сможете ли вы определить лежащий в основе тип данных
subset
в следующем примере кода LINQ?
static void LinqQueryOverInts()
{
int[] numbers = { 10, 20, 30, 40, 1, 2, 3, 8 };
// Запрос LINQ!
var subset = from i in numbers where i < 10 select i;
Console.Write("Values in subset: ");
foreach (var i in subset)
{
Console.Write("{0} ", i);
}
Console.WriteLine();
// К какому же типу относится subset?
Console.WriteLine("subset is a: {0}", subset.GetType().Name);
Console.WriteLine("subset is defined in: {0}",
subset.GetType().Namespace);
}
Вы можете предположить, что типом данных
subset
будет массив целочисленных значений. Но на самом деле он представляет собой низкоуровневый тип данных LINQ, о котором вы вряд ли что-то знаете, если только не работаете с LINQ длительное время или не откроете скомпилированный образ в утилите ildasm.exe
. Хорошая новость в том, что при использовании LINQ вы редко (если вообще когда-либо) беспокоитесь о типе возвращаемого значения запроса; вы просто присваиваете значение неявно типизированной локальной переменной.
Фактически можно было бы даже утверждать, что единственным случаем, когда применение ключевого слова
var
полностью оправдано, является определение данных, возвращаемых из запроса LINQ. Запомните, если вы знаете, что нужна переменная int
, то просто объявляйте ее с типом int
! Злоупотребление неявной типизацией в производственном коде (через ключевое слово var
) большинство разработчиков расценивают как плохой стиль кодирования.
Все языки программирования предлагают средства для повторения блоков кода до тех пор, пока не будет удовлетворено условие завершения. С каким бы языком вы не имели дело в прошлом, итерационные операторы C# не должны вызывать особого удивления или требовать лишь небольшого объяснения. В C# предоставляются четыре итерационные конструкции:
• цикл
for
;
• цикл
foreach/in
;
• цикл
while
;
• цикл
do/while
.
Давайте рассмотрим каждую конструкцию зацикливания по очереди, создав новый проект консольного приложения по имени
IterationsAndDecisions
.
На заметку! Материал данного раздела главы будет кратким и по существу, т.к. здесь предполагается наличие у вас опыта работы с аналогичными ключевыми словами (
if
, for
, switch
и т.д.) в другом языке программирования. Если нужна дополнительная информация, просмотрите темы "Iteration Statements (C# Reference)" ("Операторы итераций (справочник по С#)"), "Jump Statements (C# Reference)" ("Операторы перехода (справочник по С#)") и "Selection Statements (C# Reference)" ("Операторы выбора (справочник по С#)") в документации по C# (https://docs.microsoft.com/ru-ru/dotnet/csharp/
).
Когда требуется повторять блок кода фиксированное количество раз, хороший уровень гибкости предлагает оператор
for
. В действительности вы имеете возможность указывать, сколько раз должен повторяться блок кода, а также задавать условие завершения. Не вдаваясь в излишние подробности, ниже представлен пример синтаксиса:
// Базовый цикл for.
static void ForLoopExample()
{
// Обратите внимание, что переменная i видима только в контексте цикла for.
for(int i = 0; i < 4; i++)
{
Console.WriteLine("Number is: {0} ", i);
}
// Здесь переменная i больше видимой не будет.
}
Все трюки, которые вы научились делать в языках С, C++ и Java, по-прежнему могут использоваться при формировании операторов
for
в С#. Допускается создавать сложные условия завершения, строить бесконечные циклы и циклы в обратном направлении (посредством операции --
), а также применять ключевые слова goto
, continue
и break
.
Ключевое слово
foreach
языка C# позволяет проходить в цикле по всем элементам внутри контейнера без необходимости в проверке верхнего предела. Тем не менее, в отличие от цикла for
цикл foreach
будет выполнять проход по контейнеру только линейным (п+1) образом (т.е. не получится проходить по контейнеру в обратном направлении, пропускать каждый третий элемент и т.п.).
Однако если нужно просто выполнить проход по коллекции элемент за элементом, то цикл
foreach
будет великолепным выбором. Ниже приведены два примера использования цикла foreach
— один для обхода массива строк и еще один для обхода массива целых чисел. Обратите внимание, что тип, указанный перед ключевым словом in
, представляет тип данных контейнера.
// Проход по элементам массива посредством foreach.
static void ForEachLoopExample()
{
string[] carTypes = {"Ford", "BMW", "Yugo", "Honda" };
foreach (string c in carTypes)
{
Console.WriteLine(c);
}
int[] myInts = { 10, 20, 30, 40 };
foreach (int i in myInts)
{
Console.WriteLine(i);
}
}
За ключевым словом
in
может быть указан простой массив (как в приведенном примере) или, точнее говоря, любой класс, реализующий интерфейс IEnumerable
. Как вы увидите в главе 10, библиотеки базовых классов .NET Core поставляются с несколькими коллекциями, которые содержат реализации распространенных абстрактных типов данных. Любой из них (скажем, обобщенный тип List
) может применяться внутри цикла foreach
.
В итерационных конструкциях
foreach
также допускается использование неявной типизации. Как и можно было ожидать, компилятор будет выводить корректный "вид типа". Вспомните пример метода LINQ, представленный ранее в главе. Даже не зная точного типа данных переменной subset
, с применением неявной типизации все-таки можно выполнять итерацию по результирующему набору. Поместите в начало файла следующий оператор using
:
using System.Linq;
static void LinqQueryOverInts()
{
int[] numbers = { 10, 20, 30, 40, 1, 2, 3, 8 };
// Запрос LINQ!
var subset = from i in numbers where i < 10 select i;
Console.Write("Values in subset: ");
foreach (var i in subset)
{
Console.Write("{0} ", i);
}
}
Итерационная конструкция
while
удобна, когда блок операторов должен выполняться до тех пор, пока не будет удовлетворено некоторое условие завершения. Внутри области видимости цикла while
необходимо позаботиться о том, чтобы это условие действительно удовлетворялось, иначе получится бесконечный цикл. В следующем примере сообщение "In while loop"
будет постоянно выводиться на консоль, пока пользователь не завершит цикл вводом yes
в командной строке:
static void WhileLoopExample()
{
string userIsDone = "";
// Проверить копию строки в нижнем регистре.
while(userIsDone.ToLower() != "yes")
{
Console.WriteLine("In while loop");
Console.Write("Are you done? [yes] [no]: "); // Запрос продолжения
userIsDone = Console.ReadLine();
}
}
С циклом
while
тесно связан оператор do/while
. Подобно простому циклу while
цикл do/while
используется, когда какое-то действие должно выполняться неопределенное количество раз. Разница в том, что цикл do/while
гарантирует, по крайней мере, однократное выполнение своего внутреннего блока кода. С другой стороны, вполне возможно, что цикл while
вообще не выполнит блок кода, если условие оказывается ложным с самого начала.
static void DoWhileLoopExample()
{
string userIsDone = "";
do
{
Console.WriteLine("In do/while loop");
Console.Write("Are you done? [yes] [no]: ");
userIsDone = Console.ReadLine();
}while(userIsDone.ToLower() != "yes"); // Обратите внимание на точку с запятой!
}
Как и во всех языках, основанных на С (С#, Java и т.д.), область видимости создается с применением фигурных скобок. Вы уже видели это во многих примерах, приведенных до сих пор, включая пространства имен, классы и методы. Конструкции итерации и принятия решений также функционируют в области видимости, что иллюстрируется в примере ниже:
for(int i = 0; i < 4; i++)
{
Console.WriteLine("Number is: {0} ", i);
}
Для таких конструкций (в предыдущем и следующем разделах) законно не использовать фигурные скобки. Другими словами, показанный далее код будет в точности таким же, как в примере выше:
for(int i = 0; i < 4; i++)
Console.WriteLine("Number is: {0} ", i);
Хотя фигурные скобки разрешено не указывать, обычно поступать так — не лучшая идея. Проблема не с однострочным оператором, а с оператором, который начинается в одной строке и продолжается в нескольких строках. В отсутствие фигурных скобок можно допустить ошибки при расширении кода внутри конструкций итерации или принятия решений. Скажем, приведенные ниже два примера — не одинаковы:
for(int i = 0; i < 4; i++)
{
Console.WriteLine("Number is: {0} ", i);
Console.WriteLine("Number plus 1 is: {0} ", i+1)
}
for(int i = 0; i < 4; i++)
Console.WriteLine("Number is: {0} ", i);
Console.WriteLine("Number plus 1 is: {0} ", i+1)
Если вам повезет (как в этом примере), то дополнительная строка кода вызовет ошибку на этапе компиляции, поскольку переменная
i
определена только в области видимости цикла for
. Если же не повезет, тогда вы выполните код, не помеченный как ошибка на этапе компиляции, но является логической ошибкой, которую труднее найти и устранить.
Теперь, когда вы умеете многократно выполнять блок операторов, давайте рассмотрим следующую связанную концепцию — управление потоком выполнения программы. Для изменения потока выполнения программы на основе разнообразных обстоятельств в C# определены две простые конструкции:
• оператор
if/else
;
• оператор
switch
.
На заметку! В версии C# 7 выражение
is
и операторы switch
расширяются посредством приема, называемого сопоставлением с образцом. Ради полноты здесь приведены основы того, как эти расширения влияют на операторы if/else
и switch
. Расширения станут более понятными после чтения главы 6, где рассматриваются правила для базовых и производных классов, приведение и стандартная операция is
.
Первым мы рассмотрим оператор
if/else
. В отличие от С и C++ оператор if/else
в языке C# может работать только с булевскими выражениями, но не с произвольными значениями вроде -1
и 0
.
Обычно для получения литерального булевского значения в операторах
if/else
применяются операции, описанные в табл. 3.8.
И снова программисты на С и C++ должны помнить о том, что старые трюки с проверкой условия, которое включает значение, не равное нулю, в языке C# работать не будут. Пусть необходимо проверить, содержит ли текущая строка более нуля символов. У вас может возникнуть соблазн написать такой код:
static void IfElseExample()
{
// This is illegal, given that Length returns an int, not a bool.
string stringData = "My textual data";
if(stringData.Length)
{
// Строка длиннее 0 символов
Console.WriteLine("string is greater than 0 characters");
}
else
{
// Строка не длиннее 0 символов
Console.WriteLine("string is not greater than 0 characters");
}
Console.WriteLine();
}
Если вы хотите использовать свойство
String.Length
для определения истинности или ложности, тогда выражение в условии понадобится изменить так, чтобы оно давало в результате булевское значение:
// Допустимо, т.к. условие возвращает true или false.
If (stringData.Length > 0)
{
Console.WriteLine("string is greater than 0 characters");
}
В версии C# 7.0 появилась возможность применять в операторах
if/else
сопоставление с образцом, которое позволяет коду инспектировать объект на наличие определенных особенностей и свойств и принимать решение на основе их существования (или не существования). Не стоит беспокоиться, если вы не знакомы с объектно-ориентированным программированием; смысл предыдущего предложения станет ясен после чтения последующих глав. Пока просто имейте в виду, что вы можете проверять тип объекта с применением ключевого слова is
, присваивать данный объект переменной в случае соответствия образцу и затем использовать эту переменную.
Метод
IfElsePatternMatching()
исследует две объектные переменные и выясняет, имеют ли они тип string
либо int
, после чего выводит результаты на консоль:
static void IfElsePatternMatching()
{
Console.WriteLine("===If Else Pattern Matching ===/n");
object testItem1 = 123;
object testItem2 = "Hello";
if (testItem1 is string myStringValue1)
{
Console.WriteLine($"{myStringValue1} is a string");
// testIteml имеет тип string
}
if (testItem1 is int myValue1)
{
Console.WriteLine($"{myValue1} is an int");
// testIteml имеет тип int
}
if (testItem2 is string myStringValue2)
{
Console.WriteLine($"{myStringValue2} is a string");
// testItem2 имеет тип string
}
if (testItem2 is int myValue2)
{
Console.WriteLine($"{myValue2} is an int");
// testItem2 имеет тип int
}
Console.WriteLine();
}
В версии C# 9.0 внесено множество улучшений в сопоставление с образцом, как показано в табл. 3.9.
В модифицированном методе
IfElsePatternMatchingUpdatedInCSharp9()
новые образцы демонстрируются в действии:
static void IfElsePatternMatchingUpdatedInCSharp9()
{
Console.WriteLine("================ C# 9
If Else Pattern Matching Improvements
===============/n");
object testItem1 = 123;
Type t = typeof(string);
char c = 'f';
// Образцы типов
if (t is Type)
{
Console.WriteLine($"{t} is a Type");
// t является Type
}
// Относительные, конъюнктивные и дизъюнктивные образцы
if (c is >= 'a' and <= 'z' or >= 'A' and <= 'Z')
{
Console.WriteLine($"{c} is a character");
// с является символом
};
//Parenthesized patterns
if (c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '.' or ',')
{
Console.WriteLine($"{c} is a character or separator");
// c является символом или разделителем
};
//Negative patterns
if (testItem1 is not string)
{
Console.WriteLine($"{testItem1} is not a string");
// с не является строкой
}
if (testItem1 is not null)
{
Console.WriteLine($"{testItem1} is not null");
// с не является null
}
Console.WriteLine();
}
Условная операция (
?:
), также называемая тернарной условной операцией, является сокращенным способом написания простого оператора if/else
. Вот ее синтаксис:
условие ? первое_выражение : второе_выражение;
Условие представляет собой условную проверку (часть
if
оператора if/else
). Если проверка проходит успешно, тогда выполняется код, следующий сразу за знаком вопроса (?
). Если результат проверки отличается от true
, то выполняется код, находящийся после двоеточия (часть else
оператора if/else
). Приведенный ранее пример кода можно было бы переписать с применением условной операции:
static void ExecuteIfElseUsingConditionalOperator()
{
string stringData = "My textual data";
Console.WriteLine(stringData.Length > 0
? "string is greater than 0 characters" // строка длиннее 0 символов
: "string is not greater than 0 characters"); // строка не длиннее 0 символов
Console.WriteLine();
}
С условной операцией связаны некоторые ограничения. Во-первых, типы конструкций
первое_выражение
и второе_выражение
должны иметь неявные преобразования из одной в другую или, что является нововведением в версии C# 9.0, каждая обязана поддерживать неявное преобразование в целевой тип.
Во-вторых, условная операция может использоваться только в операторах присваивания. Следующий код приведет к выдаче на этапе компиляции сообщения об ошибке "Only assignment, call, increment, decrement, and new object expressions can be used as a statement" (В качестве оператора могут применяться только выражения присваивания, вызова, инкремента, декремента и создания объекта):
stringData.Length > 0
? Console.WriteLine("string is greater than 0 characters")
: Console.WriteLine("string is not greater than 0 characters");
В версии C# 7.2 появилась возможность использования условной операции для возвращения ссылки на результат условия. В следующем примере задействованы две формы условной операции:
static void ConditionalRefExample()
{
var smallArray = new int[] { 1, 2, 3, 4, 5 };
var largeArray = new int[] { 10, 20, 30, 40, 50 };
int index = 7;
ref int refValue = ref ((index < 5)
? ref smallArray[index]
: ref largeArray[index - 5]);
refValue = 0;
index = 2;
((index < 5)
? ref smallArray[index]
: ref largeArray[index - 5]) = 100;
Console.WriteLine(string.Join(" ", smallArray));
Console.WriteLine(string.Join(" ", largeArray));
}
Если вы не знакомы с ключевым словом
ref
, то переживать пока не стоит, т.к. оно будет подробно раскрыто в следующей главе. В первом примере возвращается ссылка на местоположение массива с условием, которая присваивается переменной refValue
. С концептуальной точки зрения считайте ссылку указателем на позицию в массиве, а не на фактическое значение, которое в ней находится. Это позволяет изменять значение в позиции массива напрямую, изменяя значение, которое присвоено переменной refValue
. Результатом установки значения переменной refValue
в 0
будет изменение значений второго массива: 10,20,0,40,50. Во втором примере значение во второй позиции первого массива изменяется на 100, давая в результате 1,2,100,4,5.
Для выполнения более сложных проверок оператор
if
может также включать сложные выражения и содержать операторы else
. Синтаксис идентичен своим аналогам в языках С (C++) и Java. Для построения сложных выражений язык C# предлагает вполне ожидаемый набор логических операций, которые описан в табл. 3.10.
На заметку! Операции
&&
и ||
при необходимости поддерживают сокращенный путь выполнения. Другими словами, после того, как было определено, что сложное выражение должно дать в результате false
, оставшиеся подвыражения вычисляться не будут. Если требуется, чтобы все выражения вычислялись безотносительно к чему-либо, тогда можно использовать операции &
и |
.
Еще одной простой конструкцией C# для реализации выбора является оператор
switch
. Как и в остальных основанных на С языках, оператор switch
позволяет организовать выполнение программы на основе заранее определенного набора вариантов. Например, в следующем коде для каждого из двух возможных вариантов выводится специфичное сообщение (блок default
обрабатывает недопустимый выбор):
// Switch on a numerical value.
static void SwitchExample()
{
Console.WriteLine("1 [C#], 2 [VB]");
Console.Write("Please pick your language preference: ");
// Выберите предпочитаемый язык:
string langChoice = Console.ReadLine();
int n = int.Parse(langChoice);
switch (n)
{
case 1:
Console.WriteLine("Good choice, C# is a fine language.");
// Хороший выбор. C# - замечательный язык.
break;
case 2:
Console.WriteLine("VB: OOP, multithreading, and more!");
// VB: ООП, многопоточность и многое другое!
break;
default:
Console.WriteLine("Well...good luck with that!");
// Что ж... удачи с этим!
break;
}
}
На заметку! Язык C# требует, чтобы каждый блок
case
(включая default
), который содержит исполняемые операторы, завершался оператором return
, break
или goto
во избежание сквозного прохода по блокам.
Одна из замечательных особенностей оператора
switch
в C# связана с тем, что вдобавок к числовым значениям он позволяет оценивать данные string
. На самом деле все версии C# способны оценивать типы данных char
, string
, bool
, int
, long
и enum
. В следующем разделе вы увидите, что в версии C# 7 появились дополнительные возможности. Вот модифицированная версия оператора switch
, которая оценивает переменную типа string
:
static void SwitchOnStringExample()
{
Console.WriteLine("C# or VB");
Console.Write("Please pick your language preference: ");
string langChoice = Console.ReadLine();
switch (langChoice.ToUpper())
{
case "C#":
Console.WriteLine("Good choice, C# is a fine language.");
break;
case "VB":
Console.WriteLine("VB: OOP, multithreading and more!");
break;
default:
Console.WriteLine("Well...good luck with that!");
break;
}
}
Оператор
switch
также может применяться с перечислимым типом данных. Как будет показано в главе 4, ключевое слово enum
языка C# позволяет определять специальный набор пар "имя-значение". В качестве иллюстрации рассмотрим вспомогательный метод SwitchOnEnumExample()
, который выполняет проверку switch
для перечисления System.DayOfWeek
. Пример содержит ряд синтаксических конструкций, которые пока еще не рассматривались, но сосредоточьте внимание на самом использовании switch
с типом enum
; недостающие фрагменты будут прояснены в последующих главах.
static void SwitchOnEnumExample()
{
Console.Write("Enter your favorite day of the week: ");
// Введите любимый день недели:
DayOfWeek favDay;
try
{
favDay = (DayOfWeek) Enum.Parse(typeof(DayOfWeek), Console.ReadLine());
}
catch (Exception)
{
Console.WriteLine("Bad input!");
// Недопустимое входное значение!
return;
}
switch (favDay)
{
case DayOfWeek.Sunday:
Console.WriteLine("Football!!");
// Футбол! !
break;
case DayOfWeek.Monday:
Console.WriteLine("Another day, another dollar");
// Еще один день, еще один доллар.
break;
case DayOfWeek.Tuesday:
Console.WriteLine("At least it is not Monday");
// Во всяком случае, не понедельник.
break;
case DayOfWeek.Wednesday:
Console.WriteLine("A fine day.");
// Хороший денек.
break;
case DayOfWeek.Thursday:
Console.WriteLine("Almost Friday...");
// Почти пятница...
break;
case DayOfWeek.Friday:
Console.WriteLine("Yes, Friday rules!");
// Да, пятница рулит!
break;
case DayOfWeek.Saturday:
Console.WriteLine("Great day indeed.");
// Действительно великолепный день.
break;
}
Console.WriteLine();
}
Сквозной проход от одного оператора
case
к другому оператору case
не разрешен, но что, если множество операторов case
должны вырабатывать тот же самый результат? К счастью, их можно комбинировать, как демонстрируется ниже:
case DayOfWeek.Saturday:
case DayOfWeek.Sunday:
Console.WriteLine("It’s the weekend!");
break;
Помещение любого кода между операторами
case
приведет к тому, что компилятор сообщит об ошибке. До тех пор, пока операторы case следуют друг за другом, как показано выше, их можно комбинировать для разделения общего кода.
В дополнение к операторам
return
и break
, показанным в предшествующих примерах кода, оператор switch
также поддерживает применение goto
для выхода из условия case
и выполнения другого оператора case
. Несмотря на наличие поддержки, данный прием почти повсеместно считается антипаттерном и в общем случае не рекомендуется. Ниже приведен пример использования оператора goto
в блоке switch
:
static void SwitchWithGoto()
{
var foo = 5;
switch (foo)
{
case 1:
// Делать что-то
goto case 2;
case 2:
// Делать что-то другое
break;
case 3:
// Еще одно действие
goto default;
default:
// Стандартное действие
break;
}
}
До выхода версии C# 7 сопоставляющие выражения в операторах
switch
ограничивались сравнением переменной с константными значениями, что иногда называют образцом с константами. В C# 7 операторы switch
способны также задействовать образец с типами, при котором операторы case
могут оценивать тип проверяемой переменной, и выражения case
больше не ограничиваются константными значениями. Правило относительно того, что каждый оператор case
должен завершаться с помощью return
или break
, по-прежнему остается в силе; тем не менее, операторы goto
не поддерживают применение образца с типами.
На заметку! Если вы новичок в объектно-ориентированном программировании, тогда материал этого раздела может слегка сбивать с толку. Все прояснится в главе 6, когда мы вернемся к новым средствам сопоставления с образцом C# 7 в контексте базовых и производных классов. Пока вполне достаточно понимать, что появился мощный новый способ написания операторов
switch
.
Добавьте еще один метод по имени
ExecutePatternMatchingSwitch()
со следующим кодом:
static void ExecutePatternMatchingSwitch()
{
Console.WriteLine("1 [Integer (5)], 2 [String (\"Hi\")], 3 [Decimal (2.5)]");
Console.Write("Please choose an option: ");
string userChoice = Console.ReadLine();
object choice;
// Стандартный оператор switch, в котором применяется
// сопоставление с образцом с константами
switch (userChoice)
{
case "1":
choice = 5;
break;
case "2":
choice = "Hi";
break;
case "3":
choice = 2.5;
break;
default:
choice = 5;
break;
}
// Новый оператор switch, в котором применяется
// сопоставление с образцом с типами
switch (choice)
{
case int i:
Console.WriteLine("Your choice is an integer.");
// Выбрано целое число
break;
case string s:
Console.WriteLine("Your choice is a string.");
// Выбрана строка
break;
case decimal d:
Console.WriteLine("Your choice is a decimal.");
// Выбрано десятичное число
break;
default:
Console.WriteLine("Your choice is something else");
// Выбрано что-то другое
break;
}
Console.WriteLine();
}
В первом операторе
switch
используется стандартный образец с константами; он включен только ради полноты этого (тривиального) примера. Во втором операторе switch
переменная типизируется как object
и на основе пользовательского ввода может быть разобрана в тип данных int
, string
или decimal
. В зависимости от типа переменной совпадения дают разные операторы case
. Вдобавок к проверке типа данных в каждом операторе case
выполняется присваивание переменной (кроме случая default
). Модифицируйте код, чтобы задействовать значения таких переменных:
// Новый оператор switch, в котором применяется
// сопоставление с образцом с типами
switch (choice)
{
case int i:
Console.WriteLine("Your choice is an integer {0}.",i);
break;
case string s:
Console.WriteLine("Your choice is a string. {0}", s);
break;
case decimal d:
Console.WriteLine("Your choice is a decimal. {0}", d);
break;
default:
Console.WriteLine("Your choice is something else");
break;
}
Кроме оценки типа сопоставляющего выражения к операторам
case
могут быть добавлены конструкции when
для оценки условий на переменной. В представленном ниже примере в дополнение к проверке типа производится проверка на совпадение преобразованного типа:
static void ExecutePatternMatchingSwitchWithWhen()
{
Console.WriteLine("1 [C#], 2 [VB]");
Console.Write("Please pick your language preference: ");
object langChoice = Console.ReadLine();
var choice = int.TryParse(langChoice.ToString(),
out int c) ? c : langChoice;
switch (choice)
{
case int i when i == 2:
case string s when s.Equals("VB", StringComparison.OrdinalIgnoreCase):
Console.WriteLine("VB: OOP, multithreading, and more!");
// VB: ООП, многопоточность и многое другое!
break;
case int i when i == 1:
case string s when s.Equals("C#", StringComparison.OrdinalIgnoreCase):
Console.WriteLine("Good choice, C# is a fine language.");
// Хороший выбор. C# - замечательный язык.
break;
default:
Console.WriteLine("Well...good luck with that!");
// Хорошо, удачи с этим!
break;
}
Console.WriteLine();
}
Здесь к оператору
switch
добавляется новое измерение, поскольку порядок следования операторов case
теперь важен. При использовании образца с константами каждый оператор case
обязан быть уникальным. В случае применения образца с типами это больше не так. Например, следующий код будет давать совпадение для каждого целого числа в первом операторе case
, а второй и третий оператор case
никогда не выполнятся (на самом деле такой код даже не скомпилируется):
switch (choice)
{
case int i:
//do something
break;
case int i when i == 0:
//do something
break;
case int i when i == -1:
// do something
break;
}
В первоначальном выпуске C# 7 возникало небольшое затруднение при сопоставлении с образцом, когда в нем использовались обобщенные типы. В версии C# 7.1 проблема была устранена. Обобщенные типы рассматриваются в главе 10.
На заметку! Все продемонстрированные ранее улучшения сопоставления с образцом в C# 9.0 также можно применять в операторах
switch
.
В версии C# 8 появились выражения
switch
, позволяющие присваивать значение переменной в лаконичном операторе. Рассмотрим версию C# 7 метода FromRainbowClassic()
, который принимает имя цвета и возвращает для него шестнадцатеричное значение:
static string FromRainbowClassic(string colorBand)
{
switch (colorBand)
{
case "Red":
return "#FF0000";
case "Orange":
return "#FF7F00";
case "Yellow":
return "#FFFF00";
case "Green":
return "#00FF00";
case "Blue":
return "#0000FF";
case "Indigo":
return "#4B0082";
case "Violet":
return "#9400D3";
default:
return "#FFFFFF";
};
}
С помощью новых выражений
switch
в C# 8 код предыдущего метода можно переписать следующим образом, сделав его гораздо более лаконичным:
static string FromRainbow(string colorBand)
{
return colorBand switch
{
"Red" => "#FF0000",
"Orange" => "#FF7F00",
"Yellow" => "#FFFF00",
"Green" => "#00FF00",
"Blue" => "#0000FF",
"Indigo" => "#4B0082",
"Violet" => "#9400D3",
_ => "#FFFFFF",
};
}
В приведенном примере присутствует много непонятного, начиная с лямбда-операции (
=>
) и заканчивая отбрасыванием (_
). Все это будет раскрыто позже в книге и данный пример окончательно прояснится.
Перед тем, как завершить обсуждение темы выражений
switch
, давайте рассмотрим еще один пример, в котором вовлечены кортежи. Кортежи подробно раскрываются в главе 4, а пока считайте кортеж простой конструкцией, которая содержит более одного значения и определяется посредством круглых скобок, подобно следующему кортежу, содержащему значения string
и int
:
(string, int)
В показанном ниже примере два значения, передаваемые методу
RockPapeScissors()
, преобразуются в кортеж, после чего выражение switch
вычисляет два значения в единственном выражении. Такой прием позволяет сравнивать в операторе switch
более одного выражения:
//Switch expression with Tuples
static string RockPaperScissors(string first, string second)
{
return (first, second) switch
{
("rock", "paper") => "Paper wins.",
("rock", "scissors") => "Rock wins.",
("paper", "rock") => "Paper wins.",
("paper", "scissors") => "Scissors wins.",
("scissors", "rock") => "Rock wins.",
("scissors", "paper") => "Scissors wins.",
(_, _) => "Tie.",
};
}
Чтобы вызвать метод
RockPaperScissors()
, добавьте в метод Main()
следующие строки кода:
Console.WriteLine(RockPaperScissors("paper","rock"));
Console.WriteLine(RockPaperScissors("scissors","rock"));
Мы еще вернемся к этому примеру в главе 4, где будут представлены кортежи.
Цель настоящей главы заключалась в демонстрации многочисленных ключевых аспектов языка программирования С#. Мы исследовали привычные конструкции, которые могут быть задействованы при построении любого приложения. После ознакомления с ролью объекта приложения вы узнали о том, что каждая исполняемая программа на C# должна иметь тип, определяющий метод
Main()
, либо явно, либо с использованием операторов верхнего уровня. Данный метод служит точкой входа в программу.
Затем были подробно описаны встроенные типы данных C# и разъяснено, что применяемые для их представления ключевые слова (например,
int
) на самом деле являются сокращенными обозначениями полноценных типов из пространства имен System
(System.Int32
в данном случае). С учетом этого каждый тип данных C# имеет набор встроенных членов. Кроме того, обсуждалась роль расширения и сужения, а также ключевых слов checked
и unchecked
.
В завершение главы рассматривалась роль неявной типизации с использованием ключевого слова
var
. Как было отмечено, неявная типизация наиболее полезна при работе с моделью программирования LINQ. Наконец, мы бегло взглянули на различные конструкции С#, предназначенные для организации циклов и принятия решений.
Теперь, когда вы понимаете некоторые базовые механизмы, в главе 4 завершится исследование основных средств языка. После этого вы будете хорошо подготовлены к изучению объектно-ориентированных возможностей С#, которое начнется в главе 5.
В настоящей главе завершается обзор основных аспектов языка программирования С#, который был начат в главе 3. Первым делом мы рассмотрим детали манипулирования массивами с использованием синтаксиса C# и продемонстрируем функциональность, содержащуюся внутри связанного класса
System.Array
.
Далее мы выясним различные подробности, касающиеся построения методов, за счет исследования ключевых слов
out
, ref
и params
. В ходе дела мы объясним роль необязательных и именованных параметров. Обсуждение темы методов завершится перегрузкой методов.
Затем будет показано, как создавать типы перечислений и структур, включая детальное исследование отличий между типами значений и ссылочными типами. В конце главы объясняется роль типов данных, допускающих
null
, и связанных с ними операций.
После освоения материала главы вы можете смело переходить к изучению объектно-ориентированных возможностей языка С#, рассмотрение которых начнется в главе 5.
Как вам уже наверняка известно, массив — это набор элементов данных, для доступа к которым применяется числовой индекс. Выражаясь более конкретно, массив представляет собой набор расположенных рядом элементов данных одного и того же типа (массив элементов
int
, массив элементов string
, массив элементов SportsCar
и т.д.). Объявлять, заполнять и получать доступ к массиву в языке C# довольно просто. В целях иллюстрации создайте новый проект консольного приложения по имени FunWithArrays
, содержащий вспомогательный метод SimpleArrays()
:
Console.WriteLine("***** Fun with Arrays *****");
SimpleArrays();
Console.ReadLine();
static void SimpleArrays()
{
Console.WriteLine("=> Simple Array Creation.");
// Создать и заполнить массив из 3 целых чисел.
int[] myInts = new int[3];
// Создать строковый массив из 100 элементов с индексами 0 - 99.
string[] booksOnDotNet = new string[100];
Console.WriteLine();
}
Внимательно взгляните на комментарии в коде. При объявлении массива C# с использованием подобного синтаксиса число, указанное в объявлении, обозначает общее количество элементов, а не верхнюю границу. Кроме того, нижняя граница в массиве всегда начинается с
0
. Таким образом, в результате записи int[] myInts = new int[3]
получается массив, который содержит три элемента, проиндексированные по позициям 0
, 1
, 2
.
После определения переменной массива можно переходить к заполнению элементов от индекса к индексу, как показано ниже в модифицированном методе
SimpleArrays()
:
static void SimpleArrays()
{
Console.WriteLine("=> Simple Array Creation.");
// Создать и заполнить массив из трех целочисленных значений.
int[] myInts = new int[3];
myInts[0] = 100;
myInts[1] = 200;
myInts[2] = 300;
// Вывести все значения.
foreach(int i in myInts)
{
Console.WriteLine(i);
}
Console.WriteLine();
}
На заметку! Имейте в виду, что если массив объявлен, но его элементы явно не заполнены по каждому индексу, то они получат стандартное значение для соответствующего типа данных (например, элементы массива
bool
будут установлены в false
, а элементы массива int
— в 0
).
В дополнение к заполнению массива элемент за элементом есть также возможность заполнять его с применением синтаксиса инициализации массивов. Для этого понадобится указать значения всех элементов массива в фигурных скобках (
{}
). Такой синтаксис удобен при создании массива известного размера, когда нужно быстро задать его начальные значения. Например, вот как выглядят альтернативные версии объявления массива:
static void ArrayInitialization()
{
Console.WriteLine("=> Array Initialization.");
// Синтаксис инициализации массивов с использованием ключевого слова new.
string[] stringArray = new string[]
{ "one", "two", "three" };
Console.WriteLine("stringArray has {0} elements", stringArray.Length);
// Синтаксис инициализации массивов без использования ключевого слова new.
bool[] boolArray = { false, false, true };
Console.WriteLine("boolArray has {0} elements", boolArray.Length);
// Инициализация массива с применением ключевого слова new и указанием размера.
int[] intArray = new int[4] { 20, 22, 23, 0 };
Console.WriteLine("intArray has {0} elements", intArray.Length);
Console.WriteLine();
}
Обратите внимание, что в случае использования синтаксиса с фигурными скобками нет необходимости указывать размер массива (как видно на примере создания переменной
stringArray
), поскольку размер автоматически вычисляется на основе количества элементов внутри фигурных скобок. Кроме того, применять ключевое слово new
не обязательно (как при создании массива boolArray
).
В случае объявления
intArray
снова вспомните, что указанное числовое значение представляет количество элементов в массиве, а не верхнюю границу. Если объявленный размер и количество инициализаторов не совпадают (инициализаторов слишком много или не хватает), тогда на этапе компиляции возникнет ошибка. Пример представлен ниже:
// Несоответствие размера и количества элементов!
int[] intArray = new int[2] { 20, 22, 23, 0 };
В главе 3 рассматривалась тема неявно типизированных локальных переменных. Как вы помните, ключевое слово var позволяет определять переменную, тип которой выводится компилятором. Аналогичным образом ключевое слово
var
можно использовать для определения неявно типизированных локальных массивов. Такой подход позволяет выделять память под новую переменную массива, не указывая тип элементов внутри массива (обратите внимание, что применение этого подхода предусматривает обязательное использование ключевого слова new
):
static void DeclareImplicitArrays()
{
Console.WriteLine("=> Implicit Array Initialization.");
// Переменная а на самом деле имеет тип int[].
var a = new[] { 1, 10, 100, 1000 };
Console.WriteLine("a is a: {0}", a.ToString());
// Переменная b на самом деле имеет тип doublet].
var b = new[] { 1, 1.5, 2, 2.5 };
Console.WriteLine("b is a: {0}", b.ToString());
// Переменная с на самом деле имеет тип string [].
var c = new[] { "hello", null, "world" };
Console.WriteLine("c is a: {0}", c.ToString());
Console.WriteLine();
}
Разумеется, как и при создании массива с применением явного синтаксиса С#, элементы в списке инициализации массива должны принадлежать одному и тому же типу (например, должны быть все
int
, все string
или все SportsCar
). В отличие от возможных ожиданий, неявно типизированный локальный массив не получает по умолчанию тип System.Object
, так что следующий код приведет к ошибке на этапе компиляции:
// Ошибка! Смешанные типы!
var d = new[] { 1, "one", 2, "two", false };
В большинстве случаев массив определяется путем указания явного типа элементов, которые могут в нем содержаться. Хотя это выглядит довольно прямолинейным, существует одна важная особенность. Как будет показано в главе 6, изначальным базовым классом для каждого типа (включая фундаментальные типы данных) в системе типов .NET Core является
System.Object
. С учетом такого факта, если определить массив типа данных System.Object
, то его элементы могут представлять все что угодно. Взгляните на следующий метод ArrayOfObjects()
:
static void ArrayOfObjects()
{
Console.WriteLine("=> Array of Objects.");
// Массив объектов может содержать все что угодно.
object[] myObjects = new object[4];
myObjects[0] = 10;
myObjects[1] = false;
myObjects[2] = new DateTime(1969, 3, 24);
myObjects[3] = "Form & Void";
foreach (object obj in myObjects)
{
// Вывести тип и значение каждого элемента в массиве.
Console.WriteLine("Type: {0}, Value: {1}", obj.GetType(), obj);
}
Console.WriteLine();
}
Здесь во время прохода по содержимому массива
myObjects
для каждого элемента выводится лежащий в основе тип, получаемый с помощью метода GetType()
класса System.Object
, и его значение.
Не вдаваясь пока в детали работы метода
System.Object.GetType()
, просто отметим, что он может использоваться для получения полностью заданного имени элемента (службы извлечения информации о типах и рефлексии исследуются в главе 17). Приведенный далее вывод является результатом вызова метода ArrayOfObjects()
:
=> Array of Objects.
Type: System.Int32, Value: 10
Type: System.Boolean, Value: False
Type: System.DateTime, Value: 3/24/1969 12:00:00 AM
Type: System.String, Value: Form & Void
В дополнение к одномерным массивам, которые вы видели до сих пор, в языке C# поддерживаются два вида многомерных массивов. Первый вид называется прямоугольным массивом, который имеет несколько измерений, а содержащиеся в нем строки обладают одной и той же длиной. Прямоугольный многомерный массив объявляется и заполняется следующим образом:
static void RectMultidimensionalArray()
{
Console.WriteLine("=> Rectangular multidimensional array.");
// Прямоугольный многомерный массив.
int[,] myMatrix;
myMatrix = new int[3,4];
// Заполнить массив (3 * 4).
for(int i = 0; i < 3; i++)
{
for(int j = 0; j < 4; j++)
{
myMatrix[i, j] = i * j;
}
}
// Вывести содержимое массива (3 * 4).
for(int i = 0; i < 3; i++)
{
for(int j = 0; j < 4; j++)
{
Console.Write(myMatrix[i, j] + "\t");
}
Console.WriteLine();
}
Console.WriteLine();
}
Второй вид многомерных массивов носит название зубчатого (или ступенчатого) массива. Такой массив содержит какое-то число внутренних массивов, каждый из которых может иметь отличающийся верхний предел. Вот пример:
static void JaggedMultidimensionalArray()
{
Console.WriteLine("=> Jagged multidimensional array.");
// Зубчатый многомерный массив (т.е. массив массивов).
// Здесь мы имеем массив из 5 разных массивов.
int[][] myJagArray = new int[5][];
// Создать зубчатый массив.
for (int i = 0; i < myJagArray.Length; i++)
{
myJagArray[i] = new int[i + 7];
}
// Вывести все строки (помните, что каждый элемент имеет
// стандартное значение 0).
for(int i = 0; i < 5; i++)
{
for(int j = 0; j < myJagArray[i].Length; j++)
{
Console.Write(myJagArray[i][j] + " ");
}
Console.WriteLine();
}
Console.WriteLine();
}
Ниже показан вывод, полученный в результате вызова методов
RectMultidimensionalArray()
и JaggedMultidimensionalArray()
:
=> Rectangular multidimensional array:
0 0 0 0
0 1 2 3
0 2 4 6
=> Jagged multidimensional array:
0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0
После создания массив можно передавать как аргумент или получать его в виде возвращаемого значения. Например, приведенный ниже метод
PrintArray()
принимает входной массив значений int
и выводит все его элементы на консоль, а метод GetStringArray()
заполняет массив значений string
и возвращает его вызывающему коду:
static void PrintArray(int[] myInts)
{
for(int i = 0; i < myInts.Length; i++)
{
Console.WriteLine("Item {0} is {1}", i, myInts[i]);
}
}
static string[] GetStringArray()
{
string[] theStrings = {"Hello", "from", "GetStringArray"};
return theStrings;
}
Указанные методы вызываются вполне ожидаемо:
static void PassAndReceiveArrays()
{
Console.WriteLine("=> Arrays as params and return values.");
// Передать массив в качестве параметра.
int[] ages = {20, 22, 23, 0} ;
PrintArray(ages);
// Получить массив как возвращаемое значение.
string[] strs = GetStringArray();
foreach(string s in strs)
{
Console.WriteLine(s);
}
Console.WriteLine();
}
К настоящему моменту вы должны освоить процесс определения, заполнения и исследования содержимого переменной типа массива С#. Для полноты картины давайте проанализируем роль класса
System.Array
.
Каждый создаваемый массив получает значительную часть своей функциональности от класса
System.Array
. Общие члены этого класса позволяют работать с массивом, применяя согласованную объектную модель. В табл. 4.1 приведено краткое описание наиболее интересных членов класса System.Array
(полное описание всех его членов можно найти в документации).
Давайте посмотрим на некоторые из членов в действии. Показанный далее вспомогательный метод использует статические методы
Reverse()
и Clear()
для вывода на консоль информации о массиве строковых типов:
static void SystemArrayFunctionality()
{
Console.WriteLine("=> Working with System.Array.");
// Инициализировать элементы при запуске.
string[] gothicBands = {"Tones on Tail", "Bauhaus", "Sisters of Mercy"};
// Вывести имена в порядке их объявления.
Console.WriteLine("-> Here is the array:");
for (int i = 0; i < gothicBands.Length; i++)
{
// Вывести имя.
Console.Write(gothicBands[i] + ", ");
}
Console.WriteLine("\n");
// Обратить порядок следования элементов...
Array.Reverse(gothicBands);
Console.WriteLine("-> The reversed array");
// ...и вывести их.
for (int i = 0; i < gothicBands.Length; i++)
{
// Вывести имя.
Console.Write(gothicBands[i] + ", ");
}
Console.WriteLine("\n");
// Удалить все элементы кроме первого.
Console.WriteLine("-> Cleared out all but one...");
Array.Clear(gothicBands, 1, 2);
for (int i = 0; i < gothicBands.Length; i++)
{
// Вывести имя.
Console.Write(gothicBands[i] + ", ");
}
Console.WriteLine();
}
Вызов метода
SystemArrayFunctionality()
дает в результате следующий вывод:
=> Working with System.Array.
-> Here is the array:
Tones on Tail, Bauhaus, Sisters of Mercy,
-> The reversed array
Sisters of Mercy, Bauhaus, Tones on Tail,
-> Cleared out all but one...
Sisters of Mercy,,,
Обратите внимание, что многие члены класса
System.Array
определены как статические и потому вызываются на уровне класса (примерами могут служить методы Array.Sort()
и Array.Reverse()
). Методам подобного рода передается массив, подлежащий обработке. Другие члены System.Array
(такие как свойство Length
) действуют на уровне объекта, поэтому могут вызываться прямо на типе массива.
Для упрощения работы с последовательностями (включая массивы) в версии C# 8 были введены два новых типа и две новых операции, применяемые при работе с массивами:
•
System.Index
представляет индекс в последовательности;
•
System.Range
представляет поддиапазон индексов;
• операция конца (
^
) указывает, что индекс отсчитывается относительно конца последовательности;
• операция диапазона (
...
) устанавливает в своих операндах начало и конец диапазона.
На заметку! Индексы и диапазоны можно использовать с массивами, строками,
Span
и ReadOnlySpan
.
Как вы уже видели, индексация массивов начинается с нуля (
0
). Конец последовательности — это длина последовательности минус единица. Показанный выше цикл for
, который выводил содержимое массива gothicBands
, можно записать по-другому:
for (int i = 0; i < gothicBands.Length; i++)
{
Index idx = i;
// Вывести имя.
Console.Write(gothicBands[idx] + ", ");
}
Индекс с операцией конца позволяет указывать количество позиций, которые необходимо отсчитать от конца последовательности, начиная с длины. Не забывайте, что последний элемент в последовательности находится в позиции, на единицу меньше длины последовательности, поэтому
^0
приведет к ошибке. В следующем коде элементы массива выводятся в обратном порядке:
for (int i = 1; i <= gothicBands.Length; i++)
{
Index idx = ^i;
// Вывести имя.
Console.Write(gothicBands[idx] + ", ");
}
Операция диапазона определяет начальный и конечный индексы и обеспечивает доступ к подпоследовательности внутри списка. Начало диапазона является включающим, а конец — исключающим. Например, чтобы извлечь первые два элемента массива, создайте диапазон от 0 (позиция первого элемента) до 2 (на единицу больше желаемой позиции):
foreach (var itm in gothicBands[0..2])
{
// Вывести имя.
Console.Write(itm + ", ");
}
Console.WriteLine("\n");
Диапазоны можно передавать последовательности также с использованием нового типа данных
Range
, как показано ниже:
Range r = 0..2; //the end of the range is exclusive
foreach (var itm in gothicBands[r])
{
// Вывести имя.
Console.Write(itm + ", ");
}
Console.WriteLine("\n");
Диапазоны можно определять с применением целых чисел или переменных типа
Index
. Тот же самый результат будет получен посредством следующего кода:
Index idx1 = 0;
Index idx2 = 2;
Range r = idx1..idx2; // Конец диапазона является исключающим.
foreach (var itm in gothicBands[r])
{
// Вывести имя.
Console.Write(itm + ", ");
}
Console.WriteLine("\n");
Если не указано начало диапазона, тогда используется начало последовательности. Если не указан конец диапазона, тогда применяется длина диапазона. Ошибка не возникает, т.к. конец диапазона является исключающим. В предыдущем примере с массивом, содержащим три элемента, все диапазоны представляют одно и то же подмножество:
gothicBands[..]
gothicBands[0..^0]
gothicBands[0..3]
Давайте займемся исследованием деталей определения методов. Методы определяются модификатором доступа и возвращаемым типом (или
void
, если ничего не возвращается) и могут принимать параметры или не принимать их. Метод, который возвращает значение вызывающему коду, обычно называется функцией, а метод, не возвращающий значение, как правило, называют собственно методом.
На заметку! Модификаторы доступа для методов (и классов) раскрываются в главе 5. Параметры методов рассматриваются в следующем разделе.
До настоящего момента в книге каждый из рассматриваемых методов следовал такому базовому формату:
// Вспомните, что статические методы могут вызываться
// напрямую без создания экземпляра класса,
class Program
{
// static воэвращаемыйТип ИмяМетода(список параметров)
// { /* Реализация */ }
static int Add(int x, int y)
{
return x + y;
}
}
В нескольких последующих главах вы увидите, что методы могут быть реализованы внутри области видимости классов, структур или интерфейсов (нововведение версии C# 8).
Вы уже знаете о простых методах, возвращающих значения, вроде метода
Add()
. В версии C# 6 появились члены, сжатые до выражений, которые сокращают синтаксис написания однострочных методов. Например, вот как можно переписать метод Add()
:
static int Add(int x, int y) => x + y;
Обычно такой прием называют "синтаксическим сахаром", имея в виду, что генерируемый код IL не изменяется по сравнению с первоначальной версией метода. Он является всего лишь другим способом написания метода. Одни находят его более легким для восприятия, другие — нет, так что выбор стиля зависит от ваших персональных предпочтений (или предпочтений команды разработчиков).
На заметку! Не пугайтесь операции
=>
. Это лямбда-операция, которая подробно рассматривается в главе 12, где также объясняется, каким образом работают члены, сжатые до выражений. Пока просто считайте их сокращением при написании однострочных операторов.
В версии C# 7.0 появилась возможность создавать методы внутри методов, которые официально называются локальными функциями. Локальная функция является функцией, объявленной внутри другой функции, она обязана быть закрытой, в версии C# 8.0 может быть статической (как демонстрируется в следующем разделе) и не поддерживает перегрузку. Локальные функции допускают вложение: внутри одной локальной функции может быть объявлена еще одна локальная функция.
Чтобы взглянуть на средство локальных функций в действии, создайте новый проект консольного приложения по имени
FunWithLocalFunctions
. Предположим, что вы хотите расширить используемый ранее пример с методом Add()
для включения проверки достоверности входных данных. Задачу можно решить многими способами, простейший из которых предусматривает добавление логики проверки достоверности прямо в сам метод Add()
. Модифицируйте предыдущий пример следующим образом (логика проверки достоверности представлена комментарием):
static int Add(int x, int y)
{
// Здесь должна выполняться какая-то проверка достоверности.
return x + y;
}
Как видите, крупных изменений здесь нет. Есть только комментарий, в котором указано, что реальный код должен что-то делать. А что, если вы хотите отделить фактическую реализацию цели метода (возвращение суммы аргументов) от логики проверки достоверности аргументов? Вы могли бы создать дополнительные методы и вызывать их из метода
Add()
. Но это потребовало бы создания еще одного метода для использования только в методе Add()
. Такое решение может оказаться излишеством. Локальные функции позволяют сначала выполнять проверку достоверности и затем инкапсулировать реальную цель метода, определенного внутри метода AddWrapper()
:
static int AddWrapper(int x, int y)
{
// Здесь должна выполняться какая-то проверка достоверности.
return Add();
int Add()
{
return x + y;
}
}
Содержащийся в
AddWrapper()
метод Add()
можно вызывать лишь из объемлющего метода AddWrapper()
. Почти наверняка вас интересует, что это вам дало? В приведенном примере мало что (если вообще что-либо). Но если функцию Add()
нужно вызывать во многих местах метода AddWrapper()
? И вот теперь вы должны осознать, что наличие локальной функции, не видимой за пределами того места, где она необходима, содействует повторному использованию кода. Вы увидите еще больше преимуществ, обеспечиваемых локальными функциями, когда мы будем рассматривать специальные итераторные методы (в главе 8) и асинхронные методы (в главе 15).
На заметку!
AddWrapper()
является примером локальной функции с вложенной локальной функцией. Вспомните, что функции, объявляемые в операторах верхнего уровня, создаются как локальные функции. Локальная функция Add()
находится внутри локальной функции AddWrapper()
. Такая возможность обычно не применяется за рамками учебных примеров, но если вам когда-нибудь понадобятся вложенные локальные функции, то вы знаете, что они поддерживаются в С#.
В версии C# 9.0 локальные функции обновлены, чтобы позволить добавлять атрибуты к самой локальной функции, ее параметрам и параметрам типов, как показано далее в примере (не беспокойтесь об атрибуте
NotNullWhen
, который будет раскрыт позже в главе):
#nullable enable
private static void Process(string?[] lines, string mark)
{
foreach (var line in lines)
{
if (IsValid(line))
{
// Логика обработки. ..
}
}
bool IsValid([NotNullWhen(true)] string? line)
{
return !string.IsNullOrEmpty(line) && line.Length >= mark.Length;
}
}
В версии C# 8 средство локальных функций было усовершенствовано — появилась возможность объявлять локальную функцию как статическую. В предыдущем примере внутри локальной функции
Add()
производилась прямая ссылка на переменные из главной функции. Результатом могут стать неожиданные побочные эффекты, поскольку локальная функция способна изменять значения этих переменных.
Чтобы увидеть возможные побочные эффекты в действии, создайте новый метод по имени
AddWrapperWithSideEffeet()
с таким кодом:
static int AddWrapperWithSideEffect(int x, int y)
{
// Здесь должна выполняться какая-то проверка достоверности
return Add();
int Add()
{
x += 1;
return x + y;
}
}
Конечно, приведенный пример настолько прост, что вряд ли что-то подобное встретится в реальном коде. Для предотвращения ошибки подобного рода добавьте к локальной функции модификатор
static
. Это не позволит локальной функции получать прямой доступ к переменным родительского метода, генерируя на этапе компиляции исключение CS8421, "A static local function cannot contain a reference to ‘<имя переменной>’" (Статическая локальная функция не может содержать ссылку на ‘<имя переменной>’).
Ниже показана усовершенствованная версия предыдущего метода:
static int AddWrapperWithStatic(int x, int y)
{
// Здесь должна выполняться какая-то проверка достоверности
return Add(x,y);
static int Add(int x, int y)
{
return x + y;
}
}
Параметры методов применяются для передачи данных вызову метода. В последующих разделах вы узнаете детали того, как методы (и вызывающий их код) обрабатывают параметры.
Стандартный способ передачи параметра в функцию — по значению. Попросту говоря, если вы не помечаете аргумент каким-то модификатором параметра, тогда в функцию передается копия данных. Как объясняется далее в главе, то, что в точности копируется, будет зависеть от того, относится параметр к типу значения или к ссылочному типу.
Хотя определение метода в C# выглядит достаточно понятно, с помощью модификаторов, описанных в табл. 4.2, можно управлять способом передачи аргументов методу.
Чтобы проиллюстрировать использование перечисленных ключевых слов, создайте новый проект консольного приложения по имени
FunWithMethods
. А теперь давайте рассмотрим их роль.
Когда параметр не имеет модификатора, поведение для типов значений предусматривает передачу параметра по значению, а для ссылочных типов — по ссылке.
На заметку! Типы значений и ссылочные типы рассматриваются позже в главе.
По умолчанию параметр типа значения передается функции по значению. Другими словами, если параметр не помечен каким-либо модификатором, то в функцию передается копия данных. Добавьте в класс
Program
следующий метод, который оперирует с двумя параметрами числового типа, передаваемыми по значению:
// По умолчанию аргументы типов значений передаются по значению.
static int Add(int x, int y)
{
int ans = x + y;
// Вызывающий код не увидит эти изменения,
// т.к. модифицируется копия исходных данных
// original data.
x = 10000;
y = 88888;
return ans;
}
Числовые данные относятся к категории типов значений. Следовательно, в случае изменения значений параметров внутри контекста члена вызывающий код будет оставаться в полном неведении об этом, потому что изменения вносятся только в копию первоначальных данных из вызывающего кода:
Console.WriteLine("***** Fun with Methods *****\n");
// Передать две переменные по значению.
int x = 9, y = 10;
Console.WriteLine("Before call: X: {0}, Y: {1}", x, y);
// Значения перед вызовом
Console.WriteLine("Answer is: {0}", Add(x, y));
// Результат сложения
Console.WriteLine("After call: X: {0}, Y: {1}", x, y);
// Значения после вызова
Console.ReadLine();
Как видно в показанном далее выводе, значения
х
и у
вполне ожидаемо остаются идентичными до и после вызова метода Add()
, поскольку элементы данных передавались по значению. Таким образом, любые изменения параметров, производимые внутри метода Add()
, вызывающему коду не видны, т.к. метод Add()
оперирует на копии данных.
***** Fun with Methods *****
Before call: X: 9, Y: 10
Answer is: 19
After call: X: 9, Y: 10
Стандартный способ, которым параметр ссылочного типа отправляется функции, предусматривает передачу по ссылке для его свойств, но передачу по значению для него самого. Детали будут представлены позже в главе после объяснения типов значений и ссылочных типов.
На заметку! Несмотря на то что строковый тип данных формально относится к ссылочным типам, как обсуждалось в главе 3, он является особым случаем. Когда строковый параметр не имеет какого-либо модификатора, он передается по значению.
Теперь мы рассмотрим выходные параметры. Перед покиданием области видимости метода, который был определен для приема выходных параметров (посредством ключевого слова
out
), им должны присваиваться соответствующие значения (иначе компилятор сообщит об ошибке). В целях демонстрации ниже приведена альтернативная версия метода AddUsingOutParam()
, которая возвращает сумму двух целых чисел с применением модификатора out
(обратите внимание, что возвращаемым значением метода теперь является void
):
// Значения выходных параметров должны быть
// установлены внутри вызываемого метода.
static void AddUsingOutParam(int x, int y, out int ans)
{
ans = x + y;
}
Вызов метода с выходными параметрами также требует использования модификатора
out
.Однако предварительно устанавливать значения локальных переменных, которые передаются в качестве выходных параметров, вовсе не обязательно (после вызова эти значения все равно будут утрачены). Причина, по которой компилятор позволяет передавать на первый взгляд неинициализированные данные, связана с тем, что вызываемый метод обязан выполнить присваивание. Чтобы вызвать обновленный метод AddUsingOutParam()
, создайте переменную типа int
и примените в вызове модификатор out
:
int ans;
AddUsingOutParam(90, 90, out ans);
Начиная с версии C# 7.0, больше нет нужды объявлять параметры
out
до их применения. Другими словами, они могут объявляться внутри вызова метода:
AddUsingOutParam(90, 90, out int ans);
В следующем коде представлен пример вызова метода с встраиваемым объявлением параметра
out
:
Console.WriteLine("***** Fun with Methods *****");
// Присваивать начальные значения локальным переменным, используемым
// как выходные параметры, не обязательно при условии, что они
// применяются в таком качестве впервые.
// Версия C# 7 позволяет объявлять параметры out в вызове метода.
AddUsingOutParam(90, 90, out int ans);
Console.WriteLine("90 + 90 = {0}", ans);
Console.ReadLine();
Предыдущий пример по своей природе предназначен только для иллюстрации; на самом деле нет никаких причин возвращать значение суммы через выходной параметр. Тем не менее, модификатор out в C# служит действительно практичной цели: он позволяет вызывающему коду получать несколько выходных значений из единственного вызова метода:
// Возвращение множества выходных параметров.
static void FillTheseValues(out int, out string b, out bool c)
{
a = 9;
b = "Enjoy your string.";
c = true;
}
Теперь вызывающий код имеет возможность обращаться к методу
FillTheseValues()
. Не забывайте, что модификатор out
должен применяться как при вызове, так и при реализации метода:
Console.WriteLine("***** Fun with Methods *****");
FillTheseValues(out int i, out string str, out bool b);
Console.WriteLine("Int is: {0}", i); // Вывод целочисленного значения
Console.WriteLine("String is: {0}", str); // Вывод строкового значения
Console.WriteLine("Boolean is: {0}", b); // Вывод булевского значения
Console.ReadLine();
На заметку! В версии C# 7 также появились кортежи, представляющие собой еще один способ возвращения множества значений из вызова метода. Они будут описаны далее в главе.
Всегда помните о том, что перед выходом из области видимости метода, определяющего выходные параметры, этим параметрам должны быть присвоены допустимые значения. Таким образом, следующий код вызовет ошибку на этапе компиляции, потому что внутри метода отсутствует присваивание значения выходному параметру:
static void ThisWontCompile(out int a)
{
Console.WriteLine("Error! Forgot to assign output arg!");
// Ошибка! Забыли присвоить значение выходному параметру!
}
Если значение параметра
out
не интересует, тогда в качестве заполнителя можно использовать отбрасывание. Отбрасывания представляют собой временные фиктивные переменные, которые намеренно не используются. Их присваивание не производится, они не имеют значения и для них может вообще не выделяться память. Отбрасывание способно обеспечить выигрыш в производительности, а также сделать код более читабельным. Его можно применять с параметрами out
, кортежами (как объясняется позже в главе), сопоставлением с образцом (см. главы 6 и 8) или даже в качестве автономных переменных.
Например, если вы хотите получить значение для
int
в предыдущем примере, но остальные два параметра вас не волнуют, тогда можете написать такой код:
// Здесь будет получено значение только для а;
// значения для других двух параметров игнорируются.
FillTheseValues(out int a, out _, out _);
Обратите внимание, что вызываемый метод по-прежнему выполняет работу, связанную с установкой значений для всех трех параметров; просто последние два параметра отбрасываются, когда происходит возврат из вызова метода.
В версии C# 7.3 были расширены допустимые местоположения для использования параметра
out
. В дополнение к методам параметры конструкторов, инициализаторы полей и свойств, а также конструкции запросов можно декорировать модификатором out
. Примеры будут представлены позже в главе.
А теперь посмотрим, как в C# используется модификатор
ref
. Ссылочные параметры необходимы, когда вы хотите разрешить методу манипулировать различными элементами данных (и обычно изменять их значения), которые объявлены в вызывающем коде, таком как процедура сортировки или обмена.
Обратите внимание на отличия между ссылочными и выходными параметрами.
• Выходные параметры не нуждаются в инициализации перед передачей методу. Причина в том, что метод до своего завершения обязан самостоятельно присваивать значения выходным параметрам.
• Ссылочные параметры должны быть инициализированы перед передачей методу. Причина связана с передачей ссылок на существующие переменные. Если начальные значения им не присвоены, то это будет равнозначно работе с неинициализированными локальными переменными.
Давайте рассмотрим применение ключевого слова
ref
на примере метода, меняющего местами значения двух переменных типа string
(естественно, здесь мог бы использоваться любой тип данных, включая int
, bool
, float
и т.д.):
// Ссылочные параметры.
public static void SwapStrings(ref string s1, ref string s2)
{
string tempStr = s1;
s1 = s2;
s2 = tempStr;
}
Метод
SwapStrings()
можно вызвать следующим образом:
Console.WriteLine("***** Fun with Methods *****");
string str1 = "Flip";
string str2 = "Flop";
Console.WriteLine("Before: {0}, {1} ", str1, str2); // До
SwapStrings(ref str1, ref str2);
Console.WriteLine("After: {0}, {1} ", str1, str2); // После
Console.ReadLine();
Здесь вызывающий код присваивает начальные значения локальным строковым данным (
str1
и str2
). После вызова метода SwapStrings()
строка str1
будет содержать значение "Flop"
, а строка str2
— значение "Flip"
:
Before: Flip, Flop
After: Flop, Flip
Модификатор
in
обеспечивает передачу значения по ссылке (для типов значений и ссылочных типов) и препятствует модификации значений в вызываемом методе. Это четко формулирует проектный замысел в коде, а также потенциально снижает нагрузку на память. Когда параметры типов значений передаются по значению, они (внутренне) копируются вызываемым методом. Если объект является большим (вроде крупной структуры), тогда добавочные накладные расходы на создание копии для локального использования могут оказаться значительными. Кроме того, даже когда параметры ссылочных типов передаются без модификатора, в вызываемом методе их можно модифицировать. Обе проблемы решаются с применением модификатора in
.
В рассмотренном ранее методе
Add()
есть две строки кода, которые изменяют параметры, но не влияют на значения для вызывающего метода. Влияние на значения отсутствует из-за того, что метод Add()
создает копию переменных х
и у
с целью локального использования. Пока вызывающий метод не имеет неблагоприятных побочных эффектов, но что произойдет, если бы код метода Add()
был таким, как показано ниже?
static int Add2(int x,int y)
{
x = 10000;
y = 88888;
int ans = x + y;
return ans;
}
В данном случае метод возвращает значение
98888
независимо от переданных ему чисел, что очевидно представляет собой проблему. Чтобы устранить ее, код метода понадобится изменить следующим образом:
static int AddReadOnly(in int x,in int y)
{
// Ошибка CS8331 Cannot assign to variable 'in int'
// because it is a readonly variable
// He удается присвоить значение переменной in int,
// поскольку она допускает только чтение
// х = 10000;
// у = 88888;
int ans = x + y;
return ans;
}
Когда в коде предпринимается попытка изменить значения параметров, компилятор сообщит об ошибке CS8331, указывая на то, что значения не могут быть изменены из-за наличия модификатора
in
.
В языке C# поддерживаются массивы параметров с использованием ключевого слова
params
, которое позволяет передавать методу переменное количество идентично типизированных параметров (или классов, связанных отношением наследования) в виде единственного логического параметра. Вдобавок аргументы, помеченные ключевым словом params
, могут обрабатываться, когда вызывающий код передает строго типизированный массив или список элементов, разделенных запятыми. Да, это может сбивать с толку! В целях прояснения предположим, что вы хотите создать функцию, которая позволяет вызывающему коду передавать любое количество аргументов и возвращает их среднее значение.
Если вы прототипируете данный метод так, чтобы он принимал массив значений
double
, тогда в вызывающем коде придется сначала определить массив, затем заполнить его значениями и, наконец, передать его методу. Однако если вы определите метод CalculateAverage()
как принимающий параметр params
типа double[]
, то вызывающий код может просто передавать список значений double
, разделенных запятыми. "За кулисами" список значений double
будет упакован в массив типа double
.
// Возвращение среднего из некоторого количества значений double.
static double CalculateAverage(params double[] values)
{
Console.WriteLine("You sent me {0} doubles.", values.Length);
double sum = 0;
if(values.Length == 0)
{
return sum;
}
for (int i = 0; i < values.Length; i++)
{
sum += values[i];
}
return (sum / values.Length);
}
Метод
CalculateAverage()
был определен для приема массива параметров типа double
. Фактически он ожидает передачи любого количества (включая ноль) значений double
и вычисляет их среднее. Метод может вызываться любым из показанных далее способов:
Console.WriteLine("***** Fun with Methods *****");
// Передать список значений double, разделенных запятыми...
double average;
average = CalculateAverage(4.0, 3.2, 5.7, 64.22, 87.2);
// Вывод среднего значения для переданных данных
Console.WriteLine("Average of data is: {0}", average);
// ...или передать массив значений double.
double[] data = { 4.0, 3.2, 5.7 };
average = CalculateAverage(data);
// Вывод среднего значения для переданных данных
Console.WriteLine("Average of data is: {0}", average);
// Среднее из 0 равно 0!
// Вывод среднего значения для переданных данных
Console.WriteLine("Average of data is: {0}", CalculateAverage());
Console.ReadLine();
Если модификатор
params
в определении метода CalculateAverage()
не задействован, тогда его первый вызов приведет к ошибке на этапе компиляции, т.к. компилятору не удастся найти версию CalculateAverage()
, принимающую пять аргументов типа double
.
На заметку! Во избежание любой неоднозначности язык C# требует, чтобы метод поддерживал только один параметр
params
, который должен быть последним в списке параметров.
Как и можно было догадаться, данный прием — всего лишь удобство для вызывающего кода, потому что .NET Core Runtime создает массив по мере необходимости. В момент, когда массив окажется внутри области видимости вызываемого метода, его можно трактовать как полноценный массив .NET Core, обладающий всей функциональностью базового библиотечного класса
System.Array
. Взгляните на вывод:
You sent me 5 doubles.
Average of data is: 32.864
You sent me 3 doubles.
Average of data is: 4.3
You sent me 0 doubles.
Average of data is: 0
Язык C# дает возможность создавать методы, которые могут принимать необязательные аргументы. Такой прием позволяет вызывать метод, опуская ненужные аргументы, при условии, что подходят указанные для них стандартные значения.
Для иллюстрации работы с необязательными аргументами предположим, что имеется метод по имени
EnterLogData()
с одним необязательным параметром:
static void EnterLogData(string message, string owner = "Programmer")
{
Console.Beep();
Console.WriteLine("Error: {0}", message); // Сведения об ошибке
Console.WriteLine("Owner of Error: {0}", owner); // Владелец ошибки
}
Здесь последнему аргументу
string
было присвоено стандартное значение "Programmer"
через операцию присваивания внутри определения параметров. В результате метод EnterLogData()
можно вызывать двумя способами:
Console.WriteLine("***** Fun with Methods *****");
...
EnterLogData("Oh no! Grid can't find data");
EnterLogData("Oh no! I can't find the payroll data", "CFO");
Console.ReadLine();
Из-за того, что в первом вызове
EnterLogData()
не был указан второй аргумент string
, будет использоваться его стандартное значение — "Programmer"
. Во втором вызове EnterLogData()
для второго аргумента передано значение "CFO"
.
Важно понимать, что значение, присваиваемое необязательному параметру, должно быть известно на этапе компиляции и не может вычисляться во время выполнения (если вы попытаетесь сделать это, то компилятор сообщит об ошибке). В целях иллюстрации модифицируйте метод
EnterLogData()
, добавив к нему дополнительный необязательный параметр:
// Ошибка! Стандартное значение для необязательного
// аргумента должно быть известно на этапе компиляции!
static void EnterLogData(string message,
string owner = "Programmer", DateTime timeStamp =
DateTime.Now)
{
Console.Beep();
Console.WriteLine("Error: {0}", message); // Сведения об ошибке
Console.WriteLine("Owner of Error: {0}", owner); // Владелец ошибки
Console.WriteLine("Time of Error: {0}", timeStamp);
// Время возникновения ошибки
}
Такой код не скомпилируется, поскольку значение свойства
Now
класса DateTime
вычисляется во время выполнения, а не на этапе компиляции.
На заметку! Во избежание неоднозначности необязательные параметры должны всегда помещаться в конец сигнатуры метода. Если необязательные параметры обнаруживаются перед обязательными, тогда компилятор сообщит об ошибке.
Еще одним полезным языковым средством C# является поддержка именованных аргументов. Именованные аргументы позволяют вызывать метод с указанием значений параметров в любом желаемом порядке. Таким образом, вместо передачи параметров исключительно по позициям (как делается в большинстве случаев) можно указывать имя каждого аргумента, двоеточие и конкретное значение. Чтобы продемонстрировать использование именованных аргументов, добавьте в класс
Program
следующий метод:
static void DisplayFancyMessage(ConsoleColor textColor,
ConsoleColor backgroundColor, string message)
{
//Сохранить старые цвета для их восстановления после вывода сообщения.
ConsoleColor oldTextColor = Console.ForegroundColor;
ConsoleColor oldbackgroundColor = Console.BackgroundColor;
// Установить новые цвета и вывести сообщение.
Console.ForegroundColor = textColor;
Console.BackgroundColor = backgroundColor;
Console.WriteLine(message);
// Восстановить предыдущие цвета.
Console.ForegroundColor = oldTextColor;
Console.BackgroundColor = oldbackgroundColor;
}
Теперь, когда метод
DisplayFancyMessage()
написан, можно было бы ожидать, что при его вызове будут передаваться две переменные типа ConsoleColor
, за которыми следует переменная типа string
. Однако с помощью именованных аргументов метод DisplayFancyMessage()
допустимо вызывать и так, как показано ниже:
Console.WriteLine("***** Fun with Methods *****");
DisplayFancyMessage(message: "Wow! Very Fancy indeed!",
textColor: ConsoleColor.DarkRed,
backgroundColor: ConsoleColor.White);
DisplayFancyMessage(backgroundColor: ConsoleColor.Green,
message: "Testing...",
textColor: ConsoleColor.DarkBlue);
Console.ReadLine();
В версии C# 7.2 правила применения именованных аргументов слегка изменились. До выхода C# 7.2 при вызове метода позиционные параметры должны были располагаться перед любыми именованными параметрами. В C# 7.2 и последующих версиях именованные и неименованные параметры можно смешивать, если параметры находятся в корректных позициях.
На заметку! Хотя в C# 7.2 и последующих версиях именованные и позиционные аргументы можно смешивать, поступать так — не особо удачная идея. Возможность не значит обязательность!
Ниже приведен пример:
// Все нормально, т.к. позиционные аргументы находятся перед именованными.
DisplayFancyMessage(ConsoleColor.Blue,
message: "Testing...",
backgroundColor: ConsoleColor.White);
// Все нормально, т.к. все аргументы располагаются в корректном порядке.
DisplayFancyMessage(textColor: ConsoleColor.White,
backgroundColor:ConsoleColor.Blue,
"Testing...");
// ОШИБКА в вызове, поскольку позиционные аргументы следуют после именованных.
DisplayFancyMessage(message: "Testing...",
backgroundColor: ConsoleColor.White,
ConsoleColor.Blue);
Даже если оставить в стороне указанное ограничение, то все равно может возникать вопрос: при каких условиях вообще требуется такая языковая конструкция? В конце концов, для чего нужно менять позиции аргументов метода?
Как выясняется, при наличии метода, в котором определены необязательные аргументы, данное средство может оказаться по-настоящему полезным. Предположим, что метод
DisplayFancyMessage()
переписан с целью поддержки необязательных аргументов, для которых указаны подходящие стандартные значения:
static void DisplayFancyMessage(ConsoleColor textColor = ConsoleColor.Blue,
ConsoleColor backgroundColor = ConsoleColor.White,
string message = "Test Message")
{
...
}
Учитывая, что каждый аргумент имеет стандартное значение, именованные аргументы позволяют указывать в вызывающем коде только те параметры, которые не должны принимать стандартные значения. Следовательно, если нужно, чтобы значение
"Hello!"
появлялось в виде текста синего цвета на белом фоне, то в вызывающем коде можно просто записать так:
DisplayFancyMessage(message: "Hello!");
Если же необходимо, чтобы строка
"Test Message"
выводилась синим цветом на зеленом фоне, тогда должен применяться такой вызов:
DisplayFancyMessage(backgroundColor: ConsoleColor.Green);
Как видите, необязательные аргументы и именованные параметры часто работают бок о бок. В завершение темы построения методов C# необходимо ознакомиться с концепцией перегрузки методов.
Подобно другим современным языкам объектно-ориентированного программирования в C# разрешена перегрузка методов. Выражаясь просто, когда определяется набор идентично именованных методов, которые отличаются друг от друга количеством (или типами) параметров, то говорят, что такой метод был перегружен.
Чтобы оценить удобство перегрузки методов, давайте представим себя на месте разработчика, использующего Visual Basic 6.0 (VB6). Предположим, что на языке VB6 создается набор методов, возвращающих сумму значений разнообразных типов (
Integer
, Double
и т.д.). С учетом того, что VB6 не поддерживает перегрузку методов, придется определить уникальный набор методов, каждый из которых будет делать по существу одно и то же (возвращать сумму значений аргументов):
' Примеры кода VB6.
Public Function AddInts(ByVal x As Integer, ByVal y As Integer) As Integer
AddInts = x + y
End Function
Public Function AddDoubles(ByVal x As Double, ByVal y As Double) As Double
AddDoubles = x + y
End Function
Public Function AddLongs(ByVal x As Long, ByVal y As Long) As Long
AddLongs = x + y
End Function
Такой код не только становится трудным в сопровождении, но и заставляет помнить имена всех методов. Применяя перегрузку, вызывающему коду можно предоставить возможность обращения к единственному методу по имени
Add()
. Ключевой аспект в том, чтобы обеспечить для каждой версии метода отличающийся набор аргументов (различий только в возвращаемом типе не достаточно).
На заметку! Как будет объясняться в главе 10, существует возможность построения обобщенных методов, которые переносят концепцию перегрузки на новый уровень. Используя обобщения, можно определять заполнители типов для реализации метода, которая указывается во время его вызова.
Чтобы попрактиковаться с перегруженными методами, создайте новый проект консольного приложения по имени
FunWithMethodOverloading
. Добавьте новый класс по имени AddOperations.cs
и приведите его код к следующему виду:
namespace FunWithMethodOverloading {
// Код С#.
// Overloaded Add() method.
public static class AddOperations
{
// Перегруженный метод Add().
public static int Add(int x, int y)
{
return x + y;
}
// Перегруженный метод Add().
public static double Add(double x, double y)
{
return x + y;
}
// Перегруженный метод Add().
public static long Add(long x, long y)
{
return x + y;
}
}
}
Замените код в
Program.cs
показанным ниже кодом:
using System;
using FunWithMethodOverloading;
using static FunWithMethodOverloading.AddOperations;
Console.WriteLine("***** Fun with Method Overloading *****\n");
// Вызов версии int метода Add()
Console.WriteLine(Add(10, 10));
// Вызов версии long метода Add() с использованием нового
// разделителя групп цифр
Console.WriteLine(Add(900_000_000_000, 900_000_000_000));
// Вызов версии double метода Add()
Console.WriteLine(Add(4.3, 4.4));
Console.ReadLine();
На заметку! Оператор
using static
будет раскрыт в главе 5. Пока считайте его клавиатурным сокращением для использования методов, содержащихся в статическом классе по имени AddOperations
из пространства имен FunWithMethodOverloading
.
В операторах верхнего уровня вызываются три разных версии метода
Add()
с применением для каждой отличающегося типа данных.
Среды Visual Studio и Visual Studio Code оказывают помощь при вызове перегруженных методов. Когда вводится имя перегруженного метода (такого как хорошо знакомый метод
Console.WriteLine()
), средство IntelliSense отображает список всех его доступных версий. Обратите внимание, что по списку можно перемещаться с применением клавиш со стрелками вниз и вверх (рис. 4.1).
Если перегруженная версия принимает необязательные параметры, тогда компилятор будет выбирать метод, лучше всего подходящий для вызывающего кода, на основе именованных и/или позиционных аргументов. Добавьте следующий метод:
static int Add(int x, int y, int z = 0)
{
return x + (y*z);
}
Если необязательный аргумент в вызывающем коде не передается, то компилятор даст соответствие с первой сигнатурой (без необязательного параметра). Хотя существует набор правил для нахождения методов, обычно имеет смысл избегать создания методов, которые отличаются только необязательными параметрами.
Наконец,
in
, ref
и out
не считаются частью сигнатуры при перегрузке методов, когда используется более одного модификатора. Другими словами, приведенные ниже перегруженные версии будут приводить к ошибке на этапе компиляции:
static int Add(ref int x) { /* */ }
static int Add(out int x) { /* */ }
Однако если модификатор
in
, ref
или out
применяется только в одном методе, тогда компилятор способен проводить различие между сигнатурами. Таким образом, следующий код разрешен:
static int Add(ref int x) { /* */ }
static int Add(int x) { /* */ }
На этом начальное изучение построения методов с использованием синтаксиса C# завершено. Теперь давайте выясним, как строить перечисления и структуры и манипулировать ими.
Вспомните из главы 1, что система типов .NET Core состоит из классов, структур, перечислений, интерфейсов и делегатов. Чтобы начать исследование таких типов, рассмотрим роль перечисления (
епшп
), создав новый проект консольного приложения по имени FunWithEnums
.
На заметку! Не путайте термины перечисление и перечислитель; они обозначают совершенно разные концепции. Перечисление — специальный тип данных, состоящих из пар "имя-значение". Перечислитель — тип класса или структуры, который реализует интерфейс .NET Core по имени
IEnumerable
. Обычно упомянутый интерфейс реализуется классами коллекций, а также классом System.Array
. Как будет показано в главе 8, поддерживающие IEnumerable
объекты могут работать с циклами foreach
.
При построении какой-либо системы часто удобно создавать набор символических имен, которые отображаются на известные числовые значения. Например, в случае создания системы начисления заработной платы может возникнуть необходимость в ссылке на типы сотрудников с применением констант вроде
VicePresident
(вице-президент), Manager
(менеджер), Contractor
(подрядчик) и Grunt
(рядовой сотрудник). Для этой цели в C# поддерживается понятие специальных перечислений. Например, далее представлено специальное перечисление по имени EmpTypeEnum
(его можно определить в том же файле, где находятся операторы верхнего уровня, если определение будет помещено в конец файла):
using System;
Console.WriteLine("**** Fun with Enums *****\n");
Console.ReadLine();
// Здесь должны находиться локальные функции:
// Специальное перечисление.
enum EmpTypeEnum
{
Manager, // = 0
Grunt, // = 1
Contractor, // = 2
VicePresident // = 3
}
На заметку! По соглашению имена типов перечислений обычно снабжаются суффиксом
Enum
. Поступать так необязательно, но подобный подход улучшает читабельность кода.
В перечислении
EmpTypeEnum
определены четыре именованные константы, которые соответствуют дискретным числовым значениям. По умолчанию первому элементу присваивается значение 0
, а остальным элементам значения устанавливаются по схеме n+1. При желании исходное значение можно изменять подходящим образом. Например, если имеет смысл нумеровать члены EmpTypeEnum
со значения 102 до 105, тогда можно поступить следующим образом:
// Начать нумерацию со значения 102.
enum EmpTypeEnum
{
Manager = 102,
Grunt, // = 103
Contractor, // = 104
VicePresident // = 105
}
Нумерация в перечислениях не обязана быть последовательной и содержать только уникальные значения. Если (по той или иной причине) перечисление
EmpTypeEnum
необходимо сконфигурировать так, как показано ниже, то компиляция пройдет гладко и без ошибок:
// Значения элементов в перечислении не обязательно должны
// быть последовательными!
enum EmpType
{
Manager = 10,
Grunt = 1,
Contractor = 100,
VicePresident = 9
}
По умолчанию для хранения значений перечисления используется тип
System.Int32
(int
в языке С#); тем не менее, при желании его легко заменить. Перечисления в C# можно определять в похожей манере для любых основных системных типов (byte
, short
, int
или long
). Например, чтобы значения перечисления EmpTypeEnum
хранились с применением типа byte
, а не int
, можно записать так:
// На этот раз для элементов EmpTypeEnum используется тип byte.
enum EmpTypeEnum : byte
{
Manager = 10,
Grunt = 1,
Contractor = 100,
VicePresident = 9
}
Изменение типа, лежащего в основе перечисления, может быть полезным при построении приложения .NET Core, которое планируется развертывать на устройствах с небольшим объемом памяти, а потому необходимо экономить память везде, где только возможно. Конечно, если в качестве типа хранилища для перечисления указан
byte
, то каждое значение должно входить в диапазон его допустимых значений. Например, следующая версия EmpTypeEnum
приведет к ошибке на этапе компиляции, т.к. значение 999 не умещается в диапазон допустимых значений типа byte
:
// Ошибка на этапе компиляции! Значение 999 слишком велико для типа byte!
enum EmpTypeEnum : byte
{
Manager = 10,
Grunt = 1,
Contractor = 100,
VicePresident = 999
}
После установки диапазона и типа хранилища перечисление можно использовать вместо так называемых "магических чисел". Поскольку перечисления — всего лишь определяемые пользователем типы данных, их можно применять как возвращаемые значения функций, параметры методов, локальные переменные и т.д. Предположим, что есть метод по имени
AskForBonus()
, который принимает в качестве единственного параметра переменную EmpTypeEnum
. На основе значения входного параметра в окно консоли будет выводиться подходящий ответ на запрос о надбавке к зарплате.
Console.WriteLine("**** Fun with Enums *****");
// Создать переменную типа EmpTypeEnum.
EmpTypeEnum emp = EmpTypeEnum.Contractor;
AskForBonus(emp);
Console.ReadLine();
// Перечисления как параметры.
static void AskForBonus(EmpTypeEnum e)
{
switch (e)
{
case EmpType.Manager:
Console.WriteLine("How about stock options instead?");
// He желаете ли взамен фондовые опционы?
break;
case EmpType.Grunt:
Console.WriteLine("You have got to be kidding...");
// Вы должно быть шутите...
break;
case EmpType.Contractor:
Console.WriteLine("You already get enough cash...");
// Вы уже получаете вполне достаточно...
break;
case EmpType.VicePresident:
Console.WriteLine("VERY GOOD, Sir!");
// Очень хорошо, сэр!
break;
}
}
Обратите внимание, что когда переменной
enum
присваивается значение, вы должны указывать перед этим значением (Grunt
) имя самого перечисления (EmpTypeEnum
). Из-за того, что перечисления представляют собой фиксированные наборы пар "имя-значение", установка переменной enum
в значение, которое не определено прямо в перечислимом типе, не допускается:
static void ThisMethodWillNotCompile()
{
// Ошибка! SalesManager отсутствует в перечислении EmpTypeEnum!
EmpTypeEnum emp = EmpType.SalesManager;
// Ошибка! He указано имя EmpTypeEnum перед значением Grunt!
emp = Grunt;
}
С перечислениями .NET Core связан один интересный аспект — они получают свою функциональность от класса
System.Enum
. В классе System.Enum
определено множество методов, которые позволяют исследовать и трансформировать заданное перечисление. Одним из них является метод Enum.GetUnderlyingType()
, который возвращает тип данных, используемый для хранения значений перечислимого типа (System.Byte
в текущем объявлении EmpTypeEnum
):
Console.WriteLine("**** Fun with Enums *****");
...
// Вывести тип хранилища для значений перечисления.
Console.WriteLine("EmpTypeEnum uses a {0} for storage",
Enum.GetUnderlyingType(emp.GetType()));
Console.ReadLine();
Метод
Enum.GetUnderlyingType()
требует передачи System.Type
в качестве первого параметра. В главе 15 будет показано, что класс Туре
представляет описание метаданных для конкретной сущности .NET Core.
Один из возможных способов получения метаданных (как демонстрировалось ранее) предусматривает применение метода
GetType()
, который является общим для всех типов в библиотеках базовых классов .NET Core. Другой подход заключается в использовании операции typeof
языка С#. Преимущество такого способа связано с тем, что он не требует объявления переменной сущности, описание метаданных которой требуется получить:
// На этот раз для получения информации о типе используется операция typeof
Console.WriteLine("EmpTypeEnum uses a {0} for storage",
Enum.GetUnderlyingType(typeof(EmpTypeEnum)));
Кроме метода
Enum.GetUnderlyingType()
все перечисления C# поддерживают метод по имени ToString()
, который возвращает строковое имя текущего значения перечисления. Ниже приведен пример:
EmpTypeEnum emp = EmpTypeEnum.Contractor;
...
// Выводит строку "emp is a Contractor."
Console.WriteLine("emp is a {0}.", emp.ToString());
Console.ReadLine();
Если интересует не имя, а значение заданной переменной перечисления, то можно просто привести ее к лежащему в основе типу хранилища, например:
Console.WriteLine("**** Fun with Enums *****");
EmpTypeEnum emp = EmpTypeEnum.Contractor;
...
// Выводит строку "Contractor = 100".
Console.WriteLine("{0} = {1}", emp.ToString(), (byte)emp);
Console.ReadLine();
На заметку! Статический метод
Enum.Format()
предлагает более высокий уровень форматирования за счет указания флага желаемого формата. Полный список флагов форматирования ищите в документации.
В типе
System.Enum
определен еще один статический метод по имени GetValues()
, возвращающий экземпляр класса System.Array
. Каждый элемент в массиве соответствует члену в указанном перечислении. Рассмотрим следующий метод, который выводит на консоль пары "имя-значение" из перечисления, переданного в качестве параметра:
// Этот метод выводит детали любого перечисления.
static void EvaluateEnum(System.Enum e)
{
Console.WriteLine("=> Information about {0}", e.GetType().Name);
// Вывести лежащий в основе тип хранилища.
Console.WriteLine("Underlying storage type: {0}",
Enum.GetUnderlyingType(e.GetType()));
// Получить все пары "имя-значение" для входного параметра.
Array enumData = Enum.GetValues(e.GetType());
Console.WriteLine("This enum has {0} members.", enumData.Length);
// Вывести строковое имя и ассоциированное значение,
// используя флаг формата D (см. главу 3).
for(int i = 0; i < enumData.Length; i++)
{
Console.WriteLine("Name: {0}, Value: {0:D}",
enumData.GetValue(i));
}
}
Чтобы протестировать метод
EvaluateEnum()
, модифицируйте код для создания переменных нескольких типов перечислений, объявленных в пространстве имен System
(вместе с перечислением EmpTypeEnum
):
Console.WriteLine("**** Fun with Enums *****");
...
EmpTypeEnum e2 = EmpType.Contractor;
// Эти типы являются перечислениями из пространства имен System.
DayOfWeek day = DayOfWeek.Monday;
ConsoleColor cc = ConsoleColor.Gray;
EvaluateEnum(e2);
EvaluateEnum(day);
EvaluateEnum(cc);
Console.ReadLine();
Ниже показана часть вывода:
=> Information about DayOfWeek
Underlying storage type: System.Int32
This enum has 7 members.
Name: Sunday, Value: 0
Name: Monday, Value: 1
Name: Tuesday, Value: 2
Name: Wednesday, Value: 3
Name: Thursday, Value: 4
Name: Friday, Value: 5
Name: Saturday, Value: 6
В ходе чтения книги вы увидите, что перечисления широко применяются во всех библиотеках базовых классов .NET Core. При работе с любым перечислением всегда помните о возможности взаимодействия с парами "имя-значение", используя члены класса
System.Enum
.
Побитовые операции предлагают быстрый механизм для работы с двоичными числами на уровне битов. В табл. 4.3 представлены побитовые операции С#, описаны их действия и приведены примеры.
Чтобы взглянуть на побитовые операции в действии, создайте новый проект консольного приложения по имени
FunWithBitwiseOperations
. Поместите в файл Program.cs
следующий код:
using System;
using FunWithBitwiseOperations;
Console.WriteLine("===== Fun wih Bitwise Operations");
Console.WriteLine("6 & 4 = {0} | {1}", 6 & 4, Convert.ToString((6 & 4),2));
Console.WriteLine("6 | 4 = {0} | {1}", 6 | 4, Convert.ToString((6 | 4),2));
Console.WriteLine("6 ^ 4 = {0} | {1}", 6 ^ 4, Convert.ToString((6 ^ 4),2));
Console.WriteLine("6 << 1 = {0} | {1}", 6 << 1, Convert.ToString((6 << 1),2));
Console.WriteLine("6 >> 1 = {0} | {1}", 6 >> 1, Convert.ToString((6 >> 1),2));
Console.WriteLine("~6 = {0} | {1}", ~6, Convert.ToString(~((short)6),2));
Console.WriteLine("Int.MaxValue {0}", Convert.ToString((int.MaxValue),2));
Console.readLine();
Ниже показан результат выполнения этого кода:
===== Fun wih Bitwise Operations
6 & 4 = 4 | 100
6 | 4 = 6 | 110
6 ^ 4 = 2 | 10
6 << 1 = 12 | 1100
6 >> 1 = 3 | 11
~6 = -7 | 11111111111111111111111111111001
Int.MaxValue 1111111111111111111111111111111
Теперь, когда вам известны основы побитовых операций, самое время применить их к перечислениям. Добавьте в проект новый файл по имени
ContactPreferenceEnum.cs
и приведите его код к такому виду:
using System;
namespace FunWithBitwiseOperations
{
[Flags]
public enum ContactPreferenceEnum
{
None = 1,
Email = 2,
Phone = 4,
Ponyexpress = 6
}
}
Обратите внимание на атрибут
Flags
. Он позволяет объединять множество значений из перечисления в одной переменной. Скажем, вот как можно объединить Email
и Phone
:
ContactPreferenceEnum emailAndPhone = ContactPreferenceEnum.Email |
ContactPreferenceEnum.Phone;
В итоге появляется возможность проверки, присутствует ли одно из значений в объединенном значении. Например, если вы хотите выяснить, имеется ли значение
ContactPreference
в переменной emailAndPhone
, то можете написать такой код:
Console.WriteLine("None? {0}", (emailAndPhone |
ContactPreferenceEnum.None) == emailAndPhone);
Console.WriteLine("Email? {0}", (emailAndPhone |
ContactPreferenceEnum.Email) == emailAndPhone);
Console.WriteLine("Phone? {0}", (emailAndPhone |
ContactPreferenceEnum.Phone) == emailAndPhone);
Console.WriteLine("Text? {0}", (emailAndPhone |
ContactPreferenceEnum.Text) == emailAndPhone);
В результате выполнения кода в окне консоли появляется следующий вывод:
None? False
Email? True
Phone? True
Text? False
Теперь, когда вы понимаете роль типов перечислений, давайте посмотрим, как использовать структуры .NET Core. Типы структур хорошо подходят для моделирования в приложении математических, геометрических и других "атомарных" сущностей. Структура (такая как перечисление) — это определяемый пользователем тип; тем не менее, структура не является просто коллекцией пар "имя-значение". Взамен структуры представляют собой типы, которые могут содержать любое количество полей данных и членов, действующих на таких полях.
На заметку! Если вы имеете опыт объектно-ориентированного программирования, тогда можете считать структуры "легковесными типами классов", т.к. они предоставляют способ определения типа, который поддерживает инкапсуляцию, но не может использоваться для построения семейства взаимосвязанных типов. Когда возникает потребность в создании семейства типов, связанных отношением наследования, необходимо применять классы.
На первый взгляд процесс определения и использования структур выглядит простым, но, как часто бывает, самое сложное скрыто в деталях. Чтобы приступить к изучению основ типов структур, создайте новый проект по имени
FunWithStructures
. В языке C# структуры определяются с применением ключевого слова struct
. Определите новую структуру по имени Point
, представляющую точку, которая содержит две переменные типа int
и набор методов для взаимодействия с ними:
struct Point
{
// Поля структуры.
public int X;
public int Y;
// Добавить 1 к позиции (X, Y).
public void Increment()
{
X++; Y++;
}
// Вычесть 1 из позиции (X, Y).
public void Decrement()
{
X--; Y--;
}
// Отобразить текущую позицию.
public void Display()
{
Console.WriteLine("X = {0}, Y = {1}", X, Y);
}
}
Здесь определены два целочисленных поля (
X
и Y
) с использованием ключевого слова public
, которое является модификатором управления доступом (их обсуждение будет продолжено в главе 5). Объявление данных с ключевым словом public
обеспечивает вызывающему коду возможность прямого доступа к таким данным через переменную типа Point
(посредством операции точки).
На заметку! Определение открытых данных внутри класса или структуры обычно считается плохим стилем программирования. Взамен рекомендуется определять закрытые данные, доступ и изменение которых производится с применением открытых свойств. Более подробные сведения приведены в главе 5.
Вот код, который позволяет протестировать тип
Point
:
Console.WriteLine("***** A First Look at Structures *****\n");
// Создать начальную переменную типа Point.
Point myPoint;
myPoint.X = 349;
myPoint.Y = 76;
myPoint.Display();
// Скорректировать значения X и Y.
myPoint.Increment();
myPoint.Display();
Console.ReadLine();
Вывод выглядит вполне ожидаемо:
***** A First Look at Structures *****
X = 349, Y = 76
X = 350, Y = 77
Для создания переменной типа структуры на выбор доступно несколько вариантов. В следующем коде просто создается переменная типа
Point
и затем каждому ее открытому полю данных присваиваются значения до того, как обращаться к членам переменной. Если не присвоить значения открытым полям данных (X
и Y
в данном случае) перед использованием структуры, то компилятор сообщит об ошибке:
// Ошибка! Полю Y не присвоено значение.
Point p1;
p1.X = 10;
p1.Display();
// Все в порядке! Перед использованием значения присвоены обоим полям.
Point p2;
p2.X = 10;
p2.Y = 10;
p2.Display();
В качестве альтернативы переменные типа структур можно создавать с применением ключевого слова
new
языка С#, что приводит к вызову стандартного конструктора структуры. По определению стандартный конструктор не принимает аргументов. Преимущество вызова стандартного конструктора структуры заключается в том, что каждое поле данных автоматически получает свое стандартное значение:
// Установить для всех полей стандартные значения,
// используя стандартный конструктор.
Point p1 = new Point();
// Выводит Х=0, Y=0
p1.Display();
Допускается также проектировать структуры со специальным конструктором, что позволяет указывать значения для полей данных при создании переменной, а не устанавливать их по отдельности. Конструкторы подробно рассматриваются в главе 5; однако в целях иллюстрации измените структуру
Point
следующим образом:
struct Point
{
// Поля структуры.
public int X;
public int Y;
// Специальный конструктор.
public Point(int xPos, int yPos)
{
X = xPos;
Y = yPos;
}
...
}
Затем переменные типа
Point
можно создавать так:
// Вызвать специальный конструктор.
Point p2 = new Point(50, 60);
// Выводит X=50,Y=60.
p2.Display();
Структуры можно также помечать как допускающие только чтение, если необходимо, чтобы они были неизменяемыми. Неизменяемые объекты должны устанавливаться при конструировании и поскольку изменять их нельзя, они могут быть более производительными. В случае объявления структуры как допускающей только чтение все свойства тоже должны быть доступны только для чтения. Но может возникнуть вопрос, как тогда устанавливать свойство, если оно допускает только чтение? Ответ заключается в том, что значения свойств должны устанавливаться во время конструирования структуры. Модифицируйте класс, представляющий точку, как показано ниже:
readonly struct ReadOnlyPoint
{
// Fields of the structure.
public int X {get; }
public int Y { get; }
// Display the current position and name.
public void Display()
{
Console.WriteLine($"X = {X}, Y = {Y}");
}
public ReadOnlyPoint(int xPos, int yPos)
{
X = xPos;
Y = yPos;
}
}
Методы
Increment()
и Decrement()
были удалены, т.к. переменные допускают только чтение. Обратите внимание на свойства X
и Y
. Вместо определения их в виде полей они создаются как автоматические свойства, доступные только для чтения. Автоматические свойства рассматриваются в главе 5.
В версии C# 8.0 появилась возможность объявления индивидуальных полей структуры как
readonly
. Это обеспечивает более высокий уровень детализации, чем объявление целой структуры как допускающей только чтение. Модификатор readonly
может применяться к методам, свойствам и средствам доступа для свойств. Добавьте следующий код структуры в свой файл за пределами класса Program
:
struct PointWithReadOnly
{
// Поля структуры.
public int X;
public readonly int Y;
public readonly string Name;
// Отобразить текущую позицию и название.
public readonly void Display()
{
Console.WriteLine($"X = {X}, Y = {Y}, Name = {Name}");
}
// Специальный конструктор.
public PointWithReadOnly(int xPos, int yPos, string name)
{
X = xPos;
Y = yPos;
Name = name;
}
}
Для использования этой новой структуры добавьте к операторам верхнего уровня такой код:
PointWithReadOnly p3 =
new PointWithReadOnly(50,60,"Point w/RO");
p3.Display();
При определении структуры в C# 7.2 также появилась возможность применения модификатора
ref
. Он требует, чтобы все экземпляры структуры находились в стеке и не могли присваиваться свойству другого класса. Формальная причина для этого заключается в том, что ссылки на структуры ref
из кучи невозможны. Отличие между стеком и кучей объясняется в следующем разделе.
Ниже перечислены дополнительные ограничения структур
ref
:
• их нельзя присваивать переменной типа
object
или dynamic
, и они не могут быть интерфейсного типа;
• они не могут реализовывать интерфейсы;
• они не могут использоваться в качестве свойства структуры, не являющейся
ref
;
• они не могут применяться в асинхронных методах, итераторах, лямбда-выражениях или локальных функциях.
Показанный далее код, в котором создается простая структура и затем предпринимается попытка создать в этой структуре свойство, типизированное как структура
ref
, не скомпилируется;
struct NormalPoint
{
// Этот код не скомпилируется.
public PointWithRef PropPointer { get; set; }
}
Модификаторы
readonly
и ref
можно сочетать для получения преимуществ и ограничений их обоих.
Как было указано в предыдущем разделе, структуры
ref
(и структуры ref
, допускающие только чтение) не могут реализовывать интерфейсы, а потому реализовать IDisposable
нельзя. В версии C# 8.0 появилась возможность делать структуры ref
и структуры ref
, допускающие только чтение, освобождаемыми, добавляя открытый метод void Dispose()
.
Добавьте в главный файл следующее определение структуры:
ref struct DisposableRefStruct
{
public int X;
public readonly int Y;
public readonly void Display()
{
Console.WriteLine($"X = {X}, Y = {Y}");
}
// Специальный конструктор.
public DisposableRefStruct(int xPos, int yPos)
{
X = xPos;
Y = yPos;
Console.WriteLine("Created!"); // Экземпляр создан!
}
public void Dispose()
{
// Выполнить здесь очистку любых ресурсов.
Console.WriteLine("Disposed!"); // Экземпляр освобожден!
}
}
Теперь поместите в конце операторов верхнего уровня приведенный ниже код, предназначенный для создания и освобождения новой структуры:
var s = new DisposableRefStruct(50, 60);
s.Display();
s.Dispose();
На заметку! Темы времени жизни и освобождения объектов раскрываются в главе 9.
Чтобы углубить понимание выделения памяти в стеке и куче, необходимо ознакомиться с отличиями между типами значений и ссылочными типами .NET Core.
На заметку! В последующем обсуждении типов значений и ссылочных типов предполагается наличие у вас базовых знаний объектно-ориентированного программирования. Если это не так, тогда имеет смысл перейти к чтению раздела "Понятие типов С#, допускающих
null
" далее в главе и возвратиться к настоящему разделу после изучения глав 5 и 6.
В отличие от массивов, строк и перечислений структуры C# не имеют идентично именованного представления в библиотеке .NET Core (т.е. класс вроде
System.Structure
отсутствует), но они являются неявно производными от абстрактного класса System.ValueType
. Роль класса System.ValueType
заключается в обеспечении размещения экземпляра производного типа (например, любой структуры) в стеке, а не в куче с автоматической сборкой мусора. Выражаясь просто, данные, размещаемые в стеке, могут создаваться и уничтожаться быстро, т.к. время их жизни определяется областью видимости, в которой они объявлены. С другой стороны, данные, размещаемые в куче, отслеживаются сборщиком мусора .NET Core и имеют время жизни, которое определяется многими факторами, объясняемыми в главе 9.
С точки зрения функциональности единственное назначение класса
System.ValueType
— переопределение виртуальных методов, объявленных в классе System.Object
, с целью использования семантики на основе значений, а не ссылок. Вероятно, вы уже знаете, что переопределение представляет собой процесс изменения реализации виртуального (или возможно абстрактного) метода, определенного внутри базового класса. Базовым классом для ValueType
является System.Object
. В действительности методы экземпляра, определенные в System.ValueType
, идентичны методам экземпляра, которые определены в System.Object
:
// Структуры и перечисления неявно расширяют класс System.ValueType.
public abstract class ValueType : object
{
public virtual bool Equals(object obj);
public virtual int GetHashCode();
public Type GetType();
public virtual string ToString();
}
Учитывая, что типы значений применяют семантику на основе значений, время жизни структуры (что относится ко всем числовым типам данных (
int
, float
), а также к любому перечислению или структуре) предсказуемо. Когда переменная типа структуры покидает область определения, она немедленно удаляется из памяти:
// Локальные структуры извлекаются из стека,
// когда метод возвращает управление.
static void LocalValueTypes()
{
// Вспомните, что int - на самом деле структура System.Int32.
int i = 0;
// Вспомните, что Point - в действительности тип структуры.
Point p = new Point();
} // Здесь i и р покидают стек!
Когда переменная одного типа значения присваивается переменной другого типа значения, выполняется почленное копирование полей данных. В случае простого типа данных, такого как
System.Int32
, единственным копируемым членом будет числовое значение. Однако для типа Point
в новую переменную структуры будут копироваться значения полей X
и Y
. В целях демонстрации создайте новый проект консольного приложения по имени FunWithValueAndReferenceTypes
и скопируйте предыдущее определение Point
в новое пространство имен, после чего добавьте к операторам верхнего уровня следующую локальную функцию:
// Присваивание двух внутренних типов значений дает
// в результате две независимые переменные в стеке.
static void ValueTypeAssignment()
{
Console.WriteLine("Assigning value types\n");
Point p1 = new Point(10, 10);
Point p2 = p1;
// Вывести значения обеих переменных Point.
p1.Display();
p2.Display();
// Изменить pl.X и снова вывести значения переменных.
// Значение р2.Х не изменилось.
p1.X = 100;
Console.WriteLine("\n=> Changed p1.X\n");
p1.Display();
p2.Display();
}
Здесь создается переменная типа
Point(p1)
, которая присваивается другой переменной типа Point(р2)
. Поскольку Point
— тип значения, в стеке находятся две копии Point
, каждой из которых можно манипулировать независимым образом. Поэтому при изменении значения p1.X
значение р2.X
остается незатронутым:
Assigning value types
X = 10, Y = 10
X = 10, Y = 10
=> Changed p1.X
X = 100, Y = 10
X = 10, Y = 10
По контрасту с типами значений, когда операция присваивания применяется к переменным ссылочных типов (т.е. экземплярам всех классов), происходит перенаправление на то, на что ссылочная переменная указывает в памяти. В целях иллюстрации создайте новый класс по имени
PointRef
с теми же членами, что и у структуры Point
, но только переименуйте конструктор в соответствии с именем данного класса:
// Классы всегда являются ссылочными типами.
class PointRef
{
// Те же самые члены, что и в структуре Point...
// Не забудьте изменить имя конструктора на PointRef!
public PointRef(int xPos, int yPos)
{
X = xPos;
Y = yPos;
}
}
Задействуйте готовый тип
PointRef
в следующем новом методе. Обратите внимание, что помимо использования класса PointRef
вместо структуры Point
код идентичен коду метода ValueTypeAssignment()
:
static void ReferenceTypeAssignment()
{
Console.WriteLine("Assigning reference types\n");
PointRef p1 = new PointRef(10, 10);
PointRef p2 = p1;
// Вывести значения обеих переменных PointRef.
p1.Display();
p2.Display();
// Изменить pl.X и снова вывести значения.
p1.X = 100;
Console.WriteLine("\n=> Changed p1.X\n");
p1.Display();
p2.Display();
}
В рассматриваемом случае есть две ссылки, указывающие на тот же самый объект в управляемой куче. Таким образом, когда значение
X
изменяется с использованием ссылки p1
, изменится также и значение р2.X
. Вот вывод, получаемый в результате вызова этого нового метода:
Assigning reference types
X = 10, Y = 10
X = 10, Y = 10
=> Changed p1.X
X = 100, Y = 10
X = 100, Y = 10
Теперь, когда вы лучше понимаете базовые отличия между типами значений и ссылочными типами, давайте обратимся к более сложному примеру. Предположим, что имеется следующий ссылочный тип (класс), который поддерживает информационную строку (
InfoString
), устанавливаемую с применением специального конструктора:
class ShapeInfo
{
public string InfoString;
public ShapeInfo(string info)
{
InfoString = info;
}
}
Далее представим, что переменная типа
ShapeInfo
должна содержаться внутри типа значения по имени Rectangle
. Кроме того, в типе Rectangle
предусмотрен специальный конструктор, который позволяет вызывающему коду указывать значение для внутренней переменной-члена типа ShapeInfo
. Вот полное определение типа Rectangle
:
struct Rectangle
{
// Структура Rectangle содержит член ссылочного типа.
public ShapeInfo RectInfo;
public int RectTop, RectLeft, RectBottom, RectRight;
public Rectangle(string info, int top, int left, int bottom, int right)
{
RectInfo = new ShapeInfo(info);
RectTop = top; RectBottom = bottom;
RectLeft = left; RectRight = right;
}
public void Display()
{
Console.WriteLine("String = {0}, Top = {1}, Bottom = {2}, " +
"Left = {3}, Right = {4}",
RectInfo.InfoString, RectTop, RectBottom, RectLeft, RectRight);
}
}
Здесь ссылочный тип содержится внутри типа значения. Возникает важный вопрос: что произойдет в результате присваивания одной переменной типа
Rectangle
другой переменной того же типа? Учитывая то, что уже известно о типах значений, можно корректно предположить, что целочисленные данные (которые на самом деле являются структурой — System.Int32
)должны быть независимой сущностью для каждой переменной Rectangle
. Но что можно сказать о внутреннем ссылочном типе? Будет ли полностью скопировано состояние этого объекта или же только ссылка на него? Чтобы получить ответ, определите следующий метод и вызовите его:
static void ValueTypeContainingRefType()
{
// Создать первую переменную Rectangle.
Console.WriteLine("-> Creating r1");
Rectangle r1 = new Rectangle("First Rect", 10, 10, 50, 50);
// Присвоить новой переменной Rectangle переменную r1.
Console.WriteLine("-> Assigning r2 to r1");
Rectangle r2 = r1;
// Изменить некоторые значения в r2.
Console.WriteLine("-> Changing values of r2");
r2.RectInfo.InfoString = "This is new info!";
r2.RectBottom = 4444;
// Вывести значения из обеих переменных Rectangle.
r1.Display();
r2.Display();
}
Вывод будет таким:
-> Creating r1
-> Assigning r2 to r1
-> Changing values of r2
String = This is new info!, Top = 10, Bottom = 50, Left = 10, Right = 50
String = This is new info!, Top = 10, Bottom = 4444, Left = 10, Right = 50
Как видите, в случае модификации значения информационной строки с использованием ссылки
r2
для ссылки r1
отображается то же самое значение. По умолчанию, если тип значения содержит другие ссылочные типы, то присваивание приводит к копированию ссылок. В результате получаются две независимые структуры, каждая из которых содержит ссылку, указывающую на один и тот же объект в памяти (т.е. создается поверхностная копия). Для выполнения глубокого копирования, при котором в новый объект полностью копируется состояние внутренних ссылок, можно реализовать интерфейс ICloneable
(что будет показано в главе 8).
Ранее в главе объяснялось, что ссылочные типы и типы значений могут передаваться методам как параметры. Тем не менее, передача ссылочного типа (например, класса) по ссылке совершенно отличается от его передачи по значению. Чтобы понять разницу, предположим, что есть простой класс
Person
, определенный в новом проекте консольного приложения по имени FunWithRefTypeValTypeParams
:
class Person
{
public string personName;
public int personAge;
// Constructors.
public Person(string name, int age)
{
personName = name;
personAge = age;
}
public Person(){}
public void Display()
{
Console.WriteLine("Name: {0}, Age: {1}", personName, personAge);
}
}
А что если мы создадим метод, который позволит вызывающему коду передавать объект
Person
по значению (обратите внимание на отсутствие модификаторов параметров, таких как out или ref
)?
static void SendAPersonByValue(Person p)
{
// Изменить значение возраста в р?
p.personAge = 99;
// Увидит ли вызывающий код это изменение?
p = new Person("Nikki", 99);
}
Здесь видно, что метод
SendAPersonByValue()
пытается присвоить входной ссылке на Person
новый объект Person
, а также изменить некоторые данные состояния. Протестируем этот метод с помощью следующего кода:
// Передача ссылочных типов по значению.
Console.WriteLine("***** Passing Person object by value *****");
Person fred = new Person("Fred", 12);
Console.WriteLine("\nBefore by value call, Person is:");
// Перед вызовом с передачей по значению
fred.Display();
SendAPersonByValue(fred);
Console.WriteLine("\nAfter by value call, Person is:");
// После вызова с передачей по значению
fred.Display();
Console.ReadLine();
Ниже показан результирующий вывод:
***** Passing Person object by value *****
Before by value call, Person is:
Name: Fred, Age: 12
After by value call, Person is:
Name: Fred, Age: 99
Легко заметить, что значение
PersoneAge
было изменено. Такое поведение, которое обсуждалось ранее, должно стать более понятным теперь, когда вы знаете, как работают ссылочные типы. Учитывая, что попытка изменения состояния входного объекта Person
прошла успешно, возникает вопрос: что же тогда было скопировано? Ответ: была получена копия ссылки на объект из вызывающего кода. Следовательно, раз уж метод SendAPersonByValue()
указывает на тот же самый объект, что и вызывающий код, становится возможным изменение данных состояния этого объекта. Нельзя лишь переустанавливать ссылку так, чтобы она указывала на какой-то другой объект.
Предположим, что имеется метод
SendAPersonByReference()
, в котором ссылочный тип передается по ссылке (обратите внимание на наличие модификатора параметра ref
):
static void SendAPersonByReference(ref Person p)
{
// Изменить некоторые данные в р.
p.personAge = 555;
// р теперь указывает на новый объект в куче!
p = new Person("Nikki", 999);
}
Как и можно было ожидать, вызываемому коду предоставлена полная свобода в плане манипулирования входным параметром. Вызываемый код может не только изменять состояние объекта, но и переопределять ссылку так, чтобы она указывала на новый объект
Person
. Взгляните на следующий обновленный код:
// Передача ссылочных типов по ссылке.
Console.WriteLine("***** Passing Person object by reference *****");
...
Person mel = new Person("Mel", 23);
Console.WriteLine("Before by ref call, Person is:");
// Перед вызовом с передачей по ссылке
mel.Display();
SendAPersonByReference(ref mel);
Console.WriteLine("After by ref call, Person is:");
// После вызова с передачей по ссылке
mel.Display();
Console.ReadLine();
Вот вывод:
***** Passing Person object by reference *****
Before by ref call, Person is:
Name: Mel, Age: 23
After by ref call, Person is:
Name: Nikki, Age: 999
Здесь видно, что после вызова объект по имени
Mel
возвращается как объект по имени Nikki
, поскольку метод имел возможность изменить то, на что указывала в памяти входная ссылка. Ниже представлены основные правила, которые необходимо соблюдать при передаче ссылочных типов.
• Если ссылочный тип передается по ссылке, тогда вызываемый код может изменять значения данных состояния объекта, а также объект, на который указывает ссылка.
• Если ссылочный тип передается по значению, то вызываемый код может изменять значения данных состояния объекта, но не объект, на который указывает ссылка.
В завершение данной темы в табл. 4.4 приведена сводка по основным отличиям между типами значений и ссылочными типами.
Несмотря на различия, типы значений и ссылочные типы могут реализовывать интерфейсы и поддерживать любое количество полей, методов, перегруженных операций, констант, свойств и событий.
Давайте исследуем роль типов данных, допускающих значение
, с применением проекта консольного приложения по имени null
FunWithNullableValueTypes
. Как вам уже известно, типы данных C# обладают фиксированным диапазоном значений и представлены в виде типов пространства имен System. Например, тип данных System.Boolean
может принимать только значения из набора (true
, false)
. Вспомните, что все числовые типы данных (а также Boolean
) являются типами значений. Типам значений никогда не может быть присвоено значение null
, потому что оно служит для представления пустой объектной ссылки.
// Ошибка на этапе компиляции!
// Типы значений нельзя устанавливать в null!
bool myBool = null;
int myInt = null;
В языке C# поддерживается концепция типов данных, допускающих значение
. Выражаясь просто, допускающий null
null
тип может представлять все значения лежащего в основе типа плюс null
. Таким образом, если вы объявите переменную типа bool
, допускающего null
, то ей можно будет присваивать значение из набора {true, false, null}
. Это может быть чрезвычайно удобно при работе с реляционными базами данных, поскольку в таблицах баз данных довольно часто встречаются столбцы, для которых значения не определены. Без концепции типов данных, допускающих null
, в C# не было бы удобного способа для представления числовых элементов данных без значений.
Чтобы определить переменную типа, допускающего
null
, необходимо добавить к имени интересующего типа данных суффикс в виде знака вопроса (?
). До выхода версии C# 8.0 такой синтаксис был законным только в случае применения к типам значений (более подробные сведения ищите в разделе "Использование ссылочных типов, допускающих null
" далее в главе). Подобно переменным с типами, не допускающими null
, локальным переменным, имеющим типы, которые допускают null
, должно присваиваться начальное значение, прежде чем ими можно будет пользоваться:
static void LocalNullableVariables()
{
// Определить несколько локальных переменных
// с типами, допускающими null
int? nullableInt = 10;
double? nullableDouble = 3.14;
bool? nullableBool = null;
char? nullableChar = 'a';
int?[] arrayOfNullableInts = new int?[10];
}
В языке C# система обозначений в форме суффикса
?
представляет собой сокращение для создания экземпляра обобщенного типа структуры System.Nullable
. Она также применяется для создания ссылочных типов, допускающих null
, но ее поведение несколько отличается. Хотя подробное исследование обобщений мы отложим до главы 10, сейчас важно понимать, что тип System.Nullable
предоставляет набор членов, которые могут применяться всеми типами, допускающими null
.
Например, с помощью свойства
HasValue
или операции !=
можно программно выяснять, действительно ли переменной, допускающей null
, было присвоено значение null
. Значение, которое присвоено типу, допускающему null
, можно получать напрямую или через свойство Value
. Учитывая, что суффикс ?
является просто сокращением для использования Nullable
, предыдущий метод LocalNullableVariables()
можно было бы реализовать следующим образом:
static void LocalNullableVariablesUsingNullable()
{
// Определить несколько типов, допускающих null,
// с применением Nullable.
Nullable nullableInt = 10;
Nullable nullableDouble = 3.14;
Nullable nullableBool = null;
Nullable nullableChar = 'a';
Nullable[] arrayOfNullableInts = new Nullable[10];
}
Как отмечалось ранее, типы данных, допускающие
null
, особенно полезны при взаимодействии с базами данных, потому что столбцы в таблицах данных могут быть намеренно оставлены пустыми (скажем, быть неопределенными). В целях демонстрации рассмотрим показанный далее класс, эмулирующий процесс доступа к базе данных с таблицей, в которой два столбца могут принимать значения null
. Обратите внимание, что метод GetlntFromDatabase()
не присваивает значение члену целочисленного типа, допускающего null
, тогда как метод GetBoolFromDatabase()
присваивает допустимое значение члену типа bool
?
class DatabaseReader
{
// Поле данных типа, допускающего null.
public int? numericValue = null;
public bool? boolValue = true;
// Обратите внимание на возвращаемый тип, допускающий null.
public int? GetIntFromDatabase()
{ return numericValue; }
// Обратите внимание на возвращаемый тип, допускающий null.
public bool? GetBoolFromDatabase()
{ return boolValue; }
}
В следующем коде происходит обращение к каждому члену класса
DatabaseReader
и выяснение присвоенных значений с применением членов HasValue
и Value
, а также операции равенства C# (точнее операции "не равно"):
Console.WriteLine("***** Fun with Nullable Value Types *****\n");
DatabaseReader dr = new DatabaseReader();
/// Получить значение int из "базы данных".
int? i = dr.GetIntFromDatabase();
if (i.HasValue)
{
Console.WriteLine("Value of 'i' is: {0}", i.Value);
// Вывод значения переменной i
}
else
{
Console.WriteLine("Value of 'i' is undefined.");
// Значение переменной i не определено
}
// Получить значение bool из "базы данных".
bool? b = dr.GetBoolFromDatabase();
if (b != null)
{
Console.WriteLine("Value of 'b' is: {0}", b.Value);
// Вывод значения переменной b
}
else
{
Console.WriteLine("Value of 'b' is undefined.");
// Значение переменной b не определено
}
Console.ReadLine();
Важным средством, добавленным в версию C# 8, является поддержка ссылочных типов, допускающих значение
null
. На самом деле изменение было настолько значительным, что инфраструктуру .NET Framework не удалось обновить для поддержки нового средства. В итоге было принято решение поддерживать C# 8 только в .NET Core 3.0 и последующих версиях и также по умолчанию отключить поддержку ссылочных типов, допускающих null
. В новом проекте .NET Core 3.0/3.1 или .NET 5 ссылочные типы функционируют точно так же, как в C# 7. Это сделано для того, чтобы предотвратить нарушение работы миллиардов строк кода, существовавших в экосистеме до появления C# 8. Разработчики в своих приложениях должны дать согласие на включение ссылочных типов, допускающих null
.
Ссылочные типы, допускающие
null
, подчиняются множеству тех же самых правил, что и типы значений, допускающие null
. Переменным ссылочных типов, не допускающих null
, во время инициализации должны присваиваться отличающиеся от null
значения, которые позже нельзя изменять на null
. Переменные ссылочных типов, допускающих null
, могут принимать значение null
, но перед первым использованием им по-прежнему должны присваиваться какие-то значения (либо фактический экземпляр чего-нибудь, либо значение null
).
Для указания способности иметь значение
null
в ссылочных типах, допускающих null
, применяется тот же самый символ ?
. Однако он не является сокращением для использования System.Nullable
, т.к. на месте Т
могут находиться только типы значений. Не забывайте, что обобщения и ограничения рассматриваются в главе 10.
Поддержка для ссылочных типов, допускающих
null
, управляется установкой контекста допустимости значения null
. Это может распространяться на целый проект (за счет обновления файла проекта) или охватывать лишь несколько строк (путем применения директив компилятора). Вдобавок можно устанавливать следующие два контекста.
• Контекст с заметками о допустимости значения
null:
включает/отключает заметки о допустимости null(?)
для ссылочных типов, допускающих null
.
• Контекст с предупреждениями о допустимости значения
null:
включает/отключает предупреждения компилятора для ссылочных типов, допускающих null
.
Чтобы увидеть их в действии, создайте новый проект консольного приложения по имени
FunWithNullableReferenceTypes
. Откройте файл проекта (если вы используете Visual Studio, тогда дважды щелкните на имени проекта в окне Solution Explorer или щелкните правой кнопкой мыши на имени проекта и выберите в контекстном меню пункт Edit Project file (Редактировать файл проекта)). Модифицируйте содержимое файла проекта для поддержки ссылочных типов, допускающих null
, за счет добавления элемента
(все доступные варианты представлены в табл. 4.5).
Exe
net5.0
enable
Элемент
оказывает влияние на весь проект. Для управления меньшими частями проекта используйте директиву компилятора #nullable
, значения которой описаны в табл. 4.6.
Во многом из-за важности изменения ошибки с типами, допускающими значение
null
, возникают только при их ненадлежащем применении. Добавьте в файл Program.cs
следующий класс:
public class TestClass
{
public string Name { get; set; }
public int Age { get; set; }
}
Как видите, это просто нормальный класс. Возможность принятия значения
null
появляется при использовании данного класса в коде. Взгляните на показанные ниже объявления:
string? nullableString = null;
TestClass? myNullableClass = null;
Настройка в файле проекта помещает весь проект в контекст допустимости значения
null
, который разрешает применение объявлений типов string
и TestClass
с заметками о допустимости значения null
(?
). Следующая строка кода вызывает генерацию предупреждения (CS8600) из-за присваивания null
типу, не допускающему значение null
, в контексте допустимости значения null
:
// Предупреждение CS8600 Converting null literal or possible null
// value to non-nullable type
// Преобразование литерала null или возможного значения null
// в тип, не допускающий null
TestClass myNonNullableClass = myNullableClass;
Для более точного управления тем, где в проекте находятся контексты допустимости значения
null
, с помощью директивы компилятора #nullable
можно включать или отключать контекст (как обсуждалось ранее). В приведенном далее коде контекст допустимости значения null
(установленный на уровне проекта) сначала отключается, после чего снова включается за счет восстановления настройки из файла проекта:
#nullable disable
TestClass anotherNullableClass = null;
// Предупреждение CS8632 The annotation for nullable reference types
// should only be used in code within a '#nullable' annotations
// Заметка для ссылочных типов, допускающих значение null,
// должна использоваться только в коде внутри
// #nullable enable annotations
TestClass? badDefinition = null;
// Предупреждение CS8632 The annotation for nullable reference types
// should only be used in code within a '#nullable' annotations
// Заметка для ссылочных типов, допускающих значение null,
// должна использоваться только в коде внутри
#nullable enable annotations
string? anotherNullableString = null;
#nullable restore
В заключение важно отметить, что ссылочные типы, допускающие значение
null
, не имеют свойств HasValue
и Value
, т.к. они предоставляются System.Nullable
.
Если при переносе кода из C# 7 в C# 8 или C# 9 вы хотите задействовать ссылочные типы, допускающие значение
null
, то можете использовать для работы с кодом комбинацию настройки проекта и директив компилятора. Общепринятая практика предусматривает первоначальное включение предупреждений и отключение заметок о допустимости значения null
для всего проекта. Затем по мере приведения в порядок областей кода применяйте директивы компилятора для постепенного включения заметок.
Для работы с типами, допускающими значение
null
, в языке C# предлагается несколько операций. В последующих разделах рассматриваются операция объединения с null
, операция присваивания с объединением с null
и null
-условная операция. Для проработки примеров используйте ранее созданный проект FunWithNullableValueTypes
.
Следующий важный аспект связан с тем, что любая переменная, которая может иметь значение null (т.е. переменная ссылочного типа или переменная типа, допускающего
null
), может использоваться с операцией ??
языка С#, формально называемой операцией объединения с null
. Операция ??
позволяет присваивать значение типу, допускающему null
, если извлеченное значение на самом деле равно null
. В рассматриваемом примере мы предположим, что в случае возвращения методом GetlntFromDatabase()
значения null
(конечно, данный метод запрограммирован так, что он всегда возвращает null
, но общую идею вы должны уловить) локальной переменной целочисленного типа, допускающего null
, необходимо присвоить значение 100
. Возвратитесь к проекту NullableValueTypes
(сделайте его стартовым) и введите следующий код:
// Для краткости код не показан
Console.WriteLine("***** Fun with Nullable Data *****\n");
DatabaseReader dr = new DatabaseReader();
// Если значение, возвращаемое из GetlntFromDatabase(), равно
// null, тогда присвоить локальной переменной значение 100.
int myData = dr.GetIntFromDatabase() ?? 100;
Console.WriteLine("Value of myData: {0}", myData);
Console.ReadLine();
Преимущество применения операции
??
заключается в том, что она дает более компактную версию кода, чем традиционный условный оператор if/else
. Однако при желании можно было бы написать показанный ниже функционально эквивалентный код, который в случае возвращения null
обеспечит установку переменной в значение 100:
// Более длинный код, в котором не используется синтаксис ??.
int? moreData = dr.GetIntFromDatabase();
if (!moreData.HasValue)
{
moreData = 100;
}
Console.WriteLine("Value of moreData: {0}", moreData);
// Вывод значения moreData
В версии C# 8 появилась операция присваивания с объединением с
null
(??=
), основанная на операции объединения с null
. Эта операция выполняет присваивание левого операнда правому операнду, только если левый операнд равен null
. В качестве примера введите такой код:
// Операция присваивания с объединением с null
int? nullableInt = null;
nullableInt ??= 12;
nullableInt ??= 14;
Console.WriteLine(nullableInt);
Сначала переменная
nullableInt
инициализируется значением null
. В следующей строке переменной nullableInt
присваивается значение 12, поскольку левый операнд действительно равен null
. Но в следующей за ней строке переменной nullableInt
не присваивается значение 14, т.к. она не равна null
.
При разработке программного обеспечения обычно производится проверка на предмет
null
входных параметров, которым передаются значения, возвращаемые членами типов (методами, свойствами, индексаторами). Например, пусть имеется метод, который принимает в качестве единственного параметра строковый массив. В целях безопасности его желательно проверять на предмет null
, прежде чем начинать обработку. Поступая подобным образом, мы не получим ошибку во время выполнения, если массив окажется пустым. Следующий код демонстрирует традиционный способ реализации такой проверки:
static void TesterMethod(string[] args)
{
// Перед доступом к данным массива мы должны проверить его
// на равенство null!
if (args != null)
{
Console.WriteLine($"You sent me {args.Length} arguments.");
// Вывод количества аргументов
}
}
Чтобы устранить обращение к свойству
Length
массива string
в случае, когда он равен null
, здесь используется условный оператор. Если вызывающий код не создаст массив данных и вызовет метод TesterMethod()
примерно так, как показано ниже, то никаких ошибок во время выполнения не возникнет:
TesterMethod(null);
В языке C# имеется маркер
null
-условной операции (знак вопроса, находящийся после типа переменной, но перед операцией доступа к члену), который позволяет упростить представленную ранее проверку на предмет null
. Вместо явного условного оператора, проверяющего на неравенство значению null
, теперь можно написать такой код:
static void TesterMethod(string[] args)
{
// Мы должны проверять на предмет null перед доступом к данным массива!
Console.WriteLine($"You sent me {args?.Length} arguments.");
}
В этом случае условный оператор не применяется. Взамен к переменной массива
string
в качестве суффикса добавлена операция ?
. Если переменная args
равна null
, тогда обращение к свойству Length
не приведет к ошибке во время выполнения. Чтобы вывести действительное значение, можно было бы воспользоваться операцией объединения с null
и установить стандартное значение:
Console.WriteLine($"You sent me {args?.Length ?? 0} arguments.");
Существуют дополнительные области написания кода, в которых
null
-условная операция окажется очень удобной, особенно при работе с делегатами и событиями. Данные темы раскрываются позже в книге (см. главу 12) и вы встретите еще много примеров.
В завершение главы мы исследуем роль кортежей, используя проект консольного приложения по имени
FunWithTuples
. Как упоминалось ранее в главе, одна из целей применения параметров out
— получение более одного значения из вызова метода. Еще один способ предусматривает использование конструкции под названием кортежи.
Кортежи, которые являются легковесными структурами данных, содержащими множество полей, фактически появились в версии C# 6, но применяться могли в крайне ограниченной манере. Кроме того, в их реализации C# 6 существовала значительная проблема: каждое поле было реализовано как ссылочный тип, что потенциально порождало проблемы с памятью и/или производительностью (из-за упаковки/распаковки).
В версии C# 7 кортежи вместо ссылочных типов используют новый тип данных
ValueTuple
, сберегая значительных объем памяти. Тип данных ValueTuple
создает разные структуры на основе количества свойств для кортежа. Кроме того, в C# 7 каждому свойству кортежа можно назначать специфическое имя (подобно переменным), что значительно повышает удобство работы с ними.
Относительно кортежей важно отметить два момента:
• поля не подвергаются проверке достоверности;
• определять собственные методы нельзя.
В действительности кортежи предназначены для того, чтобы служить легковесным механизмом передачи данных.
Итак, достаточно теории, давайте напишем какой-нибудь код! Чтобы создать кортеж, просто повестите значения, подлежащие присваиванию, в круглые скобки:
("a", 5, "c")
Обратите внимание, что все значения не обязаны относиться к тому же самому типу данных. Конструкция с круглыми скобками также применяется для присваивания кортежа переменной (или можно использовать ключевое слово
var
и тогда компилятор назначит типы данных самостоятельно). Показанные далее две строки кода делают одно и то же — присваивают предыдущий пример кортежа переменной. Переменная values
будет кортежем с двумя свойствами string
и одним свойством int
.
(string, int, string) values = ("a", 5, "c");
var values = ("a", 5, "c");
По умолчанию компилятор назначает каждому свойству имя
ItemX
, где X
представляет позицию свойства в кортеже, начиная с 1. В предыдущем примере свойства именуются как Item1
, Item2
и Item3
. Доступ к ним осуществляется следующим образом:
Console.WriteLine($"First item: {values.Item1}"); // Первый элемент
Console.WriteLine($"Second item: {values.Item2}"); // Второй элемент
Console.WriteLine($"Third item: {values.Item3}"); // Третий элемент
Кроме того, к каждому свойству кортежа справа или слева можно добавить специфическое имя. Хотя назначение имен в обеих частях оператора не приводит к ошибке на этапе компиляции, имена в правой части игнорируются, а использоваться будут имена в левой части. Показанные ниже две строки кода демонстрируют установку имен в левой и правой частях оператора, давая тот же самый результат:
(string FirstLetter, int TheNumber, string SecondLetter)
valuesWithNames = ("a", 5, "c");
var valuesWithNames2 = (FirstLetter: "a", TheNumber: 5, SecondLetter: "c");
Теперь доступ к свойствам кортежа возможен с применением имен полей, а также системы обозначений
ItemX
:
Console.WriteLine($"First item: {valuesWithNames.FirstLetter}");
Console.WriteLine($"Second item: {valuesWithNames.TheNumber}");
Console.WriteLine($"Third item: {valuesWithNames.SecondLetter}");
// Система обозначений ItemX по-прежнему работает!
Console.WriteLine($"First item: {valuesWithNames.Item1}");
Console.WriteLine($"Second item: {valuesWithNames.Item2}");
Console.WriteLine($"Third item: {valuesWithNames.Item3}");
Обратите внимание, что при назначении имен в правой части оператора должно использоваться ключевое слово
var
для объявления переменной. Установка типов данных специальным образом (даже без специфических имен) заставляет компилятор применять синтаксис в левой части оператора, назначать свойствам имена согласно системе обозначений ItemX
и игнорировать имена, указанные в правой части. В следующих двух операторах имена Custom1
и Custom2
игнорируются:
(int, int) example = (Custom1:5, Custom2:7);
(int Field1, int Field2) example = (Custom1:5, Custom2:7);
Важно также понимать, что специальные имена полей существуют только на этапе компиляции и не доступны при инспектировании кортежа во время выполнения с использованием рефлексии (рефлексия раскрывается в главе 17).
Кортежи также могут быть вложенными как кортежи внутри кортежей. Поскольку с каждым свойством в кортеже связан тип данных, и кортеж является типом данных, следующий код полностью законен:
Console.WriteLine("=> Nested Tuples");
var nt = (5, 4, ("a", "b"));
В C# 7.1 появилась возможность выводить имена переменных кортежей, как показано ниже:
Console.WriteLine("=> Inferred Tuple Names");
var foo = new {Prop1 = "first", Prop2 = "second"};
var bar = (foo.Prop1, foo.Prop2);
Console.WriteLine($"{bar.Prop1};{bar.Prop2}");
Дополнительным средством в версии C# 7.1 является эквивалентность (
==
) и неэквивалентность (!=
) кортежей. При проверке на неэквивалентность операции сравнения будут выполнять неявные преобразования типов данных внутри кортежей, включая сравнение допускающих и не допускающих null
кортежей и/или свойств. Это означает, что следующие проверки нормально работают, несмотря на разницу между int
и long
:
Console.WriteLine("=> Tuples Equality/Inequality");
// Поднятые преобразования
var left = (a: 5, b: 10);
(int? a, int? b) nullableMembers = (5, 10);
Console.WriteLine(left == nullableMembers); // Тоже True
// Преобразованным типом слева является (long, long)
(long a, long b) longTuple = (5, 10);
Console.WriteLine(left == longTuple); // Тоже True
// Преобразования выполняются с кортежами (long, long)
(long a, int b) longFirst = (5, 10);
(int a, long b) longSecond = (5, 10);
Console.WriteLine(longFirst == longSecond); // Тоже True
Кортежи, которые содержат кортежи, также можно сравнивать, но только если они имеют одну и ту же форму. Нельзя сравнивать кортеж с тремя свойствами
int
и кортеж, содержащий два свойства int
плюс кортеж.
Ранее в главе для возвращения из вызова метода более одного значения применялись параметры
out
. Для этого существуют другие способы вроде создания класса или структуры специально для возвращения значений. Но если такой класс или структура используется только в целях передачи данных для одного метода, тогда нет нужды выполнять излишнюю работу и писать добавочный код. Кортежи прекрасно подходят для решения задачи, т.к. они легковесны, просты в объявлении и несложны в применении.
Ниже представлен один из примеров, рассмотренных в разделе о параметрах
out
. Метод FillTheseValues()
возвращает три значения, но требует использования в вызывающем коде трех параметров как механизма передачи:
static void FillTheseValues(out int a, out string b, out bool c)
{
a = 9;
b = "Enjoy your string.";
c = true;
}
За счет применения кортежа от параметров можно избавиться и все равно получать обратно три значения:
static (int a,string b,bool c) FillTheseValues()
{
return (9,"Enjoy your string.",true);
}
Вызывать новый метод не сложнее любого другого метода:
var samples = FillTheseValues();
Console.WriteLine($"Int is: {samples.a}");
Console.WriteLine($"String is: {samples.b}");
Console.WriteLine($"Boolean is: {samples.c}");
Возможно, даже лучшим примером будет разбор полного имени на отдельные части (имя (
first
), отчество (middle
), фамилия (last
)). Следующий метод SplitNames()
получает полное имя и возвращает кортеж с составными частями:
static (string first, string middle, string last) SplitNames(string fullName)
{
// Действия, необходимые для расщепления полного имени.
return ("Philip", "F", "Japikse");
}
Продолжим пример с методом
SplitNames()
. Пусть известно, что требуются только имя и фамилия, но не отчество. В таком случае можно указать имена свойств для значений, которые необходимо возвращать, а ненужные значения заменить заполнителем в виде подчеркивания (_
):
var (first, _, last) = SplitNames("Philip F Japikse");
Console.WriteLine($"{first}:{last}");
Значение, соответствующее отчеству, в кортеже отбрасывается.
Теперь, когда вы хорошо разбираетесь в кортежах, самое время возвратиться к примеру выражения
switch
с кортежами, который приводился в конце главы 3:
// Выражения switch с кортежами
static string RockPaperScissors(string first, string second)
{
return (first, second) switch
{
("rock", "paper") => "Paper wins.",
("rock", "scissors") => "Rock wins.",
("paper", "rock") => "Paper wins.",
("paper", "scissors") => "Scissors wins.",
("scissors", "rock") => "Rock wins.",
("scissors", "paper") => "Scissors wins.",
(_, _) => "Tie.",
};
}
В этом примере два параметра преобразуются в кортеж, когда передаются выражению
switch
. В выражении switch
представлены подходящие значения, а все остальные случаи обрабатывает последний кортеж, состоящий из двух символов отбрасывания.
Сигнатуру метода
RockPaperScissors()
можно было бы записать так, чтобы метод принимал кортеж, например:
static string RockPaperScissors(
(string first, string second) value)
{
return value switch
{
// Для краткости код не показан
};
}
Деконструирование является термином, описывающим отделение свойств кортежа друг от друга с целью применения по одному. Именно это делает метод
FillTheseValues()
. Но есть и другой случай использования такого приема — деконструирование специальных типов.
Возьмем укороченную версию структуры
Point
, которая применялась ранее в главе. В нее был добавлен новый метод по имени Deconstruct()
, возвращающий индивидуальные свойства экземпляра Point
в виде кортежа со свойствами XPos
и YPos
:
struct Point
{
// Поля структуры.
public int X;
public int Y;
// Специальный конструктор.
public Point(int XPos, int YPos)
{
X = XPos;
Y = YPos;
}
public (int XPos, int YPos) Deconstruct() => (X, Y);
}
Новый метод
Deconstruct()
выделен полужирным. Его можно именовать как угодно, но обычно он имеет имя Deconstruct()
. В результате с помощью единственного вызова метода можно получить индивидуальные значения структуры путем возвращения кортежа:
Point p = new Point(7,5);
var pointValues = p.Deconstruct();
Console.WriteLine($"X is: {pointValues.XPos}");
Console.WriteLine($"Y is: {pointValues.YPos}");
Когда кортежи имеют доступный метод
Deconstruct()
, деконструирование можно применять в выражении switch
, основанном на кортежах. Следующий код полагается на пример Point
и использует значения сгенерированного кортежа в конструкциях when
выражения switch
:
static string GetQuadrant1(Point p)
{
return p.Deconstruct() switch
{
(0, 0) => "Origin",
var (x, y) when x > 0 && y > 0 => "One",
var (x, y) when x < 0 && y > 0 => "Two",
var (x, y) when x < 0 && y < 0 => "Three",
var (x, y) when x > 0 && y < 0 => "Four",
var (_, _) => "Border",
};
}
Если метод
Deconstruct()
определен с двумя параметрами out
, тогда выражение switch
будет автоматически деконструировать экземпляр Point
. Добавьте к Point
еще один метод Deconstruct()
:
public void Deconstruct(out int XPos, out int YPos)
=> (XPos,YPos)=(X, Y);
Теперь можно модифицировать (или добавить новый) метод
GetQuadrant()
, как показано ниже:
static string GetQuadrant2(Point p)
{
return p switch
{
(0, 0) => "Origin",
var (x, y) when x > 0 && y > 0 => "One",
var (x, y) when x < 0 && y > 0 => "Two",
var (x, y) when x < 0 && y < 0 => "Three",
var (x, y) when x > 0 && y < 0 => "Four",
var (_, _) => "Border",
};
}
Изменение очень тонкое (и выделено полужирным). В выражении
switch
вместо вызова р.Deconstruct()
применяется просто переменная Point
.
Глава начиналась с исследования массивов. Затем обсуждались ключевые слова С#, которые позволяют строить специальные методы. Вспомните, что по умолчанию параметры передаются по значению; тем не менее, параметры можно передавать и по ссылке, пометив их модификаторами
ref
или out
. Кроме того, вы узнали о роли необязательных и именованных параметров, а также о том, как определять и вызывать методы, принимающие массивы параметров.
После рассмотрения темы перегрузки методов в главе приводились подробные сведения, касающиеся способов определения перечислений и структур в C# и их представления в библиотеках базовых классов .NET Core. Попутно рассматривались основные характеристики типов значений и ссылочных типов, включая их поведение при передаче в качестве параметров методам, а также способы взаимодействия с типами данных, допускающими
null
, и переменными, которые могут иметь значение null
(например, переменными ссылочных типов и переменными типов значений, допускающих null
), с использованием операций ?
, ??
и ??=
.
Финальный раздел был посвящен давно ожидаемому средству в языке C# — кортежам. После выяснения, что они собой представляют и как работают, кортежи применялись для возвращения множества значений из методов и для деконструирования специальных типов. В главе 5 вы начнете погружаться в детали объектно-ориентированного программирования.