《InsideUE4》UObject(二)类型系统概述
曾子曰:吾日三省吾身——为人谋而不忠乎?与朋友交而不信乎?传不习乎?
引言
上一篇我们谈到了在游戏引擎,或者在程序和高级编程语言中,设计一个统一对象模型得到的好处,和要付出的代价,以及在UE里是怎么对之尽量降低规避的。那么从本篇开始,我们就开始谈谈如何开始构建这么一个对象模型,并在此之上逐渐扩展以适应引擎的各种功能需求的。
众所周知,一般游戏引擎最底层面对的都是操作系统API,硬件SDK,所能借助到的工具也往往只有C++本身。所以考虑从原生的C++基础上,搭建对象系统,往往得从头开始造轮子,最底层也是最核心的机制当然必须得掌控在自己的手中,以后升级改善增加功能也才能不受限制。
那么,从头开始的话,Object系统有那么多功能:GC,反射,序列化,编辑器支持……应该从哪一个开始?哪一个是必需的?GC不是,因为大不了我可以直接new/delete或者智能指针引用技术,毕竟别的很多引擎也都是这么干的。序列化也不是,大不了每个子类里手写数据排布,麻烦是麻烦,但是功能上也是可以实现的。编辑器支持,默认类对象,统计等都是之后额外附加的功能了。那你说反射为何是必需的?大多数游戏引擎用的C++没有反射,不也都用得好好的?确实也如此,不利用反射的那些功能,不动态根据类型创建对象,不遍历属性成员,不根据名字调用函数,大不了手写绕一下,没有过不去的坎。但是既然上文已经论述了一个统一Object模型的好处,那么如果在Object身上不加上反射,无疑就像是砍掉了Object的一双翅膀,让它只能在地上行走,而不能在更宽阔空间内发挥威力。还有另一个方面的考虑是,反射作为底层的系统,如果实现完善了,也可以大大裨益其他系统的实现,比如有了反射,实现序列化起来就很方便了;有没有反射,也关系到GC实现时的方案选择,完全是两种套路。简单举个例,反射里对每个object有个class对象保存信息,所以理论上class身上就可以保存所有该类型的object指针引用,这个信息GC就可以利用起来实现一些功能;而没有这个class对象的话,GC的实现就得走别的方案路子了。所以说是先实现反射,有了一个更加扎实的对象系统基础后,再在此之上实现GC才更加的明智。
类型系统
虽然之上一直用反射的术语来描述我们熟知的那一套运行时得到类型信息的系统,动态创建类对象等,但是其实“反射”只是在“类型系统”实现之后的附加功能,人们往往都太过注重最后表露的强大功能,而把朴实的本质支撑给忘记了。想想看,如果我实现了class类用来提供Object的类型信息,但是不提供动态创建,动态调用函数等功能,请问还有没有意义?其实还仍然是非常有意义的,光光是提供了一个类型信息,就提供了一个Object之外的静态信息载体,也能构建起object之间的派生从属关系,想想如果UE里去掉了根据名字创建类对象的能力,是会损失一些便利功能,但确实也还没有到元气大伤的程度,GC依然能跑得起来。
所以以后更多用“类型系统”这个更精确的术语来表述object之外的类型信息构建,而用“反射”这个术语来描述运行时得到类型的功能,通过类型信息反过来创建对象,读取修改属性,调用方法的功能行为。反射更多是一种行为能力,更偏向动词。类型系统指的是程序运行空间内构建出来的类型信息树组织,
C# Type
因C++本身运行时类型系统的疲弱,所以我们首先拿一个已经实现完善的语言,来看看其最后成果是什么样子。这里选择了C#而不是java,是因为我认为C#比java更强大优雅(不辩),Unity用C#作为脚本语言,UE本身也是用C#作为编译UBT的实现语言。
在C#里,你可以通过以下一行代码方便的得到类型信息:
Type type = obj.GetType(); //or typeof(MyClass)
本篇不是C#反射教程(关心的自己去找相关教程),但这里还是简单提一下我们需要关注的:
- Assembly是程序集的意思,通常指的是一个dll。
- Module是程序集内部的子模块划分。
- Type就是我们最关心的Class对象了,完整描述了一个对象的类型信息。并且Type之间也可以通过BaseType,DeclaringType之类的属性来互相形成Type关系图。
- ConstructorInfo描述了Type中的构造函数,可以通过调用它来调用特定的构造函数。
- EventInfo描述了Type中定义的event事件(UE中的delegate大概)
- FiedInfo描述了Type中的字段,就是C++的成员变量,得到之后可以动态读取修改值
- PropertyInfo描述了Type中的属性,类比C++中的get/set方法组合,得到后可以获取设置属性值。
- MethodInfo描述了Type中的方法。获得方法后就可以动态调用了。
- ParameterInfo描述了方法中的一个个参数。
- Attributes指的是Type之上附加的特性,这个C++里并没有,可以简单理解为类上的定义的元数据信息。
可以看到C#里的Type几乎提供了一切信息数据,简直就像是把编译器编译后的数据都给暴露出来了给你。实际上C#的反射还可以提供其他更高级的功能,比如运行时动态创建出新的类,动态Emit编译代码,不过这些都是后话了(在以后讲解蓝图时应该还会提到)。当前来说,我希望读者们能有一个大概的印象就是,用代码声明定义出来的类型,当然可以通过一种数据结构完整描述出来,并在运行时再得到。
C++ RTTI
而谈到C++中的运行时类型系统,我们一般会说RTTI(Run-Time Type Identification),只提供了两个最基本的操作符:
typeid
这个关键字的主要作用就是用于让用户知道是什么类型,并提供一些基本对比和name方法,作用也顶多只是让用户判断从属于不同的类型,所以其实说起来type_info的应用并不广泛,一般来说也只是把它当作编译器提供的一个唯一类型Id。
const std::type_info& info = typeid(MyClass);
class type_info
{
public:
type_info(type_info const&) = delete;
type_info& operator=(type_info const&) = delete;
size_t hash_code() const throw();
bool operator==(type_info const& _Other) const throw();
bool operator!=(type_info const& _Other) const throw();
bool before(type_info const& _Other) const throw();
char const* name() const throw();
};
dynamic_cast
该转换符用于将一个指向派生类的基类指针或引用转换为派生类的指针或引用,使用条件是只能用于含有虚函数的类。转换引用失败会抛出bad_cast异常,转换指针失败会返回null。
Base* base=new Derived();
Derived* p=dynamic_cast<Derived>(base);
if(p){...}else{...}
dynamic_cast内部机制其实也是利用虚函数表里的类型信息来判断一个基类指针是否指向一个派生类对象。其目的更多是用于在运行时判断对象指针是否为特定一个子类的对象。
其他的比如运用模板,宏标记就都是编译期的手段了。C++在RTTI方面也确实是非常的薄弱,传说中的标准反射提案也遥遥无期,所以大家就都得八仙过海各显神通,采用各种方式模拟实现了。C++都能用于去实现别的语言底层,不就是多一个轮子的事嘛。
C++当前实现反射的方案
既然C++本身没提供足够的类型信息,那我们就采用各种其他各种额外方式来搜集,并构建保存起来之后供程序后继使用。根据搜集信息的方式不同,C++的反射方案也有以下流派:
宏
基本思想是采用手动标记。在程序中用手动的方式注册各个类,方法,数据。大概就像这样:
struct Test
{
Declare_Struct(Test);
Define_Field(1, int, a)
Define_Field(2, int, b)
Define_Field(3, int, c)
Define_Metadata(3)
};
用宏偷梁换柱的把正常的声明换成自己的结构。简单可见这种方式还比较的原始,写起来也非常的繁琐。因此往往用的不多。更重要的是往往需要打破常规的书写方式,因此常常被摒弃掉。
模板
C++中的模板应该算是最大区别于别的语言的一个大杀器,引导其强大的编译器类型识别能力构建出相应的数据结构,理论上也是可以实现出类型系统的一部分功能。举一个Github实现比较优雅的C++RTTI反射库做例子:rttr
#include <rttr/registration>
using namespace rttr;
struct MyStruct { MyStruct() {}; void func(double) {}; int data; };
RTTR_REGISTRATION
{
registration::class_<MyStruct>("MyStruct")
.constructor<>()
.property("data", &MyStruct::data)
.method("func", &MyStruct::func);
}
说实话,这写得已经非常简洁优雅了。算得上是达到了C++模板应用的巅峰。但是可以看到,仍然需要一个个的手动去定义类并获取方法属性注册。优点是轻量程序内就能直接内嵌,缺点是不适合懒人。
编译器数据分析
还有些人就想到既然C++编译器编译完整个代码,那肯定是有完整类型信息数据的。那能否把它们转换保存起来供程序使用呢?事实上这也是可行的,比如@vczh的GacUI里就分析了VC编译生成后pdb文件,然后抽取出类型定义的信息实现反射。VC确实也提供了IDiaDataSource COM组件用来读取pdb文件的内容。用法可以参考:GacUI Demo:PDB Viewer(分析pdb文件并获取C++类声明的详细内容)。
理论上来说,只要你能获取到跟编译器同级别的类型信息,你基本上就像是全知了。但是缺点是分析编译器的生成数据,太过依赖平台(比如只能VC编译,换了Clang就是另一套方案),分析提取的过程往往也比较麻烦艰深,在正常的编译前需要多加一个编译流程。但优点也是得到的数据最是全面。
这种方案也因为太过麻烦,所以业内用的人不多。
工具生成代码
自然的有些人就又想到,既然宏和模板的方法,太过麻烦。那我能不能写一个工具来自动完成呢?只要分析好C++代码文件,或者分析编译器数据也行,然后用预定义好的规则生成相应的C++代码来跟源文件对应上。
一个好例子就是Qt里面的反射:
#include <QObject>
class MyClass : public QObject
{
Q_OBJECT
Q_PROPERTY(int Member1 READ Member1 WRITE setMember1 )
Q_PROPERTY(int Member2 READ Member2 WRITE setMember2 )
Q_PROPERTY(QString MEMBER3 READ Member3 WRITE setMember3 )
public:
explicit MyClass(QObject *parent = 0);
signals:
public slots:
public:
Q_INVOKABLE int Member1();
Q_INVOKABLE int Member2();
Q_INVOKABLE QString Member3();
Q_INVOKABLE void setMember1( int mem1 );
Q_INVOKABLE void setMember2( int mem2 );
Q_INVOKABLE void setMember3( const QString& mem3 );
Q_INVOKABLE int func( QString flag );
private:
int m_member1;
int m_member2;
QString m_member3;
};
大概过程是Qt利用基于moc(meta object compiler)实现,用一个元对象编译器在程序编译前,分析C++源文件,识别一些特殊的宏Q_OBJECT、Q_PROPERTY、Q_INVOKABLE……然后生成相应的moc文件,之后再一起全部编译链接。
UE里UHT的方案
不用多说,你们也能想到UE当前的方案也是如此,实现在C++源文件中空的宏做标记,然后用UHT分析生成generated.h/.cpp文件,之后再一起编译。
UCLASS()
class HELLO_API UMyClass : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(BlueprintReadWrite, Category = "Test")
float Score;
UFUNCTION(BlueprintCallable, Category = "Test")
void CallableFuncTest();
UFUNCTION(BlueprintNativeEvent, Category = "Test")
void NavtiveFuncTest();
UFUNCTION(BlueprintImplementableEvent, Category = "Test")
void ImplementableFuncTest();
};
这种方式的优点是能够比较小的对C++代码做修改,所要做的只是在代码里加一些空标记,并没有破坏原来的类声明结构,而且能够以比较友好的方式把元数据和代码关联在一起,生成的代码再复杂也可以对用户隐藏起来。一方面分析源码得力的话能够得到和编译器差不多的信息,还能通过自己的一些自定义标记来提供更多生成代码的指导。缺点是实现起来其实也是挺累人的,完整的C++的语法分析往往是超级复杂的,所以限制是自己写的分析器只能分析一些简单的C++语法规则和宏标记,如果用户使用比较复杂的语法时候,比如用#if /#endif包裹一些声明,就会让自己的分析器出错了,还好这种情况不多。关于多一次编译的问题,也可以通过自定义编译器的编译脚本UBT来规避。
如果是熟悉C#的朋友,一眼就能看出来这和C#的Attribute的语法简直差不多一模一样,所以UE也是吸收了C#语法反射的一些优雅写法,并利用上了C++的宏魔法,当然生成的代码里模板肯定也是少不了的。采取众长最后确定了这种类型信息搜集方案。
总结
本篇主要是解释了为何要以类型系统作为搭建Object系统的第一步,并描绘了C#语言里完善的类型系统看起来是什么样子,接着讨论了C++当前的RTTI工具,然后环顾一下当前C++业内的各种反射方案。知道别人家好的是什么样子,知道自己现在手里有啥,知道当前业内别人家是怎么尝试解决这个问题的,才能心中有数知道为何UE选择了目前的方案,知道UE的这套方案在业内算是什么水平。
依然说些废话,笔者一向认为想解释清楚一件东西,更多的应该是解释清楚背后的各种概念。否则对着源码,罗列出来各个类,说一下每个接口的作用,数据互相怎么引用,流程是怎么跑的,你能很快的就知道一大堆信息。你只是知道了What,How,但是还挡不住别人问一句Why。而功力的提升就在于问一个个why中,A办法能做,B办法也行,那为什么最后选了C方法?想要回答这个问题,你就得朔古至今,旁征博引,了解各种方法的理念,优劣点,偏重倾向,综合起来才能更好的进行权衡。而设计,就是权衡的艺术。这么写起来也确实有点慢,但是个人权衡一下还是系统性更加的重要。宁愿慢点,质量第一。
下篇,我们将开始讲述在UE里是怎么组织类型信息数据。
引用
UE4.14.1
知乎专栏:InsideUE4
UE4深入学习QQ群:456247757(非新手入门群,请先学习完官方文档和视频教程)
微信公众号:aboutue,关于UE的一切新闻资讯、技巧问答、文章发布,欢迎关注。
个人原创,未经授权,谢绝转载!
《InsideUE4》UObject(二)类型系统概述的更多相关文章
- python3.4学习笔记(二) 类型判断,异常处理,终止程序
python3.4学习笔记(二) 类型判断,异常处理,终止程序,实例代码: #idle中按F5可以运行代码 #引入外部模块 import xxx #random模块,randint(开始数,结束数) ...
- 「Haskell 学习」二 类型和函数(上)
随着学习的深入,笔记会补充和修订.当然,这个补充修订也许会鸽,但我一定会坚持写完. 这个笔记假定你至少学过C/C++及Python,或与这两种语言类型相同的语言. 类型系统概述 “Haskell’s ...
- TS学习随笔(二)->类型推论,联合类型
这篇内容指南: -----类型推论 -----联合类型 类型推论 第一篇中我们看了TS的基本使用和基本数据类型的使用,知道了变量在使用的时候都得加一个类型,那我们可不可以不加呢,这个嘛 ...
- CLR via C#深解笔记二 - 类型设计
类型基础 所有类型都从System.Object派生 CLR要求所有对象都用new 操作符来创建. Employee e = new Employee("Constructor Para ...
- Python中的对象行为与特殊方法(二)类型检查与抽象基类
类型检查 创建类的实例时,该实例的类型为类本身: class Foo(object): pass f = Foo() 要测试实例是否属于某个类,可以使用type()内置函数: >>> ...
- 《InsideUE4》UObject(三)类型系统设定和结构
垃圾分类,从我做起! 引言 上篇我们谈到了为何设计一个Object系统要从类型系统开始做起,并探讨了C#的实现,以及C++中各种方案的对比,最后得到的结论是UE采用UHT的方式搜集并生成反射所需代码. ...
- ios -- 教你如何轻松学习Swift语法(二)
前言:swift语法基础篇(二)来了,想学习swift的朋友可以拿去参考哦,有兴趣可以相互探讨,共同学习哦. 一.可选类型(重点内容) 1.什么是可选类型? 1.1在OC开 ...
- 苹果新的编程语言 Swift 语言进阶(十三)--类型检查与类型嵌套
一 类型检查 1. 类型检查操作符 类型检查用来检查或转换一个实例的类型到另外的类型的一种方式. 在Swift中,类型检查使用is和as操作符来实现. is操作符用来检查一个实例是否是某种特定类型,如 ...
- scrapy分布式爬虫scrapy_redis二篇
=============================================================== Scrapy-Redis分布式爬虫框架 ================ ...
随机推荐
- SpringMVC一路总结(二)
冰冻三尺非一日之寒.对技术的学习尤其得遵循这么一个理.在<SpringMVC一路总结(一)>中,清楚的总结了SpringMVC的入门案例,对于这类入门demo,理清套路,整理思绪是最为重要 ...
- 用CIL写程序:定义一个叫“慕容小匹夫”的类
前文回顾: <用CIL写程序:你好,沃尔德> <用CIL写程序:写个函数做加法> 前言: 今天是乙未羊年的第一天,小匹夫先在这里给各位看官拜个年了.不知道各位看官是否和匹夫一样 ...
- Android GradientDrawable(shape标签定义) 静态使用和动态使用(圆角,渐变实现)
Android GradientDrawable使用优势: 1. 快速实现一些基本图形(线,矩形,圆,椭圆,圆环) 2. 快速实现一些圆角,渐变,阴影等效果 3. 代替图片设置为View的背景 4. ...
- MyCat源码分析系列之——SQL下发
更多MyCat源码分析,请戳MyCat源码分析系列 SQL下发 SQL下发指的是MyCat将解析并改造完成的SQL语句依次发送至相应的MySQL节点(datanode)的过程,该执行过程由NonBlo ...
- Python 正则表达式入门(初级篇)
Python 正则表达式入门(初级篇) 本文主要为没有使用正则表达式经验的新手入门所写. 转载请写明出处 引子 首先说 正则表达式是什么? 正则表达式,又称正规表示式.正规表示法.正规表达式.规则表达 ...
- ASP.NET MVC——CodeFirst开发模式
Entity Framework框架提供了几种开发模式,比如Database First,Model First,Code First.Database First是最老也是应用得最广泛的一种设计方式 ...
- 搭建公司内部的NuGet Server
随着公司业务慢慢的拓展,项目便会越来越来多,很多项目会依赖其他项目DLL,比如一些底层的技术框架DLL引用,还有各业务系统的也有可能会有引用的可能. 项目多,交叉引用多,如果要是有一个DLL更新,那就 ...
- 一段良好的程序永远不应该发生panic异常
panic来自被调函数的信号,表示发生了某个已知的bug.一段良好的程序永远不应该发生panic异常 对于大部分程序而言,永远无法保证能够成功运行,因为错误原因往往超出程序员的控制范围.任何进行io操 ...
- JSP简单记录
JSP,全称是Java Server Page,是运行在服务器端的页面,是建立在Servlet规范的动态网页技术,JSP文件在第一次请求时,会被编译成Servlet,所以JSP也可以看成是运行中的Se ...
- ubuntu安装navicat及常见问题解决
1.安装navicat Step1: 下载Navicat ,网址:http://www.navicat.com/en/download/download.html Step2:进入下载目录,解压压缩包 ...