C++ 属性访问器





4.00/5 (6投票s)
展示如何创建支持 C#-like 语法的 C++ 属性访问器。
引言
如果编程语言是音乐艺术家或乐队,那么在软件世界中,音乐会是什么样的?首先,无处不在的 Java 将是 U2。它无处不在,每个人都或多或少地喜欢它。C# 将是滚石乐队,原因类似。JavaScript/AJAX 将是新派嘻哈——过度播放。LISP,具有令人着迷的自我意识水平的语言,将是感恩的死者或 Phish。整个 VB 语言已被多次视觉修改,最终结果远不如原始 BASIC 有吸引力。有一队音乐家与 VB 对应。Fortran 是猫王。Befunge 是 RadioHead,2000年以后。COBOL 是过了巅峰期但仍在发行专辑的音乐家。我们就不点名了!
尽管 XML 严格来说不是一种编程语言(除非你喜欢手写 ANT),但我脑中爬行动物的部分却说服了我提及它。它当初是如何流行起来的,为什么至今仍在使用?它就像是男团现象。它最初并没有很大的潜力(解析起来**慢得要命**),而且也不会变得更好!
C 和 C++ 又如何呢?C 可以很冗长。它的追随者倾向于让它保持复杂。它可能很有趣(就像我们在 C++ 中封装时一样),但我们也会做一些事情来降低它的乐趣(比如把东西放在结构和过程中)。C 是 Rush,"数学摇滚"的大师。我是一个老歌迷,也是一个老 C 程序员,所以我可以毫不顾忌地说这话。事实上,我就是老了,句号。C 的复杂文化延续到了 C++,我们又增加了新的“代码异味”,吸走了编码的所有乐趣和效率,比如模板狂热和多重继承。我们如何才能让 C++ 被更广泛的受众所使用呢?我们可以从我们的编码方式入手!我们可以创建超出数学摇滚范畴的 C++ 代码,并将其推向大众。即使在当前的 C++ 标准下,我们也可以通过代码做一些事情,让 C++ 成为软件世界的披头士。
在本文中,我将重点讨论一个能让代码更直观的编程语言概念,并将展示如何在 C++ 中实现它,尽管 C++ 并不直接支持它。
背景
在我的工作中,我需要切换多种语言。它们都有各自的优缺点。在评估一门语言的优劣时,我总是得出相同的结论:最终决定因素是直观性。可用 API 的完整性固然重要,但 API 可以围绕任何编程语言构建。因此,为了本次讨论的目的,我将把直观性或可读性作为首要目标。你能否向你的老板展示一段代码,并自信地认为他/她能理解它的作用?这很大程度上取决于语言的语法。向老板展示一段 SQL 代码,你将不得不解释它。展示一段 Java 代码,非技术人员可能也能理解一些。
在我开始滔滔不绝地赞美 C# 的某个特性之前,请允许我先表达我对该语言在由其唯一的指导委员会成员领导下所发展方向的担忧。我理解对 `lambdas` 等特性的需求,但在任何语言中,我那小小的脑袋都很难跟上代码中的逻辑。我会处理 `lambdas`,因为它们似乎很有价值,但这可能会打开潘多拉的盒子。我担心微软会把所有东西都塞进语言中,并推广一套不断增长的关键字和流程逻辑,而普通人类在四维限制下无法可视化。根据我“语言应该直观”的标准,我只是不认为 C# 中的 `Linq` 内联查询和新的 `yield` 关键字是“进步”。底层 `Linq` 技术是尖端的,但可以在没有模糊语法的情况下充分使用它。为什么要让 C# 代码看起来像 SQL?这有点像滚石乐队和鞭挞金属的混搭!
抱怨够了。我有一个令人愉快的消息想让你保存到你大脑最左边的节点。C# 中有一个语言特性,我认为它是自 C 成为 C++ 或 Java 成为 Java 以来,该语言家族中最大的改进。**访问器**是我们在 C# 中现在认为理所当然的东西。它们就像贝斯手——处于背景中,不被赏识。没有贝斯手,音乐会变成什么样?它将不那么悦耳。同样,没有真正访问器的语言中的类对眼睛来说也不那么悦目。在我看来,没有访问器的类实际上更难使用。为什么?访问器区分**属性**和**方法**,并且自动完成功能会给你不同的图标。类中的**属性**和**方法**有什么区别?**属性**是一种设置和获取实例属性的方式。它更像是名词而不是动词。**方法**更像是一种动作,调用动作意味着将会有更多的工作发生。如果类是 `Cup`,那么 `LiquidVolume` 是一个属性,`fill` 是一个方法。`LiquidVolume` 是原子性的,所以调用它涉及的成本非常小。调用 `fill` 需要 `Cup` 实例或填充它的 `CoffeePot` 做更多的工作。
既然我们正在谈论数学摇滚,那就让我们继续探讨数学含量高的代码吧。下面是 C# 和 Java 中我们假想的 `Cup` 类的代码片段:
//
// Some C# code
class Cup
{
double _diameter,
_liquidHeight;
public double LiquidVolume {
get {
double radius = _diameter / 2.0;
return 3.14159 * (radius * radius) * _liquidHeight;
}
}
public double LiquidHeight
{
get {
return _liquidHeight;
}
set {
//validate, throw if necessary
//...
_liquidHeight = value;
}
}
///some stuff not shown for brevity
public void fill(CoffeePot pot, double pouringTime, double outputVelocity)
{
//Do a bunch of calculations that take some CPU cycles
}
}
//using the class in C#
Cup cup = new Cup();
cup.LiquidHeight = 2; //By inspection, looks like a prop set
cup.Diameter = 2; //Ditto!
cup.fill(pot, 100, 50); //Looks like we're doing some non-atomic operation here,
//calling a method!
Trace.WriteLine( cup.LiquidVolume.ToString() );
//
// Some Java code
class Cup
{
double _diameter,
_liquidHeight;
public double getLiquidVolume()
{
double radius = _diameter / 2.0;
return 3.14159 * (radius * radius) * _liquidHeight;
}
public double getLiquidHeight()
{
return _liquidHeight;
}
public void setLiquidHeight(double value)
{
//validate, throw if necessary
//...
_liquidHeight = value;
}
///some stuff not shown for brevity
public void fill(CoffeePot pot, double pouringTime, double outputVelocity)
{
//Do a bunch of calculations that take some CPU cycles
}
}
//using the class in Java
Cup cup = new Cup();
cup.setLiquidHeight(2); //Is this method with some computations or a simple,
//atomic operation?
cup.setDiameter(2); //Yeah, ditto that!
cup.fill(pot, 100, 50); //Does this do more work internally than the
//preceding two lines?
System.out.println( cup.getLiquidVolume().ToString() );
该代码片段阐释了 Java 和 C# 在属性方面的区别。我并非在 Sun 与 Microsoft 之间选边站,请在适当的语境下理解这些评论。C# 属性访问器是对该语言家族的改进。当你有视觉线索时,它显然能让代码更具可读性。这是每个程序员都已知道的事实;可读性正是我们进行缩进和创建编码标准的原因。
请注意,我遵循非 Microsoft 的类、方法和属性命名约定。英语和类似语言中的大写词通常表示名词(例如 Chris)。在德语中,所有名词都以大写字母开头。因此,类和属性使用大写是合乎逻辑的。将方法小写,与 Microsoft 使用的编码标准形成直接对比,是一种区分名词和动词,或动作和属性的方式。我对此不担心局部变量,并且当我的函数很短时,我从未觉得有任何必要。
这段代码片段也说明了访问器允许你在调用属性时省略 ()。这让代码审查者可以区分近乎“原子”的操作和那些需要更多 CPU 周期的操作。这听起来可能不重要,但在循环中,这是一个关键的区别。你可以在循环内部调用属性 `get` 访问器,但应该将较重的方法调用的结果保存到局部变量中。
关于 Java 和 C# 就说这么多吧!C++ 呢?
如果你是按照教科书编程,那么在 C++ 中创建具有与 C# 完全相同的访问器语法的类,这一点是完全不明显的。我想很少有人看到过这种需求,因为我从未见过以这种方式设计的任何 API。在 C++ 中支持访问器语法实际上非常简单。它只是比 C# 中更冗长一些!但这对于我们这些数学摇滚爱好者来说没问题。基本要素是使用内部类的实例和重载操作符。内部类是“访问器”代码,而重载操作符使所需的语法成为可能。
示例应用程序是一个围绕 `Trigonometry` 类的 DOS 驱动程序。它演示了如何构建访问器类以及如何调用它们。这是源代码的核心部分:
#include "stdafx.h"
#include <math.h>
#include <stdlib.h>
#include <assert.h>
#include <iostream>
using namespace std;
const double PI = 3.141592653589793238462643,
TWOPI = 2 * PI,
DEGREE = PI / 180.0;
class Trigonometry
{
public:
Trigonometry()
: Sine(_radians), AngleInDegrees(_radians),
Radians(_radians), _radians(0.0)
{
}
protected:
//All the accessors share and expose the value of this member variable.
double _radians;
///////////////// Utility functions
static bool approximatelyEqual(double d1, double d2)
{
return fabs( d1 - d2 ) <= 0.0001;
}
//Ensures that the value of target is 0 < target < 2*PI
static void setRadians(double &target, const double newVal)
{
target = fmod(newVal, TWOPI);
while( target < 0 )
target += TWOPI;
}
//Exposes the value of _radians in actual radians.
class RadianAccessor
{
double &_target; //Holds a reference to the variable
//in the outer class.
public:
RadianAccessor(double& target)
: _target(target)
{
}
//Allows this class to intrinsically cast to double.
operator double () const
{
return _target;
}
//The "set" accessor function.
double operator = (double newVal)
{
setValue(newVal);
return value();
}
//This is a prefix operator, not postfix. Increments the radians
//value by 1.0 modularly.
double operator ++(){
setValue(_target + 1.0);
return _target;
}
//Comparison to double operator.
bool operator == (double d) const
{
return approximatelyEqual( d, value() );
}
protected:
double value() const
{
return _target;
}
void setValue(double newVal)
{
setRadians(_target, newVal);
}
};
class AngleAccessor
{
double &_target; //Holds a reference to the variable
//in the outer class.
public:
AngleAccessor(double& target)
: _target(target)
{
}
//Allows this class to intrinsically cast to double.
operator double () const
{
return value();
}
//The "set" accessor function.
double operator = (double newVal)
{
setValue(newVal);
return value();
}
//This is a prefix operator, not postfix.
//Increments the angle by one degree.
double operator ++(){
setValue(value() + 1);
return value();
}
//Increments the angle by the supplied number of degrees.
double operator +=(double addend){
setValue(value() + addend);
return value();
}
//Comparison to double operator.
bool operator == (double d) const
{
return approximatelyEqual( d, value() );
}
protected:
double value() const
{
double res = _target / DEGREE;
return res < 0 ? res + 360 : res;
}
void setValue(double newVal)
{
setRadians(_target, newVal * DEGREE);
}
};
class SineAccessor
{
double &_target; //Holds a reference to the variable
//in the outer class.
public:
SineAccessor(double& target)
: _target(target)
{
}
//Allows this class to intrinsically cast to double.
operator double () const
{
return value();
}
//The "set" accessor function.
double operator = (double newVal)
{
setValue(newVal);
return value();
}
//This is a prefix operator, not postfix. Increments the sine
//value by 0.1.
double operator ++(){
setValue(0.1 + sin(_target));
return value();
}
//Comparison to double operator.
bool operator == (double d) const
{
return approximatelyEqual( d, value() );
}
protected:
double value() const
{
return sin(_target);
}
void setValue(double newVal)
{
if( newVal > 1.0 )
newVal = 1.0;
else if( newVal < -1.0 )
newVal = -1.0;
setRadians(_target, asin(newVal));
}
};
public:
//Accessor that maintains the _radians member by its sine value.
SineAccessor Sine;
//Accessor that maintains the _radians member converted to degrees.
AngleAccessor AngleInDegrees;
//Accessor that maintains the _radians member as radians. Like all
//the accessors, it ensures that _radians stays in the 0..2*PI range.
RadianAccessor Radians;
};
//A DOS app for trig fans...
int _tmain(int argc, _TCHAR* argv[])
{
Trigonometry trig;
//Set the radians by the SINE value.
trig.Sine = -1.0; //Note the syntax -- not very C-like.
//Test assertion.
assert( trig.AngleInDegrees == 270.0 );
//Try the prefix operator out on SINE
while( trig.Sine < 1.0 ){
cout << "sin(" << trig.Radians << ") = " << trig.Sine << endl;
++trig.Sine;
}
cout << "Press ENTER to continue or just wait for your computer to crash:
" << endl;
getchar();
//Set the radians using degrees...
trig.AngleInDegrees = 630.0; //Looks like a simple set member statement,
// but actually validates
//in the set accessor...
assert( trig.AngleInDegrees == 270.0 ); //... and makes sure
//_radians < 2 * PI
for( int n=0; n < 72; n++ ){
cout << trig.AngleInDegrees << "Degrees => " << trig.Radians
<< " Radians" << endl;
trig.AngleInDegrees += 10;
}
cout << "Press ENTER to continue. Don't worry; it won't burn your fingers: "
<< endl;
getchar();
return 0;
}
关注点
不可否认,访问器类代码有些冗长,尤其是当你开始重载二元运算符时。如果不重载这些运算符,你将无法支持一元和二元操作。为了简洁起见,我这里只实现了几个运算符。那么,为什么还要费心编写访问器类呢?
思考在生产代码中编写访问器是否值得是一件好事。在类设计中,我使用这种技术有一个明确的案例——API。如果你要重用代码,那么如何使用一个类就必须非常直观。让我举个例子。今天,我正在使用一个用于在 C++ 中操作文件和目录路径的 API。仅仅查看 IDE 中的自动补全列表并不能很好地告诉我应该在实例上调用哪个函数。我区分类属性和方法的唯一方法是检查是否存在相应的“set”函数(几乎所有方法都是 const)。我不得不不断地查阅文档,这基本上将函数名扩展成一个句子。除非这些类在我记忆中仍然清晰,否则它们的使用会降低我的生产力。在 .NET API 中相应的类实际上更容易使用,不是因为它们更好,而是因为名词和动词之间有明确的分离。在使用 C# 中的文件和目录类时,我很少需要参考文档。
对于生成 C++ 访问器类,有一个潜在有趣的解决方案。你可以用 Python 等语言“元编程”你的访问器,在预构建步骤中运行脚本,然后 `#include` 元生成的 C++ 代码。你还可以将脚本代码嵌入到 `#pragma` 中,并编写一个单独的预处理器脚本来读取、评估和内联展开 `#pragma` 脚本。这允许你批量生成运算符,并适当地过滤列表。它还让你一劳永逸地解决了前缀和后缀运算符的语法难题——后缀运算符有一种非常有趣的语法。
结论
优雅、可读的代码使我们成为雇主更好的投资。当我们重用易于理解的代码时,我们的工作效率会更高。当歌词清晰时,我们无需停下来倒带歌曲;当使用的 API 直观时,我们无需浪费时间在 Google 上搜索。我们应该寻求使 C++ 语言更容易使用的方法,尤其是在不需要新标准的情况下。语言本身只是“代码栈”的底层;你在语言语法之上所做的工作对可读性有着巨大的影响。嘿,这就是我们都缩进的原因,对吧?如果你能采取措施最大限度地提高自动补全列表的实用性,你将节省浏览文档的时间。区分类中的名词和动词,你将一次又一次地重用这些类。可读的代码就像披头士的精选集——它将存在很长时间。
摇滚起来!
历史
- 版本 1.0,2009 年 3 月