类型应该在面向数据的设计中有方法吗?

dan*_*jar 4 c++ architecture encapsulation data-oriented-design

目前,我的应用程序包含三种类型的类。它应该遵循面向数据的设计,如果不是,请纠正我。这是三种类型的类。代码示例并不那么重要,您可以根据需要跳过它们。他们只是为了给人留下印象。我的问题是,我应该向我的类型类添加方法吗?

当前设计

类型只是保存值。

struct Person {
    Person() : Walking(false), Jumping(false) {}
    float Height, Mass;
    bool Walking, Jumping;
};
Run Code Online (Sandbox Code Playgroud)

每个模块实现一个独特的功能。它们可以访问所有类型,因为它们是全局存储的。

class Renderer : public Module {
public:
    void Init() {
        // init opengl and glew
        // ...
    }
    void Update() {
        // fetch all instances of one type
        unordered_map<uint64_t, *Model> models = Entity->Get<Model>();
        for (auto i : models) {
            uint64_t id = i.first;
            Model *model = i.second;
            // fetch single instance by id
            Transform *transform = Entity->Get<Transform>(id);
            // transform model and draw
            // ...
        }
    }
private:
    float time;
};
Run Code Online (Sandbox Code Playgroud)

管理器是一种通过基Module类注入模块的助手。上面使用的Entity是一个实体管理器的实例。其他管理器包括消息传递、文件访问、sql 存储等。简而言之,应该在模块之间共享的每个功能。

class ManagerEntity {
public:
    uint64_t New() {
        // generate and return new id
        // ...
    }
    template <typename T>
    void Add(uint64_t Id) {
        // attach new property to given id
        // ...
    }
    template <typename T>
    T* Get(uint64_t Id) {
        // return property attached to id
        // ...
    }
    template <typename T>
    std::unordered_map<uint64_t, T*> Get() {
        // return unordered map of all instances of that type
        // ...
    }
};
Run Code Online (Sandbox Code Playgroud)

有问题

现在您已经了解了我当前的设计。现在考虑一个类型需要更复杂的初始化的情况。例如,该Model类型刚刚为其纹理和顶点缓冲区存储了 OpenGL id。实际数据必须先上传到视频卡。

struct Model {
    // vertex buffers
    GLuint Positions, Normals, Texcoords, Elements;
    // textures
    GLuint Diffuse, Normal, Specular;
    // further material properties
    GLfloat Shininess;
};
Run Code Online (Sandbox Code Playgroud)

目前,有一个Models带有Create()功能的模块,负责设置模型。但是这样,我只能从这个模块创建模型,而不能从其他模块创建模型。我应该Model在复杂化它的同时将它移动到类型类吗?我认为类型定义就像以前的接口一样。

小智 6

首先,您不一定需要在任何地方都应用面向数据的设计。它最终是一种优化,即使是对性能至关重要的代码库仍然有很多部分无法从中受益。

我倾向于经常将其视为消除结构,以支持处理更有效的大数据块。以图像为例。为了有效地表示其像素,通常需要存储一个简单的数值数组,而不是例如用户定义的抽象像素对象的集合,这些对象具有虚拟指针作为夸大的例子。

想象一下使用浮点数但仅使用 8 位 alpha 的 4 分量 (RGBA) 32 位图像,无论出于何种原因(抱歉,这是一个愚蠢的例子)。如果我们甚至struct对像素类型使用基本类型,由于对齐所需的结构填充,我们通常最终会使用像素结构需要更多的内存。

struct Image
{
    struct Pixel
    {
        float r;
        float g;
        float b;
        unsigned char alpha;
        // some padding (3 bytes, e.g., assuming 32-bit alignment
        // for floats and 8-bit alignment for unsigned char)
    };
    vector<Pixel> Pixels;
};
Run Code Online (Sandbox Code Playgroud)

即使在这种简单的情况下,将其转换为具有 8 位 alpha 并行数组的平面浮点数组也可以减少内存大小,并因此有可能提高顺序访问速度。

struct Image
{
    vector<float> rgb;
    vector<unsigned char> alpha;
};
Run Code Online (Sandbox Code Playgroud)

...这就是我们最初应该如何思考的:关于数据、内存布局。当然,图像通常已经被有效地表示,并且图像处理算法已经被实现以批量处理大量像素。

然而,通过将这种表示应用于比像素高得多的事物,面向数据的设计将其提升到比平常更高的水平。以类似的方式,您可能会受益于建模 aParticleSystem而不是单个,Particle以便为优化留出这样的喘息空间,甚至People代替Person.

但是让我们回到图像示例。这往往意味着缺乏国防部:

struct Image
{
    struct Pixel
    {
        // Adjust the brightness of this pixel.
        void adjust_brightness(float amount);

        float r;
        float g;
        float b;
    };
    vector<Pixel> Pixels;
};
Run Code Online (Sandbox Code Playgroud)

这种adjust_brightness方法的问题在于,从接口的角度来看,它被设计为处理单个像素。这会使应用优化和算法变得困难,这些优化和算法受益于一次访问多个像素。与此同时,像这样的事情:

struct Image
{
    vector<float> rgb;
};
void adjust_brightness(Image& img, float amount);
Run Code Online (Sandbox Code Playgroud)

... 可以通过一次访问多个像素受益的方式编写。我们甚至可以用 SoA 代表这样表示:

struct Image
{
    vector<float> r;
    vector<float> g;
    vector<float> b;
};
Run Code Online (Sandbox Code Playgroud)

...如果您的热点与顺序处理相关,这可能是最佳选择。细节没那么重要。对我而言,重要的是您的设计留有优化空间。DOD 对我来说的价值实际上是将这种类型的想法放在首位会给你这些类型的界面设计,让你有喘息的空间在以后根据需要进行优化,而无需进行侵入性的设计更改。

多态性

多态的经典例子也倾向于集中在细粒度的一次一件事的思维方式上,比如Dog继承Mammal。在有时会导致瓶颈的游戏中,开发人员开始不得不与类型系统作斗争,按子类型对多态基指针进行排序以改善 vtable 上的临时局部性,尝试使数据成为特定子类型(Dog例如),并使用自定义分配器连续分配改善每个子类型实例的空间局部性等。

如果我们在更粗略的级别上建模,则不需要存在这些负担。您可以Dogs继承 abstract Mammals。现在虚拟调度的成本降低到每一次哺乳动物,每哺乳动物不是一次,和一个特定类型的所有哺乳动物能够有效且连续来表示。

您仍然可以尽情享受并以 D​​OD 的心态利用 OOP 和多态性。诀窍是确保您在足够粗略的级别上设计事物,以便您不会试图与类型系统作斗争并绕过数据类型以重新控制诸如内存布局之类的事情。如果您在足够粗略的水平上设计事物,则不必理会任何这些。

界面设计

至少在我看来,DOD 仍然涉及界面设计,并且您可以在类中使用方法。设计适当的高级接口仍然非常重要,您仍然可以使用虚函数和模板并变得非常抽象。我要关注的实际区别是您设计了聚合接口,就像adjust_brightness上面的方法一样,这为您提供了优化的喘息空间,而无需在整个代码库中进行级联设计更改。我们设计了一个界面来处理整个图像的多个像素,而不是一次处理一个像素。

DOD 接口设计通常设计为批量处理,并且通常采用具有最佳内存布局的方式,用于必须访问所有内容的最关键的性能、线性复杂性顺序循环。

因此,如果我们以您的示例为例Model,缺少的是接口的聚合端。

struct Models {
    // Methods to process models in bulk can go here.

    struct Model {
        // vertex buffers
        GLuint Positions, Normals, Texcoords, Elements;
        // textures
        GLuint Diffuse, Normal, Specular;
        // further material properties
        GLfloat Shininess;
    };

    std::vector<Model> models;
};
Run Code Online (Sandbox Code Playgroud)

这并不严格必须使用带有方法的类来表示。它可以是一个接受数组的函数structs。这些细节并不是那么重要,重要的是接口主要设计为批量顺序处理,而数据表示是针对这种情况进行最佳设计的。

热/冷分裂

看看你的Person类,你可能仍然在以一种经典的接口方式思考(即使这里的接口只是数据)。同样,struct只有当它是最关键性能循环的最佳内存配置时,国防部才会主要使用一个整体。这不是关于人类的逻辑组织,而是关于机器的数据组织。

struct Person {
    Person() : Walking(false), Jumping(false) {}
    float Height, Mass;
    bool Walking, Jumping;
};
Run Code Online (Sandbox Code Playgroud)

首先让我们把它放在上下文中:

struct People {
    struct Person {
        Person() : Walking(false), Jumping(false) {}
        float Height, Mass;
        bool Walking, Jumping;
     };
};
Run Code Online (Sandbox Code Playgroud)

在这种情况下,所有字段是否经常一起访问?让我们假设,答案是否定的。这些WalkingJumping字段仅偶尔访问(冷),而HeightMass始终重复访问(热)。在这种情况下,可能更优化的表示可能是:

struct People {
    vector<float> HeightMass;
    vector<bool> WalkingJumping;
};
Run Code Online (Sandbox Code Playgroud)

当然,您可以在此处创建两个单独的结构,一个指向另一个,等等。关键是您最终从内存布局/性能的角度设计它,并且最好手上有一个好的分析器并且对常见的用户端代码路径。

从界面的角度来看,你设计重点对处理人,而不是接口一个人。

问题

顺便说一下,解决您的问题:

我只能从这个模块创建模型,不能从其他模块创建。我应该在复杂化它的同时将它移动到类型类 Model 吗?

这更像是一种子系统设计问题。由于您的Model代表完全与 OpenGL 数据有关,因此它可能应该属于可以正确初始化/销毁/渲染它的模块。它甚至可能是该模块的私有/隐藏实现细节,此时您可以在模块的实现中应用 DOD 思维方式。

然而,外部世界可用于添加模型、销毁模型、渲染它们等的接口最终应该是为批量设计的。可以把它想象成为容器设计一个高级接口,其中你想要为每个元素添加的方法最终属于容器,就像我们上面的图像示例中的adjust_brightness.

复杂的初始化/销毁通常需要一次一个的设计思路,但关键是你通过一个聚合接口来做到这一点。在这里,您可能仍然放弃标准的构造函数和析构函数Model,转而支持在添加 GPUModel进行渲染时进行初始化,在将其从列表中删除时清理 GPU 资源。它在某种程度上回到了针对单个类型(例如,person)的 C 风格编码,尽管您仍然可以使用 C++ 好东西来为聚合接口(例如,people)变得非常复杂。

我的问题是,我应该向我的类型类添加方法吗?

主要为批量设计,你应该在路上。在您展示的示例中,通常没有。这不一定是最难的规则,但您的类型正在对单个事物进行建模,并且为 DOD 留出空间通常需要缩小并设计处理许多事物的界面。