Сравнение D и C++ и Rust на примерах

в 14:50, , рубрики: Rust

Данный пост основывается на Сравнение Rust и С++ на примерах и дополняет приведенные там примеры кодом на D с описанием различий.

Все примеры были собраны с помощью компилятора DMD v2.065 x86_64.

Проверка типов шаблона

Шаблоны в Rust проверяются на корректность до их инстанцирования, поэтому есть чёткое разделение между ошибками в самом шаблоне (которых быть не должно, если Вы используете чужой/библиотечный шаблон) и в месте инстанцирования, где всё, что от Вас требуется — это удовлетворить требования к типу, описанные в шаблоне:

trait Sortable {}
fn sort<T: Sortable>(array: &mut [T]) {}
fn main() {
    sort(&mut [1,2,3]);
}

В D используется другой подход: на шаблоны, функции, структуры можно повесить guard, который не даст включить функцию в overload set, если шаблонный параметр не обладает определенным свойством.

import std.traits;

// auto sort(T)(T[] array) {} - версия без guard компилируется
auto sort(T)(T[] array) if(isFloatingPoint!T) {}

void main()
{
    sort([1,2,3]);
}

Компилятор выразит недовольство следующим образом:

source/main.d(27): Error: template main.sort cannot deduce function from argument types !()(int[]), candidates are:
source/main.d(23): main.sort(T)(T[] array) if (isFloatingPoint!T)

Однако получить почти идентичное «разрешающее» поведение Rust можно следующим образом:

template Sortable(T)
{
    // допустим, мы можем отсортировать, если есть функция swap для этого типа
    enum Sortable = __traits(compiles, swap(T.init, T.init));
    // В случае ошибки выведем понятное сообщение
    static assert(Sortable, "Sortable isn't implemented for "~T.stringof~". swap function isn't defined.");
}

auto sort(T)(T[] array) if(Sortable!T) {}

void main()
{
    sort([1,2,3]);
}

Вывод компилятора:

source/main.d(41): Error: static assert «Sortable isn't implemented for int. swap function isn't defined.»
source/main.d(44): instantiated from here: Sortable!int
source/main.d(48): instantiated from here: sort!()

Возможность выводить свои сообщения об ошибках позволяет почти во всех случаях избежать километровых логов компилятора о проблемах с шаблонами, но и цена такой свободы высока — приходится продумывать пределы применимости своих шаблонов и писать руками понятные(!) сообщения. С учетом того, что шаблонный параметр T может быть: типом, лямбдой, другим шаблоном (шаблоном шаблона и т.д., это позволяет имитировать depended types), выражением, списком выражений — зачастую обрабатывается только некоторое подмножество извращенных фантазий пользователя ошибок.

Обращение к удаленной памяти

В D отсутствуют операторы освобождения памяти, максимум можно финализировать объект, чтобы освободить ресурсы когда надо программисту, а не GC. Но есть возможность выделять память через C-шное семейство функций malloc:

import std.c.stdlib;

void main()
{
    auto x = cast(int*)malloc(int.sizeof);
    // гарантированно освободим память при выходе из scope
    scope(exit) free(x); 
    
    // а теперь выстрелим себе в ногу
    free(x);
    *x = 0;
}

*** Error in `demo': double free or corruption (fasttop): 0x0000000001b02650 ***

D позволяет программировать на разных уровнях, вплоть до встраиваемого ассемблера. Отказываемся от GC — берем на себя ответственность за класс ошибок: утечки, обращения к удаленной памяти. Применение RAII (scope выражения в примере) может значительно сократить головную боль при таком подходе.

В недавно вышедшей книге D Cookbook есть главы, посвященные разработке кастомных массивов с ручным управлением памятью и написанию модуля ядра на D (без GC и без рантайма). Стандартная библиотека действительно становится практически бесполезной при полном отказе от рантайма и GC, но она была спроектирована изначально под использование их особенностей. Место embedded-style библиотеки все еще никем не занято.

Потерявшийся указатель на локальную переменную

Версия Rust:

fn bar<'a>(p: &'a int) -> &'a int {
    return p;
}
fn foo(n: int) -> &int {
    bar(&n)
}
fn main() {
    let p1 = foo(1);
    let p2 = foo(2);
    println!("{}, {}", *p1, *p2);
}

Аналог на D (практически повторяет пример на C++ из поста-источника):

import std.stdio;

int* bar(int* p) {
    return p;
}

int* foo(int n) {
    return bar(&n);
}

void main() {
    int* p1 = foo(1);
    int* p2 = foo(2);
    writeln(*p1, ",", *p2);
}

Вывод:

2,2

Rust в данном примере имеет преимущество, я не знаю ни один подобный язык, в который был встроен такой мощный анализатор времени жизни переменных. Единственное, что я могу сказать в защиту D, что в режиме safe компилятор предыдущий код не скомпилирует:

Error: cannot take address of parameter n in @ safe function foo

Также в 90% кода на D указатели не используются (низкий уровень — высокая ответственность), для большинства случаев подходит ref:

import std.stdio;

ref int bar(ref int p) {
    return p;
}

ref int foo(int n) {
    return bar(n);
}

void main() 
{
    auto p1 = foo(1);
    auto p2 = foo(2);
    writeln(p1, ",", p2);
}

Вывод:

1,2

Неинициированные переменные

C++

#include <stdio.h>
int minval(int *A, int n) {
  int currmin;
  for (int i=0; i<n; i++)
    if (A[i] < currmin)
      currmin = A[i];
  return currmin;
}
int main() {
    int A[] = {1,2,3};
    int min = minval(A,3);
    printf("%dn", min);
}

В D все значения по умолчанию иницилизируются значением T.init, но есть возможность указать компилятору, что в конкретном случае инициализация не требуется:

import std.stdio;

int minval(int[] A) 
{
    int currmin = void; // undefined behavior
    foreach(a; A)
        if (a < currmin)
            currmin = a;
    return currmin;
}

void main() {
    auto A = [1,2,3];
    int min = minval(A);
    writeln(min);
}

Положительный момент: чтобы выстрелить в ногу нужно специально этого захотеть. Случайно неинициализовать переменную в D практически невозможно (может быть, copy-paste методом).

Более идиоматичный (и работающий) вариант этой функции выглядел бы так:

fn minval(A: &[int]) -> int {
  A.iter().fold(A[0], |u,&a| {
    if a<u {a} else {u}
  })
}

Для сравнения вариант на D:

int minval(int[] A)
{
    return A.reduce!"a < b ? a : b";
    // или
    //return A.reduce!((a,b) => a < b ? a : b);
}

Неявный конструктор копирования

C++

struct A{
    int *x;
    A(int v): x(new int(v)) {}
    ~A() {delete x;}
};

int main() {
    A a(1), b=a;
}

Аналогичная версия на D:

struct A
{
    int *x;
    
    this(int v)
    {
        x = new int;
        *x = v;
    }
}

void main()
{
    auto a = A(1);
    auto b = a;
    
    *b.x = 5;
    assert(*a.x == 1); // fails
}

В D структуры поддерживают только семантику копирования, а также не имеют механизма наследования (заменяется примесями), виртуальных функций и остальных особенностей объектов. Структура — просто кусок памяти, компилятор не добавляет ничего лишнего. Для корректной реализации примера необходимо определить postblit конструктор (почти конструктор копирования):

    this(this) // в таком конструкторе есть доступ только к this
    {             // доступа к структуре откуда копируем не имеем
        auto newx = new int;
        *newx = *x;
        x = newx;
    }

Rust ничего за Вашей спиной делать не будет. Хотите автоматическую реализацию Eq или Clone? Просто добавьте свойство deriving к Вашей структуре:

#[deriving(Clone, Eq, Hash, PartialEq, PartialOrd, Ord, Show)]
struct A{
    x: Box<int>
}

Аналога данного механизма в D нет. Для структур все подобные операции перегружаются через structual typing (часто путают с duck typing), если у структуры есть подходящий метод, то используется он, если нет, то реализация по умолчанию.

Перекрытие области памяти

#include <stdio.h>
struct X {  int a, b; };

void swap_from(X& x, const X& y) {
    x.a = y.b; x.b = y.a;
}
int main() {
    X x = {1,2};
    swap_from(x,x);
    printf("%d,%dn", x.a, x.b);
}

Выдаёт нам:

2,2

Аналогичный код на D, который тоже не работает:

struct X { int a, b; }

void swap_from(ref X x, const ref X y)
{
    x.a = y.b; x.b = y.a;
}

void main()
{
    auto x = X(1,2);
    swap_from(x, x);
    writeln(x.a, ",", x.b);
}

Rust в этом случае однозначно побеждает. Я не нашел способа обнаружить memory overlapping на этапе компиляции на D.

Испорченный итератор

В D абстракция итераторов заменена на Ranges, попробуем изменить контейнер при проходе:

import std.stdio;

void main()
{
    int[] v;
    v ~= 1;
    v ~= 2;
    
    foreach(val; v)
    {
        if(val < 5)
        {
            v ~= 5 - val;
        }
    }
    writeln(v);
}

Вывод:

[1, 2, 4, 3]

При изменении массива range, полученный ранее не меняется, до конца блока foreach данный range будет указывать на данные «старого» массива. Можно заметить, что все изменения происходят в хвосте массива, можно усложнить пример и добавлять в начало и в конец одновременно:

import std.stdio;
import std.container;

void main()
{
    DList!int v;
    v.insert(1);
    v.insert(2);
    
    foreach(val; v[]) // оператор [] возвращает range 
    {
        if(val < 5)
        {
            v.insertFront(5 - val);
            v.insertBack(5 - val);
        }
    }
    writeln(v[]);
}

Вывод:

[3, 4, 1, 2, 4, 3]

В данном случае использовался двусвязный список из стандартной библиотеки. При использовании массива добавление в его начала всегда приводит к его пересозданию, но это не ломает алгоритм, старый range указывает на старый массив, а мы работаем с новыми копиями массива, а благодаря GC мы можем не беспокоиться о повисших в памяти огрызках. А в случае со списком не требуется перевыделения всей памяти, только под новые элементы.

Опасный Switch

#include <stdio.h>
enum {RED, BLUE, GRAY, UNKNOWN} color = GRAY;
int main() {
  int x;
  switch(color) {
    case GRAY: x=1;
    case RED:
    case BLUE: x=2;
  }
  printf("%d", x);
}

Выдаёт нам «2». В Rust жы Вы обязаны перечислить все варианты при сопоставлении с образцом. Кроме того, код автоматически не прыгает на следующий вариант, если не встретит break.

В D перед switch может стоять ключевое слово final, тогда компилятор насильно заставит написать все варианты сопоставления. При отсутствии final обязательным условием является наличие default блока. Также в последних версиях компилятора неявное «проваливание» на следующую метку помечено как deprecated, необходим явный goto case. Пример:

import std.stdio;

enum Color {RED, BLUE, GRAY, UNKNOWN}
Color color = Color.GRAY;

void main()
{
    int x;
    final switch(color) {
        case Color.GRAY: x = 1;
        case Color.RED:
        case Color.BLUE: x = 2;
    }
    
    writeln(x);
}

Вывод компилятора:

source/main.d(227): Error: enum member UNKNOWN not represented in final switch
source/main.d(229): Warning: switch case fallthrough — use 'goto case;' if intended
source/main.d(229): Warning: switch case fallthrough — use 'goto case;' if intended

Случайная точка с запятой

int main() {
  int pixels = 1;
  for (int j=0; j<5; j++);
    pixels++;
}

В Rust Вы обязаны заключать тела циклов и сравнений в фигурные скобки. Мелочь, конечно, но одим классом ошибок меньше.

В D компилятор выдаст предупреждение (по умолчанию предупреждения — ошибки) и предложит заменить; на {}.

Многопоточность

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

class Resource {
    int *value;
public:
    Resource(): value(NULL) {}
    ~Resource() {delete value;}
    int *acquire() {
        if (!value) {
            value = new int(0);
        }
        return value;
    }
};

void* function(void *param) {
    int *value = ((Resource*)param)->acquire();
    printf("resource: %pn", (void*)value);
    return value;
}

int main() {
    Resource res;
    for (int i=0; i<5; ++i) {
        pthread_t pt;
        pthread_create(&pt, NULL, function, &res);
    }
    //sleep(10);
    printf("donen");
}

Порождает несколько ресурсов вместо одного:
done

resource: 0x7f229c0008c0
resource: 0x7f22840008c0
resource: 0x7f228c0008c0
resource: 0x7f22940008c0
resource: 0x7f227c0008c0

В D аналогично Rust компилятор проверяет обращение к разделяемым ресурсам. По умолчанию вся память является неразделямой, каждый поток работает со своей копией окружения (которая хранится в TLS), а все разделяемые ресурсы помечаются ключевым словом shared. Попробуем записать на D:

import std.concurrency;
import std.stdio;

class Resource
{
    private int* value;
    
    int* acquire()
    {
        if(!value)
        {
            value = new int;
        }
        return value;
    }
}

void foo(shared Resource res)
{
    // Error: non-shared method main.Resource.acquire is not callable using a shared object
    writeln("resource ", res.acquire);
}

void main()
{
    auto res = new shared Resource();
    foreach(i; 0..5)
    {
        spawn(&foo, res);
    }
    writeln("done");
}

Компилятор не увидел явной синхронизации и не дал скомпилировать код с потенциальной race condition. В D есть множество примитивов синхронизации, но для простоты рассмотрим Java-like монитор-мьютекс для объектов:

synchronized class Resource
{
    private int* value;
    
    shared(int*) acquire()
    {
        if(!value)
        {
            value = new int;
        }
        return value;
    }
}

Вывод:

done
resource 7FDED3805FF0
resource 7FDED3805FF0
resource 7FDED3805FF0
resource 7FDED3805FF0
resource 7FDED3805FF0

При каждом вызове acquire, монитор объекта захватывается потоком и все остальные потоки блокируются до освобождения ресурса. Обратите внимание на возращаемый тип функции acquire, в D такие модификаторы как shared, const, immutable являются транзитивными, если ими отмечена ссылка на класс, то и все поля и возвращаемые указатели на поля также метятся модификатором.

Немного про небезопасный код

В отличие от Rust весь код в D по умолчанию является @ system, т.е. небезопасным. Код, помеченный @ safe, ограничивает программиста и не дает играться с указателями, вставками ассемблера, небезопасными преобразованиями типов и прочими опасными возможностями. Для использования небезопасного кода в безопасном коде есть модификатор @ trusted, это ключевые места, которые должны быть тщательно покрыты тестами.

Сравнивая с Rust, я очень желаю такую мощную систему анализа времени жизни ссылок для D. «Культурный» обмен между этими языками пойдет им только на пользу.

Автор: NCrashed

Источник

Поделиться

* - обязательные к заполнению поля