技术探讨 | 知行一

分类目录归档:技术探讨

从抽象谈面向对象与泛型编程 part.1

1. 前言

抽象是我们常用的思维过程。一系列事物通过大脑的提炼,归纳和综合,让我们可以从无序中找出有序,从一个个具体的问题中找出通用的解决方法。编程中的抽象是从面相对象的程序设计,后文称为OOP,开始才有了比较完善的语言支持。如今软件复杂程度越来越高,OOP一直都是解决复杂软件设计的重要方法。使用OOP可以屏蔽各个任务具体的细节,抽象出一个简化而统一的流程,从而降低复杂软件开发的难度。这是一个建模的过程,也是OOP能够成功解决问题的原因。但是OOP的抽象能力是有限的,OOP的滥用反而会使复杂度无控制地上升。鄙文会浅讨OOP抽象适用的场景,并会阐述为什么作者觉得everyting is object是一个错误的设计。这个系列的文章后面还会探讨另外一种抽象能力更强大的范式,泛型编程GP。鄙文不是引战OOP与GP孰优孰劣,而是以抽象为线索,浅讨如何编写出能应付更加复杂程序的方法。

2. 传统OOP中的抽象

OOP有一个经典的案例。鱼会游泳,鸟会飞行,而它们都是动物。于是用OOP就很自然的有了下面的代码:

许多文章和书籍对OOP都是这样教的。从animal继承以扩展新的动物类,如哺乳类。从bird或fish继承,以扩展出具体的物种。当然还有许多文章也拿这个例子作为OOP的反面教材。因为当我们要实现飞鱼的时候,飞鱼既可以游泳,也可以飞行,这个继承结构无所适从。那么究竟是哪里出了问题?

熟悉OOP的人会说,这里违反了里氏里氏替换原则(LSP)。飞鱼属于鱼类,但是我们抽象的鱼类无法满足要求。那我们给鱼增加一个fly接口:

这能解决飞鱼这一个情况,但现实中好像还有更多复杂的情况。例如天鹅属于鸟类,但是它既会走路,也会飞行,当然也会游泳。这个时候我们又不得不扩充鸟类的接口。问题似乎变复杂了,绝大部分的鱼并不会飞行,但是为了兼容飞鱼我们为鱼类添加了fly接口。其实,真正的问题在于,我们为了实现一个可以游泳的动物,根据有限的经验抽象出了鱼类,并把这个动物归为鱼类。这种抽象的方式的代价,就是给实现类与抽象类之间,增加了一个is_a的关系,这个关系是一个很强的依赖关系。

对于我们举的这个例子而言,这种错误很容易发现,这是得益于动物这个话题是人们所熟知的。但是现实的问题往往都是未充分了解的,所以我们的抽象会很容易地违反LSP。其次,仅仅使用接口的异同类对事物进行抽象和归属,似乎并不是一个很好的主意。于是OOP有了改良,对行为的抽象看起来要更合理一些。

3. 接口编程与行为抽象

来到了接口编程,我们不再为类别进行抽象了,而是抽象行为。例如,上面的例子,我们不再为动物区分哺乳类,鸟类和鱼类等,也不会为鱼类实现飞鱼或者鲈鱼等其他具体的物种。简单起见,假设动物的行为有飞行,行走和游泳,于是我们抽象出了这三个接口。

飞鱼既可以飞行也可以游泳,我们只要实现这两个行为,飞鱼的问题就很好地解决了:

如果语言不支持多重继承,可以使用组合的方式实现。目前的设计好像能应付所有的动物,甚至能推广得更远。例如,实现飞机也很容易:

现在我们要实现一个功能,让我们实现的飞鱼先飞行再游泳。在OOP中,对象的具体语义是不可知的,例如我们访问飞鱼的fly接口是通过flyable接口对象,对于swim同样是从swimmable对象访问,而我们并不知道flyable和swimmable是否是从飞鱼而来的。OOP在这里会带来运行期的额外开销。呃,这里好像碰到了点什么问题。我们如何保证flyable和swimmable两个接口对象,都来自于同一个飞鱼对象?

在我们继续往下解决这个问题之前,让我们在回想一下,为什么要抽象。抽象是为了屏蔽每个任务具体的细节,提炼出一个统一的处理流程。也就是说,统一的处理流程是我们抽象的目标。而现在的这个先飞行再游泳的功能,无法在我们现有抽象的模型中表达。问题已经很清楚了,是我们的抽象出了问题。这就是OOP在设计的时候一定要避免的误区,我们的抽象是为了得到统一的处理流程,而不是为了适应更广泛复杂的真实世界的模型而过度抽象,遵守KISS很重要。

对于这个例子,如果我们的需求只是希望在程序中,统一控制实体先飞行再游泳,应该这样抽象

如果我们的需求是想统一控制实体的fly和swin行为,并能够灵活地组合这些行为,可以这样抽象:

OOP的抽象的程度应该到此为止,如果问题更复杂可以适当使用设计模式解决,当然设计模式不是鄙文探讨的范围。坚守KISS原则是一件很难的事情。Everything is object,这个极具诱惑和理想主义的咒语,一直在不停地将我们拉入深渊。是的,一直有一条路通往那里。

4. 深渊,从这里开始

下面的设计,不应该归于OOP的范畴了。因为它们为了试图用统一的方法解决一切问题,而且都违背了KISS最基本的教义。首先,如果我们想要继续坚持之前的设计,又希望问题能够得到解决,本着接口统一的原则,我们可以像COM一样,提供一个接口查询的机制:

还有一个解决方案,就是使用发送消息:

以上两个方法很类似,就放在一起讨论。两个方法都引入了一个共同的公共基类,object_base. 对!Everything is object就从这里开始了。第一种方法提供了一个统一的接口查询机制,而后者提供了一个统一的发送消息机制。两者都需要全局唯一的ID来区分不同的接口或者消息。接口查询的方式会带来接口对象不安全强转,而发送消息会招致不安全的数据转换。就目前而言所引入的公共基类和全局ID的负载,还有不安全的代码都不是很严重的问题。例如后者,可以在对象语义完整的时候,创建代理来绑定操作,从而抵消不安全的代码。最严重的问题是,OOP在这里已经荡然无存了。没有了接口的约束,之前抽象的模型也毫无意义了,这于过程式编程毫无差别。如此抽象的结果,却是写出了等同之前毫无抽象的代码,这种设计难道不是一个悖论吗?

深渊也是有底部的,我们继续把查询接口与查询消息推广,其实就是现代语言都提供的反射。反射是一种内省机制,可以让我们查询对象的成员方法和成员数据。当然,与其这样使用反射,何不去使用一门动态语言?

5. 小结

OOP使用的接口抽象的方法,本质上是围绕接口进行的归类方法。这种方法可以做到统一调度流程,屏蔽具体的细节,但同时也许多代价:
1. 围绕接口进行抽象,只利用了语法约束,但丢弃了语义;
2. 归类引入了很强的is_a的依赖关系,而程序中的归类结果通常与现实中经验相差甚远;
3. 对于静态类型,有很明确的编译期的语言,OOP把负担都扔给了运行期;

OOP有着适中的抽象能力,用来对现实问题的简化模型进行抽象,而everything is object是OOP的滥用。

下一篇,我们将讨论静态强类型语言所擅长的泛型编程。GP具有比OOP强大许多的抽象能力,它比OOP更适合于抽象复杂的模型。我们会以最大公约数算法为线索,讨论GP在C++编程语言中的实践方法。

一个C++14模板元实现的深度学习神经网络模板类,支持任意层数

构造编译期矩阵以及数据传递代码,headonly
搜遍了github,在模板元这块机器学习还是空白,正好是个填补,我接下来会逐渐丰富这个库(倒是有几个模板元数学运算库,都很简陋)
大量的矩阵运算用模板元进行有几个让人非常惬意的优势,也发觉模板元其实很适合这种编程
(不知道是否唯有C++才有的优势,数学专用语言不算在内,比如m、r这些):
1、永远不用担心数组越界,也不用写检查数组越界的代码
2、矩阵运算不用检查行列是否匹配,行列的要求通过模板函数参数就能限定了
3、快,只有cpper才懂的快
代码在这里 https://github.com/bowdar/DeepLearning

先看使用方法,过程极其简单

模板类的申明,开头是用来迭代整形模板参数的UnpackInts,根据Index取值,没有使用TypeList
代码使用到的矩阵模板类和数学公式就没贴了

模板类的实现

一种更通用的编译期反射

magic_get编译期反射的局限性

magic_get可以实现编译期获取pod类型,是以一种“无痕”的方式实现的,即无需宏、特殊标记、专门工具。看起来确实很精妙,不过也存在一些局限性,比如只能支持pod类型,不能获取反射类型的字段名,也不支持遍历访问对象字段。这些局限性导致magic_get无法在更广泛的环境下应用。

一种更通用的编译期反射方法

基本的反射功能应该包括根据索引获取字段,根据索引获取字段名,遍历对象的所有字段,支持所有类型的对象。
一种更通用的编译期反射应该支持下面这些。

这种编译期反射有几个优点:使用简单,通用,接口完备,支持所有类型的对象而不仅仅是pod类型,非侵入式。

存在的不足之处在于需要定义一个宏,这个非侵入式的宏是用来获取对象的元数据的,是不可少的。magic_get之所以不需要定义宏,是因为利用了pod类型内存连续的特殊性,可以直接转换为内存连续的tuple,这个tuple提供了元数据,不过这个元数据是有缺陷的,即只有字段值而没有其他信息。而定义宏的方式则提供了丰富而完整的元数据信息,会更通用和方便。

由于这种编译期反射方式把对象元数据和元数据的操作分离了,所以用户可以基于这个通用的编译期反射很自由地做自己感兴趣的事情。

编译期反射可以用来做什么

编译期反射非常适合用来做ORM引擎和序列化/反序列化引擎,以ORM为例,我们可以基于反射来做多种数据库的ORM,比如sqlite, mysql, postgresql,oracle,sqlserver等数据库。有了ORM之后使用起来会非常方便,ORM给用户提供简单通用的接口,把数据库差异、对象和实体相互转换等繁琐的细节都屏蔽了。以基于编译期反射实现sqlite的ORM为例:

从上面的例子可以看到,基于编译期反射实现的ORM接口非常简单、通用和强大,你几乎可以用这几个接口做任何事。即使对于其他数据库,接口仍然保持不变,这将会极大地提高数据库开发效率和降低数据库开发的难度,这就是编译期反射的威力!

关于编译期反射的实现原理和ORM的实现原理敬请关注即将开始的purecpp社区第一期技术公开课

如何优雅地管理constant buffer

1. Preface

Constant buffer是我们在编写shader的时候,打交道最多的一种buffer resource了。constant表明了constant buffer中的数据,在一次draw call的执行过程中都是不变的;而在不同的draw call之间,我们可以修改其中的数据。它是我们把数据从CPU传递到GPU最常见的方法。constant buffer的概念和定义就不在这里赘述了,鄙文主要讨论如何优雅的管理constant buffer.

2. How to create and manipulate constant buffer gracefully.

Constant buffer在各种api中都有很好的支持,如在DX11中,以cbuffer为类型的闭包,可以定义一个constant buffer.

cbuffer可以根据你自己对它的功能或者buffer数据改变的策略来定义。在DX11中,我通过shader reflection的接口发现,没有在cbuffer闭包中的变量,都会被放到一个名叫@Globals的constant buffer中。当然这也取决你的fxc.exe的版本。其实让我们苦恼的并不是如何使用DX或者GL等这些api创建一个constant buffer对象,而是我们为了使用constant buffer,通常情况下需要在我们的引擎或者客户端的程序代码中,创建一个内存布局和大小与GPU端统一的数据结构。例如,我们要使用 transforms和change_every_frame这两个cbuffer,我们还要定义如下C的struct,和一些额外的代码。

这是一件很让人困扰的事情。首先,如果你开发了一个新的shader并使用了一个新的cbuffer的定义,那么你不得不修改你的引擎或者客户端代码。添加新的数据结构,还有使用和更新的代码。如果是这样,我们的程序或者引擎的扩展性就太差了!其次,你得非常小心的处理constant buffer内存布局的规则,否则你的数据不会正确的传递。例如,light_position后面要更一个float作为补位。我们应该把这些交给程序自己,而不是自己来做重复的工作。在C++中,我们必须要把这些与类型相关,也就是受限于编译期的,改成到运行期当中来计算。管理的基本方法如图所示:

726345-20160522235430982-157258810

 

将一个cbuffer分成的两个部分,一个大小跟GPU中cbuffer大小一致的memory block,和一个对cbuffer各个成员的描述meta data. 如何来实现呢?在最开始,我们需要一个枚举来描述所有的基本类型,例如float,float3,float4x4,这是从编译期转向运行期的第一步。

然后,我们需要一个结构体来描述整个constant buffer,例如change_every_frame这个cbuffer,我们要描述整个buffer的大小,light_color的data format,还有相对于cbuffer头地址的偏移量等。并且还要支持在cbuffer中使用结构体和数组。所以这个结构体应该是自递归的。如下面的代码

在编译shader之前,还需要多做一件事情,就是解析shader code中的cbuffer,把这些meta data都获取出来,创建好numeric_layout对象。当然,都已经解析了cbuffer,讲道理应该把整个shader codes都解析一遍,创建一个完整的effect框架。这部分的功能,我正在研究和开发中,希望能顺利完成并同大家分享。然后在渲染框架的与平台无关的代码部分,抽象一个constant buffer类型,并使用这个numeric_layout创建与平台无关的constant buffer对象。有了这个constant buffer对象,平台相关的代码就有足够多的信息正确创建设备上的cbuffer的对象了,无论是dx还是gl. 那么总体的流程如图:

726345-20160523001044826-737496802

在很多图形api中,对cbuffer的部分更新做的并不是很好,如只更新change_every_frame中的light_color分量。DX的UpdateSubresource无法实现,gl3.1之后有了ubo,才可以使用glSetBufferSubData来实现。在我们管理的cbuffer下,我们可以部分更新cpu中的cbuffer的memory,在一起update到gpu上来模拟这种部分更新的实现。

3. Use the powerful compile time computation of C++ to manipulate constant buffer

接下来聊聊如何利用C++新标准强大的编译期计算来方便我们创建cbuffer. 上述将编译期迁移到运行期的方法,很适合在渲染框架中使用。运行期化的代码,虽然能解决问题,但是创建的过程还是比较复杂。在写实验和测试的代码这类很小的程序的时候,上图的流程就显的笨重了。所以,我在实现numeric_layout的时候,提供了使用用户自定义类型来创建cbuffer的metadata的方法,以便小型程序使用。使用起来非常简单,代码如下:

首先定义自定义的cbuffer的结构体,跟shader code中的一模一样的结构体;然后使用boost::fusion做编译期的反射;最后使用numeric_layout的另外一个构造函数创建cbuffer的metadata,共cbuffer创建使用。大概的实现思路如下。

1. 使用large_class_wrapper<T>来避免不必要的栈内存分配。由于定义我们的constant buffer的数据结构往往是一个结构体,而我们创建numeric_layout对象并不需要这个结构体的实例或者实例的引用,只需要编译期的反射信息。我们没有必要去创建一个无用的对象,而是把类型传递给large_class_wrapper<T>类模板,让它带上类型信息,但是这个类模板本身是一个空类,大小为1,甚至会被编译期优化掉。所以这里使用large_class_wrapper<T>可以避免不比较的内存开销。

2. 然后,在使用被fusion适配成sequence之后的用户自定义类型T,对numeric_layout进行初始化。根据cbuffer的内存对齐规则,对sequence中的每一个成员类型做计算,当前成员类型计算的过程依赖上一个成员计算的结果。那么numeric_layout的构造函数调用的detail::init_variable_layout_from_tuple函数实现如下,

3. numeric_layout的构造函数,在编译期计算出了T在GPU中的大小,算法与运行期的原理基本相同,只是改写到编译期计算了。

目前还不支持struct中嵌套struct,我想不久的将来应该会支持的。仓库在这里,不过我写的很慢,连平台无关的代码都还没有写完。接下来会准备研究glslang,hlslcc,还有vulkan的SPIRV的一些库和tools的开源项目,来解决one shader all platforms的问题。

4. Tail

constant buffer曾经是我引擎开发工作当中一个比较痛苦的环节,每写一个shader,每多一个effect就得在C++代码中添加相应的数据结构和逻辑显得很DRY。而后错误的更新cbuffer招致的痛苦的调试过程也是历历在目。还有当我看到maya的cgfx如此灵活和强大功能更让我觉得cbuffer是得好好管理一下了。Powered by modern cpp and modern graphics api,希望我自己实现的渲染框架,可以在不添加一行C++代码的同时,还能高效的正确渲染新加入的effect,杜绝DRY的设计和实现。

C++中怎么对野指针进行防护

一直从事C++底层库的开发,这里以监听模式来示例野指针的防护。底层通知上层,一种方式是,底层提供一个监听接口类,上层实现,然后注册下来,一般是有注册就有反注册,可是把下层安全压在上层使用者,期望他们在释放这个监听接口类之前总是进行反注册,这个就太不明智,那么我们就需要基于框架设计能防护野指针破坏,这里我们提供一个Guard机制。
Guard翻译过来的意思就是警卫,顾名思义就是用来防护的。先看其实现:

一个Guard对象正常的通过其构造函数实例化的时候,那我们就认为这个Guard是一个主Guard,m_host设置为true,其实例化一个GuardHelper对象,这个对象有一个引用计数的标识和标识这个主Guard是否有效的标识位。我们再来分析其析构函数,如果是主Guard,其在析构的时候,会将Guard有效标识为设置为false,当引用计数为0时,释放m_guard实例。这里介绍最关键的副Guard概念,借由主Guard构建的Guard都是副Guard,在复执构造函数和赋值中m_host都是false。为了方便使用使Guard可以转化为bool。
其实说到这,我们怎么来防护野指针的思路也就有了,在类型中实例化一个主Guard,在其他需要保存这个指针的地方,保存一份其主Gurad的副Guard,那么在这个指针析构的时候,主Guard也就析构了,那么其他的副Gurad的m_vaild值就为false,那么我们在使用这个指针时就可以知道这个指针已经是野指针了。
那么主Guard该放在那里了,放在库框架的基类再合适不过,这个就可以在整个框架中的指针就行防护。

使用Guard的时候一般都是这样,保存指针和其副Guard。

这种结构过于丑陋,这里我们提供一个包裹类,将其作成一个Guard指针,和平常的指针一样使用。

使用示例:

运行结果:

对上层的野指针就行了防护,使用Guard的唯一一个需要特别注意的就是一定要值传递,不然你会挂的很惨。