欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

C++灵活易错特性-02

程序员文章站 2022-07-02 17:11:11
C++的四种类型转换:static_cast、dynamic_cast、const_cast、reinterpret_cast;typedef与类型别名,作用域和一些偶尔会看到的C风格工具。 ......

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,在模板中使用typedeftypedef的问题变得很突出。

举个例子,假定有如下的类模板:

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_castdynamic_castconst_castreinterpret_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_caststatic_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风格的变长参数列表,传arrayvector或者使用C++11引入的初始化列表更好。也可以通过variadic模板使用类型安全的变长参数列表。

预处理宏

应该用内联函数代替宏。宏易错,而且不执行类型检查。调用宏时,预处理阶段会自动展开替换,但不会真正用函数调用语义,这一行为可能导致无法预测的后果。还有,宏很容易出错,比如这样写是有问题的:

#define SQUARE(x) (x * x)

假如使用SQUARE(2 + 3),展开后变成2 + 3 * 2 + 3,计算的结果是11,不是预期中的25。正确的写法是这样的

#define SQUARE(x) ((x) * (x))

注意,最外面的括号不能省略,不然可能会因为优先级问题在算术表达式中被另行结合。

如果计算比较复杂,重复的展开替换意味着在做重复运算,就会有一笔不小的开销。