1.静态性
const并非在C++中才出现的新关键字,它是C语言的常客了,和原有语义相比,扩充了有关引用和成员函数的处理。
1.概念
常量(const)的概念很直白:意味着这“部分”在程序执行中不可被改变,此处的“部分”刻意泛指各类能被标识为常量的对象。编译时,编译器会立刻在发现对常量部分的改动时报错,就像对“私有”和“保护”类内容的行为类似,帮助我们尽快地发现并修正错误。正如下方代码所示。
const int MAX_PLAYERS = 4;
const char * pcAppName = "MyApp";
MAX_PLAYERS = 2; // Error, MAX_PLAYERS is declared const
有经验的C程序员深知用常量来代替预处理器指令#define的好处,尽管它们功能类似,但前者使编译器得以应用通常的C++类型安全,帮助了寻找错误和潜在问题;而#define则仅仅是不包括类检查的直接文本替换。这是一条基本原则:应当尽可能地依赖于编译器来检查一切,把诸如类转化的繁重而机械的工作交给编译器,从而节省我们的时间来花在更有用的地方。
使用常量关键字的另一优点是,它们会在编译时被纳入符号表,从而可被调试器使用。能在调试器中看到某常量的符号名,远比猜测某个数字究竟属于哪个#define要省心多了。任何在Win32中调试并研读过错误代码和标识的人都会爱上这个优点。
2.指针和常量
和常量结合的指针会变得难以捉摸,以下是四种结合的可能性:
int * pData1;
const int * pData2;
int * const pData3;
const int * const pData4;
显然第一行声明的是非常量指针,并指向了非常量数据,于是你可以自由修改指针本身或数据。最后一行恰恰相反,指针或数据都不能被改变。那中间两行呢?别忘了,常量修饰的是它右边的第一个词语。于是第二行是指向常量数据的非常量指针;第三行中的常量的左边紧挨着指针变量,所以指针是常量,而指向的数据则不是。可以在脑子里画些括号来厘清常量的作用范围。
// Not C++ code, just a mental aid
(const int *) pData2;
int—* (const pdatas);
C++还不满足,还有另一个语法变体来表达同样的概念,不然面试时候的难题从哪来呢?比如这行,你觉得是什么意思?
int const * pData5;
虽然它语法正确,但常量竟然在*和类型中间,像前文一样画个括号,就有了(int const *)pDate5,答案昭然若揭,与第二行相同,它是指向常量数据的非常量指针,只是常量的位置略有不同。这种写法不常见,但你可能偶遇它,所以最好知道这种替代格式的存在。
3.函数和常量
常量最常见的用法之一就是修饰函数的参数和返回值,每当我们要传入过大或复制困难的参数时,总是会用指针(或引用)来替代。这时常量就帮助我们区分了“为了优化而传入指针”和“为了修改原数据而传入指针”两种情况。更糟的情况:某函数读取了传入数据后又修改了数据,这对其他假定数据违背修改的代码而言是致命的。常量来大显身手了,它保证数据不被修改并使一切修改尝试有迹可循,更让重读代码的人类一眼看懂指针(或引用)参数的意图。
// Clearly pos is read-only
void GameEntity::SetPosition (const Point3d * pos);
// Vector entities will not be modified
int AI::SelectTarget (const vector<GameEntity*> * pEnts);
显然,仅传数据的参数不必使用常量修饰,既然仅是数据的复制,那更改又有什么关系呢?这是实现时需要关注的细节, 而调用时则不必在意。
返回值中也是同理,如果所需返回值难以复制(诸如字符串或矩阵)通常做法是返回指针,现在则可以通过返回的指针直接修改,但如果我们不想允许这样直接的修改呢?这无异于是潜在的设计漏洞。如下的例子:
// Player class with some constness problems
Class Player
{
public:
void SetName (char * name);
char * GetName();
//...
private:
char m_name[128];
};
void Player::SetName(char * name)
{
::strcpy(m_ name, name);
}
char * Player: :GetName()
{
return m_name;
}
乍一看一切都没问题,可以改名字(这样用户就能在UI中输入名字),也能获得名字来打印出来或者用聊天讯息发送。然而问题可太多了,任何人都能调用GetName()并从指针追踪到实际的字符串,不仅能读取还能顺利修改。大多数的设计应该只允许通过SetName()来修改名称。由于它不造成崩溃且隐蔽,这将是一个难以追踪的神秘漏洞。解决方法之一是检查每一帧中玩家名称是否有改动,如果有就尽可能地回应,但应当有更简单的办法。也可以写文档来解释,GetName()返回的指针不该被用作直接修改数据,但总可能有人误用,尤其是死线将近时。更好的方法是让编译器保护不该被修改的对象,如下:
// A better Player class without const problems
class Player E
{
public:
void SetName (const char * name) ;
const char * GetName() ;
//...
private:
char m_name[128];
}5
void Player::SetName (const char * name)
{
::strcpy(m_name, name) ;
}
const char * Player: :GetName()
{
return m_name;
}
现在问题解决且不必大动干戈。其实还有可优化之处,更好的方法是使用引用而非指针,而且改为字符串而非字符指针。当然更改后常量依然可用。
4.类和常量
这章到目前都在回顾C中的常量使用,可这本书明明是写C++的,为什么要回顾?因为它在C中远不如C++中使用广泛,C++扩展了它的含义到处理成员函数,例如:
// An even better Player class
class Player
{
public:
void SetName (const char * name);
const char * GetName() const;
//...
private:
char m_name[128];
};
const char * Player::GetName() const
{
return m_name;
}
注意,GetName()的声明(declaration)和实现(implementation)两处都有常量关键字。被标记为常量的函数意味着它不会改变所属对象的状态,此例中,意味着此函数不会改变玩家类中的任何东西,而它确实仅返回了一个指针。SetName()并非常量,因为它需改变玩家类中的姓名参数。
把函数参数标记为常量甚至更有用,是的函数的目的在源代码中更明确了,且编译器会遵循这个规则。聪明的编译器会在我们常量成员函数尝试返回非常量指针时报错,因为它让内部变量向调用方开放,破坏了常量性。于是这个特性在返回值和函数本身之间传递。
调用其他函数时也同理,常量成员函数不能调用非常量成员函数或修改任何数据。
你应当尽可能地使用常量,虽然一些老库中应该为常量的函数总是错误地缺失,导致你难以正确使用它,好在它可以在必要时被抛弃(后文详述)。务必不要胡乱抛弃,这是在丢掉来自编译器的助力。
5. 可变(mutable):常量非常量
截至目前我们都在讨论对象的“状态”,但它究竟是什么?编译器将它望文生义地理解成任何成员的改变,但有时成员变量并不能反映对象的真实状态——至少不是逻辑上的状态。一个简单的例子,一种负责计数某函数的调用次数的类,或许是前文中玩家类的GetName()函数,直觉来看,可以在类中加一个计数器并在函数被调用时增值,但别忘这是个常量函数,无法改变成员变量。备选方案是,降级这个函数为非常量,但这合理吗?不论如何,在类中写入一些无害的统计数据不会干扰玩家类的使用。
另一种情况:一个负责缓存数据的对象,它需要随时显式地加载或卸载大量数据。对外界而言这个对象和内部数据应当总是可见的,所以它的查询函数应当是常量,即使它要负责加载大量数据,矛盾之处就在这里。
好在有个优雅的解决方案,可变关键字,它使成员变量可被任何成员方法修改,不论方法是否为常量。所以可以把任何“不与对象的逻辑状态有关”的成员变量设置为可变,这就解决了常量函数不能修改变量的问题。回到前文的例子,加上了可变后就变成了这样:
class Player
{
public:
void SetName (const char * name);
const char * GetName() const;
//...
private:
char m_name[128];
mutable int m_iTimesGetNameCalled;
};
const char * Player::GetName() const
{
++m_iTimesGetNameCalled; // OK because it is mutable
return m_name;
}
6. 常量使用建议
应当尽可能多的在各处使用常量:变量、参数、返回值、成员函数……它使代码的更可读,也让编译器帮助设立规则。唯一的麻烦可能在于,在已有代码基础上从头开始添加它,在但这些修修改改是值得的。
2.引用
引用只是一个对象的别名。对引用的任何操作都会同样作用于原对象。难以置信,这么简单的概念是非常强大的简化工具。它的语法简单,除了&符号外和普通对象无异。它在原生类和对象中也有相同的原理。
int a = 100;
int & b = a; // b is a reference to a
b = 200; // both a and b are 200 now
Matrix4x4 rot = camera.GetRotation();
Matrix4x4 & rot2 = rot; // rot2 is a reference to rot
rot2.Inverse(); // inverses both rot and rot2
它和指针很像,区别在于对引用的操作会影响对象本身。另外,它也像指针一样高效。
1.引用 vs 指针
它们之间的不同之处:
- 使用引用不必更改语法,引用成员函数时,引用直接用“.”,而指针则需要“->”。
- 引用仅能被初始化一次,且后续无法修改。指针则会更改指向的对象。引用就像常量指针。
- 引用必须在声明时就初始化。和指针不同,不能先创建再初始化。
- 引用不能是空的(NULL)。这是第一和第二点的后果。但这不是说引用总是指向可用对象,因为它可能被删除了,或者被愚弄成指向NULL。
- 引用不能像指针一样被删除或创建,就像对象一样。
2. 引用和函数
引用的主要作用之二:为函数传参和返回值。前文已述,指针能高效地传较大的对象,省去复制的麻烦。虽然常量关键字免去了指针被更改的隐患,但由于性能而把对象改成指针也挺奇怪的。引用就来救场了。用常量引用传参,不仅达到了传值的目的,也节省性能、保持语法不变。而关于使用常量指针的建议也同样适用于常量指针。
// SetRotation takes a const reference to a new rotation
// matrix
void GameEntity::SetRotation (const Matrix4x4 & rot)
{
if (!rot.IsIdentity)
//...
}
// 复制矩阵可能很贵
Matrix4x4 rot;
// 不用复制矩阵也能调用函数
// 因为是用引用传参的!
entity.SetRotation(rot) ;
它也能用于返回值,但务必小心:直接赋值给某对象等同于复制(而复制有时很贵)。所以暂存引用时,要存引用自身。
const Matrix4x4 & GameEntity::GetRotation() const
{
return m_rotation; // Cheap. It's just a reference
}
// 小心点,这触发了复制
Matrix4x4 rot = entity.GetRotation();
// 这是存引用,很便宜
const Matrix4x4 & rot = entity.GetRotation();
//也可以把返回值引用直接传参,也很高效
camera.SetRotation (entity.GetRotation());
和指针一样,我们要保证当函数结束时,返回的引用没有越界到作用域之外。常见问题是,返回的引用指向栈上的失效地址,它被使用时会致使程序崩溃。幸运的是,大多编译器可以检测到这种情况并发出警告。
即使你还是没爱上它,也没法完全躲开它。它们出现在复制结构体和二进制计算中,所以你至少要熟悉它,希望后面的文章能说服你在自己的代码里用它。
// Copy constructor
Matrix4x4: :Matrix4x4 (const Matrix4x4 & matrix);
// Binary operator
const & Matrix4x4 Matrix4x4: :operator* (
const Matrix4x4 & matrix);
3. 引用的优点
虽然对于编译器来说,引用只是某种指针,但它对我们程序员来说是大救星:第一优点是它的语法相比指针更整洁易读,没有乱七八糟的->和*,比如下面这个对比:
// Using pointers
position = *(pEntity->GetPosition());
// Using references
position = entity.GetPosition();
第二优点是它不会是NULL,总是指向某对象或编译器误以为是对象的东西,所以不会像指针一样传空值或未初始化的指针。
和指针同理,某时被引用指向的可用对象,不一定在被访问后依然可用,这叫做“悬指针”问题:我们存了个指向已释放内存的指针,那么解指针时就会引发问题。但是由于引用必须在生命时初始化且后续不能改动,所以存储一个指向被删除对象的引用就会更难,大多数引用都在栈里,并会在离开作用域时消失,一定程度上避免了这个问题。
第三优点是,不用担心是否要在使用后解放被引用的对象。因为只有指针能被解放,所以当使用引用时,可以假定自己不用操心解放的事。(这里我没怎么看懂,贴个原文:Another advantage of references is that there is never any doubt as to whether or not the object pointed to by the reference should be freed by the code that is using the reference. This can be done only through a pointer, not a reference. So if we are working with a reference, it is safe to assume that somebody else will free it.)
所有的优点可总结为:引用是稍高级的对象操控手段,使我们忘记存储管理的细节和对象的包含关系,故能更好的集中精力于解决实际问题。总是记住:写程序不是为了展现高超的底层技巧和使用最新的C++功能,而是为了创造好游戏。更少的对内存泄漏和实现细节的担忧就是更多的集中在游戏本身,我们的效率会更高,游戏也会更稳定。
有些人认为引用的缺点之一是,无法从调用代码判断传的究竟是数值还是引用。但是对调用代码而言,判断这点有什么必要吗?重要的是参数能否被函数修改,而这取决于引用是否为常量。这个问题的答案是什么,只要看函数声明就能知道了,在现今的集成到开发环境的代码浏览工具中,往往只需一个点击。
另一个常见谬误是,“函数不会改的对象,用常量引用;函数会改的对象,用指针”。再次强调,无需查看函数声明即可更多地了解函数相对于其参数的意图。尽管在某种程度上这可能是正确的,但有时我们仍然希望将指针传递给函数,即使对象不会被修改。此外,并不是每个人都会遵循这个约定,这意味着无论如何我们都必须检查函数声明。
4. 何时用引用?
所以应该只用引用吗?并不是。即使引用有许多优点,有时候指针也有用武之地。
会被动态生成或删除的对象,应该用指针。被生成对象的拥有者通常存储指向它的指针。而当其他对象使用此对象是,它们就可以用引用,明确了它们不会负责解放此对象。而当此对象需要转换拥有者时,则需要用指针,而非引用。
当需要改变指向的对象时,由于引用不能更改指向的对象,所以指针是唯一方法,除非要改动程序结构。
指针可以是空值,或许是被某函数返回的空指针,象征着执行失败;又或许作为可选参数之一。引用则不能。值得探讨的是,好的程序设计该不该不时依赖于空指针?或许应当重构程序,用另一种方法标识失败的函数,或直接用引用?
最后的原因是,指针算数能遍历一个内存块并按指针类型转译内容。这个用法很底层且极易出问题,简直是维护噩梦,应当能避则避。但是,比如在一个多层循环里,类型安全的做法已经由于开销巨大而行不通了,分析器显示程序在这里浪费了很多时间,仅仅在此时,指针运算是可行的。
一些研究显示,大多数C++的bug由内存泄露产生。所以被引用替代的指针越多,我们的程序就越可靠稳定。
3. 强转(Casting)
转换(Conversion)意味着将改变某些数据的类型的过程,此处数据泛指包含原生类和用户创建类。编译器有一系列决定哪些转换可行以及何时触发的规则。例如把整型赋给浮点数触发了转换:
int a = 200;
float b = a; // Conversion from int to float
char txt[] = "Hello";
float c = txt; // No conversion possible
强转(Casting)则是通过在变量前加括号及目标类型,直接在源代码里强制编译器应用某种转换(Conversion)。
int n = 150;
// n is an integer, which divided by an integer results
// in another integer. f1 == 1.0
float f1 = n / 100;
// n is cast to a float, and when divided by an integer,
// the result is a float. f2 == 1.5
float f2 = (float)n / 100; // cast to a float
1. 强转的用法
强转被大多数程序员诟病,它往往不是最优解,且意味着放弃这C++这种和类型深度绑定的语言的优势,人为干扰从不犯错的编译器。所以应当尽量不使用。
强转的用处之一:链接不同的代码块。当接口需求和我们准备传入的类型不相符时,强转是正确的选择。这在C库里更常见,因为它们不使用继承和多态。例如一个通用方法,参数有两个,数据本身和数据处理方法的标识符,虽然它糟糕且不安全,但从中可以看出强转在某些情况的必要性:
void SerializeWrite (DataType type, void * pData);
char txt[] = "This is a string";
::SerializeWrite (SerializeString, (void *)txt);
float fPitch;
::SerializeWrite (SerializeFloat, (void *)&fPitch) ;
const Matrix4x4 & rot = camera.GetRotation() ;
::SerializeWrite (SerializeMatrix4x4, (void *)&rot);
另一个用处是对多态对象的使用。假设有个高大的继承树,其中大多数代码通过指向共有基类的指针来处理对象。虽然这不是个好设计,但是它可能存在。假设无法访问运行时类型信息,程序不得不判断对象类型并强转到指定类型:
void GameEntity::OnCollision (GameEntity & entity)
{
if (entity.IsType(GameEntity: : PROJECTILE) )
{
GameProjectile & projectile = (GameProjectile &)entity;
projectile.BlowUp() ;
}
//...
}
2. C++风格的强转
和刚讲的C风格强转不同,C++风格的强转对转换过程和种类有更好的控制。有四个强转运算符,它们共用同一种语法,虽然比老方法更啰嗦一些,且在源代码里很惹眼,但它不容易有拼写错误且更易懂。它们共有的格式和使用示例是:
static_cast<type>(expression)
// C++-style cast
float f2 = static_cast<float>(n) / 100; // cast to a float
1. static_cast 静态强转
它负责在原生数据类型之间的、且有继承关系的类中的转换。有时会丢失精度。它也不能更改常量性。
class A
{
};
class B : public A
{
};
// Unrelated to A and B
class C
{
};
A * a = new A;
//Success
B * b = static_cast<B*>(a);
//Error
C * c = static_cast<C*>(a);
// The old C cast would work just fine (but what would
// the program do?)
C* c = (C*)(a);
2.const_cast 常量强转
它不能在不同类之间转换,而是切换表达式的常量性。通常,从非常量到常量的转换是自动且顺畅的,反过来则只能通过强转。使用常量强转往往意味着设计缺陷,出现了驴唇不对马嘴的情况。大多数使用情景是调用C风格的常量函数时。但如果你用在了调用自己的代码上,建议立刻停手并反思自己的设计。
3. reinterpret_cast 重译强转
它和C风格强转类似,能进行任何类型和指针间的转换,无视类型安全或常量性。它的结果完全取决于被转换对象的内存布局和实现方式。需要极度谨慎地使用它,仅当其他转换都不可用且有必要时使用。
4.dynamic_cast 动态强转
和其他在编译时被编译器执行、强转失败时会有报错、且不影响运行开销的强转不同,它在运行时才会被检查是否可行,且只能作用于指针或引用而不是原生类型上。它不仅像静态强转一样需要类之间的继承关系,还检查指针指向对象的类型、评估转换是否可行,如果可行则返回新指针和处理多例继承导致的偏移;如果转换不可行,则返回空指针。显然这需要编译器访问运行时类型信息(RTTI),如果RTTI不可用,就只能另寻他法。
第三章 完