You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

遵循SOLID原则的C++多类持久化模型设计最优方案问询

C++ Class Persistence Design Following SOLID Principles

Great question—let's break this down step by step while sticking strictly to SOLID principles, especially single responsibility and the Law of Demeter. I'll walk through each of your questions and propose a concrete, low-coupling design that keeps responsibilities clear.

Core Design Philosophy

The key idea is to separate concerns completely:

  • Let A, B, C focus solely on their core business logic (no persistence/serialization code).
  • Let dedicated components handle serialization adaptation and disk I/O.
  • Keep Persist focused only on coordinating persistence (loading/saving to disk) without coupling to the internal details of A/B/C.

1) Should classes implement their own serialization? Does this violate Single Responsibility Principle?

Absolutely not. The core responsibility of A, B, or C is their business logic (e.g., processing data, executing domain-specific actions). Serialization is an infrastructure concern—adding it to these classes directly violates the Single Responsibility Principle (SRP), as they now have two distinct reasons to change: business logic updates, and serialization format changes.

2) Using third-party libraries like cereal requires adding markers inside A—is this coupling acceptable?

No, this is not a good practice. Hardcoding cereal-specific macros or methods into A creates tight coupling between your domain class and a third-party library. If you ever need to switch to a different serializer (e.g., nlohmann/json, Protobuf), you'll have to rewrite large parts of A's code. This also pollutes your domain classes with code that has nothing to do with their core purpose.

3) Should we use intermediate classes to convert A into a library-compatible object? Does this add unnecessary complexity?

This is a reasonable, SOLID-compliant compromise. These intermediate classes are often called Data Transfer Objects (DTOs)—their sole job is to hold the state of A/B/C in a format that the serialization library can handle, while keeping your domain classes completely unaware of serialization.

While it adds a few extra classes, the tradeoff is well worth it:

  • A/B/C remain clean and focused on their business logic.
  • Serialization logic is centralized in DTOs, making it easy to update or switch libraries later.
  • You avoid coupling domain code to infrastructure code.

4) Should classes be aware of the persistence module? How to handle state change notifications?

Classes should have zero knowledge of the persistence module—this is exactly what the Law of Demeter requires. For state change notifications, you have two far better options than polling:

Option 1: Observer Pattern (Automatic Updates)

Have A/B/C implement a simple observable interface that lets them notify registered listeners (like Persist) when their state changes. This avoids polling and ensures persistence happens immediately when state changes.

Option 2: Explicit Save Calls (Controlled Updates)

In your business logic layer, after modifying A/B/C's state, explicitly call Persist::save(). This is simpler for small applications and gives you full control over when persistence occurs.

Polling is the least desirable option—it wastes resources, introduces latency, and makes your code harder to reason about.


Concrete Class Interaction Example

Here's how this design would look in code:

1. Domain Classes (A, B, C)

These classes have no persistence-related code—only business logic and optional observer support:

#include <functional>
#include <vector>
#include <string>

class A {
private:
    int configValueA;
    std::string configStrA;
    std::vector<std::function<void()>> stateChangedCallbacks;

public:
    // Core business methods
    void setConfigValue(int val) {
        configValueA = val;
        notifyStateChanged();
    }

    void setConfigStr(const std::string& str) {
        configStrA = str;
        notifyStateChanged();
    }

    int getConfigValue() const { return configValueA; }
    std::string getConfigStr() const { return configStrA; }

    // Observer registration for state changes
    void registerStateChangedCallback(std::function<void()> cb) {
        stateChangedCallbacks.push_back(std::move(cb));
    }

private:
    void notifyStateChanged() {
        for (const auto& cb : stateChangedCallbacks) {
            cb();
        }
    }
};

// B and C follow the same pattern—focused on their own business logic

2. DTO Classes (Serialization Adaptors)

These classes are purpose-built for serialization with your library of choice (cereal in this case):

#include <cereal/types/string.hpp>
#include <cereal/types/struct.hpp>

struct ADto {
    int configValueA;
    std::string configStrA;

    // Cereal serialization logic—only lives here
    template<class Archive>
    void serialize(Archive& ar) {
        ar(CEREAL_NVP(configValueA), CEREAL_NVP(configStrA));
    }

    // Convert from domain class to DTO
    static ADto fromDomain(const A& a) {
        return {a.getConfigValue(), a.getConfigStr()};
    }

    // Convert from DTO back to domain class (for loading)
    static void toDomain(const ADto& dto, A& a) {
        a.setConfigValue(dto.configValueA);
        a.setConfigStr(dto.configStrA);
    }
};

// BDto and CDto follow the same pattern

3. Persist Class (Persistence Coordinator)

This class handles disk I/O and coordinates serialization/deserialization, with minimal coupling to domain classes:

#include <fstream>
#include <cereal/archives/json.hpp>

class Persist {
private:
    const std::string filePath = "my_module.json";
    A* aInstance;
    // Add pointers to B and C instances here

public:
    Persist(A* a /*, B* b, C* c */) : aInstance(a) {
        // Register callbacks to auto-save on state change
        a->registerStateChangedCallback([this]() { save(); });
        // Do the same for B and C
    }

    void save() {
        // Convert domain objects to DTOs
        ADto aDto = ADto::fromDomain(*aInstance);
        // Create BDto and CDto similarly

        // Serialize to JSON file
        std::ofstream os(filePath);
        cereal::JSONOutputArchive archive(os);
        archive(CEREAL_NVP(aDto) /*, CEREAL_NVP(bDto), CEREAL_NVP(cDto) */);
    }

    void load() {
        // Load DTOs from JSON
        std::ifstream is(filePath);
        cereal::JSONInputArchive archive(is);
        ADto aDto;
        // Load BDto and CDto similarly

        // Convert DTOs back to domain objects
        ADto::toDomain(aDto, *aInstance);
        // Do the same for B and C
    }
};

Why This Design Follows SOLID

  • Single Responsibility: Each class has one clear job (domain logic, serialization adaptation, disk I/O).
  • Open/Closed Principle: Add new domain classes by creating new DTOs—no changes to existing code. Switch serialization libraries by updating DTOs and Persist—no changes to domain classes.
  • Law of Demeter: Domain classes don't know about Persist; Persist only interacts with domain classes via their public interfaces.
  • Dependency Inversion: Persist depends on abstractions (the observer callback mechanism) rather than concrete implementations of A/B/C.

内容的提问来源于stack exchange,提问作者NTreat

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.27 09:51:16