Дмитpий Hecтepук

Блог о программировании — C#, F#, C++, архитектура, и многое другое

Posts Tagged ‘c#

Попытки отражения garbage-collected структур на ужасный неуправляемый С++

Если вы вдруг решили сконвертировать C# в C++, то большинство задач конверсии – тривиальны. int меняется на int32_t, string на std::wstring (хотя на самом деле просто string для большинства людей которым unicode не нужен). Ну и так далее. Различия в большинстве случаев несущественны.

Основной задачей, как ни странно, является конверсия reference типов, т.к. в C# можно создавать массивы, дженерики и просто ref-типы и пускать все на самотек дескать “когда-нибудь освободится”, а вот на C++ так делать нельзя. Напомню, что в С++ есть вот такие варианты:

  • Аллоцировать вещи на стеке. К сожалению, за исключением примитивных типов, в C# никто так не делает. Чтобы C#ный объект который кто-то аллоцировал через new аллоцировать в С++ коде на стеке, нужно быть увереным что объект маленький и что он действительно только вот тут, в локальном scope, что его никто не потребляет. Зато стек почистится по мере выхода их скоупа, что как бы неплохо.

  • Аллоцировать вещи вручную через new/delete: это нереально в принципе т.к. если идти по этому пути, то у каждого типа может быть свой набор ссылок на весь мир, ну и… вы поняли. Получится ад. Не вариант.

  • Использовать умные указатели так, как их нужно использовать. То есть например если у вас есть фабрика которая порождает объекты и выдает их наружу, то вы можете возвращать unique_ptr. И так далее. Одна лишь проблема: как понять какой указатель нужен? Опять нужен глубинный анализ, а это сложно.

  • Использовать везде shared_ptr, т.к. это как раз тот указатель, который можно либерально копировать повсюду и при этом не получить ситуацию когда вы пытаетесь вызвать что-то у объекта, который уже был перемещен куда-то.

Инициализация и присваивание

Отдельной пролемой стоят всякие инициализации и присваивания. Например, представим что вы создаете человека, и передаете строку. Принято ведь писать

class Person
{
  string name;
public:
  Person(const string name) : name(name) {}
};

Опаньки! А мы-то думали что все не-примитивные объекты будут shared_ptr. В результате получается, что

  • Все типы которые мы считаем “маленькими” (а это включает всякие int, float и так далее) передаются by value

  • Строки и прочие типы которые нормально себя ведут в нынешней системе типов мы передаем как const ref

  • А вот все остальное – да, проблуем использовать shared_ptr

Полноценный пример

Давайте типичный пример с человеком у которого есть адрес:

struct Address
{
  int HouseNumber;
  string StreetName;
  Address(int houseNumber, const string streetName)
    : HouseNumber(houseNumber), StreetName(streetName) {}
};

Я намеренно использую struct и C#-ное именование а также не использую properties т.к. автосвойство C# можно смело конвертировать в поле C++ without loss of generality. Так вот, у нас есть адрес который берет const string во втором аргументе и это как бы правильно.

Теперь посмотрим на человека, который обладает адресом:

struct Person
{
  int Age;
  string Name;
  shared_ptr<Address> HomeAddress;
  Person(int age, const string name, shared_ptr<Address> homeAddress)
    : Age(age), Name(name), HomeAddress(homeAddress) {}
};

Вроде тоже неплохо. Сразу понятно что подсовывать Address нам не нужно, и что нам нужна “управляемая” версия. Теперь все это можно потреблять:

auto address = make_shared<Address>(221, "Baker St");
auto person = make_shared<Person>(40, "Sherlock", address);
cout << person->Name << endl;

Более того, мы можем не нервничать насчет удаления address если его “хозяин” надоел и его грохнули:

shared_ptr<Address> addr;
{
  auto address = make_shared<Address>(221, "Baker St");
  auto person = make_shared<Person>(40, "Sherlock", address);
  addr = person->HomeAddress;
}
cout << addr->StreetName << endl;

Ну ок, вроде оно как-то работает. Хотя тут мне подумалось… а ведь по идее можно и адрес было передавать как const shared_ptr<Address>, не так ли?

Ладно, что там следующее?

Массивы

Массивы – это вообще больное место. В C# есть два разных типа – прямоугольные [,] и угловатые [][], в С++ существует только 2й вариант. Но проблема даже не в этом. Проблема в том, как вообще представить N-мерный массив чего либо через умный указатель.

Начнем с простого: у человека несколько имен и это как-то нужно хранить. Тут, самое безопасное – это vector<string>. В принципе, со строками мы можем себе позволить такой вот расклад когда мы копируем by value. Но как насчет набора адресов?

Одномерный набор адресов в C# подразумевает конверсию как List<T> так и T[] в vector<shared_ptr<T>> – конечно, для тех типов где shared_ptr нужен, т.е. не для для примитивов или string:

struct Person
{
  int Age;
  vector&lt;string&gt; Names;
  typedef vector&lt;shared_ptr&lt;Address&gt;&gt; AddressCollection;
  AddressCollection Addresses;
  Person(int age, const vector&lt;string&gt; names, const AddressCollection addresses)
    : Age(age),
      Names(names),
      Addresses(addresses)  {  }
};

Ну а тут можно воспользоваться любезно предоставленым typedef’ом и проинициализировать все это счастье в стиле С++11:

auto person = make_shared&lt;Person&gt;(23, 
  vector&lt;string&gt;{&quot;Janes&quot;, &quot;Jimmy&quot;},
  Person::AddressCollection{ 
    make_shared&lt;Address&gt;(123, &quot;Hull Rd&quot;), 
    make_shared&lt;Address&gt;(23, &quot;Sesame St.&quot;)});
for (auto addr  : person-&gt;Addresses)
{
  cout &lt;&lt; addr-&gt;StreetName &lt;&lt; endl;
}

А что же насчет std::array или например boost::shared_array? Ну, std::array на самом деле требует (сразу!) известную длину. То есть если у вас в C# написано например что int i = new int[3] то тогда да, вам повезло:

struct Person
{
  array&lt;shared_ptr&lt;Address&gt;, 2&gt; HomeAndWorkAddresses;
  explicit Person(const array&lt;shared_ptr&lt;Address&gt;, 2&gt; home_and_work_addresses)
    : HomeAndWorkAddresses(home_and_work_addresses)
  {
  }
};

От кода выше в принципе уже должно тошнить, но для тех кто не понял в чем соль, вот то, как выглядит инициализация сего счастья:

array&lt;shared_ptr&lt;Address&gt;, 2&gt; addresses = { {
  make_shared&lt;Address&gt;(123, &quot;London Rd&quot;),
  make_shared&lt;Address&gt;(23, &quot;Shirley Ave&quot;)
} };
auto person = make_shared&lt;Person&gt;(addresses);
cout &lt;&lt; &quot;Work: &quot; &lt;&lt; person-&gt;HomeAndWorkAddresses[1]-&gt;StreetName &lt;&lt; endl;

Нда, мне определенно не нравится std::array. В большинстве случаев, мне кажется, лучше всего использовать vector<T>, vector<vector<T>> и так далее.

Полузаключение

На самом деле, то, что я описал – это верхушка айсберга. Например, допустим что мы допускаем хранения vector<string> но ведь по сути дела для передачи этой коллекции куда либо (а мало ли зачем?) мы не можем передавать ее by value (это расточительно!), и с другой стороны, она не shared_ptr<T>, то есть единственный наш вариант – это передавать ее как const vector<T>. Хмм, а вообще это не такая уж и плохая идея.

Вообщем, в этом посте я для себя вывел набор (вполне вероятно ошибочных) правил по тому, как конвертировать reference-конструкты C# в нечно С++ное чем можно хоть как-то управлять. Возможно в последствии построить поверх всего этого какие-то дополнительные оптимизации. Посмотрим…

Written by Dmitri

30 января 2015 at 1:20

Опубликовано в Programming

Tagged with , ,

Короткий опыт использования С++

7 комментариев

Хочу поделиться опытом решения задачи, для которой я вместо C# решил использовать C++. История, мне кажется, очень поучительная, т.к. хорошо иллюстрирует все казусы использования этого «древнего» языка.

Сценарий

В мои руки попало несколько сот гигабайт данных – если быть конкретным, по 10Гб данных за несколько месяцев. Данные поставляются в формате базы данных SQLite. Данные в основном численные, но есть и некоторые нюансы:

  • Некоторые численные значения поставлены не как число, а как строка, например 12345.00000. Эту строку естественно надо «парсить» чтобы получить значение типа double.

  • Все даты в файле поставлены как строки в формате 1991.11.11 11:11:11.111.

  • В данных много повторяющихся строковых литералов, которые можно удалить полностью.

Основная проблема с этими данными – их слишком много, а следовательно мне не перекинуть их на мой университетский кластер. Учитывая ужасную потерю памяти за счет кодирования всего и вся как строк, я решил что будет хорошей практикой сжать данные, удалив из них все строки.

Первая попытка

Первой моей попыткой было, конечно же, тупо подключить к драйверу SQLite модель на базе Entity Framework. Но из этого ничего не получилось – я кажется установил все драйвера которые только можно, и оно все равно не заработало.

Поэтому я решил написать «выдиралку» с использованием только Connection/Command объектов – это как раз тот ADO.NET подход который был популярен во времена .NET 1.1 примерно 8 лет назад.

Написав, я столкнулся с рядом весьма очевидных проблем, главной из которых стала производительность. Например, я понял что использование BinaryFormatter для сериализации структур привело к таким тормозам, что нужно было все сразу переписывать на ручную сериализацию через BinaryReader/BinaryWriter. Производительность увеличилась, но я понял, что если я хочу переконвертировать все данные, мне придется просто оставить компьютер работать на неделю-другую.

Переход на C++

В связи со всем этим, я решил что лучше написать один раз, но написать качественно и так чтобы быстро работало. Создал новый проект С++, перевел его на компилятор Intel (для тех кто не знает, я – ярый фанат использования Intel Parallel Studio), и начал писать. Сразу оговорюсь, что писал я с уклоном на то, что результирующие бинарные данные все равно будут использовться в .NET.

Первым моим шагом было найти приемлемый интерфейс для SQLite но, как ни странно, я быстро нашел драйвер с исходниками который, будучи включенным в проект, начал работать без каких-либо особых телодвижений.

Второй шаг – это конечно подключение библиотеки Boost, которая мне потребовалась для обработки дат. И вот тут уже началось веселье. В частности, библиотека работы с датой и временем — апокалиптична. Неподготовленному человеку в ней не разобраться без нескольких часов читания доков. Не то что System.DateTime! Вот например как происходит конверсия в .NET-совместимую эпоху:

static uint64_t netEpochOffset = 441481536000000000LL;
static ptime netEpoch(date(1400,1,1), time_duration(0,0,0));
static int64_t Util::TimeToTicks(ptime time)
{
  time_duration td = time - netEpoch;
  uint64_t nano = td.total_microseconds() * 10LL;
  return nano + netEpochOffset;
}

И подобная магия тут везде. Вот в .NET у нас тоже один большой long определяет всю дату, но DateTime имеет кучу методов для того чтобы получить кол-во часов, дней, и так далее. Тут все по-другому, тут дата – это сколько вы отсчитали от той или иной эпохи, и чтобы вычитать, например, только время из даты, придется сильно попотеть. Соответственно, для того чтобы определить определенное время дня мне приходилось писать вот такой код:

time_duration _19_00 = time_from_string("1900/01/01 19:00:00.000").time_of_day();

Знаю что экспертам по Boost это покажется смешным, но для трезвого дот-нетчика, вся эта библиотека кажется адом. Даже Boost.Math, который я использую в другом проекте, и то порой эклектичен.

Работа с коллекциями

Коллекции в С++ хранятся ‘by value’, поэтому по-хорошему любой ваш список будет иметь тип vector<boost::shared_ptr<MyObject>> и разыменовывание надо будет делать через (*itr)->DoSomething, что само по себе бесит. Более того, даже работая с примитивными структурами, понимаешь что нехватает массы методов. Например, у .NETного Dictionary есть метод ContainsKey() который проверяет наличие записи с таким ключем. А что происходит с С++‘ным map, где такого метода нет? Вот, полюбуйтесь:

boost::shared_ptr<FullBarrel> barrel;
auto candidate = futureBarrels.find(instrument->InstrumentID);
if (candidate != futureBarrels.end())
  barrel = candidate->second;
else {
  barrel = boost::shared_ptr<FullBarrel>(new FullBarrel);
  futureBarrels.insert(candidate, BarrelDictionary::value_type(instrument->InstrumentID, barrel));
}

Следующее, что убивает, это const-корректность – наивный и жалкий механизм, с помощью которого С++ пытается контролировать, какие элементы можно менять а какие нет. Выливается это в то, что например задекларированный вами vector внезапно нельзя менять внутри for_each. Понятное дело, что для «зубров» С++ эти проблемы разруливаются, да и я смог разобраться, но это еще один пример того, как устаревшая парадигма языка не привносит ничего полезного, зато добавляет массу головной боли.

Сериализация

Поскольку все мои данные – численные (в основном либо double либо int64_t), я решил использовать обычную структуру, т.е. struct, для хранения данных. К счастью, С++ умеет сериализовывать эту структуру в файл полностью, считывая из нее всю память:

ofstream ofs(filename, ios::out | ios::binary);
ofs.write((char*)&myStruct, sizeof(MyStructure));
ofs.close();

Единственный сюрприз ждал меня когда я начал читать эти данные в .NET-е. Во-первых, я не угадал сначала какой размер данных у enum-ов (4 байта), а во-вторых, я забыл про упаковку данных.

Упаковка данных (structure packing) – это механизм, который выравнивает структуры на границах байтов. Очень полезно для процессора, но мой .NETный BinaryReader естественно ничего про подобное не знает и пытается читать заглушечные данные в память переменных.

Решение было простое – добавить директиву #pragma pack(1) в С++ чтобы выравнивать по одному байту, т.е. не добавлять промежуточных данных вообще. Внезапно, все заработало в .NET.

Оптимизации

Я был поражен увеличением скорости в конечной реализации, поэтому не слишком сильно напрягался насчет профилирования. Единственное, что действительно хотелось оптимизировать – это механизм разбора строковых значений вроде 12345.000 и их переведения в тип double. Конечный результат выглядит вот так:

double Util::powersOf10[] = { 1.0, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 10000000.0 };
static double Util::StringToDouble(string& s)
{
  auto dotPos = s.find('.');
  s.erase(s.begin() + dotPos);
  return _atoi64(s.c_str())/powersOf10[s.length() - dotPos] ;
}

В примере выше, мы просто переводим число в целое, парсим его как целое, а потом делим на степень 10-ти их заранее приготовленной таблицы.

Заключение

Конечный результат дал примерно 10-кратное увеличение производительности. За счет компактности С++ных structов, данные получилось ужать примерно в 100 раз. С другой стороны, производительность разработчика упала раз в 5 (а может и более). Спасает лишь то, что это единовременная проблема.

Мне кажется что вывод тут может быть один: нужно использовать правильный язык для правильной ситуации. Ведь анализ конечных данных я все равно буду проводить с использованием C#, т.е. работать на языке где нет LINQ – это как-то совсем архаично. Но многие ведь работают, не так ли?

P.S.: для работы с данными использовался SSD — к сожалению не Revo или FusionIO, а обычный Vertex 3. Обработка велась в несколько потоков на одном физическом диске, т.е. одновременно писалось и читалось несколько файлов.

Written by Dmitri

29 марта 2012 at 1:01

Опубликовано в С++

Tagged with , ,