Способы встраивания C++ в Objective-C проекты

Этот топик — мой перевод статьи про способы встраивания С++ в Objective-C проекты, в которой рассказаны некоторые интересные решения, как удачные, так и неудачные. Оригинал статьи:
www.philjordan.eu/article/strategies-for-using-c++-in-objective-c-projects

Итак,

Способы встраивания C++ в Objective-C проекты


Если вы спешите и хотите сразу перейти к решению проблемы внедрения объектов С++ в классы Objective-C без порчи заголовочных файлов, так, чтоб они могли быть включены из чистого Objective-C, вы можете прокрутить статью до заголовка Pimpl. Это решение можно использовать в ~95% случаев. Остальная часть содержит более глубокий анализ проблемы и дополнительные методы ее решения.

Зачем смешивать Objective-C и C++?


Используя Objective-C, обычно для программирования под iOs или под Mac, я часто сталкивался с ситуациями, когда нужно было вставить С++ в проект. Иногда самая хорошая библиотека для текущей задачи была написана на С++, иногда решение проблемы можно было более лаконично сделать на С++. Самый банальный пример — шаблоны С++, спасающие от написания повторяющегося стандартного кода. Менее очевидно то, что Objective-C иногда слишком объектно-ориентирован. Это звучит как ересь для «людей-для-которых-каждый-предмет-это-объект», но для нетривиальных структур данных я зачастую нахожу объектную ориентацию слишком громоздкой, а Сишные структуры слишком слабыми. Использование же С++ подходит в самый раз.

Objective-C также довольно агрессивен в плане управления памятью, когда нет, например, сборщика мусора. STL (и его новое расширение shared_ptr) позволяет забыть про эту проблему, или, по крайней мере, сосредоточить ее в конструкторах/деструкторах, а не захламлять код release'ами и retain'ами. Конечно, это дело вкуса, и зависит от ситуации; автоматическое управление памятью помогает в коде со нетривиальными структурами данных или в алгоритмически сложном коде.

Еще один повод для смешивания Objective-C и C++ — противоположная ситуация, когда необходимо вызывать функции Objective-C из проекта С++. Распространенный пример — портирование игры или движка под платформы Apple. В таких случаях также можно применять описанные ниже приемы.

Наконец, можно использовать С++ для улучшения производительности. Гибкость сообщений Objective-C накладывает некоторые издержки по сравнению с реализацией большей части виртуальных функций С++, даже учитывая техники кэширования, которые используются в современных рантаймах. Объекты Objective-C не имеют эквивалентных быстрых невиртуальных функций С++. Для оптимизации это может быть важным фактором.

Приводя к общему знаменателю: С


Один из возможных способов использовать эти два языка программирования в одном проекте — полностью разделить их, позволив взаимодействовать через чистый С. Таким образом, можно будет предотвратить их «смешение». Выглядеть это будет так: код, использующий библиотеку С++ переносится в .cpp файл, интерфейс объявлен в заголовочном файле С, С++ часть реализует этот интерфейс с помощью extern «C» функций, а код, в котором будет происходить обращение к интерфейсу С — чистый Objective-C (.m).

Это будет работать неплохо в простых случаях, но более вероятно, что придется писать некоторую оболочку. Тот, у кого есть опыт динамически подгружаемых С++ библиотек с помощью открытого интерфейса С хорошо это знает.

Сегодня фактически весь Objective-C компилируется с помощью GCC или clang. Оба компилятора поддерживают Objective-C++, а это означает, что существует более удобный способ смешать языки.

Objective-C++ и проблемы с заголовочными файлами


На первый взгляд, использование диалекта Objective-C++ выглядит самым прямолинейным подходом. Это результат слияния С++ и Objective-C вместе в одном компиляторе, надежные реализации которых есть и в GCC, и в clang. Учитывая то, насколько различны Objective-C и C++, программисты GCC сделали трудоемкую работу. Но стоит только начать переименовывать .m файлы в .mm для их объявлени как С++ файлов, приходит понимание, что не все так просто.

Заголовочные файлы и препроцессор С на протяжении многих лет являются причиной головной боли программистов С, С++ и Objective-C, а при смешивании этих языков все становится еще хуже. Допустим, необходимо использовать контейнер map(словарь, ассоциативный массив) из библиотеки STL в классе Objective-C. Насколько я знаю, фреймворк Foundation от Apple не содержит отсортированного map'a, структурно построенного на деревьях. Итак, создаем переменную-член в нашем классе:
#include <map>
@interface MyClass : NSObject {
  @private
  std::map<int, id> lookupTable;
}
// ...
@end

Однако, std::map<int, id> имеет смысл только для компилятора, поддерживающего С++, и только после #include , так что теперь этот заголовочный файл может быть импортирован (#import) только из файлов Objective-C++. А любой код, использующий этот класс теперь должен быть конвертирован в Objective-C++. Далее, по цепочке, остальные заголовочные файлы также придется конвертировать (.mm), и так со всем проектом.

В некоторых случаях это допустимо. Несмотря на это, менять весь проект или его большую часть только для того, чтобы в одном месте использовать библиотеку, — слишком громоздко и избыточно; кроме того, если вы единственный в проекте среди Objective-C программистов, знающий С++, это будет не очень хорошей идеей. Кроме того, проблемы могут возникнуть при компиляции кода на чистом С компилятором С++, редко случается, что такое проходит полностью безболезненно. Сверх того, это значит, что код нельзя будет повторно использовать автоматически в других проектах Objective-C.

В большинстве же случаев, выход, позволяющий использовать преимущества кода на чистом Objective-C или C++ — это использование Objective-C++ там, и только там, где необходимо. Чтобы сделать это идеально, придется постараться.

Стреляя в собственную ногу: void*


Становится понятно, что цель — убрать все, что есть от С++, из заголовочных файлов. Типичный для С способ скрыть тип — указатель на void. Здесь, конечно, это тоже будет работать:
@interface MyClass : NSObject {
  @private
  // на самом деле std::map<int, id>*
  void* lookupTable;
}
// ...
@end

В тех местах кода, где будет использоваться таблица, придется использовать явное приведение типов: static_cast<std::map<int, id>*>(lookupTable) или ((std::map<int, id>*)lookupTable), что будет сильно раздражать. Если действительный тип члена класса изменится, все приведения типов придется менять вручную, а это резко увеличивает вероятность ошибиться. С ростом числа членов запомнить все правильные типы становится невозможно, и в итоге получаем недостатки как статического, так и динамического типизирования. Использование этого способа для работы с объектами из иерархии классов — это опасная игра с огнем, в которой может еще и оказаться, что указатели А* и В* на один и тот же объект имеют разные представления void*

Можно сделать и лучше.

Условная компиляция


Потеря информации о типе — это плохо, но раз уж типизированные поля С++ используются из кода Objective-C, а чистому Objective-C компилятору необходимо знать только об их присутствии (для корректного выделения памяти), почему бы не сделать две различные версии кода? В Objective-C++ определен символ препроцессора _cplusplus, поэтому как насчет такой реализации:
#ifdef __cplusplus
#include <map>
#endif
@interface MyClass : NSObject {
  @private
#ifdef __cplusplus
  std::map<int, id>* lookupTable;
#else
  void* lookupTable;
#endif
}
// ...
@end

Некрасиво, но с этим гораздо легче работать. Стандарт С++ не гарантирует, что указатель на класс и указатель на void будут иметь одно и то же расположение в памяти (объект, конечно, один), но Objective-C++ не является стандартом GNU/Apple. На практике же проблему представляют только указатели на виртуальные функции, когда происходит конвертация в void*, а при попытке это сделать компилятор будет громко ругаться. Если же беспокойство не проходит, можно использовать static_cast<> вместо приведения в стиле С.

С по-прежнему удачно приводит void* к любому другому указателю неявным образом, поэтому, возможно, предпочтительно было бы заменить Сишную часть с #ifdef на указатель на структуру со сложным, но уникальным и узнаваемым названием, например, struct MyPrefix_std_map_int_id. Можно даже определить макро, расширяющееся в корректное определение типа в зависимости от компилятора:
#ifdef __cplusplus
#define OPAQUE_CPP_TYPE(cpptype, ctype) cpptype
#else
#define OPAQUE_CPP_TYPE(cpptype, ctype) struct ctype
#endif

// ...

#ifdef __cplusplus
#include <map>
#endif

@interface MyClass : NSObject {
  @private
  OPAQUE_CPP_TYPE(std::map<int, id>, cpp_std_map_int_id)* lookupTable;
}
// ...
@end

При таком способе не получится избежать условного #include заголовков С++, а это может запутать людей, которые не знают или не любят С++. Да и выглядит это не очень. К счастью, есть другие решения.

Абстрактные классы, интерфейсы и протоколы


Многие С++ программисты хорошо знакомы с чисто виртуальными функциями, и, следовательно, абстрактными классами. Другие языки, такие как Java и С#, имеют явную концепцию «интерфейса», при которой намеренно скрываются детали реализации. Этот же паттерн можно использовать и в нашем случае.

Последние версии Objective-C поддерживают протоколы, похожие на интерфейсы Java/C# по сути, если не по синтаксису. Можно объявить открытые методы класса в протоколе в заголовочном файле, а объявить и написать конкретный класс, реализующий указанный протокол, в закрытом коде (разделив таким образом, С++ и Objective-C). Это будет работать для методов экземпляра, но не получится напрямую создать экземпляр класса через протокол. Поэтому придется делегировать распределение памяти и инициализацию новых объектов фабрике классов или функции С. Хуже то, что протоколы в каком-то смысле ортогональны классам, поэтому при объявлении ссылки будут выглядеть по-другому:
id<MyProtocol> ref;

вместо ожидаемого:
MyClass* ref;

Все работает, но и это не идеал.

Абстрактные классы в Objective-C


Так что там насчет абстрактных классов? В этом языке нет прямой идиоматической поддержки для них, но даже распространенный NSString является абстрактным, и невозможно понять это, просто используя его. Один из альтернативных способов решения нашей проблемы — взять все объявления методов и поместить их все в такой класс. Придется терпеть предупреждения компилятора о неполном описании класса. В рантайме попытки вызвать несуществующий метод будут бросать исключения. Можно, конечно, создать псевдо-реализации этих методов, которые будут бросать специальные исключения, объясняющие ситуацию.

В большинстве языков для создания экземпляров нужно знать конкретный класс, либо делегировать это фабрике классов. Занимательно, что можно напрямую послать классу NSString сообщения alloc и init, а получить экземпляры классов, унаследованных от NSString. Сообщения init возвращают объект, отличный от self, которому было послано init, например, alloc у NSString может быть переопределен так, что вызывается NSCFString, тут фабрикой классов является NSString. Если делать такое самому — придется определить все init* методы, используемые конкретным классом, для абстрактного, иначе они не будут видны тем, кто им пользуется.

Таким образом, объявить все методы в абстрактном классе, наслеюдуя от него Objective-C++ классы — определенно самое непыльное решение для заголовочного файла и для тех, кто пользуется этим классом, но, одновременно, оно является самым трудоемким, требующим дополнительного класса, псевдо-методов и нетривиальных реализаций init.

Однако же, программисты С++ нашли элегантное решение подобным проблемам. Одна из серьезных проблем больших проектов С++ — это резко возрастающее время компиляции, связанное с зависимостями заголовочных файлов. В таких случаях обычно делается сокрытие внутренностей класса от использующих его программистов. Точно такое же решение можно применить к дилемме Objective-C/C++.

Pimpl


Pimpl — сокращение от «указатель на реализацию» (pointer to implementation) или «закрытая реализация» (private implementation). Эта идиома довольно проста. В открытом заголовочном файле добавляется опережающее описание реализации struct, обычно, используя имя открытого класса с суффиксом Impl, зависит от соглашения. Эта struct будет содержать все члены, которые необходимо спрятать от открытого заголовочного файла класса. Осталось добавить указатель на структуру в переменные класса и определить члены структуры в .cpp файле (в нашем случае — .mm). В конструкторе (точнее, в -init*), с помощью оператора new нужно создать экземпляр структуры, присвоить ее переменной класса и убедиться, что в -dealloc вызывается delete.
MyClass.h:

// ...
struct MyClassImpl;
@interface MyClass : NSObject {
  @private
  struct MyClassImpl* impl;
}
// объявления открытых методов...
- (id)lookup:(int)num;
// ...
@end

MyClass.mm:

#import "MyClass.h"
#include <map>
struct MyClassImpl {
  std::map<int, id> lookupTable;
};

@implementation MyClass
- (id)init
{
  self = [super init];
  if (self)
  {
    impl = new MyClassImpl;
  }
  return self;
}
- (void)dealloc
{
  delete impl;
  [super dealloc];
}
- (id)lookup:(int)num
{
  std::map<int, id>::const_iterator found =
    impl->lookupTable.find(num);
  if (found == impl->lookupTable.end()) return nil;
  return found->second;
}
// ...
@end

Все будет работать, потому что опережающее объявление структур — это допустимый код на С, даже если потом окажется, что в структуре присутствуют явные или неявные конструкторы С++ или даже родительские классы. Открытые методы класса имеют доступ к стуруктуре через указатель, а создание и удаление происходит с помощью new/delete.

Зависит от реализации, насколько большая функциональность будет у методов открытого класса. Можно повысить производительность, если в некоторых случаях избежать пересылки сообщений Objective-C, но стоит помнить о неприятностях, которые могут возникнуть, когда методы С++ должны посылать сообщения классам Objective-C.

Стоит отметить, что вместо реализации в MyClass структуры, можно унаследовать от нее MyClass. Так можно избежать непрямых вызовов:
@interface MyClass : NSObject {
  struct MyClassMap* lookupTable;
}
- (id)lookup:(int)i;
@end
and MyClass.mm:

#import "MyClass.h"
#include <map>
struct MyClassMap : std::map<int, id> { };
@implementation MyClass
- (id)init {
  self = [super init];
  if (self)
    lookupTable = new MyClassMap;
  return self;
}
- (id)lookup:(int)i {
  MyClassMap::const_iterator found = lookupTable->find(i);
  return (found == lookupTable->end()) ? nil : found->second;
}
- (void)dealloc {
  delete lookupTable; lookupTable = NULL;
  [super dealloc];
}
@end

Но с увеличением количества членов это становится непрактичным из-за большого количества new/delete.

Ограничения


На чистом С++ имплементацию можно сделать классом, но в нашем случае это не пройдет, так как опережающее объявление должно быть корректным на Objective-C. В литературе по С++ можно встретить рекомендации использования shared_ptr<> и auto_ptr<> для автоматического удаления объектов. В заголовочных файлах Objective-C такое тоже не пройдет. Также не удасться уйти от указателя на струткуру, так как компилятор должен знать, сколько под нее необходимо выделить памяти.

Имплементация закрыта, поэтому невозможен прямой доступ к членам классов-наследников. Тем не менее, можно вынести объявления структуры в полу-закрытый заголовочный файл, который будет включаться только классами-наследниками, которые должны иметь этот прямой доступ. Такие наследники должны быть написаны на Objective-C++.

Мысли в конце


Все-таки я нахожу идиому Pimpl лучшим выбором для встраивания С++ в Objective-C почти во всех случаях. Даже если структура содержит только один член, отсутствие лишних приведений типов возмещает косвенность. Производительность при этом не теряется, так как структура сама по себе не является указателем. В случае, когда необходимо включить Objective-C в С++, поступить можно таким же способом: определить открытый интерфейс С++, опережающее объявление для имплементации класса в заголовочном файле и поместить определение Objective-C членов в соответствующий .mm файл.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 13

    –1
    А так?

    @implementation MyClass
    {
    std::map<int, id> lookupTable;
    }

    ...

    @end
      0
      иногда мне кажется, что комментарии и превью рисуют два разных хабрапарсера >_<
        0
        Вот именно, что нам мешает определить ivar в Class Extension непосредственно в .mm файле?
        0
        Сам использую .mm подход
        Не уловил реальных минусов это подхода в рассуждениях автора статьи.

        1) Переименовать файлы — не проблема (а особенно если заранее их создавать с таким расширением)
        2) Вы единственный программист с++ — какая разница какое расширение у файлов, для программистов Objective-C — тоже не проблема (только если они не молятся на это)
          0
          Есть еще пару минусов:

          «Кроме того, проблемы могут возникнуть при компиляции кода на чистом С компилятором С++, редко случается, что такое проходит полностью безболезненно. Сверх того, это значит, что код нельзя будет повторно использовать автоматически в других проектах Objective-C».

          Лично у меня проблем с этим не было, но С++ не является надмножеством С, поэтому теоретически возможен случай, когда эта разница станет фатальной.

          И если мы будем использовать этот проект в других, то в них тоже по цепочке придется переименовывать файлы :) конечно, Вы правы, и это не такая уж проблема, но если исходный проект используется как, например, open-source основа во многих других, то лучше будет не заставлять людей менять что-то у себя в проектах.
            0
            Если использовать в других своих «своих» продуктах — это не проблема.
            Если ваша библиотека используется в других сторонних проектах как база, тут конечно вам надо позаботится об обратно совместимости
            Но и тут тоже возникает вопрос, зачем вам вдруг понадобился С/C++ (пример с std::map мне показался неудачным).

            Ситуация ".m" и ".mm" полностью такая же как и с ".c" и ".cpp"

            P.S. Я полностью согласен с тем, что ситуация описанная в статье реальна (редко, но тем не менее). И для lеgacy кода это вариант.

              0
              Если прочитать оригинал статьи, и посмотреть реальное использование PImpl Objective-C wrapper for the Open-VCDiff decoder.
              Такое использование очень даже оправданно и красивое.
            0
            Есть еще минусы:
            1. NSAssert не останавливает выполнение программы, да и вообще ведет себя неадекватно
            2. Придется отказаться от ARC во всем проекте
            3. Невозможность автоматического рефакторинка в xcode для mm файлов.
            +2
            - (void)dealloc
            {
            delete impl;
            [self dealloc];
            }

            = stack overflow.
              0
              прошу прощения, конечно же

              — (void)dealloc
              {
              delete impl;
              [super dealloc];
              }
              0
              > Objective-C также довольно агрессивен в плане управления памятью, когда нет, например, сборщика мусора
              > захламлять код release'ами и retain'ами

              facepalm.jpg

              По опыту, лучше любыми способами избегать C++ в ObjC-коде, а если уж совсем прижимает, писать ObjC-обертки.

                0
                Поддерживаю, более того, не могу вспомнить ниодного примера из своей практики, где бы это реально было бы необходимо (особенно, касающиеся управления памятью). Исключения конечно составляют сторонние библиотеки.
                0
                Много воды уже утекло с момента публикации перевода, но для актуальности добавлю ссылку на дополнение к оригинальной статье. www.philjordan.eu/article/mixing-objective-c-c++-and-objective-c++

                Only users with full accounts can post comments. Log in, please.