Wiki

Бинарная совместимость в C++

Автор: Matthias Ettrich
Перевод: Andi Peredri

Определение

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

Если для обеспечения работы программы с новой версией библиотеки необходима ее перекомпиляция, но не требуются какие-либо изменения в исходном коде, библиотека является совместимой на уровне исходного кода.

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

  • требовательны к ресурсам ( особенно к памяти )
  • не обеспечивают устранение ошибок в библиотеках и обновление этих библиотек.

В проекте KDE мы обеспечиваем бинарную совместимость на протяжении старшего номера версии.

Возможности и ограничения

Вы можете ...

  • добавлять новые невиртуальные функции.
  • Переопределять виртуальные функции, определенные в одном из базовых классов, если гарантировано, что программы, собранные с предыдущей версией библиотеки, вызовут их реализацию из базового класса. Это ненадежно и рискованно. Дважды подумайте, прежде чем так поступить.
  • Изменять встраиваемые ( inline ) функции или делать встраиваемые функции обычными, если гарантировано, что программы, собранные с предыдущей версией библиотеки, вызовут их старую реализацию. Это ненадежно и рискованно. Дважды подумайте, прежде чем так поступить. Вот почему классы, для которых предполагается обеспечить бинарную совместимость, должны всегда иметь невстраиваемый деструктор, даже если он пустой.
  • Удалять закрытые ( private ) невиртуальные функции, если они не вызываются какой-либо встраиваемой функцией.
  • Изменять значение по умолчанию параметра функции. Однако использование нового значения по умолчанию для аргумента возможно только после перекомпиляции.
  • Добавлять новые статические данные-члены класса.
  • Добавлять новые классы.

Вы не можете ...

  • добавлять новые виртуальные функции, так как это изменит таблицу виртуальных функций и приведет к неработоспособности наследуемых классов. ( Однако в некоторых случаях это все же возможно - спрашивайте в группах рассылки ).
  • Изменять порядок виртуальных функций в объявлении класса. Это наверняка приведет к измене содержимого таблицы виртуальных функций.
  • Изменять сигнатуру функции. Заметьте, что расширение функции еще одним параметром, даже если этот параметр имеет значение по умолчанию, приводит к изменению сигнатуры функции. Поэтому такое решение не обеспечивает бинарную совместимость ( только совместимость на уровне исходного кода ). Просто добавьте другую функцию с таким же именем и расширенным списком параметров и короткое примечание о решении бинарной совместимости ( Binary Compatibiliy Issue, BCI ), чтобы в будущих версиях библиотеки эти две функции были объединены в одну с аргументом по умолчанию. Заметьте, что изменение типа возвращаемого значения не приводит к изменению сигнатуры функции ( как минимум, в gcc ).
    	void functionname( int a );
    	void functionname( int a, int b ); //BCI: merge with int b = 0
  • Изменять права доступа к методам и данным класса, например, с private на public. Некоторые компиляторы включают эту информацию в сигнатуру. Если вам необходимо сделать закрытую функцию защищенной или даже открытой, то добавьте новую функцию, которая вызовет эту закрытую.
    Примечание: в KDE распространено изменять права доступа на методы в сторону большей доступности ( т.е private->protected->public ). Нам известен только один компилятор, поступающий таким образом, это - MSVC++. В любом случае, KDE не компилируется под Windows.
  • Добавлять новые данные-члены в класс или менять их очередность в объявлении ( не применимо к статическим данным ).
  • Изменять иерархию существующих классов, не считая добавления новых.

Технические приемы разработчиков библиотек

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

Битовые поля

Исключением являются битовые поля. Если вы в качестве компонентов структур и объединений используете битовые поля, то можете безопасно изменять их суммарный размер до ближайшего целого байта, минус 1. Класс с битовыми полями

	uint m1 : 1;
	uint m2 : 3;
	uint m3 : 1;

может быть безопасно расширен до
	uint m1 : 1;
	uint m2 : 3;
	uint m3 : 1;
	uint m4 : 2; // new member

без потери бинарной совместимости. Максимальный суммарный размер битовых полей не должен превышать 7 бит ( или 15, если их начальный размер больше 8). Использование самого старшего бита на некоторых компиляторах может привести к потере бинарной совместимости.

Использование d-указателей

Битовые поля и зарезервированные переменные являются хорошим, но не достаточным решением. Настало время рассмотреть технику d-указателей. Термин "d-указатель" ввел Arnt Gulbrandsen ( Trolltech ) для техники, использованной при разработке библиотеки Qt, обеспечив ей одной из первых C++ GUI-библиотек бинарную совместимость на протяжении многих выпусков. Эта техника была быстро адаптирована как основной прием программирования многими разработчиками KDE-библиотек. Это замечательное решение проблемы добавления новых закрытых данных-членов в класс без потери бинарной совместимости.

Примечание: Техника d-указателей неоднократно описывалась в истории информатики под различными именами, такими, как pimpl (pointer to implementation), handle/body, чеширский кот. Он-лайн версии этих документов вы можете найти с помощью Google, добавив "C++" в строку поиска.

В объявление вашего класса Foo добавьте следующую декларацию:

class FooPrivate;

и d-указатель в закрытую секцию класса:
private:
	FooPrivate* d;

Сам класс FooPrivate целиком и полностью определяется в файле реализации класса ( обычно *.cpp ), например:
class FooPrivate {
public:
	FooPrivate()
	: m1(0), m2(0)
	{};
	int m1;
	int m2;
	QString s;
};

Все, что вам теперь осталось сделать, это создать в конструкторе или инициализирующей функции объект FooPrivate:
	d = new FooPrivate;

и затем удалить его в вашем деструкторе:
	delete d;

Естественно, вы не обязаны располагать абсолютно все данные-члены в классе FooPrivate. Для повышения производительности часто используемые данные лучше поместить непосредственно в класс Foo, тем более, что встраиваемые функции не имеют доступа к данным FooPrivate. Заметьте также, что все данные, доступные через d-указатель, являются закрытыми в пределах класса Foo. Чтобы сделать их открытыми или защищенными, необходимо реализовать set- и get- функции, например:

	QString Foo::string() const
	{
		return d->s;
	}
 
	void setString( const QString& s )
	{
		d->s = s;
	}

Решение проблемы

Если у вас нет свободных битовых полей, зарезервированных переменных или d-указателя, но вам непременно нужно добавить новую закрытую переменную, у вас все еще остается возможность сделать это. Если ваш класс является производным от QObject, вы можете поместить дополнительные данные в специальный дочерний объект и затем найти его в списке дочерних объектов. Список дочерних объектов может быть получен с помощью QObject::children(). Однако наиболее быстрым и предпочтительным способом хранения соответствий между вашими объектами и дополнительными данными является использование хеш-таблиц. Для этих целей Qt предлагает словарь на основе указателей QPtrDict.

Для использования этого приема вам необходимо проделать следующие шаги:

  1. Создайте закрытый объект класса FooPrivate.
  2. Создайте статический объект QPtrDict. Заметьте, что некоторые компиляторы/сборщики ( почти все, к сожалению ) не обеспечивают создание статических объектов в библиотеках. Они просто забывают вызвать конструктор. Поэтому вам необходимо использовать статический указатель на QPtrDict и следующую функцию для доступа к данным:
    	// BCI: Add a real d-pointer
    	static QPtrDict<FooPrivate>* d_ptr = 0;
    	static void cleanup_d_ptr()
    	{
    		delete d_ptr;
    	}
    	static FooPrivate* d( const Foo* foo )
    	{
    		if ( !d_ptr ) {
    			d_ptr = new QPtrDict<FooPrivate>
     
    			qAddPostRoutine( cleanup_d_ptr );
    		}
    		FooPrivate* ret = d_ptr->find( (void*) foo );
    		if ( ! ret ) {
    			ret = new FooPrivate;
    			d_ptr->replace( (void*) foo, ret );
    		}
    		return ret;
    	}
    	static void delete_d( const Foo* foo )
    	{
    		if ( d_ptr )
    			d_ptr->remove( (void*) foo );
    	}
  3. Теперь вы можете использовать в вашем классе d-указатель почти так же просто, как и в предыдущем примере, используя вызов d(this). Например:
    	d(this)->m1 = 5;
  4. Добавьте следующую строку в ваш деструктор:
    	delete_d(this);

    Это не обязательно, но сэкономит часть ресурсов.
  5. Не забудьте добавить BCI-комментарий, чтобы этот "хак" был удален в следующих версиях библиотеки.
  6. Не забудьте добавить d-указатель в ваш следующий класс.

Источник: Binary Compatibility Issues With C++