C и C++: межъязыковые интерфейсы +38


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

Правда, есть одна сфера, где обычно наблюдается согласие между C и C++. Это — ABI (Application Binary Interface, двоичный интерфейс приложений). Структуры данных и функции одного языка могут быть, в той или иной мере, использованы в другом языке. C и C++, кроме того, достаточно сильно пересекаются в области спецификаций интерфейсов, вследствие чего один и тот же заголовочный файл можно использовать из кода, написанного на обоих языках.



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

Типы данных


В C и C++ используется один и тот же набор базовых типов данных и конструкций, которые можно применять и там и там. А вот при работе с некоторыми типами нужно учитывать то, что в них присутствуют небольшие различия.

Целочисленные типы — такие, как char, short, int, long в обоих языках имеют схожие представление и семантику. Но нужно знать о том, что у типа bool (или _Bool) и у перечислений есть некоторые особенности, о которых мы поговорим ниже.

Все типы для чисел с плавающей запятой имеют одно и то же представление. При работе с ними используется одинаковый синтаксис. Это — float, double, и, возможно, long double. Но синтаксис комплексных типов в C и C++ различается. В C имеется нечто вроде спецификатора, указывающего на реальный базовый тип, а в C++ для описания таких типов используются шаблоны.

Массивы имеют одинаковый синтаксис и представление. Но C позволяет менять размер массива, давая возможность работать с так называемыми массивами переменной длины (Variable Length Arrays, VLA).

Типы struct и union, если в них не объявляются члены-функции, имеют одно и то же представление. В C++ их называют простыми структурами данных (Plain Old Data structures, POD).

Атомарные типы имеют одно и то же представление и семантику, но разный синтаксис. В C++ используются шаблоны. В C применяются квалификаторы и спецификаторы типов.

?Логический тип данных


Логический тип данных в C «официально» называется _Bool, но существует удобный макрос, в котором объявлено ключевое слово bool, указывающее на этот тип. На самом деле, эта конструкция была создана только для обеспечения обратной совместимости с кодом, который был написан до введения логического типа. Её вполне могут убрать из будущих версий стандарта C. В C++ в использовании _Bool особого смысла нет, выглядит эта конструкция нехорошо и представляет собой введение в C++-код возможности C, которая является временной (хотя в таком статусе она уже пребывает очень и очень долго).

Поэтому легче всего для организации работы с логическими значениями просто пользоваться bool и при этом обращать внимание на то, чтобы C видел бы соответствующий заголовочный файл. Сделать это несложно:

#ifndef __cplusplus
# include
#endif
 
extern bool weAreHappy;

Как уже было сказано, подключать этот заголовочный файл в C++ не имеет никакого смысла.

?Перечисления


Простые перечисления должны обрабатываться в C и C++ одинаково. Константы перечисления имеют одинаковые значения, но разные типы. В C это — тип int, в C++ — это тип самого перечисления.

До тех пор, пока константы используются ради их значений, всё должно работать хорошо. Но если попытаться воспользоваться переменными или параметрами функций, имеющих перечисляемый тип, начнутся сложности. В C и C++ используются разные правила для неявного преобразования значений к таким типам и для преобразования значений таких типов к другим типам. Поэтому лучше их в таком качестве не использовать.

?Атомарные типы


В C++ атомарный вариант некоего базового типа описывают в виде шаблона:

extern std::atomic flags;

В C имеется два варианта объявления подобных типов:

extern unsigned _Atomic flags;  // квалификатор _Atomic
extern _Atomic(unsigned) flags; // спецификатор _Atomic

В коде, который подойдёт и для C и для C++, можно пользоваться вторым из предыдущих двух вариантов:

#ifdef __cplusplus
# include 
# define _Atomic(T) std::atomic
#else
# include 
#endif
 
extern _Atomic(unsigned) flags;

?Комплексные типы


В C++ комплексные типы, опять же, объявляют в виде шаблонных типов:

extern std::complex angle;

В C эквивалент этой конструкции выглядит так:

extern complex double angle;

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

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

#ifdef __cplusplus
# include 
typedef std::complex cfloat;
typedef std::complex cdouble;
typedef std::complex cldouble;
# define I (cfloat({ 0, 1 }))
#else
# include 
typedef complex float cfloat;
typedef complex double cdouble;
typedef complex long double cldouble;
#endif
 
extern cdouble angle;
...
 
cdouble angle = 4.0 + 3.0*I;

Кроме того, нужно учитывать то, что код, в котором применяются комплексные типы C и C++, должен использовать идентификатор I только для указания комплексного корня из -1.

Объекты


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

?Временные объекты


Надо отметить, что C и C++ очень сильно различаются в используемом в них понятии неименованных, временных объектов. В C существует два вида временных объектов:

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

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

Поэтому временные объекты в интерфейсах, как правило, лучше не использовать в тех случаях, когда они могли бы применяться в C для передачи адреса объекта функции, которая вернёт этот адрес для дальнейшего использования в текущей области видимости.

Мы ещё вернёмся к этой теме в разделах о списках аргументов переменной длины и о макросах.

?Объекты с квалификатором const


В C++ объекты с квалификатором const можно помещать в заголовочные файлы, что приводит к тому, что они ведут себя как константы базового типа.

constexpr unsigned const fortytwo = 42u;

Эта конструкция (даже без constexpr) не разрешена в C, так как её использование приведёт к объявлению объекта fortytwo во всех .o-файлах, а это приведёт к нарушению правила одного определения. Если, так сказать, сэмулировать эту возможность, объявив объект с использованием ключевого слова static, это приведёт к следующим последствиям:

  • Будет создано несколько копий объекта, размещённых в разных местах памяти. Программы, которые сравнивают указатели на подобные объекты, могут работать неправильно.
  • В C объекты, объявленные с ключевым словом static, нельзя использовать из функций, объявленных с ключевым словом inline.

При создании межъязыковых интерфейсов мы пользуемся макросами. Для типов, представленных литералами, всё выглядит довольно просто:

#define fortytwo 42u

Для структурных типов нет единого решения этой проблемы. В C используются составные литералы в макросе, в C++ применяется глобальный объект с квалификатором const.

Функции


Если речь идёт о функциях, относящихся к тем общим для C и C++ типам, о которых мы говорили выше, то на некоей платформе ABI вызова таких функций должен быть одинаковым. Это не зависит от языка, через который мы обращаемся к интерфейсу. Тут применяются одинаковые правила представления параметров функции и возвращаемых значений в аппаратных регистрах или в стеке. Первое важное различие между C и C++ заключается в том, что в C++ есть механизм перегрузки функций. Поэтому должно выполняться преобразование типов аргументов во внешней функции — если только не будет указано, что делать этого не нужно. Вот распространённая конструкция для реализации такого поведения:

#ifdef __cplusplus
extern "C" {
#endif
 
int toto(void);
 
double hui(char*);
 
#ifdef __cplusplus
}
#endif

Здесь имеются две C++-зоны, окружающие спецификацию общего интерфейса, объявляющего функцию с использованием ключевого слова extern с языковым интерфейсом C. Макрос __cplusplus при использовании любого C-компилятора гарантированно окажется необъявленным, а при использовании любого C++-компилятора — объявленным.

?Функции без параметров


В C интерфейс функции, который объявляется с пустым списком параметров, на самом деле, не имеет прототипа. Такая функция может принимать любое количество параметров (почти) любых типов. Существуют особые правила о том, как аргументы подобных функций преобразуются на вызывающей стороне.

Функция, которая действительно не принимает никаких аргументов, должна быть определена, в результате, особым образом. В частности, с использованием void в списке параметров. Например — это функция toto из вышеприведённого примера.

?Функции со списком параметров переменной длины


Вот распространённая конструкция, используемая в C для описания функций, принимающих 2-мерные массивы:

void initialize(size_t n, size_t m, double A[n][m]);

В C++ нет синтаксических конструкций, поддерживающих эту возможность. Поэтому мы не можем пользоваться подобными функциями в описаниях интерфейсов между C и C++.

В теории, правда, C++ может использовать и подобные функции, так как ABI этой функции представляет собой два значения size_t и указатель на значение double. Информация о типе матрицы в C собирается в начале выполнения функции, у вызывающей стороны нет ничего, что она могла бы предоставить функции в дополнение к аргументам.

У нас может возникнуть желание предоставить «фиктивный» интерфейс для C++, которому нужно лишь значение типа double, но подобное, при неправильном использовании функции, может легко привести к негативным последствиям. Лучше всего создать небольшую обёртку, которая принимает подходящий шаблон типа vector.

?Параметры, представленные многомерными массивами с квалификатором const


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

?Квалификатор restrict и псевдонимы


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

В C++ нет механизма, аналогичного этому. Часто по этому поводу выдвигают рекомендацию, в соответствии с которой при использовании C++ нужно просто объявить restrict в виде макроса, ничем не заменяемого. Но из-за этого теряется смысл информирования того, что вызывает функцию, о необходимости внимательно следить за тем, что передаётся функции, передавая разные аргументы в разные параметры.

Постарайтесь, если возможно, не пользоваться этим в межъязыковых интерфейсах. Если же без этого не обойтись — тщательно документируйте тот факт, что аргументы функции должны быть различными.

?Функции, объявленные с ключевым словом inline


Семантика ключевого слова inline немного отличается в C и C++, но обычно, соблюдая осторожность, им можно пользоваться. Разница заключается в создании экземпляра функции в том случае, когда компоновщику нужна её копия. C++ занимается этим сам, а в C задача по предоставлению системе в точности одного экземпляра соответствующего кода ложится на программиста. Такой код должен располагаться в одном из связанных друг с другом объектных файлов. Поэтому, пользуясь этой возможностью, нужно проконтролировать, чтобы библиотека, реализующая интерфейс, предоставляла бы подобный код.

Но функции, объявленные с ключевым словом inline, это, в первую очередь, функции. Поэтому к ним применимо всё вышесказанное о различиях между C и C++. Если вы пользуетесь этой возможностью — постарайтесь сделать функции как можно меньше и переложите большую часть реализации их внутренних механизмов на возможности одного из языков.

?Вариадические функции


Вариадические функции — это сложная тема. При работе с ними применяются непростые правила по преобразованию (продвижению) типов аргументов, у них нет внутреннего механизма, позволяющего узнать о количестве полученных аргументов. Не стоит создавать новые интерфейсы, использующие эту возможность. В результате использование этой возможности стоит ограничить несколькими стандартными функциями библиотеки C — вроде printf или scanf.

Оба языка имеют возможности, способные, в большинстве случаев, заменить вариадические функции. Но, конечно, использование вариадических шаблонов C++ выходит за рамки создания межъязыковых интерфейсов с C. Обычно в случаях, когда речь идёт о списке аргументов одного и того же типа, при объявлении которых используется квалификатор const, в промежуточных вызовах макроса может использоваться параметр, представленный временным массивом.

?Универсальные интерфейсы функций


В C и C++ используются диаметрально противоположные стратегии при реализации универсальных интерфейсов функций. В C++ имеется механизм перегрузки функций и аргументы, применяемые по умолчанию. В C есть первичные выражения _Generic, для реализации которых используются макросы. Механизмы, используемые в C, нелегко расширять, то есть — можно объединить лишь функции для известного списка типов. Если нужно поддерживать новый тип — нужно изменить выражение _Generic или макрос.

Тут у C и C++ не особенно много общего. Поэтому, если нужно реализовать подобную возможность, рассчитанную на оба языка, нужно реализовывать её по-разному для каждого из них.

В качестве достаточно простого решения этой задачи можно воспользоваться созданием разных функций для списка типов в C с использованием подходящего соглашения об именовании сущностей. При таком подходе имена, например, могут выглядеть как hu_flt и hu_dbl. В C будет использоваться макрос:

#define hu(X)            _Generic((X),                     float: hu_flt,           double: hu_dbl) (X)

В C++ будет просто использоваться интерфейс с несколькими конкретизациями шаблона:

template inline auto hu(T x);
 
template inline auto hu(float x) { return hu_flt(x); }
template inline auto hu(double x) { return hu_dbl(x); }

И тот и другой подход приведут к появлению одинаково эффективного исполняемого кода. В частности, в скомпилированной программе вызовы hu должны напрямую заменяться вызовами соответствующей C-функции.

Предположим, универсальный интерфейс предоставляет константы времени компиляции. В C это может выглядеть так:

#define needed(X)        _Generic((X),                     float: 37,               double: 51)

А в C++ можно задействовать похожий механизм, в котором вместо inline используется constexpr:

template constexpr auto needed(T x);
 
template constexpr auto needed(float x) { return 37; }
template constexpr auto needed(double x) { return 51; }

Макросы


Препроцессоры для C и C++ в наши дни должны выдавать эквивалентные варианты замены макросов соответствующим кодом. Цель тех, кто занимается стандартизацией C и C++, заключается в том, чтобы поддерживать их в полностью согласованном состоянии. Мы уже видели несколько примеров, когда препроцессор, при объявлении общих интерфейсов, приходит нам на помощь.

В частности, сравнительно недавно препроцессор C++ был оснащён макросами variadic. Это — макросы, которые могут принимать разное количество аргументов.

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

size_t median_vec(size_t len, double arr[]);

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

# define ASIZE(...)  /* механизм макроса, определяющий длину списка аргументов */
 
#ifndef __cplusplus
# define ARRAY(T, ...) ((T const[]){ __VA_ARGS__ })                  // составной литерал
#else
# define ARRAY(T, ...) (std::initializer_list({ __VA_ARGS__ }).begin()) // стандартный инициализатор массива
#endif
 
#define median(...) median_vec(ASIZE(__VA_ARGS__), ARRAY(double, __VA_ARGS__))
...
size_t med = median(0, 7, a, 33, b, c);

Приходилось ли вам налаживать взаимодействие между кодом, который написан на C и на C++?




К сожалению, не доступен сервер mySQL