Reading the documentation (in Russian)
Contents
- 1 Введение
- 2 Чтение документации
- 2.1 Модули
- 2.2 Чтение документации
- 2.3 Первый шаг
- 2.4 Второй и последующие шаги
- 2.5 Типы
- 2.6 keywords...
- 2.7 behavior...
- 2.8 safety_level
- 2.9 keywords... после функции
- 2.10 keywords при аргументах функции
- 2.11 Значения по умолчанию
- 2.12 Произвольное количество параметров
- 2.13 Шаблоны
- 2.14 Шаблоны-функции
- 2.15 Другие виды шаблонов
- 2.16 Ограничения на параметры шаблона
- 2.17 Общий вид шаблона
- 2.18 Еще несколько слов про шаблоны
Введение
Сравнительно недавно на свет появился язык программирования D: мощный инструмент, сочетающий фундаментальность C и C++ с недосягаемой ранее высокоуровневостью. Главными "фишками" языка можно считать обилие синтаксиса, предназначенного для сокращения исходного кода программы (шаблоны, миксины и прочее) и множество ключевых слов для проверки и отладки кода. Хотя язык выглядит перспективным, у многих возникают проблемы с пониманием некоторых нововведений, поэтому меня попросили написать серию тематических статей по D, направленных в первую очередь на понимание языка, а не на сухое изложение законов и синтаксиса. Статьи предназначены в первую очередь для тех, кто уже начал читать учебники или другую обучающую литературу по D, и так или иначе знаком с основной терминологией языка, но у кого остаются вопросы и кто желает найти на них понятные ответы.
Скоупы (scopes): последовательность выполнения команд, статические и динамические скоупы, пространства имен
Я не могу придумать уместного перевода слова scope на русский язык, и чтобы не вводить никого в заблуждение, введу термин "скоуп" как новый. Проще всего понять скоуп как участок кода от фигурной скобки до фигурной скобки:
import std.stdio;
void main(){ // ^ это скоуп 1
int number = 0; // |
for( int i; i < 5; ++ i) // |
{ // | ^
number += 7; // | | это скоуп 2
} // | v
writeln( number); // |
} // v скоуп 1 закончился
Область кода за пределами всех скобок – это тоже скоуп, "outer", или внешний:
import std.stdio; //^ это внешний скоуп
//|
void main(){ //| ^ это скоуп 1
int number = 0; //| |
for( int i; i < 5; ++ i) //| |
{ //| | ^
number += 7; //| | | это скоуп 2
} //| | v
writeln( number); //| |
} //| v скоуп 1 закончился
//|
struct Struct //|
{}
Скоупы нужны для смыслового деления кода, для выделения участков кода, которые выполняются или компилируются в определенных условиях, для ограничения области перечисления полей в структурах и для многих других целей. Команды, помещенные в разные скоупы, ведут себя по разному.
Статические и динамические скоупы
Мы уже говорили, что все команды выполняются последовательно – одна за другой. Однако это происходит только в динамических скоупах. Существуют и другие – статические – скоупы. Например, когда мы рассматривали первые примеры, мы не говорили, для чего нужна область кода за пределами main(){…} и что означают команды там. Расписывая построчно алгоритм программы, мы эти команды не учитывали.
Дело в том, что в некоторых скоупах команды не выполняются одна за другой. Они вообще не выполняются в процессе работы программы, они только компилируются, то есть формируют программу. Раз это происходит до выполнения, то порядок, в котором учитываются эти операции, не важен, и они "выполняются" в произвольном порядке.
Рассмотрим пример:
import std.stdio;
import std.stdio;
import std.stdio;
int a = 5;
int b = 10; //!!!
int a = 5;
int b = 10;
int a = 5; //!!!
int b = 10;
void main(){
void main(){
void main(){
a = b;
a = b;
b /= 2; //!!!
b /= 2;
b /= 2;
a = b; // !!!
write( a,' ', b);
write( a,' ', b);
write( a,' ', b);
} // вывод: 10, 5
} // вывод: 10, 5
} // вывод: 5, 5
Обратите внимание на выделенные строчки: в них изменен порядок. В первом случае это не повлияло на работу программы, а во втором – повлияло. Почему?
Вспомните, мы говорили, что выполнение программы начинается после слов main(){ и заканчивается с закрывающей фигурной скобокой }. Таким образом, внешний скоуп оказывается статическим и команды, которые стоят в нем, компилируются, а не выполняются, их порядок следования не важен. К моменту запуска программы в ней уже есть две переменные a и b с заданными исходными значениями 5 и 10 соответственно.
Некоторые команды не могут быть написаны в статическом скоупе: например, оператор присвоения. Статические скоупы предназначены для оформления интерфейса кода программы, поэтому обычно используются для объявления глобальных переменных, формирования новых типов, классов, структур, назначения псевдонимов и прочего.
Скоуп main(){…}, помимо того, что является динамическим, то есть команды в нем выполняются по ходу работы программы, является еще и первым скоупом, с которого начинается выполнение любой программы.
Важно уметь различать динамические и статические скоупы. Динамическими скоупами являются: скоуп main(){…}, скоуп определния функции, скоупы условных операторов, циклов, ветвлений и других statement'ов, другие скоупы, которые открыты внутри динамических, если они не определены как статические.
Статические скоупы: внешний скоуп любого модуля, скоуп определения структуры или класса.
Следующий пример иллюстрирует эти скоупы. Здесь статические скоупы и команды помечены буквой s, а динамические – буквой d.
s import std.stdio; //внешний скоуп кода программы - статический
s
s struct MyStruct //далее следует определение структуры - статический
s {
s int number;
s
s void memberFunction() //определение члена-функции - динамический
d {
d number += 5;
d }
s }
s
s int MyFunction(int arg) //определение регулярной функции - динамический
d {
d return arg + 10;
d }
s
s void main(){ //main(){ открывает динамический скоуп
d
d int a;
d
d class MyCLass //определение класса - статический
s {
s double number;
s void memberFunction(int count) //определение члена-функции
d {
d for( int i; i < count; ++ i)
d {
d number *= 2;
d }
d }
s }
d
d if( a == 0) //скоуп услновного оператора if(...) - динамический
d {
d writeln( a);
d
d }
d
d { //просто внутренний динамический скоуп
d int b = a;
d writeln( b);
d }
d }
Обратите внимание на несколько вещей. Статические скоупы могут быть внутри динамических и наоборот, при этом они не конфликтуют: характер скоупа определяет внутренний скоуп, не влияя на внешний. Внешний скоуп продолжается после закрытия внутреннего и сохраняет свой характер. Может показаться странным, что код выполняется построчно сверху вниз, но при этом мы видим динамические скоупы до main() – это видно в члене-функции структуры и в регулярной функции. Действительно, если программа начинает выполняться с main(){, то как она попадет в эти скоупы сверху? Все просто: как только в теле main() появляется одна из этих функций, она начинает выполняться, при этом "указатель", который сопровождает выполняемые строчки, перескакивает в тело этой функции. Как только все строки внутри функции выполнены, указатель возвращается туда, откуда его вызвали. В теле функции может быть вызвана и другая функция – любое количество функций может выполняться одна внутри другой. При этом внешняя функция всегда ожидает выполнения внутренней. Подробнее об этой концепции можно узнать в главе "функции" (http://ddili.org/ders/d.en/functions.html).
В теле main()мы определили класс MyClass. Определение типа – это не выделение переменной, и по сути в программе при этом ничего не произошло. Можно сказать, что, хотя тип MyClass был определен в теле функции main(), это определение реализуется в момент компиляции, то есть до выполнения программы. Однако объявить переменную типа MyClass можно только ниже ее определения. Если в статическом скоупе порядок определения типов и объявления переменных неважен, то в динамическом даже те команды, которые не оказывают влияния на работу программы, распространяют свое действие только на последующие строки, но не на предшествующие.
Также заметим, что в конце main()мы свободно открыли и закрыли очередной скоуп. Этот скоуп, так же как и внешний по отношению к нему, стал динамическим. В языке D можно организовывать скоупы даже без их привязки к statement'ам, функциям, структурам или классам, однако это используется крайне редко и так можно делать только в динамическом скоупе.
Пространство имен
Каждый скоуп имеет свой интерфейс, который может отличаться от интерфейса других скоупов. Каждый скоуп определяет свое пространство имен, то есть набор имен переменных, функций, типов данных, модулей и прочего, к чему можно обращаться. Пространства имен подчиняются нескольким простым правилам:
Внутреннее пространство имен включает все внешние пространства, но не наоборот. Пример:
import std.stdio;
void main(){ // здесь начинается скоуп main()
int a; // переменная a объявлена скоупе main()
{
int b; // переменная b объявлена во внутреннем скоупе
write( a); // компилируется
write( b); // компилируется - обе переменных доступны
}
write( a); // компилируется - a доступна в main()
write( b); //!!! не копмилируется - b недоступна во внешнем скоупе
}
С того момента, как была объявлена переменная a, она доступна для обращения любой строчкой ниже. То же касается и переменной b. Однако переменная b была объявлена внутри вложенного скоупа, поэтому пространство имени переменной b начинается со строки ее объявления и заканчивается, как только заканчивается этот скоуп. Переменная же a доступна и во вложенном скоупе, она перестает быть доступной только по выходу из main(){...}. По этой причине последняя команда вывода на экран не может быть скомпилирована.
В принципе, то же правило касается всех скоупов, будь то статические или динамические скоупы любого типа.
Еще одно правило касается определений функций, наследованных классов и statement'ов. Суть в том, что все имена, которые были введены в списке аргументов функции, или использовались для определения цикла, или существовали в родительском классе, входят в пространство имен образовавшегося скоупа. Пример:
void main(){
int a;
for( int i; i < 10; ++ i)
{
a += i;
}
}
Как мы видим, переменная i была объявлена внутри определения цикла for, и она доступна в скоупе этого цикла.
Как известно, дважды вводить одно и то же имя в одном пространстве имен нельзя – такой код не скомпилируется. Есть одно исключение: при перечислении аргументов члена-функции класса или структуры может использоваться имя какого-то поля этого класса или структуры. Как говорят, оригинальное имя при этом "затирается", и по этому имени теперь можно обратиться только к аргументу функции. Но имя оригинального поля не пропадает: оно все еще доступно через ключевое слово this с точкой после него. Так, следующий код описывает класс, конструктор которого присваивает полю класса number значение своего аргумента, который называется так же:
class MyClass
{
int number; // объявили поле number
this( int number) // во вложенном скоупе второе объявление с именем
// number затирает его оригинальное значение
{
this.number = number; // number означает то, что было объявлено
// в списке аргументов функции
// старое значение number доступно через
// this.number
}
}
Рекомендация
В целом при размещении нужных команд внутри динамического скоупа у программиста имеется некоторая вольность, не говоря уже о статических. Хотя синтаксис D этого не требует, существуют "правила хорошего тона" в написании программ. Так, чаще всего вводить определения и объявления рекомендуется непосредственно перед тем, как ими начинают пользоваться. Если вы объявляете переменные непосредственно перед тем, как они входят в дело, отпадает необходимость комментариев: сразу понятно, для чего нужна эта переменная. Также место в памяти будет выделяться по мере надобности, а не сразу большим объемом, а если пространства имен уже использованных и уже ненужных переменных будут постепенно закрываться, то место будет освобождаться по мере выполнения программы и она в целом не займет много места в ОЗУ.
Так же стараются держать пространства имен по возможности беднее. Чем больше имен доступно в одном скоупе, тем больше вероятность запутаться в этом обилии имен и больше возможность того, что одно имя затрет другое (например, такое возможно при импорте сразу нескольких библиотек с конфликтующими именами). Для этих целей переменные объявляют так "глубоко", как это возможно, чтобы их пространства имен кончались возможно скорее. Также зачастую из библиотек импортируют только одну или несколько необходимых функций или типов, не импортируя ее целиком.
Существует и много других способов упростить код, сделать его читаемым, а программу – легковесной и эффективной. Язык D так богат, что в одной статье охватить все возможные советы по организации кода невозможно.
Чтение документации
Программист занимается решением комплексных задач. Эти комплексные задачи можно разложить на маленькие подзадачи, которые чаще всего оказываются типичными и уже кем-то когда-то решенными. Поэтому программирование нередко сводится к подбору и собирательству готовых решений в необходимый продукт.
Готовые решения – это, например, статические библиотеки, то есть модули. Чтобы использовать код этих модулей, их достаточно подключить, и тогда пространство имен этих модулей полностью или частично добавляется к пространству имен кода программы. Но чтобы грамотно использовать эти модули, нужно знать все особенности
Модули
Подробнее о модулях и библиотеках можно узнать здесь: http://ddili.org/ders/d.en/modules.html.
Каждый файл, содержащий исходный код программы – это модуль. При этом один модуль может использовать код другого модуля, для чего его нужно подключить. Делается это ключевым словом import, после которого пишется название модуля, который нужно подключить. При этом пространство имен подключаемого модуля полностью или частично прибавляется к пространству имен нашей программы, что позволяет нам вызывать функции, находящиеся в подключенном модуле, использовать определенные там типы и объявленные там переменные.
Язык D позволяет подключать сразу несколько модулей, импортировать только некоторые определения из модуля вместо того, чтобы импортировать его целиком, делать локальные импорты, расширяющие пространство имен только внутри какого-то скоупа, а не во всей программе, давать определениям из модуля новые имена – псевдонимы, разграничивать доступные и недоступные для импорта определения внутри модуля и делать многое другое. Следующий короткий пример пояснит некоторые из этих особенностей.
Файл mymodule.d:
/* Это модуль, который *
* называется "mymodule" */
module mymodule; // название модуля идет после слова module в первой некомментированной строке
// если название не указано, модуль называется так же, как файл, только без расширения .d
public: // по умолчанию все определения public: они доступны для импорта и обращения
int fastDoubling(int arg) // импортировав этот модуль, можно будет использовать эту функцию
{
if(arg >= 100){
import std.stdio : writeln; // локальный импорт: импортируемая функция writeln() доступна в этом скоупе
// также мы импортировали только одну функцию из модуля std.stdio, а не его содержимое целиком
writeln("Argument is too large, computing result in a long way..."); // импортированная функция доступна
return arg * 2;
} // скоуп закрылся, далее writeln(...) уже недоступна
return doubledNumbers[arg]; // эта функция возвращает удвоенное число, не тратя время на умножение
}
private: // ниже идет секция private: она недоступна для импорта, этот модуль пользуется ей сам
int[100] doubledNumbers; // к этому массиву нельзя обратиться в программе, куда импортирован модуль
static this(){ // этот раздел содержит операции, которые подготавливают модуль к работе
// это динамический скоуп, который выполняется однажды в начале работы программы, использующей модуль
foreach( i, ref element; doubledNumbers){
element += i * 2;
}
}
static ~this() // аналог static this() для корректного завершения работы модуля, выполняется один раз в конце работы программы
{}
/* в этом модуле нет main(), поэтому его нельзя использовать как программу *
* тем не менее, он компилируется в составе другого модуля, содержащего main() *
* как его часть. */
Файл app.d:
import std.stdio;
import mymodule; // теперь функция int fastDoubling(int arg) доступна
void main(){
int result = fastDoubling(15); // вызов функции, определенной в другом модуле
writeln( result);
}
Как мы видим, функция, определенная в модуле mymodule.d, доступна в другом модуле при подключении. Файл app.d компилируется и скомпилированный код выдает верный результат:
30
Остальные особенности работы с модулями и библиотеками вы найдете в соответствующих главах учебников и других источниках.
Чтение документации
Модульность языка позволяет вынести куски кода в отдельные файлы, каждый из которых будет специализирован для решения своего круга задач. Комплексные задачи, решаемые программистом, могут быть решены путем использования нескольких готовых модулей, подключенных к основному коду программы. Первое, с чем сталкивается программист – с библиотекой http://dlang.org/phobos/ - Фобос, которая является стандартной библиотекой языка D и просто импортируется в коде программы – стандартный путь к ней уже указан (если на компьютере правильно установлен компилятор). Впоследствии придется прибегнуть к помощи других библиотек и модулей, написанных обычными программистами или организациями и размещенных в сети. В любом случае, чтобы грамотно и эффективно использовать подключенный модуль, нужно уметь читать к нему документацию. Обычно в документации указывается лишь интерфейс тех определений, которые предоставляет модуль или библиотека. Принципы работы кода модуля не раскрываются, что соответствует принципу черных ящиков. Иногда модули оказываются плохо документированы или не документированы вовсе, и приходится разбираться с ними, читая исходный код или разбирая некоторые иллюстративные примеры. Для библиотеки Фобос по указанному адресу можно найти документацию ко всем ее модулям.
В модулях можно найти разные виды определений. Это могут быть функции, структуры, классы и интерфейсы. Для них обязательно указывается их интерфейс, то есть для функций это типы входных и выходных данных, возможные сторонние эффекты. Для классов, структур и интерфейсов это доступные пользователю (public) поля и члены-функции (методы). В модуле могут быть определены и другие новые типы: enum, union и другие. Могут быть введены alias – псевдонимы для каких-то типов или переменных. Могут быть объявлены некоторые переменные, часто это immutable или const, которые используются как часто встречающиеся в выражениях константы, или массивы констант, например, табулированные функции, или слова и выражения, из которых составляются выдаваемые на экран сообщения.
Первый шаг
Часто для пользования библиотекой не нужно знать всех подробностей. Объясним это на примере. Мы знаем, как в общем случае объявляется функция:
type_name function_name(type1 arg1, type2 arg2, /*...*/ typen argn)
{
/*...*/
}
Объявление состоит из типа, который возвращает функция, ее названия и списка аргументов в скобках, где указывается тип и имя для каждого аргумента парами, которые перечисляются запятой. Фигурные скобки, ограничивающие тело функции, не указываются в документации.
Однако в действительности мы можем встретить более сложный и непонятный интерфейс функции:
/* ? ? ? ? type func_name ? arg_type arg */
pure nothrow @property @safe void timeOfDay( in TimeOfDay tod);
(взято из модуля std.datetime: http://dlang.org/phobos/std_datetime.html#.DateTime.timeOfDay)
Такого кода не нужно пугаться. Определения в документации могут содержать много непонятных слов, но структура у этого определения всегда одна и та же. Первое, что нужно делать всегда – выделить самое главное. Отбросив лишнее, от нашего "страшного" определения останется:
/*type func_name arg_type arg */
void timeOfDay( TimeOfDay tod);
Теперь это функция, которая называется timeOfDay, не возвращает никакого значения (тип – void) и имеет один аргумет tod типа TimeOfDay. В большинстве случаев это все, что нужно знать о timeOfDay для того, чтобы использовать ее в своей программе. Поясним, по какой схеме нужно отбрасывать "лишние" слова для получения красивого и краткого определения.
Для функций
От функции должна остаться такая схема:
type_name function_name(types arguments);
где типы аргументов и их имена идут парами через запятую. Этого достаточно, чтобы вызвать функцию – по ее названию – и "скормить" ей все необходимые ей параметры нужных типов, а также использовать результат этой функции, зная ее тип. Название функции и название ее аргументов обычно позволяет понять, для чего эта функция нужна и что она делает. Если этого недостаточно, то смысл функции можно понять из комментариев в коде модуля, в документации или по примерам.
Если перед типом какой-то переменной стоит ключевое слово in, out или ref, то имеет смысл урезать определение до такого состояния, где в списке аргументов могут встречаться не только пары, но и тройки:
type_name function_name(keywords types arguments);
Здесь вместо keywords перед некоторыми из аргументов могут стоять перечисленные выше ключевые слова. Их нельзя опускать, потому что они непосредственно связаны с тем, что и как делает функция, в частности, с ее сторонними эффектами. Так, некоторые аргументы функции могут выступать в роли результатов ее работы, а не входных данных.
Аргументы, не помеченные никаким из этих ключевых слов, обычно передаются как копии – в функцию попадают копии значений этих параметров. Поэтому функция может использовать их только как входные данные, модификация этих значений не влияет на те переменные, которые были переданы в качестве параметров. Исключение составляют ссылочные типы, такие как классы, слайсы или псевдонимы: функция может обращаться непосредственно к их значениям и модифицировать их.
ref – это ключевое слово делает аргумент ссылочным типом для функции. Поэтому функция может использовать такие аргументы и как входные данные, и может модифицировать сами переменные. Часто используется для подобных функций:
void Twice( ref int arg)
{
arg *= 2;
}
Эта функция удваивает значение своего аргумента. Можно сказать, что аргумент такой функции является и входным данным, и результатом ее работы одновременно. Отметим, что в качестве такого параметра нельзя передать литерал:
twice( 5); // <- compilation error
in – таким ключевым словом помечаются аргументы, которые функция может использовать только как входные данные. Функция не может модифицировать их значения, только считывать.
out – таким ключевым словом помечаются аргументы, которые могут служить только результатом работы. Они передаются по ссылке, то есть функция может модифицировать их значения, а кроме того, перед попаданием в функцию их значения обнуляются – им присваивается значение type.init – исходное значение для данного типа. Таким образом, функция никак не может использовать их как входные данные. Очевидно, что таким параметром не может быть литерал, так же как и для ref.
Приведя мысленно функцию в описанному выше виду, ей уже можно пользоваться и в большинстве случаев этого достаточно. Все остальные ключевые слова используются для наложения каких-то ограничений или для указания каких-то особенностей в работе функции, которые редко влияют на способ ее использования в программе. Также в определении может попасться две пары скобок. В этом случае первое внимание должно быть обращено на вторую пару скобок, где перечислены аргументы функции. Первые скобки содержат аргументы шаблона, которые мы рассмотрим ниже.
Итак, общая схема такова: оставляем два последних слова перед скобками (это будут тип, возвращаемый функцией, и ее название), если есть две пары скобок, то смотрим только на вторые, а в скобках оставляем только пары слов, перечисленные запятыми – это будут типы и названия аргументов. Также обращаем внимание на слова in, out, ref - такие слова образуют уже не пары, а тройки из ключевого слова, типа и названия аргумента. Все то же самое относится и к методам, входящим в состав классов, структур и интерфейсов, потому что они по своей сути – те же функции. У них ключевые слова могут появляться также и после скобок, на первом шаге их все можно игнорировать.
Для структур, классов и интерфейсов
Обычно структура, класс или интерфейс объявляются просто:
struct Struct;
class Class;
interface Interface;
Смысл обычно имеет не название структуры, класса или интерфейса, а поля и методы. В документации они, как правило, указываются ниже, равно как и в коде программы. На сайте библиотеки Фобос все доступные поля и члены-функции указаны рядом с объявлением структуры, класса или интерфейса, по ссылкам можно перейти к их определениям.
Поскольку поля и методы, входящие в состав структур, классов и интерфейсов, определяются так же, как и все обычные переменные и функции, на них можно отдельно не останавливаться: правила для чтения документации к ним те же.
Большое внимание нужно уделять родительским классам и интерфейсам, если класс или интерфейс являются наследованными. Все поля и члены-функции, доступные в родительском классе/интерфейсе, доступны и в наследованном. Список родительских классов и интерфейсов следует после двоеточия после названия класса или интерфейса:
class SubClass : SuperClass, Interface1, Interface2;
Также после названия класса, структуры или интерфейса могут следовать скобки. Это те же скобки, которые могут появиться перед списком аргументов у функции: это список параметров шаблона, который мы рассмотрим позже.
Для всего остального
Модуль может предоставлять enum, который является набором именованных констант, чаще всего используется для задания режимов работы, хранения информации и прочего. Модуль может объявлять какие-то переменные, обычно это просто константы, которые часто используются. Часто объявляются неизменные строки типа immutable (string), которые служат какими-то названиями, сообщениями или словами, которые должны выводиться на экран или просто использоваться в работе программы. Все эти вещи определяются и объявляются самым обычным образом, и в документации определения к ним обычно представлены полностью.
Второй и последующие шаги
Если минимальной информации об определениях модуля недостаточно, нужно разбираться со всем остальным. Как мы увидели раньше, наибольший интерес в этом смысле представляют функции. Базовые типы и производные от них: константы, енумы, псевдонимы и прочее – определяются обычным образом. Структуры, интерфейсы и классы содержат в своем составе обычные поля и методы, которые тоже являются функциями. Поэтому разберем наиболее общий случай функции.
Определение функции в общем виде может выглядеть так:
keywords... behavior... safety_level type_name function_name(keywords types args = default) keywords...;
Здесь подчеркнуты обязательные слова, без которых объявить функцию нельзя. Как мы видим, это те самые слова, которые мы оставляем, урезая функцию до минимума. Рассмотрим, что может означать каждое слово в этой схеме.
Типы
В указанной схеме тип фигурирует дважды: как тип, который возвращает функция type_name, и как тип аргументов types. Стоит отдельно подчеркнуть, что тип необязательно состоит из одного слова. Несколько примеров:
int
int[5]
int[string]
const int
immutable int
immutable(int)[]
Все эти записи представляют собой тот или иной тип. Особого внимания заслуживают типы, помеченные как const или immutable – хотя название типа состоит из двух слов, разделенных запятой, оба эти слова, упомянутые в определении функции, будут относиться к типу, поэтому слово const или immutable не входит в keywords:
/* keyword |--type--| arg */
int twice( ref const int arg)
{
int result = arg * 2;
return result;
}
То есть в общей схеме type_name и types может быть представлен комбинацией слов, а не только одним словом. Чтобы не запутаться, можно делать запись без пробелов:
int twice( ref const(int) arg);
Тип, возвращаемый функцией, может быть обозначен как auto. В этом случае этот тип выводится компилятором на основании выражения, возвращаемого оператором return. Для аргументов функции ключевое слово auto недопустимо.
Здесь же стоит отметить ключевое слово inout. Мы уже рассмотрели другие ключевые слова, которые могут характеризовать, каким образом в функции используется тот или иной аргумент. Перед каждым перечисленным аргументом в списке может быть или не быть одно ключевое слово, то есть аргументы могут следовать не парами между запятыми, а тройками. inout – еще одно ключевое слово, которое может появиться наряду с ref, in или out. Но его значение связано с типом функции и аргумента, поэтому оно вводится здесь, а не раньше.
Слово inout обязательно появляется в определении дважды: перед типом функции и перед одним из аргументов:
inout int twice( inout int arg);
Это ключевое слово означает, что модифицируемость (mutability) функции определяется модифицируемостью ее аргумента. Модифицируемость может быть трех видов: mutable (устанавливается по умолчанию), const и immutable. Со словом inout результат, который возвращает функция, имеет ту же mutability, что и аргумент. По этой причине часто пишут так:
inout(int) twice( inout(int) arg);
Эта запись может быть более понятна: вместо inout может стоять слово const, immutable или ничего не стоять в зависимости от того, от какого параметра вызывается функция. Из последнего кода можно сделать вывод, что inout можно отнести как к типу, так и к ключевому слову в перечислении аргументов. Однако стоит помнить, что нельзя одновременно использовать одно из слов ref, in или out вместе с inout.
keywords...
Рассмотрим ключевые слова, которые могут находиться перед именем функции. Это могут быть: ref, inout или auto ref. inout мы уже рассмотрели: он обязательно появляется дважды – перед названием функции и перед одним из аргументов. ref означает примерно то же, что и ключевое слово ref для аргументов. Аргументы, помеченные словом ref, передаются как псевдонимы (alias) параметров. Точно так же результат, возвращаемый функцией, помеченной как ref, возвращается как псевдоним одной из переменных в теле функции. Хороший пример:
ref int greater(ref int first, ref int second)
{
return (first > second) ? first : second;
}
Эта функция возвращает больший из двух своих аргументов по ссылке, то есть не копируя его значение. Использование ключевого слова ref перед функцией имеет определенные ограничения и особенности, о которых можно в подробностях узнать в соответствующей главе учебника или в других источниках (http://ddili.org/ders/d.en/functions_more.html).
Ключевое слово auto ref позволяет функции не только самостоятельно вывести возвращаемый тип, если он не указан, но и возвращать результат по ссылке в тех случаях, когда это возможно. Это аналог одновременного использования типа auto и ключевого слова ref, причем ref работает только тогда, когда это возможно.
behavior...
Разные функции ведут себя по-разному, и можно выделить некоторые определенные виды функций, обладающих некоторыми свойствами. Обеспечить эти свойства – забота программиста, но если они обеспечены, то можно пометить функцию определенным образом и тем самым гарантировать, что условия соблюдены. Функция может быть помечена как pure, nothrow и @nogc. У одной функции могут быть прописаны одновременно несколько из этих свойств.
pure
"Чистые" функции, помеченные как pure, обладают следующими свойствами: они не меняют модифицируемого глобального или статического состояния программы. Простыми словами, эти функции не производят никаких сторонних эффектов, они могут только возвращать значение. Понятие чистых функций важно потому, что им отдается предпочтение: они всегда просты и понятны. Эти функции не могут ничего печатать на экран, не могут обращаться к модифицируемым глобальным переменным (то есть не внутренним, объявленным не в теле функции) , они могут только использовать свои аргументы или обращаться к глобальным немодифицируемым переменным (константам). Общий вывод из этого можно сделать такой: чистые функции для любого состояния программы возвращают одни и те же значения для одних и тех же значений параметров, то есть работа таких функций зависит только от параметров.
О том, чтобы функция была чистой, должен позаботиться программист. Слово pure ставится для того, чтобы компилятор проверил ее чистоту при компиляции. В случае, если условия нарушены, компилирование будет невозможно: будет выдана ошибка. Поэтому функция, помеченная в документации как pure, в самом деле является чистой.
Некоторые из условий чистоты все-таки можно нарушать: исключения вводят обычно для того, чтобы было удобнее отлаживать функцию в процессе разработки модуля. При чтении документации мы чаще всего имеем дело с законченными проектами, так что эта особенность может не учитываться.
Очевидно, чистая функция в своем теле может вызывать только чистые же функции. Также это касается наследования у классов и интерфейсов: если в родительском классе метод был помечен как чистый, в подклассе он обязательно должен быть переписан также как чистый. Нечистые же методы можно переписывать как чистыми, так и нечистыми методами.
nothrow
Ключевым словом nothrow помечаются функции, которые не могут выдавать ошибки. Это единственное условие, которому должны удовлетворять такие функции. Если вы собираетесь отлавливать ошибки, то из такой функции вы точно никогда ничего не поймаете: при любом состоянии программы и любых значениях аргумента она хоть как-нибудь да выполнится нормально. Остальные особенности использования этого слова такие же, как и для pure.
@nogc
GC – Garbage Collector – сборщик мусора, который автоматически работает в программах, написанных на D. Сборщик мусора позволяет программисту не заботиться о возвращении занятой памяти и о распределении объектов в памяти компьютера: это происходит автоматически и по возможности наиболее оптимальным способом. Взамен сборщик мусора занимает некоторые ресурсы компьютера. В некоторых редких случаях память может быть распределена вручную, или строгий контроль за этим вовсе не требуется. В этом случае функции, не содержащие операций, вызывающих работу сборщика мусора, помечаются как @nogc, что гарантирует, что сборщик мусора в этой функции не используется. Работа сборщика мусора обычно связана с модификацией слайсов, с работой с классами и другими операциями. Для нас это означает, что если мы не хотим использовать сборщик мусора, то мы должны отдавать предпочтение функциям @nogc. Остальные особенности использования этого слова такие же, как и для pure и nothrow.
safety_level
В зависимости от того, может ли функция в каком-то своем состоянии ввести память компьютера в некорректное состояние, различают различные уровни безопасности функций. Под некорректным состоянием памяти компьютера можно понимать занятую область памяти, ссылка на которую потеряна, или наличие рабочей ссылки на нерабочую область памяти, или обращение к памяти с одним типом данных как к другому типу данных, или что-то другое. Чаще всего к таким состояниям может привести ручная работа над ссылками и адресами.
@safe
Если функция не содержит таких потенциально опасных операций, то ее можно пометить как @safe. Компилятор будет гарантировать безопасность функции. Нарушение этого условия не позволит скомпилировать функцию, а мы можем быть уверены, что ее использование безопасно. К уровням безопасности относится правило, аналогичное рассмотренным поведениям (behavior) функций: @safe функция может вызывать только @safe функции и переписываться в подклассах тоже только @safe функциями.
@trusted
Если функция не может быть помечена как @safe (например, из-за использования в ее теле потенциально опасных методов и функций), но программист доверяет ей и считает, что она, несмотря на это, не может привести к некорректной работе памяти, ее можно пометить как @trusted. Для того, кто читает документацию, @safe и @trusted практически эквивалентны: разработчик модуля уже проверил функцию и он уверен, что она безопасна настолько же, насколько могла бы быть @safe.
@system
Ключевым словом @system помечаются функции, не удовлетворяющие вышеперечисленным требованиям безопасности. Уровень безопасности @system функции получают по умолчанию, так что можно сказать, что все функции, не помеченные словами @safe или @trusted, обладают уровнем безопасности @system. В документации словом @system можно только подчеркнуть, функция не является ни @safe, ни @trusted.
keywords... после функции
Ключевые слова, относящиеся к функции, могут стоять как до, так и после ее названия, как показано на схеме. После функции обычно идут ключевые слова, которые характеризуют функцию как метод класса, структуры или интерфейса, то есть отмечающие какие-то ее особенности по отношению к другим методам или полям. На деле порядок следования ключевых слов и их расположение до и после названия функции не имеет никакого значения, поэтому в общей схеме определения функции keywords... , которые стоят до и после названия функции со скобками, равнозначны. Например, в документации к библиотеке Фобос все ключевые слова следуют до типа функции и ее названия.
Здесь я введу ключевые слова, которые характеризуют функцию как метод какой-то структуры, класса или интерфейса.
const
Ключевое слово const гарантирует, что функция не изменяет объекта, на котором она вызвана. Чаще всего это функции, которые предоставляют какую-то информацию об объекте (структуре или классе), но не изменяют ее полей. Например, функция, которая печатает на экран остаток топлива в баке автомобиля может быть помечена как const, а функция, которая увеличивает количество топлива – не может.
inout
Как уже было сказано, ключевое слово inout всегда появляется дважды в определении. Один раз это слово определяет модифицируемость типа результата, который возвращает функция. Случай, когда вид этой модифицируемости выводится из аргументов функции, мы уже рассмотрели. Модифицируемость также может быть выведена из модифицируемости всего объекта, на котором вызван метод: структуре или классе. Для этого второй раз ключевое слово inout употребляется не перед одним из аргументов, а после скобок списка аргументов:
struct Struct
{
int number;
inout(int) memberFunc() inout
{
return number;
}
}
Метод memberFunc возвращает число int такой же модифицируемости, какой обладает весь объект Struct:
immutable(Struct) S = Struct(10);
auto num = S.memberFunc;
assert( is( typeof( num) == immutable(int) ) );
// тип num это immutable(int)
keywords при аргументах функции
Мы уже рассмотрели некоторые ключевые слова, которые могут характеризовать аргументы функции: refv, in, out и inout. Рассмотрим еще три, которые меньше влияют на способ использования функции, только на ее работу, а потому не были упомянуты раньше.
lazy
Ключевое слово lazy меняет порядок вычисления функций, если параметры одной функции зависят от результатов другой. Рассмотрим пример:
a = Div( Sum( b, c), d);
В этом примере сначала вычисляется значение функции Sum от параметров b и с, а затем этот результат используется как первый параметр функции Div: функция Sum вычисляется первой. Однако на ноль делить нельзя, поэтому если d=0, то в вычислении Sum( b, c) нет никакого смысла: это окажется пустой потерей времени. По этой причине первый аргумент функции Div может быть помечен как lazy (ленивый):
int Div( lazy int arg1, int arg2)
{
if( arg2 == 0){
assert( 0, "На ноль делить нельзя!");
}
return arg1 / arg2; // <- Sum начнет вычисляться здесь
}
В этом случае вычисление функции Sum будет отложено до тех пор, пока оно не потребуется в теле функции, что позволяет иногда избежать выполнения лишних операций. Если вторым параметром функции Div окажется 0, будет возвращена ошибка, а Sum не будет вычислено вовсе. В качестве такого параметра по-прежнему можно передавать переменные или литералы, это необязательно должны быть функции, хотя свойство lazy повлияет только на случай, когда параметром является результат вычисления функции.
scope
Ключевое слово scope гарантирует, что этот аргумент не будет использован за пределами скоупа функции. На способе использования функции это вообще никак не отражается. Кроме того, особенность scope в контексте ключевого слова аргумента функции не поддерживается версией компилятора dmd 2.066.1, то есть последней на данный момент версией этого компилятора, поэтому встретить это слово в документации к какому-то модулю практически невозможно.
Ключевое слово shared требует, чтобы помеченный этим словом параметр был shared – то есть расшаренным между процессами. В качестве такого параметра может использоваться только переменная, помеченная как shared. Это понятие относится к паралеллизму и расшариванию памяти и не будет затронуто здесь более подробно. Просто помните, что shared аргументы должны быть расшарены.
Значения по умолчанию
Если после названия аргумента функции стоит знак равно и после него следует какое-то значение, это означает, что для этого аргумента задано значение по умолчанию. В этом случае значение этого параметра при вызове функции можно опускать: тогда аргумент примет свое значение по умолчанию.
int Sum( int a, int b, int c = 0)
{
return a + b + c;
}
В приведенном примере для аргумента c задано значение по умолчанию 0. Функция теперь может быть вызвана как от трех, так и от двух параметров, при этом она работает так, как если бы значение последнего параметра было равно 0:
assert( Sum( 5, 5, 5) == 15);
assert( Sum( 5, 5) == 10);
Того же эффекта можно добиться, если перегрузить функцию для трех и для двух аргументов, что является неудобным как для разработчика модуля, так и для пользователя.
Значения по умолчанию можно задавать только для аргументов, которые идут в конце:
int Sum( int a, int b, int c = 0); // <- допустимое определение
int Sum( int a, int b = 0, int c = 0); // <- допустимое определение
int Sum( int a = 0, int b = 0, int c = 0); // <- допустимое определение
int Sum( int a = 0, int b, int c); // <- ошибка компиляции
int Sum( int a = 0, int b = 0, int c); // <- ошибка компиляции
Значение по умолчанию – это необязательно литерал. Это может быть и какое-то более сложное выражение:
class Class
{}
void func( Class arg = new Class() );
Для пользователя модуля главное запомнить, что если для аргумента задано значение по умолчанию, то при вызове функции параметр можно опускать, при этом в функцию в качестве пропущенного параметра будет передано значение по умолчанию.
Произвольное количество параметров
Хотя мы уже рассмотрели схему функции, представленную выше, в полном объеме, сама эта схема не полна. Существует еще три особенности, которые нужно рассмотреть.
type_name function_name(types args ...);
(в этой схеме рассмотренные выше необязательные особенности опущены, но они могут также использоваться одновременно с произвольным количеством параметров)
Мы видим, что на конце списка аргументов в скобках стоит троеточие. Это означает, что количество параметров, от которых вызывается функция, произвольно. Рассмотрим пример:
int Sum( int[] arg ...);
(обратите внимание на то, что тип аргумента должен быть обязательно определен как слайс)
Эта функция может быть вызвана от произвольного количества слагаемых и возвращает их сумму:
int sum = Sum( 1, 2, 3, 4);
assert( sum == 10 );
Функции, у которых количество параметров может меняться, называются вариадическими (variadic functions). Известный пример такой функции – функция writeln(...) из модуля std.stdio, которая может одновременно принять и вывести на экран любое число параметров.
Тип аргумента должен быть слайсом потому, что в тело функции этот аргумент попадает и используется как слайс. Однако квадратные скобки – всего лишь часть синтаксиса произвольного количества параметров, и это не означает, что в качестве параметра должен быть передан слайс. Пример выше демонстрирует это. Вместе с тем, вместо перечисления нескольких параметров можно передать и один массив – слайс:
int[] array = [1, 2, 3, 4];
int sum = Sum( array);
В списке аргументов функции может быть только один аргумент с такой особенностью, и он должен стоять в конце.
void func( int arg1, int arg2, int[] args ...); // <- допустимое определение
Также отметим, что произвольное количество аргументов подразумевает также и нулевое их количество. В этом случае в функцию будет передан пустой слайс, и поведение функции в этом случае остается на усмотрение программиста, разрабатывавшего модуль.
int sum = Sum();
assert( sum == 0);
Шаблоны
Иногда встречается схема:
type_name function_name(...)(types args);
Между названием функции и списком аргументов появляются еще одни скобки. В этом случае функция становится шаблоном (template), а вместо многоточия в скобках указываются параметры шаблона. Шаблонами могут также быть структуры, классы, интерфейсы и практически любые другие определения, доступные в D. Параметром шаблона необязательно должно быть значение (как в случае параметров функции), чаще всего это тип, но может быть и что-то другое. Благодаря шаблонам становится возможным, например, вызывать функцию от параметров разных типов, не перегружая ее, то есть составив только одно ее определение в исходном коде программы. Шаблоны предоставляют и множество других преимуществ.
Шаблоны-функции
Сначала мы рассмотрим шаблоны-функции, а затем распространим эту особенность на более общий случай. Первое представление о шаблонах можно получить из примера:
T Sum(T)( T a, T b)
{
T result = a + b;
return result;
}
Мы видим, что тип, возвращаемый функцией, а также типы аргументов и тип внутренней переменой result определены как T. Для каждого места в коде программы, где такая функция вызывается, выводится (instantiate) определенный вариант этой функции, соответствующий условиям вызова, а вместо слова T везде ставится какой-то конкретный тип:
void main(){
int a, b;
int sum = Sum!int( a, b);
}
Чтобы определить, для какого типа мы хотим вывести функцию, этот тип пишется после восклицательного знака при вызове функции. В нашем случае будет вызвана функция такого вида:
int Sum( int a, int b)
{
int result = a + b;
return result;
}
Которая просто вернет сумму своих аргументов. Если функция-шаблон используется в программе много раз для разных типов, то для каждого случая будет выведена (instantiated) своя функция, специализированная под конкретный тип. Таким образом, особенность шаблонов успешно борется с необходимостью перегрузки функций для разных типов аргументов, разных возвращаемых типов и разных типов внутренних переменных функций. Тип T – это и есть параметр шаблона, в этом случае параметр единственный.
В случае, когда шаблоном является функция, необязательно указывать конкретный тип для вывода шаблона после восклицательного знака: компилятор может вывести его сам по параметрам, поскольку их типы ему известны:
int sum = Sum( a, b); // <- допустимый вызов функции
Проще всего понимать шаблоны как функции, у которых есть два списка аргументов: аргументы самой функции (это значения, данные) и аргументы шаблона. У этих списков аргументов есть много общего: оба пишутся в скобках, причем первые скобки – аргументы шаблона, а вторые – аргументы функции. В этом есть своя логика, потому что шаблоны – особенность, которая проявляется на этапе компиляции, то есть раньше, а аргументы шаблона используются уже при работе программы собственно в вычислениях, то есть позже. Действительно, вывод нескольких вариантов одной и той же функции под разные типы является ни чем иным, чем перегрузкой этой функции, с той разницей, что теперь это забота не программиста, а компилятора.
Аргументы шаблона и аргументы функции имеют и другие схожие черты. Например, список аргументов шаблона может содержать несколько аргументов, разделенных запятыми, а также давать аргументам значение по умолчанию:
T[] array( Count, T = int)( Count count)
{
T[] result;
for( Count i; i < count; ++ i){
result ~= T.init;
}
return result;
}
Эта функция возвращает слайс типов T, состоящий из исходных значений этих типов, причем длина массива определяется параметром функции count типа Count, а тип слайса по умолчанию – int:
assert( array!(int, int)(3) == [0, 0, 0] );
assert( array!(int)(3) == [0, 0, 0] );
assert( array(3) == [0, 0, 0] );
Особые выводы
Хотя использование шаблонов само по себе предотвращает необходимость перегружать функции, иногда в документации можно встретить повторяющиеся шаблоны-функции с одним и тем же названием. Обычно при этом в таких определениях в списке аргументов шаблона присутствует двоеточие:
T Sum(T)( T a, T b)
{
T result = a + b;
return result;
}
T Sum(T : string)( T a, T b)
{
T result = a ~ b; // <- здесь другой оператор!!!
return result;
}
Это так называемая специализация (specialization) шаблона для определенных типов. Это делается в том случае, когда для некоторых типов функция должна работать иначе, чем для всех остальных. Таких специализаций может быть несколько, и в случае, когда функция в программе вызывается для одной из своих особых специализаций, она выводится не по общему правилу, а по особенному.
Другие виды параметров шаблона. Параметры-значения
Мы уже увидели, что параметрами шаблона могут быть типы. Но параметры могут быть также и других видов: значения или кортежи. Например, эта функция:
string line( int length)( char letter)
{
string result;
for( int i; i < length; ++ i){
result ~= letter;
}
return result;
}
возвращает строку, состоящую из символа , определяемого ее аргументом, длиной length:
assert( line!4('b') == "bbbb");
Параметры шаблона-значения также могут иметь значение по умолчанию, которое указывается после знака =.
На первый взгляд может показаться, что передавать значения в качестве параметров шаблона бессмысленно: значения можно передать и через параметры функции:
string line( int length, char letter)
Однако параметры шаблона передаются при компиляции, это особенность времени компиляции, а не выполнения программы, поэтому в некоторых случаях приходится делать значения параметрами шаблона (яркий пример такого использования будет приведен ниже для шаблонов-классов).
Ключевые слова для параметров-значений
В некоторых случаях при определении значений-параметров шаблона используются ключевые слова, в результате чего эти параметры при работе программы (вообще – при ее компиляции) самостоятельно принимают те или иные значения. Для этого одно из ключевых слов указывают после знака =, как если бы для параметра было указано значение по умолчанию. Ключевые слова могут быть следующими: __MODULE__ __FILE__ __LINE__ __FUNCTION__ __PRETTY_FUNCTION__. Через такие параметры шаблон может получать информацию о том, в каком месте исходного кода программы он был выведен и вызван: это может быть определенная строка, имя файла или название функции, внутри которой произошел вызов шаблона. Поскольку пользователю подключенного модуля не нужно вручную вписывать значение этих параметров (они определяются компилятором сами) и поскольку они влияют только на внутреннюю работу вызванной функции, пользователь в большинстве случаев может игнорировать такие параметры шаблонов.
Еще одна особенность касается методов структур и классов:
class Class
{
void foo(this Type)()
{}
}
Здесь шаблоном является метод foo класса Class, и его параметр шаблона помечен как this. Через такой параметр в шаблон передается тип объекта, на котором вызван метод (в данном случае это будет Class). Этот параметр также передается автоматически, и эта особенность может в большинстве случаев не учитываться пользователем модуля.
Передача параметра шаблона через псевдоним
Помимо слова this, перед названием параметра шаблона может следовать слово alias. В этом случае параметр не получает значения сам, но вместо того, чтобы принять то значение, которое ему будет присвоено пользователем, он становится псевдонимом для него:
void Set( alias obj)( int arg)
{
obj = arg;
}
void main(){
int num;
Set!num(5);
assert( num == 5);
}
В этом примере функция Set выводится для переменной num, в результате чего num получает псевдоним, через который функция может обращаться к переменной и менять ее.
Параметры-кортежи
Кортежи (tuples) – еще одна интересная особенность языка D. Мы знаем, что в D есть переменные, и что из них можно составлять массивы. Мы также знаем, что есть другой вид информации, который так или иначе можно передавать или использовать – типы. Кортежи позволяют передавать значения или типы по нескольку сразу, как если бы типы можно было объединять в массивы и смешивать со значениями или названиями переменных. Одним из примеров кортежа может служить список аргументов, который перечисляется через запятую при объявлении функции. Еще одним примером кортежа может служить список параметров, который также перечисляется через запятую и передается в функцию уже при ее вызове.
Кортежам и работе с ними посвящена отдельная глава учебника: http://ddili.org/ders/d.en/tuples.html. Здесь мы рассмотрим только один пример, демонстрирующий возможность применения кортежей в качестве параметров шаблона:
void info(T...)(T args){}
Пример взят отсюда: http://ddili.org/ders/d.en/templates_more.html.
Здесь из-за троеточия после слова T этот параметр шаблона становится уже не типом и не значением, а кортежем, причем кортежем вариадическим (переменной длины, variadic tuple). Поскольку аргумент args обозначен как аргумент, имеющий тип T, то он сам по себе тоже становится кортежем. Разница в том, что T выражает собой кортеж, состоящий из типов, а args –из значений. Пример использования такой функции:
info(1, "abc", 2.3);
Поскольку мы используем шаблон-функцию, то нет надобности отдельно выводить такой шаблон: значение кортежа типов T компилятор может вывести сам, зная кортеж значений args, который указан в скобках как параметры функции.
Другие виды шаблонов
Выше мы проиллюстрировали функции-шаблоны – наиболее простой случай использования шаблонов. Однако эта особенность может также использоваться и с другими объектами в D. Например, шаблонами могут быть классы, структуры, интерфейсы, юнионы (union) и любые другие определения. Чтобы сделать тот или иной объект шаблоном, достаточно после его названия добавить круглые скобки, внутри которых указать список параметров шаблона. Вот простой пример шаблона-класса, который одновременно поясняет синтаксис и раскрывает некоторые полезные возможности использования шаблонов такого вида:
class Vector( T, int dim = 3)
{
T[dim] coord;
}
Этот класс может с равным успехом представлять вектор в одномерном, двумерном, трехмерном или любой другой мерности пространстве, причем его координаты могут быть как целочисленными, так и дробными, с любой степенью точности. По умолчанию вектор становится трехмерным. Пример использования:
auto vect = new Vector!( double, 2)();
Теперь переменная vect представляет собой двумерный вектор с координатами типа double.
Ограничения на параметры шаблона
На параметры шаблона можно накладывать ограничения, которые будут проверяться и иметь силу на этапе компиляции программы. Мы рассмотрим эту особенность на примере шаблонов-функций. Допустим, мы увидели где-то синтаксис по такой схеме:
type_name function_name(...)(types args) if(...);
В этой схеме мы видим, что сразу после названия функции-шаблона и списка аргументов следует оператор if. Это – ограничения (constraints) на параметры функции. Эта особенность работает только с шаблонами. if, который стоит сразу после определения и непосредственно перед телом функции, работает практически так же, как и обычный if: тело функции выполняется (на самом деле – компилируется) только в том случае, если выражение внутри if верно (true). Разница в том, что этот if – статический, то есть он выполняется на этапе компиляции, а не при работе программы. На самом деле, если этот if не будет удовлетворен, то функция не скомпилируется вообще. Как следствие, выражение в этом if не может содержать локальных или глобальных динамических переменных, оно не может зависеть от значений параметров функции, потому что эти значения доступны только в процессе работы программы. Поскольку этот if выполняется на этапе компиляции, то невыполнение условий, заключенных в скобки, влияет не на работу программы, а на процесс компиляции: компилятор выдаст ошибку. Таким образом, ограничения при шаблонах используются скорее для отладки кода программы, чем для совершения каких-то операций в процессе ее работы. Рассмотрим уже упомянутый выше пример:
T Sum(T)( T a, T b)
{
T result;
result = a + b;
return result;
}
Как уже говорилось, при различных значениях параметра шаблона T, который отражает тот или иной тип данных, эта функция может складывать два числа любого типа.
int a = 5;
int b = 6;
int sumi = Sum!(int)( a, b); // для целых чисел
assert( sumi == 11);
double c = 1.25;
double d = 1.5;
double sumd = Sum!(double)( c, d); // для дробных чисел
assert( sumd == 2.75);
Хотя оператором "+" нельзя складывать строки, функция Sum, согласно определению, может быть выведена для любого типа:
string a = "aaa";
string b = "bbb";
string sum = Sum!(string)( a, b); // для строк
При таком использовании этой функции компилятор, очевидно, выдаст ошибку компиляции, но эта ошибка будет в теле функции, то есть в готовом, проверенном и готовом к использованию модуле. Для программиста, который подключил такой модуль, это может быть неожиданно, хотя на самом деле проблема в том, что он неправильно использовал функцию. (Выше мы боролись с подобной проблемой с помощью специализации, но в таком случае кроме string все равно остается множество типов, которые нельзя складывать оператором +, например, char). Чтобы исключить такие случаи, на параметры накладывают ограничения:
T Sum(T)( T a, T b)
if( is( T == int) || is( T == double));
Условия, указанные в ограничениях, проверяются при компиляции и при несоблюдении вызывают ошибки компиляции, которые указывают на этот раз на неверное использование функции, то есть на ошибку со стороны использования модуля, а не на ошибки в модуле. В рассмотренном примере наложенное ограничение позволит использовать функцию только с типами int или double.
Ограничения и специализация во многом выполняют схожие функции. Можно, например, перегрузить представленную выше функцию таким образом:
T Sum(T)( T a, T b) if( is( T == string) )
Разница в определении лишь в том, что на этот раз ограничение допускает использование только типа string, название же функции, ее возвращаемый тип и параметры функции и шаблона остались прежними. Таким образом, мы создали две функции-шаблона с одинаковым названием и аргументами, но с разными ограничениями, и с учетом этой перегрузки мы теперь можем вызывать функцию Sum от аргументов трех разных типов: int, double и string. В случае со специализацией мы могли использовать любой тип, но для string функция работала по-другому. Таким образом, с помощью ограничений можно полностью исключить нежелательные значения параметров шаблона, оставив только несколько, а с помощью специализаций из всего спектра возможных значений можно выделить особые и изменить тело функции для них.
Также отметим, что ограничения на параметры шаблона, которые были рассмотрены выше для функций, также работают и с другими видами шаблонов. Вот простой пример для ограничения на параметры шаблона-класса:
class Vector( T, int dim = 3) if( (is(T == double) || is(T == int)) && dim > 0 )
Таким образом, в программе могут использоваться векторы с координатами только типов int или double, а мерность пространства естественным образом должна быть положительной. Понятно, что в противном случае (если dim будет отрицательным) компилятор даже не сможет создать массив отрицательной длины, но благодаря ограничениям это станет ошибкой того, кто пытается создать такой вектор, а не программиста, который написал этот шаблон.
Выражение is()
Отдельного внимания заслуживает выражение is, которое особенно часто используется в ограничениях на параметры шаблона. Это выражение нужно для выполнения логических операций на этапе компиляции. Любое выражение, помещенное в скобки оператора is(...), вычисляется не при работе программы, а при компиляции. Поэтому все входящие в него значения должны быть известны на момент компиляции. Выражение is может использоваться многими способами, некоторые из которых можно увидеть в следующем примере:
// выражение is принимает значение true, если:
static assert( is(int) ); // аргумент имеет действительный тип
static assert( ! is(blabla) ); // тип blabla недействителен: его не существует, он не был определен ранее в программе
static if( is(int AliasForInt) ) // то же, плюс тип получает псевдоним
{
AliasForInt a = 5;
}
static assert( is(short : int) ); // если тип может быть автоматически конвертирован в указанный после : (может быть использован как указанный)
static if( is(short AliasForShort : int) ){ // то же, плюс тип получает псевдоним
AliasForShort b = 5;
}
int num;
static assert( is(typeof(num) == int) ); // если тип совпадает с указанным
class Class{}
static assert( is(Class == class) ); // если тип подходит под определение. Используется со словами:
// struct union class interface enum function delegate const immutable shared
static if( is(Class AliasForClass == class) ){} // То же, плюс тип получает псевдоним. Используется со словами:
// struct union class interface const immutable shared
class SubClass : Class {}
static if( is(SubClass BaseTypesTuple == super) ){} // BaseTypeTuple становится кортежем (tuple),
//состоящим из родительских классов и интерфейсов типа (ключевое слово – super)
enum state {ready, busy, sleep}
static if( is(state AliasForImplType == enum) ){} // базовый тип enum'a получает псевдоним (ключевое слово – enum)
int func( int arg);
static if( is(func ParameterListTuple == function) ){} // ParameterListTuple становится кортежем,
//состоящим из параметров функции (ключевое слово – function)
static if( is(func AliasForReturnType == return) ){} // тип, который возвращает функция, получает псевдоним (ключевое слово – return)
int[string] array;
static if ( is(typeof(array) == Value[Key], // если тип соответствует схеме...
Value, // ...и Value - действительный тип...
Key : string) ){} // ...и Key можно использовать как (автоматически конвертировать в) string
// такие сложные is(...) могут содержать и большее число условий
// все последующие условия используют псевдонимы, определенные в первом условии (здесь - Value и Key)
Подробнее об использовании выражения is() и о его значениях можно узнать здесь http://ddili.org/ders/d.en/is_expr.html и в других источниках.
Общий вид шаблона
Давайте еще раз вспомним особенность шаблонов и посмотрим на них с другой стороны. В списке параметров шаблона мы указываем какие-то слова – это и есть параметры шаблона. Эти же самые слова ниже используются в теле шаблона так, как если бы вместо них был написан какой-то тип, значение или кортеж. Об этом иногда говорят как о пространстве имен шаблона, которое образуется за счет прибавления к внешнему пространству имен этих слов-параметров, которые для разных выводов будут разными типами, значениями и кортежами.
Хотя такой синтаксис в распространяемых модулях встречается реже, вы можете наткнуться на такую схему:
template template_name(/*параметры шаблона*/)
{
/*определения шаблона*/
}
Ключевым словом template в языке D объявляется новый шаблон. Этот шаблон пока не является ни функцией, ни классом или структурой, ничем. Все, что он делает полезного: создает внутри фигурных скобок пространство имен, дополненное параметрами шаблона. В этом пространстве имен (это статический скоуп) можно объявлять функции, классы и структуры, переменные и вообще любые другие сущности, доступные в D. При их объявлении и определении можно использовать параметры шаблона, которые могут замещать названия типов, значения или кортежи таким же образом, как они это делали в рассмотренных выше примерах для шаблонов определенного вида.
Объявление переменных и функций внутри фигурных скобок шаблона может напоминать объявление полей и методов класса или структуры. Аналогично доступ к этим переменным и функциям осуществляется через точку. Вот пример:
template MyTemplate(T)
{
T func(T value)
{
return value / 3;
}
struct Struct
{
T member;
}
}
void main(){
auto result = MyTemplate!int.func(42);
auto s = MyTemplate!double.Struct(5.6);
}
Если какое-то объявление в шаблоне называется так же, как и сам шаблон, то такой шаблон называют одноименным (eponymous) и для обращения к нему точка не нужна:
template Sum(T)
{
T Sum( T a, T b)
{
return a + b;
}
}
void main(){
int sum = Sum!int( 5, 15);
}
На самом деле все случаи шаблонов, рассмотренные выше: шаблоны-функции, шаблоны-классы и прочее, являются частным случаем одноименных шаблонов, которые объявляются без дополнительного скоупа и без слова template:
T Sum(T)( T a, T b) // то же самое, что и выше
{}
Еще несколько слов про шаблоны
Шаблоны – одно из самых удачных нововведений языка D, и оно часто используется в библиотеках. И хотя статья преследует цель научить читать документацию к библиотекам, прочитать документацию к шаблону порой оказывается недостаточно для грамотного его применения. Здесь будут упомянуты несколько отличительных особенностей шаблонов.
Шаблоны выводятся и вычисляются при компиляции
Первая особенность опять касается способности не бояться непонятного синтаксиса, умения его расшифровать и понять.
Как уже было сказано, шаблон отдельно выводится под каждый случай его использования в программе с разными значениями параметров. Но иногда от значений параметров шаблона зависит не только алгоритм и метод обработки данных, как это часто бывает с шаблонами-функциями, но и значения некоторых переменных:
int Number(T) = T.sizeof;
Здесь мы объявили шаблон-переменную типа int, которая принимает значение длины в байтах того типа, от которого она выведена:
assert( Number!short == 2 );
Здесь важно отметить, что при компиляции компилятор пробегает всю программу, ищет все возможные разные выводы для шаблона Number и производит необходимое вычисление, создавая набор шаблонов (в нашем случае – переменных с определенными значениями) для их использования в программе при ее работе. Еще раз подчеркнем, что шаблоны позволяют не только "подставлять" вместо некоторых слов конкретные типы или значения, но и производить вычисления, если это необходимо, также во время компиляции.
Каждый вывод шаблона – свой тип
Набор выведенных и специализированных шаблонов, появившийся в результате компиляции, является набором разных типов:
assert( Number!short != Number!int );
Хотя шаблоном являлась переменная типа int, при выводе шаблона образуются типы, которые называются, например, следующим образом:
Number!short
Number!int
Шаблоны – особенность времени компиляции
Хотя об этом уже было сказано, еще раз подчеркнем, что шаблоны выводятся при компиляции программы. Так, следующий код вызовет ошибку компиляции:
class Vector(int dim = 3)
{
double[dim] coord;
}
void main(){
int dim = getDim();
auto vect = new Vector!dim();
}
потому что значение переменной dim неизвестно на момент компиляции. В языке D вообще много особенностей, позволяющих писать "код, который генериурет код". Шаблоны тоже можно отнести к такого рода особенностям. Поэтому при выводе шаблона всегда нужно следить, чтобы все его параметры были известны и доступны уже при компиляции.
Руслан Будаев, buday48@mail.ru