C++灵活易错特性-02
C++笔记系列:灵活易错的特性 - 02
关键词:
- 类型和类型转换
- typedef
- 作用域
- 头文件
类型和类型转换
typedef
typedef
为已有的类型声明提供一个新名称。可以将typedef
看成已有类型声明的同义词。
typedef int* IntPtr
可以互换地使用新的类型名称及其别名:
int* p1;
IntPtr p2;
从根本上,它们就是同一类型:
// 完全合法
p1 = p2;
p2 = p2;
typedef
最常见的用法是当类型的声明过于笨拙时,提供易于管理的名称。特别是在模板当中。
如果你不想不厌其烦地写std::vector<std::string>
,就可以使用typedef
,
typedef std::vector<std::string> StringVector;
void ProcessVector(const StringVector& vec) {}
int main {
StringVector my_vector;
return 0;
}
实际上,STL广泛地使用typedef
,比如string
是这样定义的:
typedef basic_string<char, char_traits<char>, allocator<char>> string
函数指针typedef
C++中函数指针并不常见(被virtual
关键字替代),但在某些情况下还是需要获取函数指针。不过在定义函数指针时,typedef
最让人费解。
考虑一个动态链接库(DLL),库中有一个名为MyFunc()
的函数。只有需要调用MyFunc()
时,才会加载这个库。
假定MyFunc()
的原型如下,
int __stdcall MyFunc(bool b, int n, const char* p);
__stdcall
时Microsoft特有的指令,指示如何将参数传递到函数,如何执行清理。
现在可以使用typedef
为指向函数的指针定义一个缩写名称(MyFuncProc),该函数具有前面所示的原型:
typedef int (__stdcall *MyFuncProc)(bool b, int n, const char* p);
注意typedef
的名称MyFuncProc
嵌在这个语法中间,相当费解。解决这个问题还有另外更为简洁的方法,就是下一节将描述的类型别名。
类型别名
某些情况下,类型别名比typedef
更容易理解。
typedef int MyInt;
可以用类型别名写作
using MyInt = int;
当typedef
变得复杂时,类型别名就特别有用。
typedef int (*FuncType)(char, double);
// 使用类型别名
using FuncType = int (*)(char, double);
类型别名不只是易于阅读的typedef
,在模板中使用typedef
,typedef
的问题变得很突出。
举个例子,假定有如下的类模板:
template<typename T1, typename T2>
class MyTemplateClass {
};
// ok
typedef MyTemplateClass<int, double> OtherName;
// Error
typedef MyTemplateClass<T1, double> OtherName;
// 如果要这么做,应该使用模板别名
template<typename T1>
using OtherName = MyTemplateClass<T1, double>;
类型转换
C++提供了四种类型转换:static_cast
、dynamic_cast
、const_cast
、reinterpret_cast
。强烈建议再新代码中仅使用C++风格的类型转换,它们更安全,语法上也比C风格的()
更加优秀。
1.const_cast
const_cast
最直接,可以用于变量的常量特性。这是四个类型转换中唯一可以舍弃常量特性的类型转换。当然,从理论上讲,并不需要const
类型转换。变量是const
,就应该一直是const
。
但是,有时候某个函数需要把const
变量传递给非const
变量的函数。最好的方法当然是保持const
一致,很多时候使用了第三方库,没有办法,只能委曲求全,不然就只能重新构建程序。
// 第三方外部方法
extern void ThirdPartyLibraryMethod(char * str);
void f(const char* str) {
ThirdPartyLibraryMethod(const_cast<char*>(str));
}
2.static_cast
显示执行C++直接支持的转换。例如,在算术表达式中,将int
转换为double
避免截断,就可以使用static_cast
。
int i = 3;
int j = 4;
// 只需要转换其中一个就可以确保执行浮点数除法
double result = static_cast<double>(i) / j;
如果用户定义了相关的构造函数或者转换例程,也可以使用static_cast
执行显式转换。例如,类A的构造函数将类B的对象作为参数,就可以使用static_cast
将B对象转换为A对象。很多时候都需要这种行为,编译器一般会自动隐式执行这个转换。
static_cast
的另一种用法是在类继承层次结构中执行向下转换。注意,只能用于对象的指针和引用,不能转换对象本身。
另外还需要注意,static_cast
不执行运行期间的类型检测。比如下面代码可能会导致灾难性的后果,包括内存重写超出了对象的边界。
// Derived继承自Base
Base* b = new Base();
Derived* d = static_cast<Derived*>(b);
要执行具有运行时检测的更安全转换,可以用dynamic_cast
。
static_cast
不是万能的,无法将const
类型转换成non-const类型,无法把某种类型的指针转换到其它不相关类型的指针,无法直接转换对象的类型。基本上,无法完成类型规则中没有意义的转换。
3.reinterpret_cast
reinterpret_cast
比static_cast
更强大,同时安全性更差。可以用来执行技术上不被规则允许,但某些情况下又需要的类型转换。例如,可在两个引用之间相互转换,即使两者并不相关。同样,即使两个指针不存在继承关系,也可以将某种指针类型转换为其他指针类型。
reinterpret_cast
经常用于将指针转换为void*
,以及将void*
转换为指针。
class X {};
class Y {};
int main() {
X x;
Y y;
X* px = &x;
Y* py = &y;
// 无关的指针类型间转换只能用reinterpret_cast
px = reinterpret_cast<X*>(py);
void *p = px;
px = reinterpret_cast<X*>(p);
X& rx = x;
Y& ry = reinterpret_cast<Y&>(x);
}
理论上,reinterpret_cast
还可以把指针转换为int
,或者把int
转换为指针。但是,这种程序可能是不正确的。许多平台上(特别是64位),指针和int
的大小不同。例如,在64位平台上,指针是64位,整数可能是32位。将64位的指针转换为32位整数会丢失32位。
另外,使用reinterpret_cast
要特别小心,它不会执行任何类型检测。
4.dynamic_cast
dynamic_cast
为继承层次结构内的类型转换提供运行时检测。可用它来转换指针或者引用。dynamic_cast
在运行时检测底层对象的类型信息。如果类型转换没有意义,dynamic_cast
返回一个空指针(用于指针)或者抛出一个std::bad_cast
异常(用于引用)。
注意运行时类型信息存储在对象的虚表中。因此,为了使用dynamic_cast
,类至少要有一个虚方法。如果类没有虚表,使用dynamic_cast
会导致编译错误。
下面用于引用的dynamic_cast
将抛出异常:
#include <iostream>
using std::bad_cast;
using std::cout;
class Base {
public:
Base() {}
virtual ~Base() {}
};
class Derived : public Base {
public:
Derived() {}
virtual ~Derived() {}
};
int main() {
Base base;
Derived derived;
// 改成Base& br = derived;就不会抛出异常
Base& br = base;
try {
Derived& dr = dynamic_cast<Derived&>(br);
} catch (const bad_cast&) {
cout << "Bad cast!\n";
}
}
5.类型转换总结
情形 | 类型转换 |
---|---|
删除const特性 | const_cast |
语言支持的类型转换(例如,int转换为double,int转换为bool) | static_cast |
用户自定义构造函数或者转换例程所支持的类型转换 | static_cast |
类的对象转换为其他(无关)类的对象 | 无法完成 |
类对象的指针(pointer-to-object)转换为同一继承层次结构中其他类对象的指针 | static_cast或者dynamic_cast(推荐) |
类对象的引用(reference-to-object)转换为同一继承层次结构中其他类对象的引用 | static_cast或者dynamic_cast(推荐) |
类型的指针转换(pointer-to-type)为其他无关类型的指针 | reinterpret_cast |
类型的引用转换(reference-to-type)为其他无关类型的引用 | reinterpret_cast |
函数指针(pointer-to-function)为其他函数指针 | reinterpret_cast |
作用域解析
创建作用域可以使用
- 名称空间
- 函数定义
- 花括号界定的块
- 类定义
试图访问某个变量、函数或者类时,会从最近的作用域开始查找这个名称,然后时相邻的作用域,以此类推,直到全局作用域。
如果不想用默认的作用域解析某个名称,就可以使用作用域解析运算符::
和特定的作用域限定这个名称。例如,访问类的静态方法,第一种做法是将类名(方法的作用域)和作用域解析运算符放在方法名的前面。第二种方法是通过类的对象访问这个静态方法。
头文件
头文件是为代码提供抽象接口的一种机制。使用头文件需要注意的一点是避免循环引用或者多次包含同一个头文件。最常用的方法就是使用#ifndef
机制。
#ifndef
可以用来避免循环包含和多次包含。在每个头文件的开头,#ifndef
指令检测是否定义了某个标记,如果标记已经定义,则代表已经包含了头文件,编译器将调到对应的#endif
,这个指令一般位于文件的结尾。如果没有定义该标记,将定义这个标记,重复包含就会被忽略。这个机制也称为头文件保护(include guards)。
#ifndef LOGGER_H
#define LOGGER_H
#include "Preferences.h"
class Logger {
public:
static void setPreferences(const Preferences& prefs);
static void logError(const char* error);
};
#endif // LOGGER_H
如果编译器支持#pragma once
(Visual C++或者g++),可以这样写:
#pragma once
#include "Preferences.h"
class Logger {
public:
static void setPreferences(const Preferences& prefs);
static void logError(const char* error);
};
前置声明(forward declarations)是另一个避免头文件问题的工具。如果需要使用某个类,但是无法包含它的头文件(例如,这个类严重依赖当前编写的类),就可以告诉编译器,存在这么一个类,但是无法使用#include
。当然,不能在代码中使用这个类,只有链接成功后才存在这个已命名的类。
#ifndef LOGGER_H
#define LOGGER_H
class Preference; // 前置声明
class Logger {
public:
static void setPreferences(const Preferences& prefs);
static void logError(const char* error);
};
在大型项目中,使用前置声明可以减少编译和重编译的时间,因为它破坏了一个头文件对其它头文件的依赖。但是,前置声明也隐藏了依赖关系,头文件改动时,用户的代码会跳过必要的重新编译过程。
前置声明有时候会妨碍头文件变动API,特别是函数,例如扩大形参类型。另外,一般的项目,还没到需要缩短编译时间的程度。
建议函数总是用
#include
,类模板优先考虑#include
。
C的工具
C语言有一些晦涩的特性在C++中还是偶尔可以看到。比如,变长参数列表(variable length argument lists)和预处理器宏(preprocessor macros)。
变长参数列表
在C语言中,对于这个特性一定不陌生,printf()
就是使用了这种特性。
注意,新的代码应该通过variadic
模板使用类型安全的变长参数列表。
#include <cstdio>
#include <cstdarg>
bool debug = false;
void DebugOut(const char* str, ...) {
va_list ap;
if (debug) {
va_start(ap, str);
vfprintf(stderr, str, ap);
va_end(ap);
}
}
int main(int argc, char const *argv[]) {
debug = true;
DebugOut("int %d\n", 5);
DebugOut("String %s and int %d\n", "hello", 5);
DebugOut("Many ints: %d, %d, %d, %d, %d\n", 1, 2, 3, 4, 5);
return 0;
}
调用va_start()
之后必须调用va_end()
,确保函数结束后,堆栈处于稳定状态。
1.访问参数
如果想要自己访问实际参数,可以使用va_arg()
。不过,如果不提供显式的方法,就无法知道参数列表的结尾是什么。
可以让第一个参数计算参数的数目,或者当参数是一组指针时,可以要求最后一个是nullptr
。
下面给一个示例,调用者需要在第一个已命名的函数中指定提供的参数数目。
void PrintInts(int num, ...) {
int temp;
va_list ap;
va_start(ap, num);
for (int i = 0; i < num; ++i) {
temp = va_arg(ap, int);
cout << temp << " ";
}
va_end(ap);
cout << endl;
}
2,为什么不应该使用C风格变长参数列表
- 不知道参数的数目。例如在
PrintInts()
,需要信任调用者传递了与第一个参数数目相等的参数。 - 不知道参数的类型。
尽可能避免使用C风格的变长参数列表,传array
、vector
或者使用C++11引入的初始化列表更好。也可以通过variadic
模板使用类型安全的变长参数列表。
预处理宏
应该用内联函数代替宏。宏易错,而且不执行类型检查。调用宏时,预处理阶段会自动展开替换,但不会真正用函数调用语义,这一行为可能导致无法预测的后果。还有,宏很容易出错,比如这样写是有问题的:
#define SQUARE(x) (x * x)
假如使用SQUARE(2 + 3)
,展开后变成2 + 3 * 2 + 3
,计算的结果是11,不是预期中的25。正确的写法是这样的
#define SQUARE(x) ((x) * (x))
注意,最外面的括号不能省略,不然可能会因为优先级问题在算术表达式中被另行结合。
如果计算比较复杂,重复的展开替换意味着在做重复运算,就会有一笔不小的开销。
下一篇: 设计模式:策略模式