В этой небольшой статье я решил написать про язык программирования D, что он из себя представляет, его плюсы и минусы, а также какие платформы им поддерживаются и т.п.
Данная статья носит сугубо информационный характер, а решение о выборе языка принимается самим читателем. Но всё же автор (т.е. я) немного предвзят, и в статье будет немного фанбойства.
Некоторые маленькие куски были скопированы из других источников.
Статья будет дополняться в комментариях, т.к. у меня не получается описать всё важное за один раз.
Вы можете помочь с дополнением статьи, задавая свои вопросы и предлагая свои правки.
***
Кратко о том, что из себя представляет язык программирования D
Начать стоит с трёх данных вещей:
- D - очередной ужасно медленный убийца с крайне неэффективным оружием C и C++ (но заявляется только про C++). Эту задачу он благополучно провалил.
- Вакансий по нему почти нет. Т.е. если писать на нём - то разве что в качестве хобби. Язык используется в различных организациях, хоть и список известных достаточно мал. Несмотря на это, по нему проводятся конференции и различные митапы.
- Язык, вроде бы, считается мёртвым. Тем не менее, хоть на нём и не выходит каких-то крупных проектов и не случается громких анонсов, его используют довольно много небольших и средних проектов. Некоторые из них можно увидеть здесь.
D - мультипарадигменный (императивное, объектно-ориентированное, функциональное, контрактное, обобщённое программирование) статически типизированный компилируемый язык программирования с Си-подобным синтаксисом и довольно богатой стандартной библиотекой. Изначально создавался как улучшенный C++. Компилятор изначально был закрытым и платным, потом (в 2007 году) стандартная библиотека была полностью переписана. В итоге хоть язык и стабилизировался и обзавёлся аж тремя компиляторами (о них ниже), но огромной популярности не набрал.
Версионирование отличается от C и C++. В C и C++ версии привязаны к стандартам (т.е. это и есть версии), в то время как в D это что-то вроде роллинг релиза (т.е. новые версии появляются довольно часто). При этом, legacy и deprecated за 15 лет появилось очень мало, а крупных изменений синтаксиса и вовсе не было (тут я, конечно, могу ошибаться, но код десятилетней давности у меня заводился с минимальными правками).
Вот несколько интересных штук, которые есть из коробки: неизменяемые (immutable) переменные, инструменты для гарантии безопасной работы с памятью, полная поддержка юникода и инструменты для работы с ним, юнит-тесты, модули вместо заголовочных файлов (о них ниже), ассоциативные массивы, CTFE (Compile Time Function Execution - выполнение функций во время компиляции), лямбда-функции.
Что вы получите, выбрав D:
- Своевременное получение современных возможностей
- Unicode из коробки и инструменты для работы с ним
- Знакомый и интуитивный синтаксис
- Богатую стандартную библиотеку, в которой есть очень много часто используемых вещей, чтобы не пришлось велосипедить. Плюс различные функции алгоритмов (сортировки, итераторы, сравнения, поиск и т.д.), поддержка регулярных выражений, лямбда-функции,
- Исправленные недочёты касаемо других, более старых языков. Например, единица кода в D - модуль. Модуль может быть как одним файлом .d, так и набором таких файлов (для комплексных модулей). Нет разделения на заголовочный файл и файл исходного кода. Компилятор D куда более продуман и подогнан под современные реалии, так что функции можно размещать в любом порядке, не требуется жонглирование с #pragma once и extern - в общем, те вещи, которые объективно устарели, в D отсутствуют.
- Возможность связывания (binding) с кодом на языке C. Создание биндингов для готовых библиотек очень простое, при наличии списка известных функций и переменных, не в последнюю очередь из-за совместимости некоторых типов.
- Фичи, которые добавят в грядущем стандарте C++, но которые в D были уже 10 лет назад.
Компиляторы
Тут всё просто. Есть DMD, существующий ради эталонной реализации, и поддерживающий всего две платформы. Есть LDC, который поддерживает огромное количество платформ, имеет хорошую оптимизацию и рекомендуется к использованию. Есть GDC, который существует ради лицензии. Список поддерживаемых платформ находится здесь:
https://dlang.org/spec/version.html#predefined-versions (пример будет ниже).
В отличие от компиляторов C/C++, компиляторы D при ошибке компиляции выдают только важную информацию, не замусоривая вывод.
Сборка даже крупных проектов проходит очень быстро. Итоговое приложение очень хорошо оптимизировано.
Стандартная библиотека
В D есть стандартная библиотека, которая называется Phobos. Она довольно богатая и содержит много полезных функций, позволяя не велосипедить и увеличить продуктивность написания кода. Например:
алгоритмы,
правильная конвертация переменных,
работа с чексуммами,
математические функции,
регулярные выражения,
поддержка JSON.
Есть три минуса:
- Стандартную библиотеку надо всегда таскать с бинарником. Т.к. в системе она обычно не установлена (это ж не libc какая-нибудь), то при сборке она встраивается в бинарник. Возможность сборки без вкомпилирования стандартной библиотеки присутствует.
- У каждого компилятора своя реализация стандартной библиотеки.
- Отдельно библиотеку установить можно не во всех дистрибутивах. Она есть в Ubuntu, Debian, Fedora, Arch. В других я не проверял.
Стандартная библиотека занимает около 300 КБ в итоговом бинарнике.
Не все функции из стандартной библиотеки могут быть всегда доступны, об этом ниже.
Поддерживается кросскомпиляция (код может быть собран для другой платформы).
Области применения
На D возможны такие вещи, как:
- Пользовательские и системные приложения
- Динамические и статические библиотеки
- Ядра и операционные системы (PowerNex, XOmB, Trinix, https://wiki.osdev.org/D_Bare_Bones)
- Прошивки для микроконтроллеров и одноплатных ПК (пока только на ARM, с использованием LWDR) (пример)
- Браузерные приложения (WebAssembly или с помощью Emscripten, хоть и неофициально)
- С помощью rdmd можно использовать .d-файлы как скрипты, и запускать их напрямую из терминала
- Веб-сайты и веб-приложения с помощью фреймворков Vibe.d, Hunt и других.
Поддерживаемые платформы
Помимо основных платформ (Windows, Linux, macOS, FreeBSD, Android (но только в виде библиотеки)) поддерживается создание "сырых" бинарных файлов для "железа". Поддерживаются X86, ARM, MIPS, SPARC, PowerPC
и множество других платформ. Это позволяет создавать SDK под платформы вроде
Nintendo 3DS,
PSP,
Playdate и
других игровых консолей.
Модули
Вместо заголовочных файлов используются модули. Это позволяет решить две важные проблемы. Во-первых, все модули загружаются один раз в память, и дальнейшее обращение к ним происходит уже в памяти приложения. Во-вторых, модули можно импортировать много раз, в т.ч. рекурсивно - компилятор сам всё решит. В C/C++ требуется, чтобы заголовочный файл был импортирован один раз, иначе возможна ругань линковщика.
Модуль - обычный файл .d. В нём можно явно задать имя модуля (тогда название файла может быть любым), или импортировать по имени файла.
Код:
// module_test_blah_blah_1.d
module my_awesome_module;
import std.format;
string TestFunction(int a, int b)
{
return (format("The sum is %d", a + b));
}
// main.d
import std.stdio;
import my_awesome_module;
void main()
{
writeln(TestFunction(2, 4)); // The sum is 6
}
Можно импортировать модули и давать им другие названия:
Код:
import io_1 = std.stdio;
void main()
{
io_1.writeln("Hello, World!");
}
Можно импортировать только определённый список функций из модуля:
Код:
import io_1 = std.stdio : writeln; // Будет доступна только функция writeln
void main()
{
io_1.writeln("Hello, World!");
}
Неиспользуемые модули убираются из проекта при сборке. Если модуль импортирован, но из него не используются никакие переменные или функции, то он будет проигнорирован.
Для D существует
каталог сторонних модулей. Как pip для Python или npm для NodeJS. Модули устанавливаются через DUB.
GUI
На D можно писать приложения с:
- GTK (пока только 3)
- Qt5 (с некоторыми ограничениями)
- Tk
- Нативный интерфейс в Windows (через WinAPI) и macOS (через сторонний модуль)
- SDL2 (но это уже больше для игр, тем не менее возможность есть)
Честно скажу, что насчёт других тулкитов и платформ я пока не интересовался.
Структура проекта
Можно как вручную компилировать отдельные файлы, так и создать проект с полноценной структурой.
Для управления проектом существует DUB. Среди его функций:
- Инициализация проекта
- Управление сторонними модулями (поиск, установка и удаление)
- Сборка проекта
- Запуск приложения
- Тестирование приложения
Для указания свойств проекта используется файл dub.json (или dub.sdl, кому как удобнее). В нём указываются такие свойства, как:
- Тип проекта (приложение, библиотека и т.д.)
- Название проекта
- Параметры компилятора
- Параметры линкера
- Список требуемых модулей
- Список требуемых версий компиляторов
- Лицензия (для публикации проекта)
- Команды до и после сборки проекта
- Список конфигураций сборки
Про последнее напишу подробнее.
Конфигурации позволяют собирать проект с разными параметрами и под разные платформы. Вот пример:
Код:
{
"authors": [
"user"
],
"copyright": "Copyright © 2022, user",
"description": "A minimal D application.",
"license": "proprietary",
"name": "dub_test",
"configurations": [
{
"name": "config_1",
"targetType": "executable",
"platforms": ["linux"],
"buildTypes":
{
"debug":
{
"buildOptions": ["debugMode"]
},
"release":
{
"buildOptions": ["releaseMode", "optimize"]
}
}
},
{
"name": "config_2",
"targetType": "executable",
"platforms": ["linux"],
"dflags-ldc": ["-release", "-Oz"],
"postBuildCommands": ["strip -S dub_test"]
}
]
}
Здесь присутствуют две конфигурации. Первая собирает проект, и в зависимости от типа сборки (отладочная или релизная) устанавливает разные опции компилятора. По сути, buildOptions - это другие названия уже существующих опций компилятора, чтобы избавить от необходимости задавать опции для каждого отдельного компилятора (у которых могут быть свои названия опций).
dflags-ldc - пример передачи опций напрямую компилятору, в данном случае LDC. Данная опция ещё может называться dflags-dmd, dflags-gdc или просо dflags (тогда не будет учтён компилятор). -Oz - оптимизация по размеру. Использование передачи опций напрямую компилятору не рекомендуется, но такая возможность есть.
В config_2 после сборки вызывается strip.
Проект можно собрать так:
Код:
dub build -c config_1 -b=debug
dub build -c config_1 -b=release
dub build -c config_2
Где -c - название конфигурации, -b (или --build) - тип сборки.
В качестве альтернативы существует сборочная система
Reggae. Но лично я про неё ничего рассказать не могу, т.к. ещё не пользовался.
Помимо этого можно использовать Make. Лично для меня этот способ неудобен.
Типы переменных
Поддерживаются как стандартные для C типы переменных (int, char, float, short, long, double, void), так и другие: string, byte, bool, real, cent (пока не реализован, в будущем это будет 128-битный тип). Целочисленные переменные поддерживают беззнаковость (например, uint, ubyte, ulong, ushort). Полный список типов находится здесь:
https://dlang.org/spec/type.html
Поддерживается работа с указателями.
Синтаксис
Синтаксис очень сильно похож на C/C++/C#/Java. В D он интуитивно понятен и имеет различные улучшения. Условия, циклы - всё почти то же самое, что и в C/C++/etc. Если вы уже знаете какой-нибудь из этих языков или пробовали на них писать - для вас порог вхождения ещё ниже.
Пример Hello World:
Код:
import std.stdio;
void main()
{
writeln("Hello, World");
}
Отсутствуют области видимости - можно размещать функции в любом порядке:
Код:
import std.stdio;
void main()
{
TestFunction();
}
void TestFunction()
{
writeln("Hello World!");
}
Массивы, звёздочки в указателях перемещены к типу переменной, что, как мне кажется, визуально удобнее.
Многие вещи, которые являются макросами в C/C++, в D присутстувют из коробки:
Код:
int.min
int.max
float.max
char.max
char.min
ulong.max
double.min_normal
double.nan
double.infinity
double.dig
double.epsilon
double.mant_dig
double.max_10_exp
double.max_exp
double.min_10_exp
double.min_exp
Из коробки присутствует цикл foreach:
Код:
import std.stdio;
void main()
{
int[5] intArray = [1, 2, 3, 4, 5];
foreach (i; intArray)
{
writef("%d ", i);
}
}
У массивов есть свойство length:
Код:
import std.stdio;
void main()
{
string str = "Hello, World!";
int[] a = new int[](10);
writefln("str's length is: %d\na's length is: %d", str.length, a.length);
//str's length is: 13
//a's length is: 10
}
В D очень простое соединение массивов:
Код:
import std.stdio;
void main()
{
int[] arr1 = [1, 2, 3];
int[] arr2 = [4, 5];
int[] arr3 = arr1 ~ arr2; //[1, 2, 3, 4, 5]
string str1 = "Hello";
string str2 = "World";
string str_result = str1 ~ ", " ~ str2 ~ "!";
writeln(str_result); // Hello, World!
}
Оператор ~ отвечает за конкатенацию. Для неё не требуется выделение дополнительной памяти. Также возможно использование ~=, что будет равносильно += для чисел. Эти операторы работают со всеми массивами (строки тоже являются массивами - dchar[]).
Код:
import std.stdio;
void main()
{
int[] a;
for (int i = 0; i < 10; i++)
{
a ~= i;
}
writeln(a); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
}
Работа с массивами в циклах сделана удобнее:
Код:
import std.stdio;
void main()
{
int[5] intArray = [1, 2, 3, 4, 5];
foreach (i; intArray[2..$])
{
writef("%d ", i); // 3 4 5
}
writeln();
foreach (i; 0..10)
{
writef("%d ", i); // 0 1 2 3 4 5 6 7 8 9
}
}
$ означает конец массива.
Таким образом можно сравнить кусок строки:
Код:
import std.stdio;
void main()
{
string testString = "Hello, World!";
for (int i = 0; i < testString.length; i++)
{
if (testString[0..5] == "Hello")
{
writeln("World");
break;
}
}
}
Функции могут вызываться и так:
Код:
import std.stdio;
void main()
{
"Hello, World".writeln();
}
Это -
универсальный синтаксис вызова функций. Благодаря этому цепочки функций могут выглядеть так:
Код:
import std.stdio;
import std.string;
void main()
{
string hw = "Hello, World!";
writeln(replace(toUpper(hw), ", ", "_")); // HELLO_WORLD!
writeln(toUpper(hw).replace(", ", "_"));
}
Оператор switch тоже имеет некоторые улучшения:
Код:
import std.stdio;
void main()
{
string testStr = "Test";
switch (testStr)
{
case ">_<":
case "X_X":
writeln("testStr is >_< or X_X");
break;
case "Test":
writeln("testStr is Test");
break;
default:
writeln("well...");
break;
}
}
Во-первых, из коробки в языке присутствует сравнение строк. Во-вторых, можно указать несколько case. Также существует final switch на случай, если все возможные варианты учтены:
Код:
import std.stdio;
void main()
{
string testStr = "Test";
final switch (testStr)
{
case ">_<":
case "X_X":
writeln("testStr is >_< or X_X");
break;
case "Test":
writeln("testStr is Test");
break;
}
}
Благодаря тому, что в стандартной библиотеке есть реализации различных итераторов и поддержка лямбда-функций, вместо написания нескольких (
десятков) строк кода возможны такие вполне лаконичные конструкции:
Код:
import std.stdio;
import std.algorithm;
void main()
{
int[] arr = [2, 4, 6, 8];
// Проведение операции над каждым элементом массива
writeln(arr.map!((a) => a + a)); // [4, 8, 12, 16]
// Проведение операции над каждым элементом массива
writeln(arr.map!((a) => a * a)); // [4, 16, 36, 64]
// Сложение всех элементов массива
writeln(arr.fold!((a, b) => a + b)); // 20
//Сортировка
writeln(arr.sort!((a, b) => b < a)); // [8, 6, 4, 2]
}
Возможно указать код для определённой платформы:
Код:
import std.stdio;
void main()
{
version(linux)
{
writeln("Hello, Linux!");
}
version(Windows)
{
writeln("Hello, Windows!");
}
}
В D есть обработка исключений.
Код:
import std.stdio;
import std.file;
void main()
{
try
{
File someFile = File("/tmp/blahblahblah", "r"); // Попытка открыть несуществующий файл
someFile.close();
}
catch (FileException ex)
{
writeln(ex); // std.exception.ErrnoException@std/stdio.d(636): Cannot open file `/tmp/blahblahblah' in mode `r' (No such file or directory)
}
}
Блоков catch может быть несколько, и они могут быть разными.
В D из коробки присутствуют юнит-тесты. Есть два варианта их использования.
Первый:
Код:
import std.stdio;
void main()
{
writeln("Hello, World!");
assert(2 + 2 == 5); // core.exception.AssertError@onlineapp.d(7): Assertion failure
}
Второй - прописать в отдельном блоке. Тогда при сборке можно будет указать опцию -unittest, чтобы компилятор провёл юнит-тестирование.
Код:
import std.stdio;
class TestClass
{
int sum(int a, int b)
{
return a + b;
}
}
void main()
{
writeln("Hello, World!");
}
unittest
{
TestClass testClass = new TestClass();
assert(testClass.sum(2, 2) == 4);
assert(testClass.sum(3, 3) == 6);
// 1 modules passed unittests
}
Код:
import std.stdio;
class TestClass
{
int a;
this()
{
a = 5;
}
}
void main()
{
writeln("Hello, World!");
}
unittest
{
TestClass testClass = new TestClass();
assert(testClass.a < 5);
// [unittest] Assertion failure
// 1/1 modules FAILED unittests
}
Блоков unittest может быть много, и они могут присутствовать в разных файлах.
Для управления памятью, инициализации массивов и классов используются new и destroy.
Код:
import std.stdio;
struct TestStruct
{
int a;
string str;
long c;
}
void main()
{
TestStruct[] a = new TestStruct[](4);
for (int i = 0; i < a.length; i++)
{
a[i] = TestStruct(i, "Hello, World!", i * 2);
}
writeln(a); // [TestStruct(0, "Hello, World!", 0), TestStruct(1, "Hello, World!", 2), TestStruct(2, "Hello, World!", 4), TestStruct(3, "Hello, World!", 6)]
destroy(a);
}
В D можно распараллелить многое. Например:
Код:
import std.stdio;
import std.parallelism;
void main()
{
string[] testStr = new string[](5);
testStr = [
"Line 1",
"Line 2",
"Line 3",
"Line 4",
"Line 5",
];
foreach (key; testStr)
{
writeln(key);
}
writeln();
foreach (key; testStr.parallel)
{
writeln(key);
}
}
Это возможно благодаря функции parallel из модуля std.parallelism.
Результат:
Код:
Line 1
Line 2
Line 3
Line 4
Line 5
Line 1
Line 3
Line 4
Line 5
Line 2
Т.к. элементы обрабатываются параллельно, то это во многих случаях ускорит работу.
Допустим, нам нужно 100 тысяч раз посчитать квадратный корень для 100 тысяч чисел. Нет, ну вдруг. Пример слишком надуманный, но в реальной разработке могут попадаться подобные вещи.
В D есть инструменты для бенчмаркинга.
Код:
import std.datetime.stopwatch;
import std.math;
import std.parallelism;
import std.stdio;
import std.conv;
void main()
{
float[] sq = new float[](100_000);
Duration[2] bm = benchmark!({
foreach(i, ref x; sq)
{
x = sqrt(to!float(i));
}
}, {
foreach(i, ref x; sq.parallel)
{
x = sqrt(to!float(i));
}
})(100_000);
writefln("Linear sqrt test: %s msecs\nParallel sqrt test: %s msecs", bm[0].total!"msecs", bm[1].total!"msecs");
}
Результат на AMD Ryzen 5 1500X:
Код:
Linear sqrt test: 13874 msecs
Parallel sqrt test: 7998 msecs
Символ подчёркивания необязателен, он просто визуально улучшает разборчивость. to!float - конвертирование переменной из int в float.
Безопасность и стабильность
Пока вы используете инструменты и функции языка D, не обращаясь к функциям из C - вероятность сегфолтов и падений при использовании отлова исключений стремится к нулю. Исключения обработать можно для почти всех инструментов в D, даже у простых вещей, вроде выхода из границ массива. Для выделения и освобождения памяти используются new и destroy. И хоть десять раз вы решите освободить одну и ту же область памяти - приложение не упадёт, т.к. всегда происходит проверка.
Вероятность UB (undefined behavior, неопределённое поведение) сведена к минимуму. Нет "особенностей" вроде вывода мусора как в printf("%d\n"). Проверки есть
везде.
Подробнее про безопасную работу с памятью можно почитать
здесь.