Appearance
结构体和联合体
结构体是最容易理解的对象类型,因此我们将从它开始介绍面向对象编程。结构体与类有很多共同之处,而类是面向对象编程中的主要构建模块,所以对结构体的了解在日后学习类的时候会有所帮助。同时,结构体也有一些特定的区别,其中一些可以被视为限制,而另一些则被认为是优点。特别是,结构体不能拥有虚函数,但它们可用于与第三方 DLL 进行集成。
在实现算法时,在结构体和类之间进行选择,传统上是基于对对象元素的访问需求以及是否存在内部业务逻辑来决定的。如果需要一个带有结构化数据的简单容器,并且不需要检查其状态的正确性(在编程中这被称为 “不变量”),那么结构体就完全可以满足需求。如果你想要限制访问,并根据某些规则(这些规则以分配给对象的函数形式来规范,我们稍后会讨论)来支持读写操作,那么最好使用类。
MQL5 有内置的结构体类型,用于描述交易中所需的实体,特别是汇率(MqlRates
)、报价(MqlTick
)、日期和时间(MqlDateTime
)、交易请求(MqlTradeRequest
)、请求结果(MqlTradeResult
)以及许多其他类型。我们将在本书的第六部分讨论这些内容。
结构体的定义
结构体由变量组成,这些变量可以是内置类型,也可以是其他用户自定义类型。结构体的目的是将逻辑相关的数据组合在一个单一的容器中。假设我们有一个函数,它执行特定的计算,并接受一组参数:用于分析的显示报价历史的柱线数量、分析开始的日期、价格类型以及分配的信号数量(例如谐波)。
cpp
double calculate(datetime start, int barNumber,
ENUM_APPLIED_PRICE price, int components);
在实际情况中,参数可能会更多,并且将它们作为列表传递给函数并不容易。此外,基于多次计算的结果,有必要将一些最佳设置保存在某种数组中。因此,将一组参数表示为一个单一对象会更加方便。
具有相同变量的结构体描述如下:
cpp
struct Settings
{
datetime start;
int barNumber;
ENUM_APPLIED_PRICE price;
int components;
};
描述从关键字 struct
开始,后面跟着我们选择的标识符。接着是一个用花括号括起来的代码块,在其中是结构体中包含的变量的描述。这些变量也被称为结构体的字段或成员。花括号后面有一个分号,因为整个符号表示是定义一个新类型的语句,语句后面需要有 ;
。
一旦定义了类型,我们就可以像使用内置类型一样使用它。特别是,新类型允许我们以通常的方式在程序中描述 Settings
变量。
cpp
Settings s;
需要注意的是,单个结构体描述允许创建任意数量的结构体变量,甚至是这种类型的数组。每个结构体实例都将有自己的一组元素,并且它们将包含独立的值。
为了访问结构体的成员,提供了一个特殊的解引用操作符 —— 点字符 .
。在它的左边应该是结构体类型的变量,右边是其中一个可用字段的标识符。以下是如何为结构体元素赋值的示例:
cpp
void OnStart()
{
Settings s;
s.start = D'2021.01.01';
s.barNumber = 1000;
s.price = PRICE_CLOSE;
s.components = 8;
}
有一种更方便的填充结构体的方法,即聚合初始化。在这种情况下,在结构体变量的右边写入等号 =
,然后是一个用花括号括起来的、以逗号分隔的所有字段初始值的列表。
cpp
Settings s = {D'2021.01.01', 1000, PRICE_CLOSE, 8};
值的类型必须与相应的元素类型匹配。允许指定的值数量少于字段数量:那么其余的字段将被赋予零值。
请注意,这种方法仅在变量初始化时,即在定义变量时有效。不可能以这种方式为已经存在的结构体赋值,否则会得到编译错误。
cpp
Settings s;
// 错误: '{' - 不允许参数转换
s = {D'2021.01.01', 1000, PRICE_CLOSE, 8};
使用解引用操作符,还可以读取结构体元素的值。例如,我们使用柱线数量来计算组件数量。
cpp
s.components = (int)(MathSqrt(s.barNumber) + 1);
这里 MathSqrt
是内置的平方根函数。
我们引入了一个新类型 Settings
,以便更轻松地将一组参数传递给函数。现在它可以用作更新后的 calculate
函数的唯一参数:
cpp
double calculate(Settings &settings);
注意参数名称前面的 &
符号,这表示按引用传递。结构体只能按引用作为参数传递。
如果需要从函数返回一组值而不是单个值,结构体也很有用。让我们想象一下,calculate
函数不应该返回 double
类型的值,而是返回几个系数和一些交易建议(交易方向和成功概率)。然后我们可以定义结构体 Result
的类型,并在函数原型中使用它(Structs.mq5
)。
cpp
struct Result
{
double probability;
double coef[3];
int direction;
string status;
};
Result calculate(Settings &settings)
{
if(settings.barNumber > 1000) // 编辑字段
{
settings.components = (int)(MathSqrt(settings.barNumber) + 1);
}
// ...
// 模拟获取结果
Result r = {};
r.direction = +1;
for(int i = 0; i < 3; i++) r.coef[i] = i + 1;
return r;
}
Result r = {}
这一行中的空花括号表示最小聚合初始化器:它用零填充结构体的所有字段。
如果需要,结构体类型的定义和声明可以分开进行(通常,声明在头文件 .mqh
中,定义在 .mq5
文件中)。这种扩展语法将在关于类的章节中介绍。
结构体中的函数(方法)
从 calculate
函数接收到结果后,我们希望将其打印到日志中,但 Print
函数并不适用于用户自定义类型:这些类型本身必须提供一种输出信息的方式。
cpp
void OnStart()
{
Settings s = {D'2021.01.01', 1000, PRICE_CLOSE, 8};
Result r = calculate(s);
// Print(r); // 错误: 'r' - 对象只能按引用传递
// Print(&r); // 错误: 'r' - 期望类类型
}
注释中展示了尝试对结构体调用 Print
函数的情况以及随后出现的错误。第一个错误是由于结构体实例是对象,而对象必须按引用传递给函数。同时,Print
函数期望的是一个值(一个或多个)。在第二次调用 Print
函数时,变量名前使用 &
符号在 MQL5 中表示接收的是指针,而不是人们可能认为的引用。在 MQL5 中,指针仅支持类对象(不支持结构体),因此会出现第二个 “期望类类型” 的错误。我们将在下一章(请参阅“类和接口”)中更深入地了解指针。
我们可以在 Print
调用中分别指定结构体的所有成员(使用解引用操作符),但这样做相当麻烦。
对于那些需要以特殊方式处理结构体内容的情况,可以在结构体内部定义函数。定义的语法与我们熟悉的全局上下文中的函数没有区别,但定义本身位于结构体代码块内部。
这样的函数被称为方法。由于它们位于相应代码块的上下文中,因此可以从这些方法中访问结构体的字段,而无需使用解引用操作符。例如,我们在 Result
结构体中编写 print
函数的实现。
cpp
struct Result
{
...
void print()
{
Print(probability, " ", direction, " ", status);
ArrayPrint(coef);
}
};
调用结构体实例的方法就像读取其字段一样简单:使用相同的 .
操作符。
cpp
void OnStart()
{
Settings s = {D'2021.01.01', 1000, PRICE_CLOSE, 8};
Result r = calculate(s);
r.print();
}
关于类的章节将更详细地介绍方法。
结构体的复制
相同类型的结构体可以使用赋值运算符 =
进行整体复制。下面我们以 Result
结构体为例来说明这一规则。我们通过 calculate
函数得到 r
的第一个实例。
cpp
void OnStart()
{
...
Result r = calculate(s);
r.print();
// 会在日志中输出:
// 0.5 1 ok
// 1.00000 2.00000 3.00000
...
Result r2;
r2 = r;
r2.print();
// 会在日志中输出相同的值:
// 0.5 1 ok
// 1.00000 2.00000 3.00000
}
之后,我们额外创建了变量 Result r2
,并将变量 r
的所有字段内容同时复制给了它。可以通过使用 print
方法将结果输出到日志来验证操作的准确性(注释中给出了相应的输出行)。
需要注意的是,定义两个具有相同字段集的结构体类型,并不意味着这两个类型是相同的。在这种情况下,不能将一个结构体整体赋值给另一个结构体,只允许逐个成员进行赋值。
稍后我们会讨论结构体继承,这会为复制操作提供更多的选择。实际上,复制操作不仅适用于相同类型的结构体,也适用于相关类型的结构体。不过,其中有一些重要的细节,我们会在“结构体的布局与继承”部分进行介绍。
构造函数和析构函数
在可以为结构体定义的方法中,有一些特殊的方法:构造函数和析构函数。
构造函数的名称与结构体名称相同,并且不返回值(类型为 void
)。如果定义了构造函数,那么在结构体的每个新实例初始化时,构造函数都会被调用。正因如此,在构造函数中,可以以特殊的方式计算结构体的初始状态。
一个结构体可以有多个具有不同参数集的构造函数,编译器会在定义变量时,根据参数的数量和类型来选择合适的构造函数。
例如,我们可以在 Result
结构体中描述一对构造函数:一个无参数的构造函数,另一个带有一个 string
类型参数的构造函数,用于设置状态。
cpp
struct Result
{
...
void Result()
{
status = "ok";
}
void Result(string s)
{
status = s;
}
};
顺便说一下,无参数的构造函数被称为默认构造函数。如果没有显式的构造函数,对于任何包含字符串和动态数组的结构体,编译器会隐式地创建一个默认构造函数,用于将这些字段用零填充。
重要的是,其他类型的字段(例如,所有数值类型)无论结构体是否有默认构造函数,都不会被重置为零,因此在分配内存后,元素的初始值将是随机的。你要么创建构造函数,要么确保在对象创建后,立即在代码中为其赋予正确的值。
显式构造函数的存在使得无法使用聚合初始化语法。因此,calculate
方法中的 Result r = {};
这一行将无法编译。现在我们只能使用我们自己提供的构造函数之一。例如,以下语句调用无参数的构造函数:
cpp
Result r1;
Result r2();
而创建一个填充了状态的结构体可以这样做:
cpp
Result r3("success");
当创建结构体数组时,也会调用默认构造函数(显式或隐式)。例如,以下语句为 10 个 Result
结构体分配内存,并使用默认构造函数对它们进行初始化:
cpp
Result array[10];
析构函数是一个函数,在结构体对象被销毁时会被调用。析构函数的名称与结构体名称相同,但前面带有波浪号字符 ~
。析构函数和构造函数一样,不返回值,并且也不接受参数。
只能有一个析构函数。
你不能显式地调用析构函数。当退出定义了局部结构体变量的代码块时,或者当释放结构体数组时,程序会自行调用析构函数。
析构函数的目的是在结构体在构造函数中分配了动态资源的情况下,释放这些资源。例如,一个结构体可以具有持久性属性,即在从内存中卸载时将其状态保存到文件中,并在程序再次创建它时恢复该状态。在这种情况下,内置文件函数中会使用需要打开和关闭的描述符。
让我们在 Result
结构体中定义一个析构函数,并在此过程中添加构造函数,以便所有这些方法都能跟踪对象实例的数量(在它们创建和销毁时)。
cpp
struct Result
{
...
void Result()
{
static int count = 0;
Print(__FUNCSIG__, " ", ++count);
status = "ok";
}
void Result(string s)
{
static int count = 0;
Print(__FUNCSIG__, " ", ++count);
status = s;
}
void ~Result()
{
static int count = 0;
Print(__FUNCSIG__, " ", ++count);
}
};
存在三个名为 count
的静态变量,它们彼此独立:每个变量都在其自身函数的上下文中进行计数。
运行脚本的结果是,我们将收到以下日志:
Result::Result() 1
Result::Result() 2
Result::Result() 3
Result::~Result() 1
Result::~Result() 2
0.5 1 ok
1.00000 2.00000 3.00000
Result::Result(string) 1
0.5 1 ok
1.00000 2.00000 3.00000
Result::~Result() 3
Result::~Result() 4
让我们来理解一下这意味着什么。
结构体的第一个实例是在 OnStart
函数中创建的,就在调用 calculate
的同一行。进入构造函数时,计数器值 count
会初始化为零,并且每次执行构造函数时都会递增,所以第一次输出的值是 1。
在 calculate
函数内部,定义了一个 Result
类型的局部变量;它被标记为第 2 个。
第三个结构体实例不是那么明显。关键在于,为了从函数中传递结果,编译器会隐式地创建一个临时变量,并将局部变量的数据复制到其中。很可能在未来这种行为会改变,届时局部实例将“移动”出函数而无需复制。
最后一次构造函数调用是在带有字符串参数的方法中,所以调用计数为 1。
重要的是,两个构造函数的调用总数与析构函数的调用次数相同:都是 4 次。
我们将在关于类的章节中更多地讨论构造函数和析构函数。
内存中结构体的打包以及与动态链接库(DLL)的交互
为了存储结构体的一个实例,会在内存中分配一块连续的区域,该区域要足够容纳结构体的所有元素。
与 C++ 不同,在此处结构体的元素在内存中是依次排列的,不会根据元素自身的大小按照 2 字节、4 字节、8 字节或 16 字节的边界进行对齐(不同编译器和操作模式下的对齐算法有所不同)。对于大小小于指定块的元素,会通过在结构体中添加未使用的虚拟变量(程序无法直接访问这些变量)来实现对齐。对齐操作是为了优化内存性能。
在必要时,MQL5 允许更改对齐规则,主要是在将 MQL 程序与描述特定结构体类型的第三方 DLL 进行集成时。对于这些情况,需要在 MQL5 中准备等效的描述(可参考导入库相关章节)。需要着重注意的是,用于集成的结构体在定义时只能包含有限类型的字段。所以,不能使用字符串、动态数组、类对象以及类对象的指针。
通过在结构体的头部添加关键字 pack
来控制对齐。有以下两种形式:
plaintext
struct pack(size) identifier
struct identifier pack(size)
在这两种情况下,size
可以是整数 1、2、4、8、16,也可以使用 sizeof(内置类型)
运算符作为 size
,例如 sizeof(double)
。
pack(1)
这种字节对齐方式与不使用 pack
修饰符时的默认行为相同。
特殊运算符 offsetof()
可以用来确定结构体中某个特定元素相对于结构体起始位置的字节偏移量。它有两个参数:结构体对象和元素标识符。例如:
plaintext
Print(offsetof(Result, status)); // 36
在 Result
结构体的 status
字段之前,有 4 个双精度浮点型值和 1 个整型值,总共占用 36 字节。
在设计自己的结构体时,建议先放置最大的元素,然后按照元素大小递减的顺序放置其余元素。
结构体布局与继承
结构体可以将其他结构体作为其字段。例如,我们定义 Inclosure
结构体,并在 Main
结构体中使用该类型作为 data
字段(StructsComposition.mq5
):
cpp
struct Inclosure
{
double X, Y;
};
struct Main
{
Inclosure data;
int code;
};
void OnStart()
{
Main m = {{0.1, 0.2}, -1}; // 聚合初始化
m.data.X = 1.0; // 逐个元素赋值
m.data.Y = -1.0;
}
在初始化列表中,data
字段由带有 Inclosure
字段值的额外一层花括号表示。要访问这种结构体的字段,需要使用两次解引用操作。
如果嵌套结构体在其他地方不再使用,可以直接在外部结构体内部声明它。
cpp
struct Main2
{
struct Inclosure2
{
double X, Y;
}
data;
int code;
};
另一种结构体布局方式是继承。这种机制通常用于构建类层次结构(将在相应章节详细讨论),但结构体也可以使用。
在定义新的结构体类型时,程序员可以在其头部的冒号后面指定父结构体的类型(该父结构体必须在源代码中提前定义)。结果是,父结构体的所有字段将被添加到子结构体中(位于子结构体的开头),而新结构体自身的字段将在内存中位于父结构体字段之后。
cpp
struct Main3 : Inclosure
{
int code;
};
这里的父结构体不是嵌套的,而是子结构体的一个组成部分。因此,在初始化时填充字段不需要额外的花括号,也不需要一连串的多个解引用运算符。
cpp
Main3 m3 = {0.1, 0.2, -1};
m3.X = 1.0;
m3.Y = -1.0;
上述考虑的三个结构体 Main
、Main2
和 Main3
在内存中的表示和大小相同,均为 20 字节。但它们是不同的类型。
cpp
Print(sizeof(Main)); // 20
Print(sizeof(Main2)); // 20
Print(sizeof(Main3)); // 20
正如我们之前所说(见“结构体复制”),赋值运算符 =
可用于复制相关类型的结构体,更具体地说,是那些通过继承链关联的结构体。换句话说,父类型的结构体可以赋值给子类型的结构体(在这种情况下,派生结构体中新增的字段将保持不变),反之亦然,子类型的结构体也可以赋值给父类型的结构体(在这种情况下,“多余”的字段将被截断)。
例如:
cpp
Inclosure in = {10, 100};
m3 = in;
这里,变量 m3
的类型 Main3
继承自 Inclosure
。赋值 m3 = in
的结果是,基类型变量 in
中的字段 X
和 Y
(两种类型的公共部分)将被复制到派生类型变量 m3
的字段 X
和 Y
中。变量 m3
的 code
字段将保持不变。
子结构体是祖先的直接后代还是远亲并不重要,即继承链可以很长。这种公共字段的复制在“子”、“孙”以及“家族树”不同分支的其他类型组合之间都可以工作。
如果父结构体只有带参数的构造函数,那么在继承派生结构体的构造函数时,必须从初始化列表中调用它。例如:
cpp
struct Base
{
const int mode;
string s;
Base(const int m) : mode(m) { }
};
struct Derived : Base
{
double data[10];
// 如果移除构造函数,会得到错误:
Derived() : Base(1) { } // 'Base' - 错误的参数数量
};
在 Base
构造函数中,我们填充了 mode
字段。由于它有 const
修饰符,构造函数是为其设置值的唯一方式,并且必须以冒号后的特殊初始化语法形式完成(在构造函数体中不能再为常量赋值)。有了显式构造函数后,编译器将不会生成隐式(无参数)构造函数。然而,我们在 Base
结构体中没有显式的无参数构造函数,在其缺失的情况下,任何派生类都不知道如何正确调用带参数的 Base
构造函数。因此,在 Derived
结构体中,需要显式初始化基类构造函数:这同样是在构造函数头部的冒号后面使用初始化语法完成的 —— 在这种情况下,我们调用 Base(1)
。
如果我们移除 Derived
构造函数,会在基类构造函数中得到“参数数量无效”的错误,因为编译器会尝试默认调用 Base
的构造函数(该构造函数应该有 0 个参数)。
我们将在“类”章节中更详细地介绍语法和继承机制。
访问权限
如有需要,在结构体的描述中可以使用特殊的关键字,这些关键字作为访问修饰符,用于限制结构体外部对其字段的可见性。共有三种修饰符:public
(公共)、protected
(受保护)和 private
(私有)。默认情况下,结构体的所有成员都是公共的,这等同于以下写法(以 Result
结构体为例):
cpp
struct Result
{
public:
double probability;
double coef[3];
int direction;
string status;
...
};
在某个修饰符之后的所有成员都会获得相应的访问权限,直到遇到另一个修饰符或者结构体块结束。可以有多个具有不同访问权限的部分,并且这些部分可以任意设置。
被标记为 protected
的成员只能从该结构体及其派生结构体的代码中访问。也就是说,这些结构体应该有公共方法,否则将无法访问这些字段。
被标记为 private
的成员只能从结构体内部的代码中访问。例如,如果在 status
字段前添加 private
,那么很可能需要一个方法供外部代码读取 status
(例如 getStatus
)。
cpp
struct Result
{
public:
double probability;
double coef[3];
int direction;
private:
string status;
public:
string getStatus()
{
return status;
}
...
};
只能通过第二个构造函数的参数来设置 status
。直接访问该字段会导致“无法访问结构体 'Result' 的私有成员 'status'”的错误:
cpp
// 错误:
// 无法访问在结构体 'Result' 中声明的私有成员 'status'
r.status = "message";
在类中,默认的访问权限是 private
。这遵循了封装原则,我们将在“类”这一章节中详细介绍。
联合(Unions)
联合是一种用户自定义类型,它的各个字段位于同一块内存区域,因此这些字段会相互重叠。这使得我们可以向联合中写入一种类型的值,然后以另一种类型的解释方式(在比特级别)读取其内部表示。这样就能够实现从一种类型到另一种类型的非标准转换。
联合的字段可以是任何内置类型,但不包括字符串、动态数组和指针。此外,在联合中可以使用具有相同简单字段类型且没有构造函数/析构函数的结构体。
编译器会为联合分配一个大小等于所有元素类型中最大大小的内存单元。例如,对于包含 long
(8 字节)和 int
(4 字节)字段的联合,会分配 8 字节的内存。
联合的所有字段都位于相同的内存地址,也就是说,它们在联合的起始位置对齐(它们的偏移量为 0,这可以使用 offsetof
进行检查,见“结构体打包”部分)。
联合的描述语法与结构体类似,但使用 union
关键字。后面跟着一个标识符,然后是一个包含字段列表的代码块。
例如,某个算法可能会使用 double
类型的数组来存储各种设置,仅仅是因为 double
类型是字节大小最大的类型之一,为 8 字节。假设在这些设置中有 ulong
类型的数字。由于 double
类型不能保证准确重现大的 ulong
值,因此需要使用联合将 ulong
类型的值“打包”到 double
类型中,然后再“解包”回来。
cpp
#define MAX_LONG_IN_DOUBLE 9007199254740992
// 参考信息: ULONG_MAX 18446744073709551615
union ulong2double
{
ulong U; // 8 字节
double D; // 8 字节
};
ulong2double converter;
void OnStart()
{
Print(sizeof(ulong2double)); // 8
const ulong value = MAX_LONG_IN_DOUBLE + 1;
double d = value; // 由于类型转换可能会丢失数据
ulong result = d; // 由于类型转换可能会丢失数据
Print(d, " / ", value, " -> ", result);
// 9007199254740992.0 / 9007199254740993 -> 9007199254740992
converter.U = value;
double r = converter.D;
Print(r); // 4.450147717014403e-308
Print(offsetof(ulong2double, U), " ", offsetof(ulong2double, D)); // 0 0
}
ulong2double
结构体的大小为 8,因为它的两个字段都是 8 字节。因此,字段 U
和 D
完全重叠。
在整数范围内,9007199254740992 是能保证在 double
类型中可靠存储的最大整数值。在这个例子中,我们尝试在 double
类型中存储比这个值大 1 的数。
从 ulong
到 double
的标准转换会导致精度丢失:将 9007199254740993 写入 double
类型的变量 d
后,再从中读取时已经是“四舍五入”后的值 9007199254740992(关于 double
类型存储数字的细节,请参阅“实数”部分)。
使用这个转换器时,数字 9007199254740993 会“原样”写入联合,无需进行转换,因为我们将其赋值给了 ulong
类型的 U
字段。它以 double
类型表示的值同样可以直接从 D
字段获取,无需转换。我们可以将其复制到其他 double
类型的变量和数组中,无需担心。
虽然得到的 double
类型的值看起来很奇怪,但如果需要通过反向转换提取该值,只需将其写入 double
类型的 D
字段,然后从 ulong
类型的 U
字段读取,就可以得到与原始整数完全匹配的值。
联合可以有构造函数、析构函数以及方法。默认情况下,联合成员具有公共访问权限,但可以像结构体一样使用访问修饰符进行调整。