В предыдущей статье мы обсудили низкоуровневые средства для работы с различными платформами в одном коде. В данной статье речь пойдет о способах организации самого кода в кросс-платформенных программах.
Разумеется можно не обращать внимание на то, что программа делается под разные платформы и вставлять платформо-зависимый код во все участки программы, где это потребуется. Однако это очень быстро превратит код в нечитабельную свалку. Портировать его под новую платформу будет крайне сложно.
Чтобы этого избежать лучше сразу потратить время и организовать код таким образом, чтобы он был понятен и легко расширяем под новую платформу.
Объектно ориентированный подход
Простейшим средством для организации кода для какой-либо системы является Объектно ориентированный подход. В рамках данного подхода требуется создать интерфейс, через который остальной код будет работать с данной системой. Далее сделать реализацию данного интерфейса для каждой конкретной платформы и функцию, которая создаст объект данного класса, в соответствии с текущей платформой. Вот пример, иллюстрирующий базовую идею.
1
2
3
4
5
6
7
8
9
10
11
12
SomeClass.h
class SomeClass
{
virtual void Func() = 0; // специфичная функциональность
void CommonFunc(); // общая функциональность
};
SomeClass* CreateSomeClass();
SomeClass.cpp
void SomeClass::CommonFunc()
{
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PS3Class.h
class PS3Class : public SomeClass
{
virtual void Func();
};
PS3Class.cpp
void PS3Class::Func()
{
}
SomeClass* CreateSomeClass()
{
return new PS3Class();
}
// аналогичная реализация для других платформ
В настройках проекта необходимо исключить из компиляции те *.cpp файлы, которые не нужны для текущей платформы. Во внешний код подключается только SomeClass.h. Создать объект данного класса можно функцией CreateSomeClass(), при этом будет создан объект соответствующий данной платформе.
Функцию фабрики можно так же сделать одну:
1
2
3
4
5
6
7
8
9
10
SomeClass* CreateSomeClass()
{
#if defined( VERSION_PC )
return new PCClass();
#elif defined( VERSION_PS3 )
return new PS3Class();
#else
#error "Unsupported platform"
#end
}
Данный подход хорош, но не покрывает всего спектра требуемых задач. Рассмотрим другие варианты организации кода.
Функциональный подход
В данном подходе для создаваемой функциональности так же создается общий интерфейс, но не в виде класса, а в виде функций, расположенных в определенной области имен. Затем делается реализация данных функций для каждой конкретной платформы. Данный метод хорош, когда создается система, для которой не потребуется множество экземпляров. Аналогом подобного подхода является класс синглетон (сравнение синглетона со свободными функциями, можно найти тут).
Ниже приведен пример подобной организации кода. Обратите внимание, что файл Manager_internal.h является внутренним для данной системы. В нем определены структуры, константы, функции и т.п. которые являются внутренними и не должны быть видны за интерфейсом системы. Как и в предыдущем случае нужно исключать из компиляции те *.cpp файлы, которые не относятся к данной конкретной платформе.
1
2
3
4
5
6
7
// Manager.h
namespace Manager
{
bool Create();
void Destroy();
void Do();
}
1
2
3
4
5
6
7
8
9
// Manager_internal.h
namespace Manager
{
struct ManagerInnerStruct
{...
};
const int SOME_COMMON_CONST = 10;
}
1
2
3
4
5
6
7
8
9
// Manager_pc.cpp
#include "Manager.h"
#include "Manager_internal.h"
namespace Manager
{
bool Create() { ... }
void Destroy() { ... }
void Do() { ... }
}
1
2
3
4
5
6
7
8
9
// Manager_ps3.cpp
#include "Manager.h"
#include "Manager_internal.h"
namespace Manager
{
bool Create() { ... }
void Destroy() { ... }
void Do() { ... }
}
Кроссплатформенная реализация inline-функций
Два приведенных метода отлично подходят, когда требуется создать кроссплатформенную систему, но когда требуется специфицировать существующую функциональность под конкретную платформу, то лучше организовать код иначе.
Проиллюстрировать метод удобно на реализации элементов математической библиотеки. Рассмотрим класс вектора. Обратите внимание, что есть базовая реализация данного объекта в Vector_generic.h. Это значит, что когда данная математическая библиотека будет использоваться на новой платформе, то она будет работать в своей базовой реализации.
Если появляется желание более оптимально реализовать какой то метод для конкретной платформы, то его нужно просто исключить из базовой реализации добавив !defined(VERSION_WIN32) перед данной функцией, а затем реализовав ее в другом месте.
Пример приводится для двух платформ, причем функция zero() всегда используется из базовой реализации, функция length() реализуется более оптимально только в реализации для WIN32, а функция dot() специфицирована для обоих платформ.
Клиентский код подключает только файл Vector.h, который может подключить дополнительные файлы с inline-функциями, в зависимости от платформы.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Vector.h
struct Vector3d
{
float x, y, z;
float dot( const Vector3d& v );
float length();
void zero();
};
#include "Vector_generic.h"
#if defined( VERSION_WIN32 )
#include "Vector_Win32.h"
#elif defined( VERSION_PS3 )
#include "Vector_Ps3.h"
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Vector_generic.h
// базовая реализация для всего функционала
#if !defined( VERSION_WIN32 ) && !defined( VERSION_PS3 )
inline float Vector3d::dot( const Vector3d& v )
{
return x * v.x + y * v.y + z * v.z;
}
#endif
#if !defined( VERSION_WIN32 )
inline float Vector3d::length()
{
return sqrt(x * x + y * y + z * z);
}
#endif
inline float Vector3d::zero()
{
x = y = z = 0.0f;
}
1
2
3
4
5
6
7
8
9
10
11
// Vector_Win32.h
// оптимизация конкретных функций для windows
inline float Vector3d::dot( const Vector3d& v )
{
return ...;
}
inline float Vector3d::length()
{
return ...;
}
1
2
3
4
5
6
// Vector_Ps3.h
// оптимизация конкретных функций для PlayStation3
inline float Vector3d::dot( const Vector3d& v )
{
return ...;
}
Разумеется в базовой реализации может не быть каких то функций, который нужно будет отдельно реализовывать для каждой платформы. В файлах реализации может быть платформенно зависимый код, однако он будет свободен от директив, т.к. гарантируется, что этот файл будет подключен только на конкретной платформе.
Данный метод наилучшим образом подходит при создании библиотек, которые будут расширяться и которые нужно будет оптимизировать под каждую из платформ не за один раз, а в течение длительного времени.
Комментарии
mrdekk, 20 мая 2011, 13:43
Ну во первых, если мы таки используем объектно-ориентированный подход, то почему бы просто не писать разные .cpp файлы со специфичной реализацией под конкретную платформу, а общий интерфейс будет в .h файле?
Или, когда требуется в .h файле отобразить платформо-специфичный код, то почему бы не сделать интерфейс, а класс порождать в зависимости от плаформы.
Поясню мысль. По приведенному предложению получается, что будут классы
BaseClass Ps3Class Win32Class
И какая-то фабрика или функция, которая будет создавать объект определенного класса. Зачем так сложно. Почему нельзя сделать одно и то же название класса, а .h и .cpp подключать в зависимости от флагов платформы.
Тогда мы будем иметь
iBaseClass BaseClass
И объекты создавать путем
1 BaseClass* obj = new BaseClass(....);Проще ведь, чем
1 BaseClass* obj = Factory::createObjByPlatform(...)и код читается лучше
Что касается pure c подхода с namespace. То блин — 21 век на борту. Потом придется отгребать проблем, когда в области видимости namespace лежат открытыми какие-то переменные, а Junior программист решил вставить хак. Найти этот хак будет крайне сложно.
Вот такие мысли у меня возникли. А так да, делать надо именно так, а не свалку платформо-зависимого кода.
Еще можно платформо зависимый код вынести в проект foundation или os_related.
FiloXSee, 20 мая 2011, 14:02
Для классов делать один .h файл и несколько реализаций интерфейсов действительно будет работать. Но если в хедер придется вынести много платформо зависимого кода, и написать кучу define’ов, то лучше сделать наследование от интерфейса.
По поводу вынесения платформо зависимого кода в foundation абсолютно согласен. В статье показываются приемы, как это можно сделать.
Что касается функционального подхода — в статье про синглетон как раз рассматриваются плюсы и минусы обоих подходов. Нужно выбирать один из них в зависимости от задачи. Если реализуется библиотека функций, например математическая, то лучше, если это будут функции в неймспейсе, чем статическими функциями класса.
Если же речь идет про элемент, к которому применимо слово объект, то ООП может значительно упростить реализацию. Важно знать оба метода и выбирать их в зависимости от ситуации, а не применять классы везде, только потому, что С++ их поддерживает.
mrdekk, 20 мая 2011, 14:10
Скажем так, функциональный подход на мой взгляд возможен только если мы имеем дело со stateless объектами. Тогда да, и математика — это хороший вариант такого подхода. Но как только у объектов появляется состояние — то функциональный подход — уже плохо.