Skip to content

模板

在现代编程语言中,有许多内置特性可避免代码重复,进而减少错误数量并提高程序员的工作效率。在 MQL5 里,这类工具涵盖了大家熟知的函数、支持继承的对象类型(类和结构体)、预处理器宏以及文件包含功能。但要是没有模板,这份清单就不算完整。

模板是对函数或对象类型精心设计的通用定义,编译器能够依据它自动生成该函数或对象类型的可用实例。生成的实例采用相同的算法,但会对不同类型的变量进行操作,这与源代码中使用模板的具体条件相契合。

对于熟悉 C++ 的人来说,需要注意的是 MQL5 模板并不支持 C++ 模板的诸多特性,具体如下:

  1. 非类型参数:无法使用像常量整数、指针等非类型的参数来定义模板。
  2. 带默认值的参数:不能给模板参数设定默认值。
  3. 可变数量的参数:不支持可变参数模板,即无法定义接受不定数量参数的模板。
  4. 类、结构体和联合的特化(全特化和偏特化):没办法对模板进行特化处理,根据不同的模板参数给出不同的实现。
  5. 模板的模板:不能把模板作为另一个模板的参数。

从一方面来看,这确实限制了 MQL5 中模板的潜力;但从另一方面来讲,对于不熟悉这些技术的人而言,这简化了学习过程。

模板头

在 MQL5 中,你可以将函数、对象类型(类、结构体、联合体)或者它们内部的单独方法模板化。无论哪种情况,模板描述都有一个头部:

cpp
template <typename T [, typename Ti ... ]>

模板头以 template 关键字开始,后面跟着尖括号中用逗号分隔的形式参数列表:每个参数由 typename 关键字和一个标识符表示。在特定的定义中,标识符必须是唯一的。

模板头中的 typename 关键字告诉编译器,接下来的标识符应被视为一种类型。未来,MQL5 编译器很可能会像 C++ 编译器一样,支持其他种类的非类型参数。

这里对 typename 的使用不应与内置的 typename 运算符混淆,内置的 typename 运算符返回一个包含传入参数类型名称的字符串。

模板头后面是函数(方法)或类(结构体、联合体)的常规定义,在语法需要类型名称的地方,模板的形式参数(标识符 TTi)会在指令和表达式中使用。例如,对于模板函数,模板参数描述函数参数的类型或返回值的类型;在模板类中,模板参数可以指定字段的类型。

模板是一个完整的定义。模板以在模板头之后的实体(函数、方法、类、结构体、联合体)定义结束。

对于模板参数名称,习惯上采用一到两个字符的大写标识符。

参数的最少数量是 1 个,最多是 64 个。

参数的主要使用场景(以 T 参数为例)包括:

  1. 描述字段、函数/方法中的局部变量、它们的参数和返回值的类型T variable_name; T function(T parameter_name);
  2. 完全限定类型名称的组成部分之一:特别是 T::SubTypeT.StaticMember
  3. 使用修饰符构造新类型const T,指针 T *,引用 T &,数组 T[],类型定义函数 T(*func)(T)
  4. 构造新的模板类型T<Type>Type<T>,包括从模板继承时(见不存在的“模板特化”部分);
  5. 带有添加修饰符能力的类型转换 (T) 以及通过 new T(); 创建对象
  6. sizeof(T) 作为 MQL 模板中不存在的值参数的基本替代(在撰写本书时)

模板通用操作原则

让我们回顾一下函数重载。函数重载在于定义具有不同参数的多个函数版本,包括参数数量相同但类型不同的情况。通常,对于不同类型的参数,这类函数的算法是相同的。例如,MQL5 有一个内置函数 MathMax,它返回传递给它的两个值中的较大值:

cpp
double MathMax(double value1, double value2);

虽然只提供了 double 类型的函数原型,但实际上该函数能够处理其他数值类型的参数对,比如 intdatetime。换句话说,该函数是内置数值类型的重载核心。如果我们想在自己的源代码中实现相同的效果,就必须通过复制函数并使用不同的参数来重载它,如下所示:

cpp
double Max(double value1, double value2)
{
   return value1 > value2? value1 : value2;
}

int Max(int value1, int value2)
{
   return value1 > value2? value1 : value2;
}

datetime Max(datetime value1, datetime value2)
{
   return value1 > value2? value1 : value2;
}

所有的实现(函数体)都是相同的,只是参数类型发生了变化。

这就是模板发挥作用的时候。通过使用模板,我们可以描述一个带有所需实现的算法样本,编译器会自动为程序中涉及的特定类型生成该算法的多个实例。生成过程在编译期间即时进行,并且对程序员来说是不可察觉的(除非模板中存在错误)。自动生成的源代码不会插入到程序文本中,而是直接转换为二进制代码(.ex5 文件)。

在模板中,一个或多个参数是类型的形式化标识,在编译阶段,根据特殊的类型推导规则,会从内置类型或用户定义类型中为这些参数选择实际的类型。例如,Max 函数可以使用以下带有 T 类型参数的模板来描述:

cpp
template<typename T>
T Max(T value1, T value2)
{
   return value1 > value2? value1 : value2;
}

然后,将其应用于各种类型的变量(见 TemplatesMax.mq5):

cpp
void OnStart()
{
   double d1 = 0, d2 = 1;
   datetime t1 = D'2020.01.01', t2 = D'2021.10.10';
   Print(Max(d1, d2));
   Print(Max(t1, t2));
  ...
}

在这种情况下,编译器会自动为 doubledatetime 类型生成 Max 函数的变体。

模板本身不会生成源代码。为此,需要以某种方式创建模板的实例:调用模板函数,或者提及带有特定类型的模板类的名称来创建对象或派生类。

在完成此操作之前,编译器会忽略整个模板模式。例如,我们可以编写以下看似模板函数的代码,实际上它包含语法错误的代码。然而,只要在任何地方都不调用这个函数,包含此函数的模块的编译就会成功。

cpp
template<typename T>
void function()
{
  it's not a comment, but it's not source code either
   !%^&*
}

对于模板的每次使用,编译器都会确定与模板形式参数匹配的实际类型。基于此信息,会为每个唯一的参数组合自动生成模板源代码,这就是实例。

所以,在给定的 Max 函数示例中,我们调用了模板函数两次:一次是对于 double 类型的变量对,另一次是对于 datetime 类型的变量对。这就产生了 Max 函数的两个实例,其源代码分别对应于 T=doubleT=datetime 的匹配情况。当然,如果在代码的其他部分针对相同的类型调用相同的模板,将不会生成新的实例。只有当模板应用于另一种类型(或者如果有多个参数,则是一组类型)时,才需要生成模板的新实例。

请注意,模板 Max 有一个参数,它同时为函数的两个输入参数及其返回值设置了类型。换句话说,模板声明能够对有效参数的类型施加某些限制。

如果我们对不同类型的变量调用 Max,编译器将无法确定实例化模板的类型,并会抛出错误 “ambiguous template parameters, must be 'double' or 'datetime'”(模板参数不明确,必须是 doubledatetime):

cpp
Print(Max(d1, t1)); // 模板参数不明确,
                    // 可能是 'double' 或 'datetime'

这种根据模板使用的上下文来发现模板参数实际类型的过程称为类型推导。在 MQL5 中,类型推导仅适用于函数和方法模板。

对于类、结构体和联合体,使用了一种不同的将类型绑定到模板参数的方式:在创建模板实例时,在尖括号中显式指定所需的类型(如果有多个参数,则以逗号分隔的列表形式指定相应数量的类型)。有关更多信息,请参阅“对象类型模板”部分。

同样的显式方法也可以应用于函数,作为自动类型推导的替代方法。

例如,我们可以为 ulong 类型生成并调用 Max 的实例:

cpp
Print(Max<ulong>(1000, 10000000));

在这种情况下,如果没有显式指定,模板函数将与 int 类型相关联(基于整数值常量)。

模板与预处理器宏

在某些时候可能会出现这样一个问题:是否可以为了代码生成的目的而使用宏替换呢?实际上是可以的。例如,Max 函数集可以很容易地用宏来表示:

cpp
#define MAX(V1,V2) ((V1) > (V2)? (V1) : (V2))

然而,宏的功能更为有限(仅仅是文本替换而已),因此它们只在简单的情况下使用(就像上面的例子)。

在比较宏和模板时,应该注意以下差异:

  1. 处理时机和上下文信息:宏在编译开始前由预处理器在源文本中“展开”并替换。与此同时,预处理器在替换宏内容时,没有关于参数类型以及宏使用上下文的信息。特别地,宏 MAX 无法检查参数 V1V2 的类型是否相同,也无法检查是否为它们定义了比较运算符 >。此外,如果在程序文本中遇到名为 MAX 的变量,预处理器会尝试在其位置替换 MAX 宏的“调用”,并且会因为缺少参数而报错。更糟糕的是,这些替换会忽略 MAX 标识符所在的命名空间或类 —— 基本上,任何情况都会进行替换。 与之不同的是,模板由编译器根据特定的参数类型以及它们的使用位置来处理,所以模板会对模板中的所有表达式进行类型兼容性(以及普遍适用性)检查,还包括上下文绑定。例如,我们可以在一个具体的类中定义一个方法模板。
  2. 同名定义的灵活性:如果有必要,对于不同的类型可以为同名模板定义不同的实现,而给定名称的宏总是被相同的“实现”所替换。例如,对于像 MAX 这样的函数,我们可以为字符串定义一个不区分大小写的比较方式。
  3. 错误诊断的难易程度:由于宏存在的问题而导致的编译错误很难诊断,特别是当宏由多行组成时,因为带有宏“调用”的问题行被“原样”突出显示,没有文本展开后的版本(预处理器传递给编译器的就是未展开的文本)。 同时,模板是以现成的形式作为源代码的元素进入编译器的,因此模板中的任何错误都有特定的行号和在行中的位置。
  4. 副作用:宏可能会有副作用,这在“作为伪函数的 #define 形式”部分已经讨论过:如果 MAX 宏的参数是带有递增/递减操作的表达式,那么它们会被执行两次。
  5. 功能优势:然而,宏也有一些优点。宏能够生成任何文本,而不仅仅是正确的语言结构。例如,使用几个宏可以模拟对字符串的 switch 指令(尽管不推荐这种方法)。 在标准库中,宏被用于,特别是用于组织图表上的事件处理(见 MQL5/Include/Controls/Defines.mqh 中的 EVENT_MAP_BEGINEVENT_MAP_ENDON_EVENT 等)。这对于模板来说是行不通的,当然,用宏来安排事件映射的方式远不是唯一的,而且对于调试来说也不是最方便的。在宏中很难进行逐步(逐行)的代码执行调试。相反,模板完全支持调试。

模板中内置类型和对象类型的特点

应该记住,有三个重要方面对模板中类型的适用性施加了限制:

  1. 类型是内置的还是用户定义的:用户定义的类型要求通过引用传递参数,而内置类型不允许通过引用传递字面值;
  2. 对象类型是否为类:只有类支持指针;
  3. 模板算法中对相应类型数据执行的一组操作

假设我们有一个 Dummy 结构体(见脚本 TemplatesMax.mq5):

cpp
struct Dummy
{
   int x;
};

如果我们尝试对该结构体的两个实例调用 Max 函数,我们会得到一堆错误消息,主要如下:“对象只能通过引用传递” 以及 “无法应用模板”。

cpp
   // 错误:
   // 'object1' - 对象只能通过引用传递
   // 'Max' - 无法应用模板
   Dummy object1, object2;
   Max(object1, object2);

问题的关键在于以值传递模板函数参数,而这种方法与任何对象类型都不兼容。为了解决这个问题,可以将参数类型更改为引用:

cpp
template<typename T>
T Max(T &value1, T &value2)
{
   return value1 > value2? value1 : value2;
}

旧的错误会消失,但随后我们会得到一个新的错误:“> - 非法操作使用”(“> - illegal operation use”)。问题在于 Max 模板中有一个带有 > 比较运算符的表达式。因此,如果将自定义类型代入模板中,必须在模板中重载 > 运算符(而 Dummy 结构体没有重载该运算符,我们很快会讲到这一点)。对于更复杂的函数,可能需要重载更多的运算符。幸运的是,编译器会确切地告诉你缺少什么。

然而,将函数参数的传递方式更改为引用还导致之前的调用无法正常工作:

cpp
Print(Max<ulong>(1000, 10000000));

现在它会生成错误:“参数作为引用传递,需要变量”。因此,我们的函数模板不再适用于字面值和其他临时值(特别是,无法直接将表达式或调用另一个函数的结果传递给它)。

有人可能会认为,摆脱这种情况的通用方法是重载模板函数,即定义两个选项,仅在参数中的 & 符号上有所不同:

cpp
template<typename T>
T Max(T &value1, T &value2)
{
   return value1 > value2? value1 : value2;
}

template<typename T>
T Max(T value1, T value2)
{
   return value1 > value2? value1 : value2;
}

但这行不通。现在编译器会抛出错误 “具有相同参数的重载函数调用不明确”:

'Max' - 具有相同参数的重载函数调用不明确
可能是以下 2 个函数之一
   T Max(T&,T&)
   T Max(T,T)

最终可行的重载需要在引用中添加 const 修饰符。顺便说一下,我们在 Max 模板中添加了 Print 运算符,以便我们可以在日志中看到正在调用哪个重载以及对应的参数类型 T 是什么。

cpp
template<typename T>
T Max(const T &value1, const T &value2)
{
   Print(__FUNCSIG__, " T=", typename(T));
   return value1 > value2? value1 : value2;
}

template<typename T>
T Max(T value1, T value2)
{
   Print(__FUNCSIG__, " T=", typename(T));
   return value1 > value2? value1 : value2;
}

struct Dummy
{
   int x;
   bool operator>(const Dummy &other) const
   {
      return x > other.x;
   }
};

我们还在 Dummy 结构体中实现了 > 运算符的重载。因此,测试脚本中的所有 Max 函数调用都成功完成:无论是对于内置类型还是用户定义类型,以及字面值和变量。日志中的输出如下:

double Max<double>(double,double) T=double
1.0
datetime Max<datetime>(datetime,datetime) T=datetime
2021.10.10 00:00:00
ulong OnStart::Max<ulong>(ulong,ulong) T=ulong
10000000
Dummy Max<Dummy>(const Dummy&,const Dummy&) T=Dummy

细心的读者会注意到,我们现在有两个相同的函数,仅在参数的传递方式(按值传递和按引用传递)上有所不同,而这正是模板试图避免的情况。如果函数体不像我们的这么简单,这种重复可能会带来很高的成本。这可以通过常用的方法来解决:将实现分离到一个单独的函数中,并从两个 “重载” 中调用它,或者从另一个 “重载” 中调用一个 “重载”(需要一个可选参数来避免 Max 的第一个版本调用自身,从而导致栈溢出):

cpp
template<typename T>
T Max(T value1, T value2)
{
   // 调用一个按引用传递参数的函数
   return Max(value1, value2, true);
}

template<typename T>
T Max(const T &value1, const T &value2, const bool ref = false)
{
   return (T)(value1 > value2? value1 : value2);
}

我们还必须考虑与用户定义类型相关的另一个要点,即模板中指针的使用(回想一下,指针仅适用于类对象)。让我们创建一个简单的 Data 类,并尝试对指向其对象的指针调用模板函数 Max

cpp
class Data
{
public:
   int x;
   bool operator>(const Data &other) const
   {
      return x > other.x;
   }
};

void OnStart()
{
  ... 
   Data *pointer1 = new Data();
   Data *pointer2 = new Data();
   Max(pointer1, pointer2);
   delete pointer1;
   delete pointer2;
}

我们会在日志中看到 T=Data*,即指针属性影响了内联类型。这表明,如果有必要,可以编写模板函数的另一个重载,它将仅处理指针。

cpp
template<typename T>
T *Max(T *value1, T *value2)
{
   Print(__FUNCSIG__, " T=", typename(T));
   return value1 > value2? value1 : value2;
}

在这种情况下,指针 * 的属性已经存在于模板参数中,因此类型推导的结果是 T=Data。这种方法允许为指针提供单独的模板实现。

如果有多个模板适合为特定类型生成实例,则会选择最特殊化的模板版本。特别是,当使用指针参数调用 Max 函数时,有两个模板,参数分别为 TT=Data*)和 T*T=Data),但由于前者既可以接受值也可以接受指针,所以它比后者更通用,后者仅适用于指针。因此,对于指针,将选择第二个模板。换句话说,代入 T 的实际类型中的修饰符越少,模板变体就越可取。除了指针 * 的属性外,这还包括 const 修饰符。参数 const T*const T 分别比 T*T 更特殊化。

函数模板

函数模板由带有模板参数的头部(其语法之前已描述过)和函数定义组成,在函数定义中,模板参数表示任意类型。

首先来看一个交换数组两个元素的 Swap 函数示例(TemplatesSorting.mq5)。模板参数 T 用作输入数组变量的类型,以及局部变量 temp 的类型。

cpp
template<typename T>
void Swap(T &array[], const int i, const int j)
{
   const T temp = array[i];
   array[i] = array[j];
   array[j] = temp;
}

函数体中的所有语句和表达式都必须适用于实际类型,模板随后将针对这些实际类型进行实例化。在这种情况下,使用了赋值运算符 =。虽然对于内置类型,赋值运算符总是存在,但对于用户定义类型,可能需要显式重载它。

编译器默认会为类和结构体生成复制运算符的实现,但它可能会被隐式或显式地移除(见 delete 关键字)。特别是,正如我们在“对象类型转换”部分看到的,类中存在常量字段会导致编译器移除其隐式复制选项。然后,上述模板函数 Swap 就不能用于该类的对象:编译器会生成错误。

对于 Swap 函数处理的类/结构体,不仅需要有赋值运算符,还需要有复制构造函数,因为变量 temp 的声明实际上是一个带有初始化的构造,而不是赋值。有了复制构造函数,函数的第一行可以一次性执行(temp 基于 array[i] 创建),而没有复制构造函数时,会先调用默认构造函数,然后对 temp 执行 = 运算符。

让我们看看模板函数 Swap 如何在快速排序算法中使用:另一个模板函数 QuickSort 实现了快速排序算法。

cpp
template<typename T>
void QuickSort(T &array[], const int start = 0, int end = INT_MAX)
{
   if(end == INT_MAX)
   {
      end = start + ArraySize(array) - 1;
   }
   if(start < end)
   {
      int pivot = start;
      
      for(int i = start; i <= end; i++)
      {
         if(!(array[i] > array[end]))
         {
            Swap(array, i, pivot++);
         }
      }
      
      --pivot;
   
      QuickSort(array, start, pivot - 1);
      QuickSort(array, pivot + 1, end);
   }
}

请注意,QuickSort 模板的 T 参数指定了输入参数 array 的类型,然后这个数组被传递给 Swap 模板。因此,QuickSort 模板的类型推导 T 会自动确定 Swap 模板的类型 T

内置函数 ArraySize(和许多其他函数一样)能够处理任意类型的数组:从某种意义上说,它也是一个模板,尽管它是在终端中直接实现的。

排序是通过 if 语句中的 > 比较运算符完成的。正如我们前面提到的,对于要排序的任何类型 T,都必须定义这个运算符,因为它应用于类型为 T 的数组元素。

让我们检查一下内置类型数组的排序效果。

cpp
void OnStart()
{
   double numbers[] = {34, 11, -7, 49, 15, -100, 11};
   QuickSort(numbers);
   ArrayPrint(numbers);
   // -100.00000 -7.00000 11.00000 11.00000 15.00000 34.00000 49.00000
   
   string messages[] = {"usd", "eur", "jpy", "gbp", "chf", "cad", "aud", "nzd"};
   QuickSort(messages);
   ArrayPrint(messages);
   // "aud" "cad" "chf" "eur" "gbp" "jpy" "nzd" "usd"
}

对模板函数 QuickSort 的两次调用会根据传递的数组类型自动推导 T 的类型。结果,我们将得到 QuickSort 针对 doublestring 类型的两个实例。

为了检查自定义类型的排序情况,让我们创建一个带有整数字段 xABC 结构体,并在构造函数中用随机数填充它。在结构体中重载 > 运算符也很重要。

cpp
struct ABC
{
   int x;
   ABC()
   {
      x = rand();
   }
   bool operator>(const ABC &other) const
   {
      return x > other.x;
   }
};
void OnStart()
{
  ...
   ABC abc[10];
   QuickSort(abc);
   ArrayPrint(abc);
  /* 示例输出:
            [x]
      [0]  1210
      [1]  2458
      [2] 10816
      [3] 13148
      [4] 15393
      [5] 20788
      [6] 24225
      [7] 29919
      [8] 32309
      [9] 32589
   */
}

由于结构体的值是随机生成的,我们会得到不同的结果,但它们总是按升序排序。

在这种情况下,类型 T 也会自动推导。然而,在某些情况下,显式指定是将类型传递给函数模板的唯一方法。所以,如果模板函数必须返回一个唯一类型(与它的参数类型不同)的值,或者如果没有参数,那么只能显式指定类型。

例如,以下模板函数 createInstance 要求在调用指令中显式指定类型,因为无法从返回值自动“计算”出类型 T。如果不这样做,编译器会生成“模板不匹配”错误。

cpp
class Base
{
  ...
};
   
template<typename T>
T *createInstance()
{
   T *object = new T(); //调用构造函数
  ...                  //对象设置
   return object; 
}
   
void OnStart()
{
   Base *p1 = createInstance();       // 错误: 模板不匹配
   Base *p2 = createInstance<Base>(); // 正确, 显式指令
  ...
}

如果有多个模板参数,并且返回值的类型与函数的任何输入参数都不相关,那么在调用时也需要指定特定的类型:

cpp
template<typename T,typename U>
T MyCast(const U u)
{
   return (T)u;
}
   
void OnStart()
{
   double d = MyCast<double,string>("123.0");
   string f = MyCast<string,double>(123.0);
}

请注意,如果显式指定了模板的类型,那么对于所有参数都需要这样做,即使第二个参数 U 可以从传递的参数推导出来。

编译器生成模板函数的所有实例后,它们会参与从所有同名且参数数量合适的函数重载中选择最佳候选函数的标准过程。在所有重载选项(包括创建的模板实例)中,会选择类型最接近(转换次数最少)的那个。

如果模板函数有一些特定类型的输入参数,那么只有当这些类型与参数完全匹配时,它才被视为候选函数:任何转换需求都会导致模板因不合适而被“舍弃”。

非模板重载优先于模板重载,更特殊化(“针对性更强”)的模板重载会“胜出”。

如果显式指定了模板参数(类型),那么如果相应的函数参数(传递的值)的类型不同,必要时会应用隐式类型转换规则。

如果函数的几个变体同样匹配,我们会得到“具有相同参数的重载函数调用不明确”的错误。

例如,如果除了模板 MyCast 之外,还定义了一个将字符串转换为布尔类型的函数:

cpp
bool MyCast(const string u)
{
   return u == "true";
}

那么调用 MyCast<double,string>("123.0") 会开始抛出上述错误,因为这两个函数仅在返回值上不同:

'MyCast<double,string>' - 具有相同参数的重载函数调用不明确
可能是以下 2 个函数之一
   double MyCast<double,string>(const string)
   bool MyCast(const string)

在描述模板函数时,建议将所有模板参数包含在函数参数中。类型只能从参数推导出来,而不能从返回值推导。

如果函数有一个带有默认值的模板类型参数 T,并且在调用时省略了相应的参数,那么编译器也无法推导 T 的类型,并会抛出“无法应用模板”的错误。

cpp
class Base
{
public:
   Base(const Base *source = NULL) { }
   static Base *type;
};
   
static Base* Base::type;
   
template<typename T>
T *createInstanceFrom(T *origin = NULL)
{
   T *object = new T(origin);
   return object; 
}
   
void OnStart()
{
   Base *p1 = createInstanceFrom();   // 错误: 无法应用模板
   Base *p2 = createInstanceFrom(Base::type); // 正确, 从参数自动检测
   Base *p3 = createInstanceFrom<Base>();     // 正确, 显式指令, 省略了一个参数
}

对象类型模板

对象类型模板的定义以包含类型参数的头部(见“模板头”部分)开始,后面跟着类、结构体或联合体的常规定义。

cpp
template <typename T [, typename Ti ...] >
class class_name
{
  ...
};

与标准定义的唯一区别在于,模板参数可以出现在代码块中,以及语言中所有允许使用类型名称的语法结构里。

一旦定义了模板,当在代码中声明模板类型的变量时,通过在尖括号中指定具体类型来创建其工作实例:

cpp
ClassName<Type1,Type2> object;
StructName<Type1,Type2,Type3> struct;
ClassName<Type1,Type2> *pointer = new ClassName<Type1,Type2>();
ClassName1<ClassName2<Type>> object;

与调用模板函数不同,编译器无法自行推断对象模板的实际类型。

声明模板类/结构体变量并非实例化模板的唯一方式。如果模板类型被用作另一个特定(非模板)类或结构体的基类型,编译器也会生成一个实例。

例如,下面的 Worker 类,即使为空,也是针对 double 类型的 Base 的实现:

cpp
class Worker : Base<double>
{
};

这个最小定义就足够了(如果类 Base 需要构造函数,还需考虑添加构造函数),可以开始编译和验证模板代码。

在“动态对象创建”部分,我们了解了使用 new 运算符获取的对象动态指针的概念。这种灵活的机制有一个缺点:需要对指针进行监控,并在不再需要时“手动”删除它们。特别是,在退出函数或代码块时,必须通过调用 delete 来清除所有局部指针。

为了简化这个问题的解决方案,让我们创建一个模板类 AutoPtrTemplatesAutoPtr.mq5AutoPtr.mqh)。它的参数 T 用于描述字段 ptrptr 存储指向任意类对象的指针。我们将通过构造函数参数(T *p)或重载的 = 运算符来获取指针值。让我们将主要工作委托给析构函数:在析构函数中,指针将与 AutoPtr 对象一起被删除(为此分配了静态辅助方法 free)。

AutoPtr 的工作原理很简单:这个类的局部对象在退出其被描述的代码块时将自动销毁,如果之前它被指示“跟踪”某个指针,那么 AutoPtr 也会释放该指针。

cpp
template<typename T>
class AutoPtr
{
private:
   T *ptr;
   
public:
   AutoPtr() : ptr(NULL) { }
   
   AutoPtr(T *p) : ptr(p)
   {
      Print(__FUNCSIG__, " ", &this, ": ", ptr);
   }
   
   AutoPtr(AutoPtr &p)
   {
      Print(__FUNCSIG__, " ", &this, ": ", ptr, " -> ", p.ptr);
      free(ptr);
      ptr = p.ptr;
      p.ptr = NULL;
   }
   
   ~AutoPtr()
   {
      Print(__FUNCSIG__, " ", &this, ": ", ptr);
      free(ptr);
   }
   
   T *operator=(T *n)
   {
      Print(__FUNCSIG__, " ", &this, ": ", ptr, " -> ", n);
      free(ptr);
      ptr = n;
      return ptr;
   }
   
   T* operator[](int x = 0) const
   {
      return ptr;
   }
   
   static void free(void *p)
   {
      if(CheckPointer(p) == POINTER_DYNAMIC) delete p;
   }
};

此外,AutoPtr 类实现了一个复制构造函数(更确切地说是移动构造函数,因为当前对象成为指针的所有者),这允许从函数中返回一个 AutoPtr 实例以及一个受控指针。

为了测试 AutoPtr 的性能,我们将描述一个虚构的类 Dummy

cpp
class Dummy
{
   int x;
public:
   Dummy(int i) : x(i)
   {
      Print(__FUNCSIG__, " ", &this);
   }
  ...
   int value() const
   {
      return x;
   }
};

在脚本的 OnStart 函数中,输入变量 AutoPtr<Dummy>,并从函数 generator 中获取它的值。在 generator 函数本身中,我们还将描述对象 AutoPtr<Dummy>,并依次创建两个动态对象 Dummy 并将它们“附加”到该对象上(以检查从“旧”对象正确释放内存的情况)。

cpp
AutoPtr<Dummy> generator()
{
   AutoPtr<Dummy> ptr(new Dummy(1));
   // 指向 1 的指针将在执行 '=' 后被释放
   ptr = new Dummy(2);
   return ptr;
}
   
void OnStart()
{
   AutoPtr<Dummy> ptr = generator();
   Print(ptr[].value());             // 2
}

由于所有主要方法都会记录对象描述符(包括 AutoPtr 和受控指针 ptr),我们可以跟踪指针的所有“转换”(为方便起见,所有行都进行了编号)。

01 Dummy::Dummy(int) 3145728
02  AutoPtr<Dummy>::AutoPtr<Dummy>(Dummy*) 2097152: 3145728
03  Dummy::Dummy(int) 4194304
04  Dummy*AutoPtr<Dummy>::operator=(Dummy*) 2097152: 3145728 -> 4194304
05  Dummy::~Dummy() 3145728
06  AutoPtr<Dummy>::AutoPtr<Dummy>(AutoPtr<Dummy>&) 5242880: 0 -> 4194304
07  AutoPtr<Dummy>::~AutoPtr<Dummy>() 2097152: 0
08  AutoPtr<Dummy>::AutoPtr<Dummy>(AutoPtr<Dummy>&) 1048576: 0 -> 4194304
09  AutoPtr<Dummy>::~AutoPtr<Dummy>() 5242880: 0
10  2
11  AutoPtr<Dummy>::~AutoPtr<Dummy>() 1048576: 4194304
12  Dummy::~Dummy() 4194304

让我们暂时偏离模板,详细描述这个实用工具的工作方式,因为这样的类对许多人可能都有用。

OnStart 开始后,立即调用 generator 函数。它必须返回一个值来初始化 OnStart 中的 AutoPtr 对象,因此其构造函数尚未被调用。第 02 行在 generator 函数内部创建了一个 AutoPtr#2097152 对象,并获取了指向第一个 Dummy#3145728 的指针。接下来,创建了 Dummy#4194304 的第二个实例(第 03 行),它在 AutoPtr#2097152 中替换了描述符为 3145728 的前一个副本(第 04 行),并且旧副本被删除(第 05 行)。第 06 行创建了一个临时的 AutoPtr#5242880 以从 generator 中返回值,并删除了局部的 AutoPtr(第 07 行)。在第 08 行,最终使用了 OnStart 函数中 AutoPtr#1048576 对象的复制构造函数,并且来自临时对象(该临时对象在第 09 行立即被删除)的指针被转移到了它上面。接下来,我们使用指针的内容调用 Print 函数。当 OnStart 完成时,AutoPtr 的析构函数(第 11 行)自动触发,导致我们也删除了工作对象 Dummy(第 12 行)。

模板技术使 AutoPtr 类成为动态分配对象的参数化管理器。但由于 AutoPtr 有一个字段 T *ptr,它仅适用于类(更确切地说是指向类对象的指针)。例如,尝试实例化一个针对字符串的模板(AutoPtr<string> s)将导致模板文本中出现许多错误,其含义是字符串类型不支持指针。

在这里这不是一个问题,因为这个模板的目的仅限于类,但对于更通用的模板,应该记住这个细微差别(见侧边栏)。

指针和引用

请注意,T * 构造不能出现在你计划使用的模板中,包括用于内置类型或结构体的模板。关键在于,在 MQL5 中仅允许类使用指针。这并不是说理论上不能编写一个既适用于内置类型又适用于用户定义类型的模板,但可能需要一些调整。可能要么需要放弃一些功能,要么牺牲模板的通用性(制作多个模板而不是一个,重载函数等)。

将指针类型“注入”模板的最直接方法是在实例化模板时将修饰符 * 与实际类型一起包含(即它必须匹配 T=Type*)。然而,一些函数(如 CheckPointer)、运算符(如 delete)和语法结构(如类型转换 ((T)variable))对其参数/操作数是否为指针很敏感。因此,对于指针和简单类型值,相同的模板文本并不总是在语法上正确。

另一个需要记住的重要类型差异是:对象仅通过引用传递给方法,但简单类型的字面值(常量)不能通过引用传递。因此,根据推断的 T 类型,编译器可能会将 & 的存在或不存在视为错误。作为一种“解决方法”,你可以选择将参数常量“包装”到对象或变量中。

另一个技巧涉及使用模板方法。我们将在下一节中看到它。

应该注意的是,面向对象技术与模板配合得很好。由于指向基类的指针可用于存储派生类的对象,所以 AutoPtr 适用于任何派生的 Dummy 类的对象。

理论上,这种“混合”方法在容器类(vectorqueuemaplist 等)中被广泛使用,这些容器类通常是模板。根据实现的不同,容器类可能会对模板参数施加额外的要求,特别是内联类型必须有一个复制构造函数和一个赋值(复制)运算符。

MetaTrader 5 附带的 MQL5 标准库包含许多此类现成的模板:Stack.mqhQueue.mqhHashMap.mqhLinkedList.mqhRedBlackTree.mqh 等。它们都位于 MQL5/Include/Generic 目录中。确实,它们不提供对动态对象(指针)的控制。

我们将在“方法模板”中查看我们自己的一个简单容器类的示例。

方法模板

不仅整个对象类型可以是模板,其单独的方法(普通方法或静态方法)也可以是模板。不过,虚方法不能成为模板,这意味着模板方法不能在接口内部声明。然而,接口本身可以是模板,类模板中也可以存在虚方法。

当类/结构体模板中包含方法模板时,两个模板的参数必须不同。如果有多个方法模板,它们的参数之间没有关联,且可以有相同的名称。

方法模板的声明与函数模板类似,但只能在类、结构体或联合体(可以是模板,也可以不是)的上下文中进行。

cpp
[ template < typename T [, typename Ti ...] > ]
class class_name
{
  ...
  template < typename U [, typename Ui ...] >
  type method_name(parameters_with_types_T_and_U)
  {
  }
};

参数、返回值和方法体可以使用类型 T(类的通用类型)和 U(方法的特定类型)。

只有在程序代码中调用方法时,才会为特定的参数组合生成该方法的实例。

在上一节中,我们描述了用于存储和释放单个指针的模板类 AutoPtr。当有许多相同类型的指针时,将它们放在一个容器对象中会很方便。让我们创建一个具有类似功能的简单模板 —— SimpleArray 类(SimpleArray.mqh)。为了避免重复控制动态内存释放的功能,我们在类中约定,它用于存储值和对象,而不是指针。为了存储指针,我们将它们放在 AutoPtr 对象中,再将 AutoPtr 对象放入容器中。

这样做还有另一个好处:由于 AutoPtr 对象很小,很容易复制(不会在这上面过度消耗资源),而在函数之间交换数据时经常需要复制。AutoPtr 所指向的应用程序类的对象可能很大,甚至不需要在其中实现自己的复制构造函数。

当然,从函数中返回指针更节省资源,但这样就需要重新设计内存释放控制的方法。因此,使用现成的 AutoPtr 解决方案会更简单。

对于容器内的对象,我们将创建一个模板类型 T 的数据数组。

cpp
template<typename T>
class SimpleArray
{
protected:
  T data[];
  ...

由于容器的主要操作之一是添加元素,我们提供一个辅助函数来扩展数组。

cpp
  int expand()
  {
    const int n = ArraySize(data);
    ArrayResize(data, n + 1);
    return n;
  }

我们将通过重载的 << 运算符直接添加元素。它使用通用模板参数 T

cpp
public:
  SimpleArray *operator<<(const T &r)
  {
    data[expand()] = (T)r;
    return &this;
  }

这个版本通过引用接受一个值,即一个变量或对象。现在你应该注意这一点,稍后就会明白为什么这很重要。

通过重载 [] 运算符来读取元素([] 运算符优先级最高,因此在表达式中不需要使用括号)。

cpp
  T operator[](int i) const
  {
    return data[i];
  }

首先,让我们通过结构体的例子来验证这个类是否能正常工作。

cpp
struct Properties
{
  int x;
  string s;
};

为此,我们将在 OnStart 函数中描述一个用于该结构体的容器,并将一个对象放入其中(TemplatesSimpleArray.mq5)。

cpp
void OnStart()
{
  SimpleArray<Properties> arrayStructs;
  Properties prop = {12345, "abc"};
  arrayStructs << prop;
  Print(arrayStructs[0].x, " ", arrayStructs[0].s);
  ...
}

调试日志可以验证结构体是否已存入容器。

现在让我们尝试在容器中存储一些数字。

cpp
  SimpleArray<double> arrayNumbers;
  arrayNumbers << 1.0 << 2.0 << 3.0;

不幸的是,我们会得到 “参数作为引用传递,需要变量” 的错误,该错误恰好发生在重载的 << 运算符处。

我们需要一个通过值传递参数的重载版本。然而,我们不能简单地编写一个没有 const& 的类似方法:

cpp
  SimpleArray *operator<<(T r)
  {
    data[expand()] = (T)r;
    return &this;
  }

如果这样做,新的版本将导致对象类型的模板无法编译:毕竟,对象只能通过引用传递。即使该函数不用于对象,它仍然存在于类中。因此,我们将新方法定义为一个带有自己参数的模板。

cpp
template<typename T>
class SimpleArray
{
  ...
  template<typename U>
  SimpleArray *operator<<(U u)
  {
    data[expand()] = (T)u;
    return &this;
  }

只有当通过值向 << 运算符传递某些内容时,这个方法才会出现在类中,这意味着传递的肯定不是对象。确实,我们不能保证 TU 是相同的类型,所以会执行显式类型转换 (T)u。对于内置类型(如果两种类型不匹配),在某些组合中可能会发生精度损失的转换,但代码肯定可以编译。唯一的例外是禁止将字符串转换为布尔类型,但容器不太可能用于 bool 数组,所以这个限制并不重要。有兴趣的人可以解决这个问题。

有了新的模板方法,SimpleArray<double> 容器可以按预期工作,并且不会与 SimpleArray<Properties> 冲突,因为两个模板实例在生成的源代码中有差异。

最后,让我们用 AutoPtr 对象来测试这个容器。为此,我们准备一个简单的 Dummy 类,它将为 AutoPtr 内部的指针提供对象。

cpp
class Dummy
{
  int x;
public:
  Dummy(int i) : x(i) { }
  int value() const
  {
    return x;
  }
};

OnStart 函数内部,我们创建一个 SimpleArray<AutoPtr<Dummy>> 容器并填充它。

cpp
void OnStart()
{
  SimpleArray<AutoPtr<Dummy>> arrayObjects;
  AutoPtr<Dummy> ptr = new Dummy(20);
  arrayObjects << ptr;
  arrayObjects << AutoPtr<Dummy>(new Dummy(30));
  Print(arrayObjects[0][].value());
  Print(arrayObjects[1][].value());
}

回想一下,在 AutoPtr 中,[] 运算符用于返回存储的指针,所以 arrayObjects[0][] 表示:返回 SimpleArraydata 数组的第 0 个元素,即 AutoPtr 对象,然后对该对象应用第二对方括号,得到一个 Dummy* 指针。接下来,我们可以处理该对象的所有属性:在这种情况下,我们获取 x 字段的当前值。

由于 Dummy 类没有复制构造函数,不使用 AutoPtr 就不能直接使用容器来存储这些对象。

cpp
  // 错误:
  // 'Dummy' 对象不能被返回,
  // 未找到复制构造函数 'Dummy::Dummy(const Dummy &)'
  SimpleArray<Dummy> bad;

但聪明的用户可以想出如何绕过这个问题。

cpp
  SimpleArray<Dummy*> bad;
  bad << new Dummy(0);

这段代码可以编译并运行。然而,这个 “解决方案” 存在一个问题:SimpleArray 不知道如何控制指针,因此,当程序退出时,会检测到内存泄漏。

1 个未删除的对象残留
1 个类型为 Dummy 的对象残留
24 字节的内存泄漏

作为 SimpleArray 的开发者,我们有责任堵住这个漏洞。为此,我们在类中添加另一个模板方法,重载 << 运算符 —— 这次是针对指针的。由于它是一个模板,它也只会在 “需要时” 包含在生成的源代码中:当程序员尝试使用这个重载时,即向容器中写入一个指针。否则,该方法将被忽略。

cpp
template<typename T>
class SimpleArray
{
  ...
  template<typename P>
  SimpleArray *operator<<(P *p)
  {
    data[expand()] = (T)*p;
    if(CheckPointer(p) == POINTER_DYNAMIC) delete p;
    return &this;
  }

这个特化版本在使用指针类型实例化模板时会抛出编译错误(“需要对象指针”)。这样,我们就告知用户这种模式不受支持。

cpp
  SimpleArray<Dummy*> bad; // 在 SimpleArray.mqh 中生成错误

此外,它还执行另一个保护操作。如果客户端类仍然有复制构造函数,那么将动态分配的对象保存在容器中将不再导致内存泄漏:传递的指针 P *p 所指向的对象的副本会留在容器中,而原始对象会被删除。当 OnStart 函数结束时容器被销毁,其内部数组 data 将自动调用其元素的析构函数。

cpp
void OnStart()
{
  ...
  SimpleArray<Dummy> good;
  good << new Dummy(0);
} // SimpleArray 会 “清理” 其元素
  // 内存中没有被遗忘的对象

方法模板和 “普通” 方法可以像我们在 “类的声明和定义分离” 部分看到的那样,在主类块(或类模板)外部定义。同时,它们都要在前面加上模板头(TemplatesExtended.mq5):

cpp
template<typename T>
class ClassType
{
  ClassType() // 私有构造函数
  {
    s = &this;
  }
  static ClassType *s; // 对象指针(如果已创建)
public:
  static ClassType *create() // 创建(仅在首次调用时)
  {
    static ClassType single; // 每个 T 对应一个单例模式
    return single;
  }

  static ClassType *check() // 检查指针而不创建
  {
    return s;
  }

  template<typename U>
  void method(const U &u);
};

template<typename T>
template<typename U>
void ClassType::method(const U &u)
{
  Print(__FUNCSIG__, " ", typename(T), " ", typename(U));
}

template<typename T>
static ClassType<T> *ClassType::s = NULL;

它还展示了模板化静态变量的初始化,表示单例设计模式。

OnStart 函数中,创建模板的一个实例并进行测试:

cpp
void OnStart()
{
  ClassType<string> *object = ClassType<string>::create();
  double d = 5.0;
  object.method(d);
  // 输出:
  // void ClassType<string>::method<double>(const double&) string double

  Print(ClassType<string>::check()); // 1048576(实例 ID 的示例)
  Print(ClassType<long>::check());   // 0(T=long 时没有实例)
}

嵌套模板

模板可以嵌套在类/结构体中,或者嵌套在其他类/结构体模板中。对于联合体来说也是如此。

在“联合体”部分,我们看到了在长整型值和双精度浮点型值之间进行“转换”且不损失精度的能力。

现在我们可以使用模板来编写一个通用的“转换器”(TemplatesConverter.mq5)。模板类 Converter 有两个参数 T1T2,表示将要进行转换的两种类型。为了按照一种类型的规则写入值,并按照另一种类型的规则读取值,我们再次需要一个联合体。我们还将其定义为一个带有参数 U1U2 的模板(DataOverlay),并在类内部进行定义。

该类通过重载 [] 运算符提供了方便的转换功能,在运算符的实现中对联合体的字段进行写入和读取操作。

cpp
template<typename T1,typename T2>
class Converter
{
private:
   template<typename U1,typename U2>
   union DataOverlay
   {
      U1 L;
      U2 D;
   };
   
   DataOverlay<T1,T2> data;
   
public:
   T2 operator[](const T1 L)
   {
      data.L = L;
      return data.D;
   }
   
   T1 operator[](const T2 D)
   {
      data.D = D;
      return data.L;
   }
};

这个联合体用于描述类内部的字段 DataOverlay<T1,T2>data。我们本可以在 DataOverlay 中直接使用 T1T2,而不将这个联合体定义为模板。但是为了展示这种技术本身,在生成 data 字段时,外部模板的参数被传递给了内部模板。在 DataOverlay 内部,同样的这对类型将以 U1U2 的名称被识别(除了 T1T2)。

让我们看看这个模板的实际运行情况。

cpp
#define MAX_LONG_IN_DOUBLE       9007199254740992
   
void OnStart()
{
   Converter<double,ulong> c;
   
   const ulong value = MAX_LONG_IN_DOUBLE + 1;
   
   double d = value; // 由于类型转换可能会损失数据
   ulong result = d; // 由于类型转换可能会损失数据
   
   Print(value == result); // false
   
   double z = c[value];
   ulong restored = c[z];
   
   Print(value == restored); // true
}

缺失的模板特化

在某些情况下,可能需要为特定类型(或一组类型)提供与通用模板不同的实现方式。例如,通常为指针或数组准备一个特殊版本的交换函数是有意义的。在这种情况下,C++允许进行所谓的模板特化,即定义一个模板版本,其中通用类型参数 T 被所需的具体类型所替代。

在对函数和方法模板进行特化时,必须为所有参数指定具体类型,这被称为完全特化。

对于C++的对象类型模板,特化不仅可以是完全的,还可以是部分的:只指定部分参数的类型(其余参数将在模板实例化时被推断或指定)。可以有多个部分特化,唯一的条件是每个特化必须描述一个唯一的类型组合。

不幸的是,MQL5中并没有严格意义上的模板特化。

模板函数的特化与重载并无区别。例如,对于以下模板函数 func

cpp
template<typename T>
void func(T t) { ... }

可以为给定类型(如 string)提供自定义实现,形式如下:

cpp
// 显式特化
template<>
void func(string t) { ... }

或者:

cpp
// 普通重载
void func(string t) { ... }

只能选择其中一种形式,否则会得到编译错误 “func - 函数已定义且有函数体”。

至于类的特化,从指定了部分模板参数具体类型的模板进行继承,可以被视为类的部分特化的等效方式。模板方法可以在派生类中被重写。

以下示例(TemplatesExtended.mq5)展示了几种将模板参数用作父类型的使用方式,包括其中一个参数被指定为具体类型的情况。

cpp
#define RTTI Print(typename(this))
   
class Base
{
public:
   Base() { RTTI; }
};
   
template<typename T> 
class Derived : public T
{
public:
   Derived() { RTTI; }
}; 
   
template<typename T> 
class Base1
{
   Derived<T> object;
public:
   Base1() { RTTI; }
}; 
   
template<typename T>                // 完全 “特化”
class Derived1 : public Base1<Base> // 1 个参数被设置 
{
public:
   Derived1() { RTTI; }
}; 
   
template<typename T,typename E> 
class Base2 : public T
{
public:
   Base2() { RTTI; }
}; 
   
template<typename T>                    // 部分 “特化”
class Derived2 : public Base2<T,string> // 2 个参数中的 1 个被设置 
{
public:
   Derived2() { RTTI; }
};

我们将使用一个变量根据模板实例化一个对象:

cpp
   Derived2<Derived1<Base>> derived2;

使用 RTTI 宏进行调试类型日志记录会产生以下结果:

Base
Derived<Base>
Base1<Base>
Derived1<Base>
Base2<Derived1<Base>,string>
Derived2<Derived1<Base>>

在开发以封闭二进制形式提供的库时,必须确保为库的未来用户预期使用的所有类型显式实例化模板。可以通过在某个辅助函数(例如,绑定到全局变量的初始化)中显式调用函数模板并创建带有类型参数的对象来实现这一点。