C++ 中的自定义(用户定义)运算符






4.49/5 (19投票s)
C++ 本身不支持添加新运算符,但本文将展示如何通过宏和一些巧妙的重载,可以轻松地添加自己的运算符。
引言
C++ 支持运算符重载,但您不能创建自己的运算符。至少街头传闻是这样说的。实际上,这只说对了一半。C++ 给了您足够的空间来犯十次错误。好的一点是,我们有足够的能力来模拟几乎所有缺失的功能,包括创建自己的运算符。
它做什么?
一句话,语法糖。与其写成
if (CString("foo bar baz").Find("bar") != -1)
您可以定义一个“contains”运算符,然后写成
if ("foo bar baz" contains "bar")
这显然**非常**容易编写和阅读。您还可以避免非常常见但讨厌的错误,例如如果您犯了以下错误
if (CString("foo bar baz").Find("foo"))
现在,诚实地说,您花了多长时间才发现上面的错误?或者您放弃了?如果您试图**查找**有问题的行,需要多长时间?如果字符串的内容没有硬编码在同一行,需要多长时间?这种丑陋但不幸常见的 bug 可以通过我们自定义的“contains”运算符完全避免。语法糖是程序员工具箱中更有力的工具之一。正确使用时,它可以显著提高产出**并**减少 bug。
背景
如果您不知道什么是运算符重载,在尝试使用此代码或阅读本文之前,应快速谷歌一下。只需要 5-10 分钟就能对它有一个很好的掌握。由于任何有价值的 C++ 开发者都应该理解运算符和重载,所以我不会在这里赘述。
使用代码
由于你们大多数人更关心获得新玩具,而不是它如何工作,所以我们将首先让您上手。
只需将以下两行添加到您的项目中,您就会拥有一些新运算符,包括前面描述的“contains”运算符。
#include "CustomOperator.h" // IMPORTANT: SampleOperators.h assumes a TCHAR enabled MFC environment // If this does NOT describe your project, you will need to make some // adjustments before you will be able to use it. #include "SampleOperators.h"
很简单,对吧?
如果您想添加自己的自定义运算符(毕竟这是目标),您只需要再写几行,但一点也不难。如果您不想理解所有这些工作原理,最好的方法是阅读两个头文件中极其详细的注释。复制示例来运行您的前几个运算符,然后您就可以开始了。
这一切是如何工作的?
如果您不知道什么是宏,或者在想到模板函数/类时会不由自主地抽搐,我建议**不要**尝试理解代码。文件 *CustomOperator.h* 负责处理所有棘手的细节。只需按照示例文件中的示例操作,您就可以开始使用“您一直想要的那个运算符”。
对于其余的人(或者那些喜欢自虐的好奇心强的人),基本概念相当简单。让下面这行起作用,您就有效地拥有了一个自定义运算符。
#define contains == CCustomOperatorHelper_contains() ==
上面运算符的优先级将与 `==`(等于)运算符的优先级相同,因为两边都使用了它。我无需告诉您这有多强大。
现在,让上面这行在单个情况下工作并不是什么难事。通过一个小型类和两个运算符重载,您可以很好地完成小型任务。
class CCustomOperatorHelper_contains { public: CCustomOperatorHelper_contains(){} CString m_sLeft; }; inline CCustomOperatorHelper_contains& operator == (CString l, CCustomOperatorHelper_contains& mid) { mid.m_sLeft = l; return mid; } inline bool operator == (CCustomOperatorHelper_contains& mid, CString r) { return mid.m_sLeft.Find(r) != -1; }
再加上原始的 `#define`,您就拥有了一个功能性的运算符。我大约在 10 分钟内就完成了。他们为什么以前没想到这个?
真的……就这些吗?
不幸的是,不是。上面的解决方案是一个很好的概念验证,仅此而已。它存在一些严重的、不易解决的问题。首先,我们的运算符有三次额外的字符串复制操作,这意味着我们这一行的运行时长可能高达原来的四倍。如果核心操作比查找更简单(例如,比较),这一行可能比原始代码花费的时间长 100 倍,甚至更长。(免责声明:在某些 MS 专家嘲笑我之前,`CString` 据说有一些内部的写时复制逻辑来防止在这种情况下出现问题,但问题本身仍然存在,即使 `CString` 巧妙地解决了这次。)
雪上加霜的是,上面的架构不允许左侧操作数的类型变化,因为操作的后半部分以辅助函数开头,而不是原始的左侧操作数。只要辅助函数只处理 `CString`,我们就可以推断出类型是 `CString`,但如果我们想扩展它,事情就会变得非常混乱。
命运弄人,一个改变可以让我们解决这两个问题。诀窍是我们现在需要四个类来实现我们的单个自定义运算符。
class CCustomOperator_param_base { public: virtual ~CCustomOperator_param_base(){} }; template<class T_left> class CCustomOperatorHelper_contains_leftparam_T : public CCustomOperator_param_base { public: CCustomOperatorHelper_contains_leftparam_T(T_left l) { m_l_buf = l; m_pl = &m_l_buf; } T_left m_l_buf; // buffer, since we have to copy the left side value T_left* m_pl; // pointer to the value on the left }; template<class T_left> class CCustomOperatorHelper_contains_leftparamref_T : public CCustomOperator_param_base { public: CCustomOperatorHelper_contains_leftparamref_T(T_left& l) { m_pl = &l; } T_left* m_pl; /* pointer to the value on the left */ }; class CCustomOperatorHelper_contains { public: CCustomOperatorHelper_contains(){m_pLeft = NULL;} ~CCustomOperatorHelper_contains(){delete m_pLeft;} CCustomOperator_param_base* m_pLeft; };
对于初学者来说,那种模板 `<class T>` 东西让我们推迟对变量类型的判断。编译器将在需要时生成类,以满足我们与这些类一起使用的任何变量类型。如果您一直在不使用模板的情况下使用宏,那么您将获得一份大礼,因为当您将它们组合在一起时,您可以创建真正令人惊叹的语法糖,并使其保持类型安全。
我有点跑题了;回到代码。上面的类处理了一些我们无法以其他方式处理的繁琐工作。两个模板类将用作我们操作左半部分的返回值。这允许我们根据第一部分左侧的类型使用不同的重载来实现第二部分。我们有两种这样的模板类,因为我们有两种传递参数的方式。如果处理引用,我们将使用一个类;其他情况则使用另一个类。这样,我们可以避免复制大型对象或没有复制运算符的对象(如 `CDWordArray`)。(**注意**:我们不能只通过引用处理所有情况,因为这会阻止隐式转换和操作数的常量。)
为了设置前半部分,我们需要为每个可能的左侧操作数类型提供一个重载运算符。在我们最初的场景中,我们只有一种类型,但我们将把它改为三种,以便您可以看到它是如何工作的。
inline CCustomOperatorHelper_contains_leftparam_T<const TCHAR*>& operator == (const TCHAR* l, CCustomOperatorHelper_contains& r) { return *(CCustomOperatorHelper_contains_leftparam_T<const TCHAR*>*)(r.m_pLeft = new CCustomOperatorHelper_contains_leftparam_T<const TCHAR*>(l)); } inline CCustomOperatorHelper_contains_leftparamref_T<CString>& operator == (CString& l, CCustomOperatorHelper_contains& r) { return *(CCustomOperatorHelper_contains_leftparamref_T<CString>*) (r.m_pLeft = new CCustomOperatorHelper_contains_leftparamref_T<CString>(l)); } inline CCustomOperatorHelper_contains_leftparamref_T<CDWordArray>& operator == (CDWordArray& l, CCustomOperatorHelper_contains& r) { return *(CCustomOperatorHelper_contains_leftparamref_T<CDWordArray>*) (r.m_pLeft = new CCustomOperatorHelper_contains_leftparamref_T<CDWordArray>(l)); }
函数内部有点粗糙,但一旦我们将其分解为宏,这就不算什么了。另外,请注意,对于 const `TCHAR*` 我们使用了常规版本,而对于 `CString` 和 `CDWordArray` 则使用了通过引用传递的版本。这使得我们可以最大限度地减少复制,同时仍然与常量兼容。`CDWordArray` 没有拷贝构造函数,所以只有 `ref` 版本对它有效。
下一步是声明实际的运算符。我们将实际的东西声明在一个独立于最终重载的函数中,因为这使得我们在将其分解为宏时能够做到非常整洁。
inline bool _op_contains(CString& l, const TCHAR* r) { return l.Find(r) != -1; } inline bool _op_contains(CString& l, int r) { TCHAR a[20]; _itot_s(r, a, 10); return l.Find(a) != -1; } inline bool _op_contains(const TCHAR* l, const TCHAR* r) { // inefficient, but this is just an example, right? return CString(l).Find(r) != -1; } inline bool _op_contains(const TCHAR* l, int r) { TCHAR a[20]; _itot_s(r, a, 10); // inefficient, but this is just an example, right? return CString(l).Find(a) != -1; } inline bool _op_contains(CDWordArray& l, DWORD r) { for (int a = 0; a < l.GetCount(); a++) { if (l[a] == r) { return true; } } return false; }
您可以看到我们增加了接受右侧为 `int` 的能力,但左侧不行,并且我们还扩展了代码来处理在 `CDWordArray` 中搜索值。在我们的最终代码中,**只有**这五个操作才有效。您不能将左侧的 `CDWordArray` 与右侧的 const `TCHAR*` 混合。如果您这样做,将在出错的行上收到一个非常有意义的编译器错误。
现在剩下的唯一事情是为我们操作的后半部分定义五个运算符重载。每个重载都将指向上面的一个函数。
inline bool operator == (CCustomOperatorHelper_contains_leftparam_T<const TCHAR*>& l, const TCHAR* r) { return _op_contains(*l.m_pl, r); } inline bool operator == (CCustomOperatorHelper_contains_leftparam_T<const TCHAR*>& l, int r) { return _op_contains(*l.m_pl, r); } inline bool operator == (CCustomOperatorHelper_contains_leftparamref_T<CString>& l, const TCHAR* r) { return _op_contains(*l.m_pl, r); } inline bool operator == (CCustomOperatorHelper_contains_leftparamref_T<CString>& l, int r) { return _op_contains(*l.m_pl, r); } inline bool operator == (CCustomOperatorHelper_contains_leftparamref_T<CDWordArray>& l, DWORD r) { return _op_contains(*l.m_pl, r); }
假设您没有删除原始的 `#define`,那么您现在拥有一个比第一个运算符好得多的运算符。
有没有捷径?
显然,每次想要一个运算符时都经历整个过程会有点乏味。那会是**大量**额外且非常复杂的代码,只是为了让其他几行代码更方便。
为了让大家的生活更轻松,我将其分解为一系列宏。每个独立的组件都可以用宏定义。一个完整的运算符可能看起来像下面这个虽然没什么价值的运算符。
#define avg BinaryOperatorDefinition(_op_avg, /) DeclareBinaryOperator(_op_avg) DeclareOperatorLeftType(_op_avg, /, double); inline double _op_avg(double l, double r) { return (l + r) / 2; } BindBinaryOperator(double, _op_avg, /, double, double)
每个宏都在 *CustomOperator.h* 中定义,并附有您可能不想要的更多注释。
您可能已经注意到宏将其标识为“二元”运算符,这意味着它有两边。这是因为我们还可以创建后缀一元运算符(位于操作数右侧的一元运算符)。这是一个后缀一元运算符的快速示例(同样,完全没价值;希望您能想出更好的用途)。
#define squared UnaryPostOperatorDefinition(_op_squared) DeclareUnaryPostOperator(_op_squared) inline double _op_squared(double l) { return l*l; } BindUnaryPostOperator(double, _op_squared, double)
后缀一元运算符比二元运算符稍微容易一些。首先,我们可以省去左侧类型的那堆东西,因为实际上只有一个操作。其次,操作顺序无关紧要,后缀一元运算符**始终**以与乘法相同的优先级进行求值。是的,我知道,这与其他后缀运算符不同,但我认为这种行为将与代码的阅读方式匹配(我不能采用其他方式,因为某个白痴决定 `[]` 必须在对象内部被重载;该死的 C2801 错误)。
还有什么您不做的吗?
最终,我计划支持三元和多元运算符(?: 是三元运算符,内联 `switch` 语句是一个多元运算符的例子)。不幸的是,目前还没有实现。
前缀一元运算符(位于左侧)并未正式支持,但可以模拟。尝试这样做存在一个严重的问题,这就是为什么我选择**不支持**它。代码中提供了详细的解释。
最后,您的运算符必须像其他所有东西一样是 a-zA-Z0-9_ 字符,原因在于宏符号的限制,使得我们无法声明 `>>>` 或任何其他带有符号的运算符。不过,您**仍然**可以使用非常短的符号,但我建议将其大写并避免使用单个字母。