Подтвердить что ты не робот

Как отделить определение от декларации для шаблона класса с использованием "extern" в С++ 11 в библиотеке (dll, так,..)

Я разработчик библиотеки с открытым исходным кодом. Один из наших классов оформлен шаблонами для множества разных типов. В настоящее время определение находится в файле заголовка, что отрицательно влияет на время компиляции, а также заставляет пользователей включать больше заголовков, чем необходимо. Моя цель такова:

  • Чтобы сократить время компиляции, я хочу использовать явное выражение о создании, введенное с С++ 11.

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

  • Это должно выполняться кросс-платформой для всех распространенных компиляторов, которые поддерживают С++ 11 (Visual Studio 2013+, GCC и т.д.)

С++ 11 предоставляет новые возможности для шаблонов классов, в частности " явная декларация о создании. Насколько я понимаю, это можно использовать в этом контексте. Предыдущие вопросы касались этого в аналогичных контекстах, например. Как использовать шаблон extern и Разделение определения/создания классов шаблонов без "extern" , но это не касается с экспортом библиотек и их решением вызывают ошибки компоновщика, если клиент пытается использовать общую библиотеку.

В настоящее время мне удалось реализовать это способом, который он компилирует, связывает и запускает на Visual Studio 2015, но я не уверен, правильно ли я использую ключевые слова, особенно __declspec в этом случае. Это то, что я получил (упрощен):

// class.h
template<typename T>
class PropertyHelper;

template<typename T>
class PropertyHelper<const T>
{
public:
    typedef typename PropertyHelper<T>::return_type return_type;

    static inline return_type fromString(const String& str)
    {
        return PropertyHelper<T>::fromString(str);
    }

    static const int SomeValue;
};


template<>
class EXPORTDEF PropertyHelper<float>
{
public:
    typedef float return_type;

    static return_type fromString(const String& str);

    static const int SomeValue;
};

extern template EXPORTDEF class PropertyHelper<float>;

Последняя строка - это явная декларация о создании. Насколько я понимаю, это означает, что клиентам не нужно декларировать это каждый раз. EXPORTDEF - это определение, которое является либо __declspec (dllexport), либо __declspec (dllimport) в Windows. Я не уверен, что мне нужно поместить это в строку выше, потому что следующее также компилирует, связывает и запускает:

extern template class PropertyHelper<float>;

Файл cpp выглядит следующим образом:

const int PropertyHelper<float>::SomeValue(12);

PropertyHelper<float>::return_type
PropertyHelper<float>::fromString(const String& str)
{
    float val = 0.0f;

    if (str.empty())
        return val;

    //Some code here...

    return val;
}

template class PropertyHelper<float>;

Последняя строка - это явное определение экземпляра.

Итак, мой вопрос больше всего, если я сделал все правильно здесь в соответствии со стандартом С++ 11, а во-вторых (если это правда), если ключевое слово __declspec является избыточным в контексте явной декларации о создании, или что я должен сделать это, так как я не нашел надлежащей информации в документах MSDN.

4b9b3361

Ответ 1

Стандарт (из рабочего проекта С++ 0x через рабочий проект в 2014 году), раздел 14.7.2 описывает Явное создание экземпляра, в котором указано, что существуют две формы явного инстанцирования, определения и объявления. В нем говорится: "Явная декларация о создании начинается с ключевого слова extern". Далее он указывает, что объявления, использующие extern, не генерируют код.

Необходимо проявлять осторожность, чтобы декларации выдавались в пространстве имен объявления класса шаблона или, в частности, ссылались на пространство имен в квалифицированном имени, например:

namespace N {
 template< class T > void f( T& ) {}
}

template void N::f<int>(int &);

Создание экземпляра функции шаблона и генерация кода (определения). Принимая во внимание:

extern template void N::f<int>(int &);

Создает функцию шаблона для типа int в качестве объявления, но не генерирует код. Ключевое слово extern сообщает компилятору, что код будет предоставлен во время ссылки из другого источника (возможно, в динамическую библиотеку, но стандарт не обсуждает концепцию конкретной платформы).

Кроме того, можно создавать элементы-члены и функции-члены выборочно, как в:

namespace N {
template<class T> class Y { void mf() { } };
}

template void N::Y<double>::mf();

Это генерирует код только для функции mf(), для двойников. Таким образом, можно объявлять экземпляры (используя extern), а затем определять экземпляры (без extern) для определенных частей типа шаблона. Можно было бы сгенерировать код или элементы для некоторых частей класса шаблона в каждом модуле компиляции (inline) и заставить генерировать другие части кода в конкретный блок или библиотеку компиляции.

Статья IBM Knowledge Center для компилятора XLC V 11.1, поддерживающая проект С++ 0x, обсуждает стратегию использования ключевого слова extern при создании библиотек. Из их примера и проектов нормативных документов в течение нескольких лет (которые были согласованы с 2008 годом по этому вопросу) было ясно, что extern имеет ограниченную применимость к особенностям динамических библиотек, но в целом ограничивается контролем, где сгенерированный код размещены. Автору все равно придется придерживаться специфических требований к платформе относительно динамической компоновки (и загрузки). Это выходит за рамки ключевого слова extern.

Extern в равной степени применим к статическим библиотекам или динамическим библиотекам, но ограничение на дизайн библиотеки имеет большое значение.

Скажите объявление класса шаблона, представленное в файле заголовка, например:

   namespace N
    {
     template< typename T >
     class Y
        { 
          private:

          int x;
          T v;

          public:

          void f1( T & );
          void f2( T &, int );
        };    
    }

Далее в файле CPP:

namespace N
{
 template< typename T> void Y<T>::f1( T & ) { .... }
 template< typename T> void Y<T>::f2( T &, int ) { .... }
}

Теперь рассмотрим потенциальное использование Y. Потребителям библиотеки могут потребоваться только экземпляры Y для int, float и double. Все остальные виды использования не будут иметь никакого значения. Это точка дизайна автора библиотеки, а не какое-то общее понятие об этой концепции. По какой-то причине автор поддерживает только те три типа для T.

С этой целью в заголовочный файл могут быть включены явные декларации о создании объектов

extern template class N::Y< int >;
extern template class N::Y< float >;
extern template class N::Y< double >;

Поскольку это обрабатывается пользователем различными единицами компиляции, компилятор информирован о том, что для этих трех типов будет создан код, но код не генерируется в каждом модуле компиляции по мере сборки пользователем. Действительно, если автор не включает файл CPP, определяющий функции f1 и f2 для класса шаблона Y, пользователь не сможет использовать libary.

Предполагая, что на данный момент статическая библиотека является предполагаемым продуктом относительно класса шаблона Y (для упрощения этого обсуждения), автор компилирует статическую библиотеку с определяющими CPP функциями f1 и f2 вместе с явными определениями создания:

template class N::Y< int >;
template class N::Y< float >;
template class N::Y< double >;

Это приведет к созданию кода для класса шаблона Y от имени этих трех типов, создавая статическую библиотеку. Пользовательский код теперь должен будет ссылаться на эту библиотеку, но больше не использовать классы. Их единицы компиляции не будут генерировать код для класса шаблона Y, вместо этого он будет включать этот код из библиотеки.

Такая же концепция применима к динамической библиотеке, но особенности платформы, касающиеся деклараций функций, динамической загрузки и динамической компоновки, не найдены в стандартах рабочих черновиков С++ до 2014, касающихся С++ 0x, С++ 11 или C + +14, в настоящее время. Ключевое слово extern в явных шаблонных экземплярах ограничено созданием деклараций, отсутствие которых создает определения (где генерируется код).

Это вызывает вопрос о том, что пользователи такой библиотеки намерены использовать Y для unsigned long, char или другого типа, не предоставленного в динамической или статической библиотеке. У автора есть выбор отказаться от поддержки этого, не распространяя источник генерации кода (определения функций для f1 и f2 для класса шаблона Y). Однако, если автор действительно хотел бы поддержать такое использование, распространяя этот источник, для пользователя потребуется инструкция для создания новой библиотеки для замены существующей или создания второй библиотеки для дополнительных типов.

В обоих случаях было бы разумно разделить явные определения экземпляров в CPP файле, который включает заголовок, объявляющий шаблонный класс Y, включая заголовок определений funtion для f1 и f2 для класса шаблона Y (в отличие от практика включения файла CPP, который также может работать). Таким образом, пользователь создаст файл CPP, который включает заголовок для класса шаблона Y, затем определения функций для класса шаблона Y, а затем выдаст новые явные определения экземпляров:

#include "ydeclaration.h" // the declaration of template class Y
#include "ydefinition.h"  // the definition of template class Y functions (like a CPP)

template class N::Y< unsigned long >;
template class N::Y< char >;

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

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

Учитывая сложности, связанные с созданием динамических библиотек, это чудо, которое когда-либо было. Иногда просто нет другого выбора. Ключом к решению является точное определение необходимости использования динамической библиотеки. В эпоху древних эпох компьютеров с 1 ГБ ОЗУ одно из обоснований заключалось в экономии памяти за счет совместного использования кода, но для какой-либо конкретной библиотеки, какова вероятность того, что совместное использование кода приведет к экономии памяти? Для чего-то такого же общего, как среда выполнения C или DLL Windows MFC, это может быть весьма вероятным. С другой стороны, библиотеки, которые предоставляют высоконаправленные сервисы, с большей вероятностью будут использоваться только одной запущенной программой.

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

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

Другим общим оправданием является "модульность". С какой целью? Разделение единиц компиляции уже сокращает время компиляции. Динамические библиотеки будут влиять на время ссылки больше, чем время компиляции, но стоит ли это дополнительно?

В противном случае предоставление динамических библиотек, особенно для довольно небольшого продукта, действительно не стоит того.

Вся книга может быть написана на тему написания переносных динамических библиотек, применимых как к Windows, так и к Linux.

В Windows выбор использования __declspec (dllexport/dllimport) может применяться ко всему классу. Однако важно понять, что любой компилятор, используемый для генерации DLL, может использоваться только с целями, построенными с помощью того же самого компилятора или совместимых компиляторов. Внутри линии MS VC многие версии НЕ совместимы друг с другом на этом уровне, поэтому DLL, построенная с одной версией Visual Studio, может быть несовместима с другими версиями, что накладывает нагрузку на автора для создания DLL для всех возможных компиляторов/поддерживаемой версии.

Существуют похожие проблемы в отношении статических библиотек. Клиентский код должен связываться с той же версией и конфигурацией CRT, что и DLL, с помощью (является ли CRT статически связана?). Клиентский код должен также выбирать одни и те же настройки обработки исключений и параметры RTTI по ​​мере создания библиотеки.

Когда переносимость в Linux или UNIX (или Android/iOS) должна быть рассмотрена, проблемы увеличиваются. Динамическое связывание - это концепция платформы, которая не обрабатывается на С++.

Статические библиотеки, вероятно, были бы лучшим подходом, и для тех __declspec (dllexport/dllimport) не следует использовать.

При всем том, что сказано против динамических библиотек, здесь один из многих способов реализовать это в Windows (полностью неприменимый к Linux/UNIX/etc).

Простой (возможно, наивный, но удобный) подход заключается в том, чтобы охватить весь класс, экспортированный из DLL (импортированный в клиентский код). Это имеет небольшое преимущество перед объявлением каждой функции как экспортированной или импортируемой, потому что этот подход включает данные класса, и, что не менее важно, для вашего класса может создаваться код АВТОМАТИЧЕСКОГО назначения/деструктор/конструктор С++. Это может быть жизненно важно, если вы не внимательно следите за ними и экспортируете их вручную.

В заголовок, который должен быть включен для создания DLL:

#define DLL_EXPORT // or something similar, to indicate the DLL is being built

Это будет включено в верхнюю часть заголовка, объявляя классы шаблона библиотеки. Заголовок, объявляющий DLL_EXPORT, используется только в проекте, сконфигурированном для компиляции библиотеки DLL. Весь клиентский код будет импортировать пустую версию в противном случае. (Myriad Другие методы для этого существуют).

Таким образом, DLL_EXPORT определяется при создании библиотеки DLL, не определяемой при создании кода клиента.

В заголовке объявления классов шаблона библиотеки:

#ifdef _WIN32 // any Windows compliant compiler, might use _MSC_VER for VC specific code
#ifdef DLL_EXPORT
#define LIB_DECL __declspec(dllexport)
#else
#define LIB_DECL __declspec(dllimport)
#endif

Или то, что вы предпочитаете видеть вместо LIB_DECL как средство объявления всех классов, экспортируемых из DLL, импортированных в клиентский код.

Выполните объявления классов как:

namespace N
    {
     template< typename T >
     struct LIB_DECL Y
        { 
          int x;
          T v;
          std::vector< T > VecOfT;

          void f1( T & );
          void f2( T &, int );
        };    
    }

Явные объявления для создания экземпляров для этого были бы следующими:

extern template struct LIB_DECL N::Y< int >;
extern template struct LIB_DECL N::Y< float >;
extern template struct LIB_DECL N::Y< double >;

extern template class LIB_DECL std::vector< int >;
extern template class LIB_DECL std::vector< float >;
extern template class LIB_DECL std::vector< double >;

Обратите внимание на std::vector, используемый в классе Y в этом примере. Рассмотрим проблему тщательно. Если ваша библиотека DLL использует std::vector (или любой класс STL, это просто пример), то реализация, которую вы использовали в момент создания DLL, должна соответствовать тому, что пользователь выбирает при создании кода клиента. 3 явных экземпляра вектора соответствуют требованиям класса шаблона Y и создают экземпляр std::vector внутри DLL, и это объявление становится экспортируемым из DLL.

Рассмотрим, как бы использовать код DLL USE std::vector. Что будет генерировать код в DLL? Из опыта очевидно, что источник для std::vector является встроенным - это файл только заголовка. Если ваша DLL создает экземпляр векторного кода, как клиентский код сможет получить к нему доступ? Клиентский код "увидит" std::vector источник и попытается создать собственное встроенное генерирование этого кода, в котором будет работать клиент std::vector. Если и только если они будут точно совпадать, это сработает. Любая разница между источником, используемым для сборки библиотеки DLL, и источником, используемым для создания клиента, будет отличаться. Если клиентский код имел доступ к std::vector в классе шаблонов T, то был бы хаос, если бы клиент использовал другую версию или реализацию (или имел разные настройки компилятора) при использовании std::vector.

У вас есть возможность явно генерировать std::vector и информировать клиентский код для использования этого сгенерированного кода, объявляя std::vector как класс extern-шаблона, который должен быть импортирован в код клиента (экспортирован в сборках DLL).

Теперь, в CPP, где построена DLL, - определения функций библиотеки - вы должны явно создавать определения:

template struct LIB_DECL N::Y< int >;
template struct LIB_DECL N::Y< float >;
template struct LIB_DECL N::Y< double >;

template class LIB_DECL std::vector< int >;
template class LIB_DECL std::vector< float >;
template class LIB_DECL std::vector< double >;

В некоторых примерах, таких как MS KB 168958, они предлагают сделать ключевое слово extern define, изменив этот план следующим образом:

#ifdef _WIN32 // any Windows compliant compiler, might use _MSC_VER for VC specific code
#ifdef DLL_EXPORT
#define LIB_DECL __declspec(dllexport)
#define EX_TEMPLATE
#else
#define LIB_DECL __declspec(dllimport)
#define EX_TEMPLATE extern
#endif

Так что в файле заголовка для сборки DLL и клиента вы можете просто указать

EX_TEMPLATE template struct LIB_DECL N::Y< int >;
EX_TEMPLATE template struct LIB_DECL N::Y< float >;
EX_TEMPLATE template struct LIB_DECL N::Y< double >;

EX_TEMPLATE template class LIB_DECL std::vector< int >;
EX_TEMPLATE template class LIB_DECL std::vector< float >;
EX_TEMPLATE template class LIB_DECL std::vector< double >;

В то время как это имеет преимущество, выдавая эти строки один раз, в заголовке, я лично предпочитаю, чтобы явное выражение ключевого слова extern явно использовалось в заголовке, так что я знаю, что, без сомнения, единственное создание кода места может иметь место в CPP сборки DLL (где они появляются, второй раз, без ключевого слова extern). Таким образом, extern в заголовке - это декларации, которые не противоречат явным определениям инкапсуляции в CPP, и избегают обфускации ключевого слова extern при использовании в клиентском коде. Это, пожалуй, своеобразное предпочтение моего.

Возможно, вы думаете: "а как насчет другого кода клиента и std::vector". Ну, это важно учитывать. В файл заголовка входит std::vector, но помните, что ваша DLL построена с кодом, доступным для ВАС во время компиляции. У вашего клиента будет свой собственный заголовок, он в одинаковых версиях VC, который должен быть таким же. СЛЕДУЕТ не очень хороший план. Это может быть иначе. Они могут просто предположить, что VC 2015 такой же и пашет вперед. Любая разница, будь то макет объекта, фактический код... все, может обречь запущенное приложение с очень странными эффектами. Если вы экспортируете свою версию, клиентам будет рекомендовано включать объявления явных манифестаций во всех своих единицах компиляции, поэтому все использует ВАШУ версию std::vector... но есть серьезный улов.

Что, если какая-то другая библиотека сделала это тоже с другой версией std::vector?

Это делает использование STL немного неприятным в этих контекстах, поэтому существует довольно хороший дизайн, который устраняет это. Не подвергайте использование STL.

Если вы все время используете STL в своей библиотеке и никогда не подвергаете контейнер STL клиентскому коду, вы, вероятно, находитесь в понятном виде. Если вы выберете это в дизайне, вам не нужно явно создавать экземпляр std::vector (или любой STL) в вашей библиотеке.

Я включил этот пример, чтобы обсудить его, как он документально подтвержден MS (KB 168958) и почему вы, вероятно, не хотите этого делать. Однако также возникает обратный сценарий.

В исходном запросе следует использовать использование std::string (или один из его альтернатив). Подумайте об этом: в DLL, как будет использоваться экземпляр std::string? Что делать, если есть какая-либо разница между кодом std::string, доступным, когда DLL была построена по сравнению с тем, что используется клиентской сывороткой, которую они создают? В конце концов, клиент мог бы использовать другие STL, чем MS. Конечно, вы могли бы оговаривать, что они этого не делают, но... возможно, вы можете явно создать экземпляр std::string как extern WITHIN вашей DLL. Таким образом, у вас нет кода STL, встроенного в DLL, и теперь компилятор сообщает, что он должен найти этот код, созданный клиентом, а не внутри DLL. Я предлагаю это для исследований и мысли.

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

Итак, допустим, вы согласны и пропустите последние три строки в примерах, которые создают экземпляр std::vector... это сделано?

Это зависит от ваших настроек IDE, которые я вам оставлю. Вопрос касался использования __declspec (dllxxxxx) и его использования, и есть несколько способов реализовать его использование, я сосредоточился на одном. Независимо от того, нужно ли явно загружать библиотеку, полагаться на функции автоматической динамической компоновки, рассмотрите DLL_PATH... это общие темы для создания DLL, которые вы либо знаете, или находятся за пределами реальной сферы вопроса.

Ответ 2

Вот цитата из стандарта:

Для заданного набора аргументов шаблона, если явное создание шаблона появляется после объявления явной специализации для этого шаблона, явное создание экземпляра не имеет никакого эффекта. [...]

Итак, ваше явное объявление о создании

extern template EXPORTDEF class PropertyHelper<float>;

не действует, поскольку появляется после явной специализации

template<>
class EXPORTDEF PropertyHelper<float>
...

То же самое с вашим явным определением инстанцирования

template class PropertyHelper<float>;

в файле cpp (я предполагаю, что файл cpp включает class.h, так как иначе код там не будет компилироваться).

Вы уверены, что хотите явно специализировать PropertyHelper<float>?

Ответ 3

Я могу неправильно понять ваш вопрос (и я никогда не использовал Windows), поэтому, возможно, этот ответ неуместен.

Если все допустимые параметры шаблона для вашего шаблона известны во время компиляции вашей библиотеки (а не в стороннем коде пользователя), почему бы не поместить все определения и обновления в исходный файл (и скрыть это от пользователя)? Это стандартная старая методика С++ (нужный материал С++ 11 необходим).

// file.h:

template<typename T>
struct bar
{
  static_assert(/* condition here */, "invalid template argument");
  using return_type = /* definition here */;
  static return_type from_string(std::string const&);            // non-inline
};


// file.cc:

template<typename T>
typename bar<T>::return_type
bar<T>::from_string(std::string const&str)
{
  /* some code here */
}

template struct bar<float>;
template struct bar<int>;
// etc., covering all T which pass the static_assert in struct bar<T>

изменить (в ответ на комментарий от Ident)

Если вы хотите основать from_string на operator>>(std::istream,T) для всех, кроме нескольких специальных T, то вы можете просто выделить шаблон bar. Вот реализация, в которой все, кроме особых случаев, рассматриваются полностью в файле заголовка.

// file.h:

template<typename T>              // generic case: use  istream >> T
struct bar
{
  typedef T return_type;
  static return_type from_string(std::string const&str)
  {
    return_type x;
    std::istringstream(str) >> x; 
    return x;
  }
};

template<>
struct bar<std::string>           // string: return input
{
  typedef std::string const&return_type;
  static return_type from_string(std::string const&str)
  { return_type x; }
};

template<>
struct bar<special_type>          // special_type: implement in file.cc
{
  typedef special_type return_type;
  static return_type from_string(std::string const&str);  // non-inline
};

и file.cc, аналогичные приведенным выше (хотя теперь они используют специализированную специализацию, а не создание одного шаблона). Вы также можете использовать SFINAE, чтобы вызывать разные действия не для отдельных типов, а для типов, удовлетворяющих определенным условиям и т.д.

Наконец, предположим, вы хотите определить отдельный шаблон функции from_string<T> (и скрыть bar<> внутри вложенного namespace details)

template<typename T>
inline typename details::bar<T>::return_type
from_string(std::string const&str)
{ return details::bar<T>::from_string(str); }

Обратите внимание, что мы можем определить это также без ссылки на bar<T>::return_type с помощью auto:

template<typename T>
inline auto from_string(std::string const&str)
 -> decltype(details::bar<T>::from_string(str))
{ return details::bar<T>::from_string(str); }

Ответ 4

Из определения языка

A.12 Шаблоны [gram.temp]

Внизу мы находим соответствующие правила (извините за форматирование):

§ A.12 1224c ISO/IEC N4527

explicit-instantiation:
    extern opt template declaration
explicit-specialization:
    template < > declaration

Итак, я бы сказал, что ответ на второй вопрос заключается в том, что вам не нужно использовать __declspec для объявления extern.

Что касается первого вопроса, я согласен с вышеизложенным Дэйведом V. В соответствии со стандартом явные декларации кажутся посторонними. [См.: 14.8.1 1 Явная спецификация аргумента шаблона [temp.arg.explicit] выше]

Ответ 5

Я попробовал это на VS2013. Я нашел без ссылки dllexport, но он не работает. Использование depend.exe показывает нерешенный импорт, как ожидалось. Все, что было реализовано в файле С++, должно быть явно dllexport'ed для EXE, чтобы ссылаться на него, и шаблоны ничем не отличаются, поэтому я не понимаю, как вы его запускаете. Попробуйте войти в него в отладчике и убедитесь, что он вызывает код в файле .cpp в DLL.

Кстати, вам не нужно dllimport. Для клиента просто определить EXPORTDEF как ничего.

Ответ 6

Вам нужно перенаправить объявления класса шаблона с надлежащей привязкой (imports/exports для visual studio или extern для всего остального), чтобы компилятор не пытался генерировать код для импортированных типов.

Конкретные детали в моем ответе: Специализация шаблона С++ в разных dll создает ошибки компоновщика

class.h

#ifdef _WIN32
#    define TEMPLATE_EXTERN
#    ifdef EXPORT
#        define LIB_EXPORT __declspec(dllexport)
#    else
#        define LIB_EXPORT __declspec(dllimport)
#    endif
#else
#    define TEMPLATE_EXTERN extern
#    define LIB_EXPORT
#endif

class PropertyHelper<const T>
{
public:
    typedef typename PropertyHelper<T>::return_type return_type;

    static inline return_type fromString(const String& str)
    {
        return PropertyHelper<T>::fromString(str);
    }

    static const int SomeValue;
};

// forward declare the specialization
TEMPLATE_EXTERN template class LIB_EXPORT PropertyHelper<float>;

class.cpp

// define the symbol to turn on exporting
#define EXPORT
#include "class.h"
// explicitly instantiate the specialization
template class PropertyHelper<float>

test.cpp

#include "class.h"
int main() {
    PropertyHelper<float> floatHelper; // should be imported, class.h was not #include'ed with EXPORT defined
    return 0;
}