4

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

当前设计

类型只是保存值。

struct Person {
    Person() : Walking(false), Jumping(false) {}
    float Height, Mass;
    bool Walking, Jumping;
};

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

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;
};

管理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
        // ...
    }
};

有问题

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

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

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

4

1 回答 1

6

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

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

想象一个使用浮点数的 4 分量 (RGBA) 32 位图像,但出于任何原因仅使用 8 位 alpha(对不起,这是一个愚蠢的例子)。如果我们甚至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;
};

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

struct Image
{
    vector<float> rgb;
    vector<unsigned char> alpha;
};

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

然而,面向数据的设计通过将这种表示甚至应用于比像素高得多的事物,将这一点比平时更进一步。以类似的方式,您可能会受益于建模 aParticleSystem而不是 singleParticle以留出这样的优化空间,甚至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;
};

这种adjust_brightness方法的问题在于,从界面的角度来看,它被设计为在单个像素上工作。这可能会使应用受益于一次访问多个像素的优化和算法变得困难。同时,像这样:

struct Image
{
    vector<float> rgb;
};
void adjust_brightness(Image& img, float amount);

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

struct Image
{
    vector<float> r;
    vector<float> g;
    vector<float> b;
};

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

多态性

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

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

您仍然可以以国防部的思维方式获得所有幻想并利用 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;
};

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

热/冷分离

查看您的Person课程,您可能仍在以某种经典接口的方式思考(即使这里的接口只是数据)。struct同样,只有当这是最关键性能循环的最佳内存配置时,DOD 才会主要将 a用于整个事物。这不是关于人类的逻辑组织,而是关于机器的数据组织。

struct Person {
    Person() : Walking(false), Jumping(false) {}
    float Height, Mass;
    bool Walking, Jumping;
};

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

struct People {
    struct Person {
        Person() : Walking(false), Jumping(false) {}
        float Height, Mass;
        bool Walking, Jumping;
     };
};

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

struct People {
    vector<float> HeightMass;
    vector<bool> WalkingJumping;
};

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

从界面的角度来看,您设计界面的重点是处理人,而不是

问题

有了这个,你的问题:

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

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

然而,外部世界可用于添加模型、销毁模型、渲染模型等的接口最终应该是为批量设计的。可以将其视为为容器设计高级接口,您可能会想为每个元素添加的方法最终属于容器,如上图示例中的adjust_brightness.

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

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

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

于 2015-11-29T06:30:11.870 回答