你为Class外访问private对象而苦恼嘛?你为设计序列化格式而头疼嘛?

                            ——欢迎体验Google Protocol Buffer

面向对象之封装性

历史遗留问题

面向对象中最矛盾的一个特性,就是“封装性”。

在上古时期,大牛们无聊地设计了三种访问域:

public、private、protected。

大多数C++初学者都是疑惑的,甚至是对于传统C程序员而言。

在C规范中,没有class(类)的概念,只有struct(结构体)的概念。

面向对象的C++中,尽管将C规范的struct移植过来了,但是这个struct是相当特殊的。

C++中的struct,和class没有多大区别,可继承/封装/多态,也支持public/private/protected。

它只有一点不同,那就是默认访问域是public,该设计仅仅是为了兼顾熟悉C规范的程序员。

C规范里之所以没有public/private/protected,因为它不是面向对象语言,没有必要遵从OO的封装性。

如果偏要让C规范服从面向对象,那么一切皆是public,这是C++中struct存在的意义。

编程规范

第壹章讲到了Google程序员必须遵从的代码可读标准,该标准主要体现在对变量的访问上。

对于一次变量访问行为,它是常(const)访问,还是修改(mutable)访问,这显然是两种行为。

由于变量只有一个,但访问方式却有两种,于是软件工程大师们认为,面向对象的访问要以函数为载体。

这就产生了一种面向对象封装性编程规范:

一切成员变量皆private,一切访问方法皆public。

中间还有一个protected。protected的含义在不同语言里是不同的(C++与Java就不同)。

在C++中,甚至在Caffe中,我们更鼓励使用protected替代private。

具体来讲,protected既包含private对外部访问的屏蔽,又包含对继承类的开放。

Caffe中广泛使用继承类设计,而private成员变量是不会被继承的。

想象一下,Layer定义了参数W,但是继承Layer的ConvLayer居然用不了参数W,这不是反人类么?

让我们来考虑一下代码量,设变量A在C规范中,声明与定义占用一行,

那么在C++规范中,声明与定义占一行,const访问至少占一行(平均3行),mutable访问至少占一行(平均3行)。

这样,为了这个装逼的封装性,我们的代码量平均要上去5倍左右。尤其是在机器学习系统中,大量数据结构的情况下,

源码中将会充斥着大量这类无聊的get(const访问)函数,set(mutable访问)函数,不得不说,是挺无奈的事。

序列化

文本数据与序列化

喜欢玩游戏的,应该都改过类似于config.ini的文件。

比如我手里的《辐射4》根目录下的Ultra.ini,就提供了编辑显示配置的高级方式。

从零开始山寨Caffe·伍:Protocol Buffer简易指南

大部分Application Framework都提供了对INI文件的解析(Parse)。

其实这并不是难事,学过《编译原理》的人,应该都做过词法分析器的实验。

编译器的词法分析,论本质,它其实也是人工智能(AI),只不过它的智能必须基于特定规则。

归根结底,还是没有超出冯诺依曼的存储程序智能范畴,离图灵的无敌图灵机还远得很。

解析平面结构的文本是简单的,如图,INI文件只由域[XXX],和域下配置项组成。

如果是层次结构呢,比如XML?当然XML有其专门的语法树。

XML语法相当冗繁,看起来就像是机器写的(实际上大部分XML真是机器写的)。

在一个机器学习系统中,显然我们需要层次数据结构的配置。

比如Caffe中经典的层次结构:

solver{

  net{

    layer{

      blob{

考虑一个更特殊的情况,solver配置和net配置显然需要写在不同文件里,增强迁移性。

XML解析器显然没有这么高级的功能,能够整合多个XML文件。

这样,XML解析器之上,起码还需要二次编程,相当坑爹。

格式化数据与序列化

何为格式化数据?简而言之,就是:

C++写的东西,Python能用,MATLAB也能用。

目前广泛使用的格式化数据主要有两种,Binary(C++、Python)、HDF5(MATLAB)。

你肯定会问,ACM比赛不都是用文本格式存数据,为什么不用文本格式做格式化数据?

答案其实很无语:文本格式的体积要比二进制格式体积大5倍左右,读取速度也要相应慢上几倍。

所以,一个机器学习系统,可以从文本IN数据,但是千万不要尝试将数据OUT成文本格式。

文本格式除了体积问题,还存在安全性问题。文本型数据很容易被逆向破解掉。

相反,二进制等格式易于做位运算的特点,非常适合,且基本支持二进制序列化的API,

都对二进制数据进行了加密(比如Qt的QDataStream),当然安全性不是我们考虑的重点。

 

二进制虽然体积小,但是需要人工设计封装格式。这给序列化(编码),反序列(解码),带来麻烦。

在传统C++大型程序中,我们都能看到序列化和反序列化代码相当冗长。

程序员写到最后,都不知道自己到底IN进了什么数据,OUT出了什么数据,代码显得十分笨拙。

尤其是在机器学习系统中,考虑到我们需要将参数W保存到硬盘。

首先,参数W有多少个?是什么格式?顺序是什么?这些都要先记录。

记录完了之后,才能将最宝贵的参数W写到文件,是不是很蠢,很蠢,很蠢?

Google Protocol Buffer

不错的工具

Protocol Buffer是由Jeff Dean领衔开发的神奇工具。

它不仅有着非常不错的格式化数据的序列化/反序列速度,同时也支持文本格式。

更重要的是,它在自动生成序列化格式的同时,也封装了部分变量的访问接口。

使得Caffe的整体源码中,不必充斥着大量的get/set。

最后,Jeff Dean出品,速度必然是有保障的。

这位Google首席技术员,PHD专攻编译器优化,被誉为是地球上让代码跑的最快的男人。

使用方法

这玩意在墙外,在第零章提供的包里,3rdparty\bin下protoc.exe就是在Windows下本体。

确保3rdparty\bin在环境变量中,编辑proto-make.cmd脚本:

@echo off
set SRC_DIR=C:\PROTO
set DST_DIR=C:\PROTO
set PROTO_NAME=dragon
echo Check Source Proto Path:  %SRC_DIR%
echo Check Destination Proto Path:  %DST_DIR%
echo Check Proto Files Name :  %PROTO_NAME%.proto
echo ——————————————————————————————————
echo Protocol Buffer:Compliing for dragon.proto.....
start protoc -I=%SRC_DIR% --cpp_out=%DST_DIR% %SRC_DIR%\%PROTO_NAME%.proto
echo Protocol Buffer:Compliing complete!
pause

SRC_DIR为proto脚本的源路径,DST_DIR为生成路径。

proto脚本是操纵protoc.exe的唯一方式,Google为proto脚本设计了一种新的语言,非常类似于C/C++。

protoc版本会根据proto脚本生成h和cc文件,分别是数据结构的声明和定义,随时可以嵌入到你的代码中。

protoc的命令参数摘自墙外的官网,我们通常只需要设置源目录、目标目录、以及proto脚本路径:

protoc -I=%SRC_DIR% --cpp_out=%DST_DIR% %SRC_DIR%\%PROTO_NAME%.proto

第一步

在你喜欢的源目录下,新建dragon.proto,用文本编辑器打开它,

定义第一个数据结构Datum:

message Datum{
    optional int32 channels=1;
    optional int32 height=2;
    optional int32 width=3;
    optional int32 label=4;
    optional bytes data=5;
    repeated float float_data=6;
    optional bool encoded=7 [default=false];
}

Datum算是最基本的存储单元了,它其实表示的就是一张图像。

proto语言与C语言差别不是很大,结构体struct字段换成message,

变量之前需要追加optional和repeated标记字段。分别表示的是单变量,还是容器数组变量。

值得一提的是,proto提供requireed字段,但是Google程序员都懒得用,经常会出现奇怪bug,

所以一律用optional替代requireed。

repeated标记之后,本质是数组,但实际实现可能是类似于STL容器,它提供了不少类似容器的操作。

[default]可以提供默认值,对于基本数据类型,不设默认值将会同C语言一样产生类似默认值。

但我们不推荐使用proto自身提供的默认值,通常会之前接一个has_xxx(),来检测该变量是否被设置。

人工指定的默认值,has_xxx()会返回true,而proto提供的自动默认值,则是false。

另外,对于repeated int32 or int64,使用[packed=true]似乎可以优化速度,对于float其实是无效的。

Caffe里有些repeat float也打上了[packed=true],其实没什么意义。

最后,所有数据结构变量,都需要一个唯一的id,id从1开始。

这与proto内部编码系统有关,1~20编码长度小,访问速度快。随着id值增加,后续变量访问速度会递减。

 

再看Datum本身,channels、height、width都是我们熟悉的。

data和float_data的区别在于,前者用于uint8数据,比如MNIST和cifar10/100,

它们的像素值可以被压缩为一个字符串,而bytes类型在C++里,恰好就是string类型。

float_data则用于存储散装的float值了。

最后的encoded可以被忽略,我还没见过什么图像需要编码的。

Caffe需要OpenCV,主要是由于考虑到图像需要解码,省略这一步,OpenCV可以无视掉。

第二步

我们还需要为Blob提供一个序列化容器,用于存储训练参数。

message BlobShape{
    repeated int64 dim=1 [packed=true];
}

message BlobProto{
    optional BlobShape shape=1;
    repeated float data=2;
    repeated float diff=3;
    repeated double double_data=4;
    repeated double double_diff=5;
}

BlobShape用于存储Blob Shape信息。

BlobProto才是我们需要关注的,除了shape,它由四个容器数组组成。

大部分情况下,我们只会使用其中两个。

因为只有Tesla系列显卡,才支持double运算,而GTX玩家显卡,只能使用float运算。

data用于存储参数数据,diff用于存储残差,实际上diff基本是不会用的,记录参数的残差没有多少意义。

完整代码

见:https://github.com/neopenx/Dragon/blob/master/proto/dragon.proto