Policy Based Design (PBD) łączy szablony C++ ze wzorcem Strategy. Polityka to parametr szablonu, który dostarcza jedno zachowanie: sortowanie, logowanie albo alokację pamięci. Kompilator rozwiązuje politykę, więc abstrakcja nie kosztuje nic w czasie wykonania.

Podstawowa koncepcja

Polityka to parametr szablonu, który definiuje zachowanie albo algorytm. Zwykłe parametry szablonu to najczęściej konkretne typy. Polityka jest szablonem lub klasą i opisuje zestaw operacji, których oczekuje klasa gospodarz. Gospodarz używa polityk, żeby dopasować swoje zachowanie:

// Polityka 1: Strategia sortowania
struct QuickSortPolicy {
    template<typename T>
    void sort(std::vector<T>& data) const {
        std::sort(data.begin(), data.end());
    }
};

struct MergeSortPolicy {
    template<typename T>
    void sort(std::vector<T>& data) const {
        std::stable_sort(data.begin(), data.end());
    }
};

// Polityka 2: Zachowanie logowania
struct NoLoggingPolicy {
    void log(const std::string& msg) const {}
};

struct FileLoggingPolicy {
    void log(const std::string& msg) const {
        // Loguj do pliku
        std::ofstream file("debug.log", std::ios::app);
        file << msg << "\n";
    }
};

// Klasa gospodarz: dostosowana przez polityki
template<typename SortPolicy, typename LoggingPolicy>
class DataProcessor {
private:
    SortPolicy sort_policy;
    LoggingPolicy log_policy;
    std::vector<int> data;

public:
    void add_data(int value) {
        data.push_back(value);
    }

    void process() {
        log_policy.log("Rozpoczęcie sortowania...");
        sort_policy.sort(data);
        log_policy.log("Sortowanie zakończone");
    }

    void print() const {
        for (int val : data) {
            std::cout << val << " ";
        }
        std::cout << "\n";
    }
};

// Użycie: komponuj zachowanie w czasie kompilacji
int main() {
    // Szybkie przetwarzanie bez logowania
    DataProcessor<QuickSortPolicy, NoLoggingPolicy> fast_processor;
    fast_processor.add_data(30);
    fast_processor.add_data(10);
    fast_processor.add_data(20);
    fast_processor.process();
    fast_processor.print();

    // Stabilne sortowanie ze szczegółowym logowaniem
    DataProcessor<MergeSortPolicy, FileLoggingPolicy> debug_processor;
    debug_processor.add_data(30);
    debug_processor.add_data(10);
    debug_processor.add_data(20);
    debug_processor.process();  // Loguje do pliku
    debug_processor.print();

    return 0;
}

Anatomia polityki

Polityka opisuje umowę, której oczekuje klasa gospodarz:

// Przykład: interfejs Sorting Policy
struct SortingPolicy {
    // Musi być wywoływalna (funktor lub wskaźnik funkcji)
    // lub mieć członkowską funkcję taką jak:
    template<typename Container>
    void sort(Container& data) const;
};

// Przykład: interfejs Hashing Policy
struct HashingPolicy {
    template<typename T>
    size_t hash(const T& value) const;
};

// Przykład: interfejs Memory Allocation Policy
struct MemoryAllocationPolicy {
    void* allocate(size_t size);
    void deallocate(void* ptr);
};

Polityki są zwykle:

  • Bezstanowe albo z minimalnym stanem. Zwykle zawierają tylko algorytmy.
  • Świadome szablonów. Pracują z parametrami szablonu.
  • Oparte na koncepcjach. Definiują niejawny interfejs zamiast jawnego dziedziczenia.

Porównanie: polityki i cechy

Cechy opisują typy i dostarczają metadane:

template<typename T>
struct numeric_traits {
    static constexpr T max_value() { return std::numeric_limits<T>::max(); }
    static constexpr T min_value() { return std::numeric_limits<T>::min(); }
};

Polityki dostarczają zachowanie i algorytmy:

struct SaturatingArithmetic {
    template<typename T>
    T add(T a, T b) const {
        // Nasycaj zamiast przepełnienia
        if (a > std::numeric_limits<T>::max() - b) {
            return std::numeric_limits<T>::max();
        }
        return a + b;
    }
};

struct WrapAroundArithmetic {
    template<typename T>
    T add(T a, T b) const {
        // Tradycyjne owijanie się na przepełnieniu
        return a + b;
    }
};

Przykład z życia: inteligentny kontener

// Polityka magazynu
struct VectorStoragePolicy {
    template<typename T>
    using container = std::vector<T>;
};

struct DequeStoragePolicy {
    template<typename T>
    using container = std::deque<T>;
};

// Polityka bezpieczeństwa wątków
struct SingleThreadPolicy {
    void lock() const {}
    void unlock() const {}
};

struct MutexThreadPolicy {
    mutable std::mutex mtx;
    void lock() const { mtx.lock(); }
    void unlock() const { mtx.unlock(); }
};

// Polityka wzrostu
struct ExponentialGrowthPolicy {
    static constexpr float growth_factor() { return 1.5f; }
};

struct LinearGrowthPolicy {
    static constexpr float growth_factor() { return 0.25f; }
};

// Kontener oparty na politykach
template<typename T, 
         typename StoragePolicy = VectorStoragePolicy,
         typename ThreadPolicy = SingleThreadPolicy,
         typename GrowthPolicy = ExponentialGrowthPolicy>
class SmartContainer {
private:
    typename StoragePolicy::template container<T> data;
    ThreadPolicy thread_policy;
    GrowthPolicy growth_policy;

public:
    void push_back(const T& value) {
        thread_policy.lock();
        data.push_back(value);
        if (data.size() >= data.capacity() * 0.8f) {
            reserve(static_cast<size_t>(
                data.capacity() * growth_policy.growth_factor()
            ));
        }
        thread_policy.unlock();
    }

    void reserve(size_t new_capacity) {
        // Implementacja rezerwacji przy użyciu polityki magazynu
    }

    size_t size() const {
        return data.size();
    }
};

// Użycie: różne konfiguracje
int main() {
    // Jednowątkowy z wektorem, wzrost wykładniczy
    SmartContainer<int> fast_container;

    // Wielowątkowy z deque, wzrost liniowy
    SmartContainer<int, 
                   DequeStoragePolicy, 
                   MutexThreadPolicy, 
                   LinearGrowthPolicy> safe_container;

    fast_container.push_back(42);
    safe_container.push_back(99);

    return 0;
}

Zalety Policy Based Design

Zaleta Wyjaśnienie
Abstrakcja bez kosztów Polityki działają w czasie kompilacji, więc nie ma narzutu w czasie wykonania
Elastyczność Polityki łączysz dowolnie, bez ograniczeń dziedziczenia
Ponowne użycie kodu Składasz ortogonalne zachowania w różnych kombinacjach
Bezpieczeństwo typów Decyzje zapadają w czasie kompilacji, typy są sprawdzane
Brak funkcji wirtualnych Wywołania są bezpośrednie, kompilator może je wstawiać
Kontrola nad rozmiarem binarnym Instancjonujesz tylko kombinacje, których używasz
Ortogonalne zagadnienia Każda polityka zajmuje się osobnym aspektem
Budowa frameworków Rozszerzalne biblioteki bez hierarchii dziedziczenia

Wady Policy Based Design

Wada Wyjaśnienie
Czas kompilacji Duża liczba szablonów znacznie wydłuża kompilację
Rozprzestrzenianie kodu Każda kombinacja polityk generuje nowy kod
Komunikaty błędów Błędy szablonów bywają nieczytelne i trudne w debugowaniu
Krzywa uczenia To zaawansowany C++, potrzeba doświadczenia
Brak elastyczności w czasie wykonania Polityki trzeba ustalić przy kompilacji
Wsparcie IDE Uzupełnianie kodu i refaktoryzacja szablonów wypadają słabo
Debugowanie Przechodzenie przez wygenerowany kod szablonów jest męczące
Dokumentacja Niejawne interfejsy trzeba dokładnie opisać

Biblioteka oparta na politykach: podejście Loki

Biblioteka Loki to klasyczny przykład Policy Based Design:

// Z Loki (uproszczony przykład)
template<typename T,
         template<class> class CreationPolicy = OpNewCreator,
         template<class> class LifetimePolicy = DefaultLifetime,
         template<class, class> class ThreadingPolicy = SingleThreaded>
class SmartPtr {
    // Implementacja używająca polityk
};

// Użycie: dostosuj zachowanie SmartPtr
SmartPtr<MyClass,             // Typ
         OpNewCreator,        // Jak stworzyć
         DefaultLifetime,     // Jak zarządzać czasem życia
         SingleThreaded>      // Bezpieczeństwo wątków
my_ptr;

// Do użytku wielowątkowego:
SmartPtr<MyClass,
         OpNewCreator,
         DefaultLifetime,
         ObjectLevelLocking>  // Inna polityka wątków
thread_safe_ptr;

Kiedy używać Policy Based Design

Dobre przypadki użycia:

  • Budujesz bibliotekę wielokrotnego użytku z ortogonalnymi wariantami.
  • Masz kilka niezależnych osi dostosowania: magazyn, wątki, wzrost.
  • Potrzebujesz abstrakcji bez narzutu.
  • Wszystkie kombinacje polityk da się ustalić w czasie kompilacji.
  • Piszesz framework albo bibliotekę.

Unikaj, gdy:

  • Potrzebujesz tylko 1-2 punktów dostosowania, a szablony to przerost formy.
  • Algorytm ma być wybierany w czasie wykonania.
  • Zespół ma małe doświadczenie z szablonami.
  • Czas kompilacji jest ograniczony.
  • Wystarczyłoby proste dziedziczenie.

Porównanie podejść Strategy

Aspekt Oparte na szablonach Oparte na politykach Wybór w czasie wykonania
Czas wyboru Czas kompilacji Czas kompilacji Czas wykonania
Narzut Zero Zero 1-3 cykle (vtable)
Osie dostosowania 1-2 Wiele (ortogonalne) Jedna (polimorfizm)
Czas kompilacji Dobry Słaby (ciężkie szablony) Szybki
Rozmiar binarny Umiarkowany Może być duży Mały
Elastyczność w czasie wykonania Brak Brak Pełna
Bezpieczeństwo typów Silne Silne Słabe
Krzywa uczenia Łatwa Trudna Łatwa
Wsparcie IDE Dobre Słabe Dobre
Odpowiednie dla Prostych algorytmów Frameworków bibliotecznych Kodu aplikacji

Przykład: pula połączeń bazodanowych

// Polityki do dostosowania
struct FIFOQueuePolicy {
    template<typename T>
    using container = std::queue<T>;
};

struct LIFOStackPolicy {
    template<typename T>
    using container = std::stack<T>;
};

struct NoValidationPolicy {
    void validate(const Connection& conn) const {}
};

struct PingValidationPolicy {
    void validate(const Connection& conn) const {
        conn.ping();  // Zweryfikuj, czy połączenie żyje
    }
};

// Pula połączeń oparta na politykach
template<typename QueuePolicy, typename ValidationPolicy>
class ConnectionPool {
private:
    typename QueuePolicy::template container<Connection> available;
    ValidationPolicy validator;

public:
    Connection get_connection() {
        if (available.empty()) {
            return Connection::create();
        }

        auto conn = available.front();  // lub top() dla LIFO
        available.pop();

        validator.validate(conn);  // Może być no-op w zależności od polityki
        return conn;
    }

    void return_connection(Connection conn) {
        available.push(conn);
    }
};

// Konfiguracje
using FastPool = ConnectionPool<FIFOQueuePolicy, NoValidationPolicy>;
using SafePool = ConnectionPool<LIFOStackPolicy, PingValidationPolicy>;

Podsumowanie

Policy Based Design pozwala bibliotece mieć kilka niezależnych punktów dostosowania bez kosztu w czasie wykonania. Działa, bo każda polityka jest typem, który kompilator widzi na wylot. Rachunek przychodzi przy kompilacji: więcej instancjacji, dłuższy build i komunikaty błędów, które trudno czytać. PBD ma sens w bibliotekach i frameworkach. W kodzie aplikacji prostsze warianty Strategy zwykle wystarczą.

Patrz też