C--14-快速语法参考-二-

42 阅读39分钟

C++14 快速语法参考(二)

原文:C++ 14 Quick Syntax Reference, 2nd Edition

协议:CC BY-NC-SA 4.0

十六、访问级别

每个类成员都有一个可访问性级别,它决定了该成员在哪里可见。C++ 里有三种:publicprotectedprivate。类成员的默认访问级别是private。要更改一段代码的访问级别,需要使用一个访问修饰符,后跟一个冒号。该标签后面的每个字段或方法都将具有指定的访问级别,直到设置了另一个访问级别或类声明结束。

class MyClass
{
  int myPrivate;
 public:
  int myPublic;
  void publicMethod();
};

私人访问

所有成员,不管它们的访问级别是什么,都可以在声明它们的类(封闭类)中访问。这是唯一可以访问私有成员的地方。

class MyClass
{
  // Unrestricted access
  public: int myPublic;

  // Defining or derived class only
  protected: int myProtected;

  // Defining class only
  private: int myPrivate;
  void test()
  {
    myPublic    = 0; // allowed
    myProtected = 0; // allowed
    myPrivate   = 0; // allowed
  }
};

受保护的访问

受保护的成员也可以从派生类内部访问,但不能从不相关的类访问。

class MyChildpublic MyClass
{
  void test()
  {
    myPublic    = 0; // allowed
    myProtected = 0; // allowed
    myPrivate   = 0; // inaccessible
  }
};

公共访问

公共访问允许从代码中的任何地方进行不受限制的访问。

class OtherClass
{
  void test(MyClass& c)
  {
    c.myPublic    = 0; // allowed
    c.myProtected = 0; // inaccessible
    c.myPrivate   = 0; // inaccessible
  }
};

访问级别指南

作为一项准则,在选择访问级别时,通常最好使用最严格的级别。这是因为成员可以被访问的位置越多,它可以被错误访问的位置就越多,这使得代码更难调试。使用限制性访问级别还会使修改类变得更容易,而不会破坏使用该类的任何其他程序员的代码。

友元类和函数

通过将一个类声明为友元,可以允许该类访问另一个类的私有和受保护成员。这是通过使用friend修改器来完成的。允许友元访问定义该友元的类中的所有成员,但不允许反过来。

class MyClass
{
  int myPrivate;

  // Give OtherClass access
  friend class OtherClass;
};

class OtherClass
{
  void test(MyClass c) { c.myPrivate = 0; } // allowed
};

一个全局函数也可以被声明为一个类的朋友,以获得相同级别的访问。

class MyClass
{
  int myPrivate;

  // Give myFriend access
  friend void myFriend(MyClass c);
};

void myFriend(MyClass c) { c.myPrivate = 0; } // allowed

公共、受保护和私人继承

当一个类在 C++ 中被继承时,可以改变被继承成员的访问级别。公共继承允许所有成员保持其原始访问级别。受保护的继承减少了公共成员对受保护的。私有继承将所有继承的成员限制为私有访问。

class MyChildprivate MyClass
{
  // myPublic is private
  // myProtected is private
  // myPrivate is private
};

Private 是默认的继承级别,尽管 public 继承几乎总是被使用。

十七、静态

static关键字用于创建只存在于一个副本中的类成员,该副本属于类本身。这些成员由该类的所有实例共享。这与实例(非静态)成员不同,后者是作为每个新对象的新副本创建的。

静态字段

静态字段(类字段)不能像实例字段一样在类内部初始化。相反,它必须在类声明之外定义。这种初始化只发生一次,静态字段将在应用程序的整个生命周期中保持初始化状态。

class MyCircle
{
 public:
  double r;         // instance field (one per object)
  static double pi; // static field (only one copy)
};

double MyCircle::pi = 3.14;

若要从类外部访问静态成员,请使用类名,后跟范围解析运算符和静态成员。这意味着不需要创建一个类的实例来访问它的静态成员。

int main()
{
  double p = MyCircle::pi;
}

静态方法

除了字段之外,方法也可以被声明为static,在这种情况下,它们也可以被调用,而不必定义类的实例。但是,因为静态方法不是任何实例的一部分,所以它不能使用实例成员。因此,只有当方法执行独立于任何实例变量的通用函数时,它们才应该被声明为static。另一方面,与静态方法相反,实例方法可以使用静态和实例成员。

class MyCircle
{
 public:
  double r;         // instance variable (one per object)
  static double pi; // static variable (only one copy)

  double getArea() return pi * r * r; }
  static double newArea(double a) return pi * a * a; }
};

int main()
{
  double a = MyCircle::newArea(1);
}

静态局部变量

函数内部的局部变量可以声明为static,让函数记住变量。静态局部变量仅在执行第一次到达声明时初始化一次,然后在随后的每次执行中忽略该声明。

int myFunc()
{
  static int count = 0; // holds # of calls to function
  count++;
}

静态全局变量

最后一个可以应用static关键字的地方是全局变量。这将把变量的可访问性限制为仅当前源文件,因此可以用来帮助避免命名冲突。

// Only visible within this source file
static int myGlobal;

十八、枚举类型

Enum 是用户定义的类型,由固定的命名常量列表组成。在下面的例子中,枚举类型称为Color,包含三个常量:RedGreenBlue

enum Color { Red, Green, Blue };

Color类型可用于创建保存这些常量值之一的变量。

int main()
{
    Color c = Red;
}

为了更加清楚,枚举常量可以以枚举名称为前缀。然而,这些常量总是未被划分,因此必须小心避免命名冲突。

Color c = Color::Red;

枚举示例

switch 语句提供了一个很好的例子,说明枚举何时有用。与使用普通常量相比,枚举的优势在于它允许程序员清楚地指定变量应该包含什么值。

switch(c)
{
    case Red:   break;
    case Green: break;
    case Blue:  break;
}

枚举常量值

通常不需要知道常量所代表的基本值,但在某些情况下这可能是有用的。默认情况下,枚举列表中的第一个常量的值为零,每个后续常量的值都大一个。

enum Color
{
    Red   // 0
    Green // 1
    Blue  // 2
};

这些默认值可以通过给常量赋值来覆盖。这些值可以计算,并且不必是唯一的。

enum Color
{
    Red   = 5,        // 5
    Green = Red,      // 5
    Blue  = Green + 2 // 7
};

枚举转换

编译器可以隐式地将枚举常量转换为整数。但是,将整数转换回枚举变量需要显式强制转换,因为这种转换可能会分配一个不包含在枚举常量列表中的值。

int i = Red;
Color c = (Color)i;

枚举范围

不必全局声明枚举。它也可以作为类成员放在类中,或者局部放在函数中。

class MyClass
{
    enum Color { Red, Green, Blue };
};

void myFunction()
{
    enum Color { Red, Green, Blue };
}

强类型枚举

C++11 中引入了 enum 类,为常规 enum 提供了更安全的替代方法。这些新枚举的定义方式与常规枚举相同,只是增加了 class 关键字。

enum class Speed
{
    Fast,
    Normal,
    Slow
};

对于新的枚举,指定的常量属于枚举类名的范围,而不是常规枚举的外部范围。因此,要访问枚举类常量,必须用枚举名对其进行限定。

Speed s = Speed::Fast;

标准没有定义常规枚举的基础整数类型,并且可能在不同的实现之间有所不同。相反,默认情况下,类枚举总是使用 int 类型。这个类型可以被重写为另一个整数类型,如下所示。

enum class MyEnumunsigned short {};

枚举类的最后一个重要优势是它们的类型安全性。与常规枚举不同,枚举类是强类型的,因此不会隐式转换为整数类型。

if (s == Speed::Fast) {} // ok
if (s == 0) {}           // error

十九、结构和联合

结构体

C++ 中的 struct 相当于一个类,只是 struct 的成员默认为公共访问,而不是类中的私有访问。按照惯例,使用结构而不是类来表示主要包含公共字段的简单数据结构。

struct Point
{
    int x, y; // public
};

class Point
{
    int x, y; // private
};

声明者列表

要声明结构的对象,可以使用普通的声明语法。

Point p, q; // object declarations

另一种常用于结构的语法是在定义结构时声明对象,方法是将对象名放在最后一个分号之前。这个位置被称为声明符列表,可以包含逗号分隔的声明符序列。

struct Point
{
    int x, y;
} r, s; // object declarations

聚合初始化通常也与结构一起使用,因为这种语法上的快捷方式只适用于具有公共字段的简单聚合类型。对于支持 C++11 的编译器,统一的初始化语法是首选,因为它消除了聚合和非聚合类型初始化之间的区别。

int main()
{
  // Aggregate initialization
  Point p = { 2, 3 };

  // Uniform initialization
  Point q { 2, 3 };
}

联盟

虽然与 struct 相似,但联合类型的不同之处在于所有字段共享相同的内存位置。因此,联合的大小就是它包含的最大字段的大小。例如,在下面的例子中,这是一个 4 字节大的整数字段。

union Mix
{
    char c;  // 1 byte
    short s; // 2 bytes
    int i;   // 4 bytes
} m;

这意味着联合类型一次只能用于存储一个值,因为更改一个字段将会覆盖其他字段的值。

int main()
{
    m.c = 0xFF; // set first 8 bits
    m.s = 0;    // reset first 16 bits
}

除了有效的内存使用之外,联合的好处还在于它提供了查看同一内存位置的多种方式。例如,下面的 union 有三个数据成员,允许以多种方式访问同一个 4 字节组。

union Mix
{
    char c[4];                  // 4 bytes
    structshort hi, lo; } s; // 4 bytes
    int i;                      // 4 bytes
} m;

整数字段将一次访问所有 4 个字节。使用 struct 一次可以查看 2 个字节,使用 char 数组可以单独引用每个字节。

int main()
{
m.i=0xFF00F00F; // 11111111 00000000 11110000 00001111
m.s.lo;         // 11111111 00000000
m.s.hi;         //                   11110000 00001111
m.c[3];         // 11111111
m.c[2];         //          00000000
m.c[1];         //                   11110000
m.c[0];         //                            00001111}

匿名联盟

可以在没有名称的情况下声明联合类型。这被称为匿名联合,它定义了一个未命名的对象,该对象的成员可以从声明它的作用域直接访问。匿名联合不能包含方法或非公共成员。

int main()
{
    unionshort s; }; // defines an unnamed union object s = 15;
}

全局声明的匿名联合必须是静态的。

static union {};

二十、运算符重载

运算符重载允许在一个或两个操作数属于用户定义的类时重新定义和使用运算符。如果操作正确,这可以简化代码,并使用户定义的类型像基本类型一样易于使用。

运算符重载示例

在下面的例子中,有一个名为MyNum的类,它有一个整数字段和一个用于设置该字段的构造器。该类还有一个加法方法,将两个MyNum对象相加,并将结果作为一个新对象返回。

class MyNum
{
 public:
  int val;
  MyNum(int i) : val(i) {}

  MyNum add(MyNum &a)
  { return MyNum( val + a.val ); }
}

使用这种方法可以将两个MyNum实例添加在一起。

MyNum a = MyNum(10), b = MyNum(5);
MyNum c = a.add(b);

二进制运算符重载

运算符重载的作用是简化语法,从而为类提供更直观的接口。要将add方法转换为加法符号的重载,请将方法名替换为operator关键字,后跟要重载的运算符。关键字和操作符之间的空格可以选择省略。

MyNum operator + (MyNum &a)
{ return MyNum( val + a.val ); }

由于该类现在重载了加法符号,因此该运算符可用于执行所需的计算。

MyNum c = a + b;

请记住,运算符只是调用实际方法的替代语法。

MyNum d = a.operator + (b);

一元运算符重载

加法是二元运算符,因为它需要两个操作数。第一个操作数是调用该方法的对象,第二个操作数是传递给该方法的对象。当重载一元操作符时,比如前缀增量(++),不需要方法参数,因为这些操作符只影响调用它们的对象。

对于一元运算符,应该总是返回与对象类型相同的引用。这是因为当对一个对象使用一元运算符时,程序员希望结果返回同一个对象,而不仅仅是一个副本。另一方面,当使用二元运算符时,程序员希望返回结果的副本,因此应该使用按值返回。

MyNum& operator++() // ++ prefix
{ ++val; return *this; }

并非所有一元运算符都应通过引用返回。两个后缀操作符——后递增和后递减——应该改为按值返回,因为后缀操作应该在递增或递减发生之前返回对象的状态。请注意,后缀运算符指定了一个未使用的int参数。此参数用于将它们与前缀运算符区分开来。

MyNum operator++(int) // postfix ++
{
  MyNum t = MyNum(val);
  ++val;
  return t;
}

过载运算符

C++ 允许重载语言中几乎所有的操作符。从下表中可以看出,大多数运算符都是二进制类型的。其中只有少数是一元的,有些特殊运算符不能归为这两种。还有一些运算符根本不能重载。

|

二元运算符

|

一元运算符

| | --- | --- | | + - * / % | + - !~ & * ++ - | | = + = - = * = / = % = | 特殊操作员 | | & = ^ = | = << = > > = | ()[ ]删除新的 | | == != > < > = < = | 不可过载 | | & | ^ << > > && || | 。。::?□sizo | | –> – > , |   |

二十一、自定义转换

自定义类型转换可以被定义为允许一个对象从另一种类型构造或者转换成另一种类型。在下面的例子中,有一个名为MyNum的类,只有一个整数字段。使用转换构造器,可以将整数类型隐式转换为该对象的类型。

class MyNum
{
  public:
    int value;
};

隐式转换构造器

为了使这种类型转换生效,需要添加一个构造器,它接受所需类型的单个参数,在本例中是一个int

class MyNum
{
  public:
    int value;
    MyNum(int i) { value = i; }
};

当一个整数被赋给一个对象MyNum时,这个构造器将被隐式调用来执行类型转换。

MyNum A = 5; // implicit conversion

这意味着任何只接受一个参数的构造器既可以用于构造对象,也可以用于执行到该对象类型的隐式类型转换。

MyNum B = MyNum(5); // object construction
MyNum C(5);         // object construction

这些转换不仅适用于特定的参数类型,还适用于可以隐式转换为该类型的任何类型。例如,char可以被隐式转换成int,因此也可以被隐式转换成MyNum对象。

MyNum D = 'H'; // implicit conversion (char->int->MyNum)

显式转换构造器

为了帮助防止潜在的意外对象类型转换,可以禁用单参数构造器的第二次使用。然后应用explicit构造器修饰符,它指定构造器只能用于对象构造,而不能用于类型转换。

class MyNum
{
  public:
    int value;
    explicit MyNum(int i) { value = i; }
};

因此,必须使用显式构造器语法来创建新对象。

MyNum A = 5;        // error
MyNum B(5);         // allowed
MyNum C = MyNum(5); // allowed

转换运算符

自定义转换运算符允许从另一个方向指定转换:从对象的类型到另一个类型。然后使用 operator 关键字,后面是目标类型、一组括号和一个方法体。主体返回目标类型的值,在本例中为 int。

class MyNum
{
  public:
    int value;
    operator int() return value; }
};

当在 int 上下文中计算该类的对象时,将调用该转换运算符来执行类型转换。

MyNum A { 5 };
int i = A; // 5

显式转换运算符

C++11 标准在语言中增加了显式转换操作符。与显式构造器类似,包含 explicit 关键字可以防止隐式调用转换运算符。

class True
{
  explicit operator bool() const {
    return true;
  }
};

上面的类提供了一个安全的 bool,通过 bool 转换操作符防止其对象被错误地用在数学上下文中。在下面的示例中,第一次比较导致编译错误,因为不能隐式调用 bool 转换运算符。第二个比较是允许的,因为转换运算符是通过类型转换显式调用的。

True a, b;
if (a == b) {}             // error
if ((bool)a == (bool)b) {} // allowed

请记住,需要 bool 值的上下文(如 if 语句的条件)算作显式转换。

if (a) {} // allowed

二十二、名称空间

名称空间用于通过允许实体(如类和函数)被分组到一个单独的作用域下来避免命名冲突。在下面的例子中,有两个属于全局范围的类。因为两个类共享相同的名称和范围,所以代码不会被编译。

class Table {};
class Table {}; // error: class type redefinition

解决这个问题的一个方法是重命名其中一个冲突的类。另一种解决方案是将它们中的一个或两个放在一个不同的名称空间中,将它们放在一个名称空间块中。这些类属于不同的作用域,因此不会再引起命名冲突。

namespace furniture
{
    class Table {};
}

namespace html
{
    class Table {};
}

访问命名空间成员

要从名称空间外部访问该名称空间的成员,需要指定该成员的完全限定名。这意味着成员名必须以它所属的名称空间为前缀,后面跟着范围解析操作符。

int main()
{
    furniture::Table fTable;
    html::Table hTable;
}

嵌套命名空间

可以嵌套任意深度的名称空间,以进一步构建程序实体。

namespace furniture
{
    namespace wood { class Table {}; }
}

当在另一个命名空间中使用嵌套的命名空间成员时,请确保使用完整的命名空间层次结构来限定它们。

furniture::wood::Table fTable;

导入命名空间

为了避免每次使用其成员时都必须指定命名空间,可以借助 using 声明将命名空间导入到全局或局部范围。该声明包括using namespace关键字,后跟要导入的名称空间。它可以放置在本地或全局。在局部范围内,声明只在代码块的末尾有效,而在全局范围内,它将应用于声明之后的整个源文件。

using namespace html;    // global namespace import
int main()
{
    using namespace html; // local namespace import
}

请记住,将名称空间导入全局范围违背了使用名称空间的主要目的,即避免命名冲突。然而,这种冲突主要是使用几个独立开发的代码库的项目中的问题。

命名空间成员导入

如果您想避免键入完全限定名和导入整个名称空间,还有第三种选择。也就是说,只从名称空间导入所需的特定成员。这是通过用关键字using一次声明一个成员,后跟要导入的完全限定的名称空间成员来实现的。

using html::Table; // import a single namespace member

命名空间别名

缩短完全限定名的另一种方法是创建名称空间别名。然后使用关键字namespace,后跟一个别名,为其分配完全限定的名称空间。

namespace myAlias = furniture::wood; // namespace alias

然后可以用这个别名代替它所代表的名称空间限定符。

myAlias::Table fTable;

请注意,命名空间成员导入和命名空间别名都可以在全局和本地声明。

类型别名

也可以为类型创建别名。使用关键字typedef后跟类型和别名来定义类型别名。

typedef my::name::MyClass MyType;

然后,该别名可以用作指定类型的同义词。

MyType t;

Typedef 不仅适用于现有类型,还可以包含用户定义类型的定义,如类、结构、联合或枚举。

typedef structint len; } Length;
Length a, b, c;

C++11 增加了 using 语句,为别名类型提供了更直观的语法。使用这种语法,关键字 using 后跟别名,然后分配类型。与 typedef 不同,using 语句还允许模板有别名。

using MyType = my::name::MyClass;

别名不常用,因为它们会混淆代码。但是,如果使用得当,类型别名可以简化冗长或易混淆的类型名。它们提供的另一个功能是从单一位置改变类型定义的能力。

包括命名空间成员

请记住,在 C++ 中,仅仅导入一个名称空间并不能提供对该名称空间中成员的访问。为了访问名称空间成员,原型也必须可用,例如通过使用适当的# include指令。

// Include input/output prototypes
#include <iostream>

// Import standard library namespace to global scope using namespace std;

二十三、常量

常量是一个变量,它的值一旦被赋值就不能改变。这允许编译器强制确保变量值不会在代码的任何地方被错误地更改。

常量变量

通过在数据类型之前或之后添加关键字可以将变量变成常量。这个修饰符意味着变量变成只读的,因此必须在声明的同时给它赋值。试图在其他地方更改该值会导致编译时错误。

const int var = 5;
int const var2 = 10; // alternative order

常量指针

说到指针,const有两种用法。首先,指针可以保持不变,这意味着它不能改变指向另一个位置。

int myPointee;
int* const p = &myPointee; // pointer constant

其次,指针对象可以声明为常量。这意味着指向的变量不能通过这个指针修改。

const int* q = &var; // pointee constant

可以将指针和指针对象都声明为常量,使它们都是只读的。

const int* const r = &var; // pointer & pointee constant

注意常量变量不能被非常量指针指向。这可以防止程序员意外地使用指针重写常量变量。

int* s = &var; // error: const to non-const assignment

常量引用

引用可以像指针一样声明为常量。然而,因为永远不允许重新放置参考,所以将参考声明为const是多余的。只有保护裁判不被改变才有意义。

const int& y = var; // referee constant

常量对象

正如变量、指针和引用一样,对象也可以声明为常量。以下面这个类为例。

class MyClass
{
  public: int x;
  void setX(int a) { x = a; }
};

不能将此类的常量对象重新分配给另一个实例。

一个对象的常量也会影响它的字段并阻止它们被改变。

const MyClass a, b;
a = b;    // error: object is const
a.x = 10; // error: object field is const

常量方法

由于这最后一个限制,常量对象不能调用非常量方法,因为这样的方法被允许改变对象的字段。

a.setX(2); // error: cannot call non-const method

它们可能只调用常量方法,即在方法体之前用const修饰符标记的方法。

int getX() const return x; } // constant method

这个const修饰符意味着该方法不允许修改对象的状态,因此可以被该类的常量对象安全地调用。更具体地说,const修饰符适用于隐式传递给方法的this指针。这有效地限制了方法修改对象的字段或调用类中的任何非常量方法。

常量返回类型和参数

除了使方法成为常量之外,返回类型和方法参数也可以成为只读的。例如,如果字段是通过引用而不是常量方法的值返回的,那么为了保持对象的常量性,将它作为常量返回是很重要的。不是所有的 C++ 编译器都能捕捉到这个微妙的错误。

const int& getX() const return x; }

常量字段

类中的静态和实例字段都可以声明为常量。必须使用构造器初始化列表为常量实例字段赋值。这与初始化常规(非常量、非静态)字段的首选方式相同。

class MyClass
{
 public:
  int i;
  const int c;
  MyClass() : c(5), i(5) {}
}

常量静态字段必须在类声明之外定义,就像非常量静态字段一样。例外情况是当常量静态字段是整数数据类型时。这样的字段也可以在声明的同时在类中初始化。

class MyClass
{
 public:
  static int si;
  const static double csd;
  const static int csi = 5;
};
int MyClass::si = 1.23;
const double MyClass::csd = 1.23;

常量表达式

C++11 中引入了关键字 constexpr 来表示常量表达式。像 const 一样,它可以应用于变量,使它们成为常量,如果任何代码试图修改该值,就会导致编译错误。

constexpr int myConst = 5;
myConst = 3; // error: variable is const

与可能在运行时赋值的常量变量不同,常量表达式变量将在编译时计算。因此,在需要编译时常量的地方,比如在数组和枚举声明中,总是可以使用这样的变量。在 C++11 之前,这仅允许用于常量整型和枚举类型。

int myArray[myConst + 1];

函数和类构造器也可能被定义为常量表达式,这对于 const 是不允许的。在函数上使用 constexpr 会限制函数允许做的事情。简而言之,函数必须由单个 return 语句组成,并且只能引用其他 constexpr 函数和全局 constexpr 变量。C++14 放松了这些约束,允许 constexpr 函数包含其他可执行语句。

constexpr int getDefaultSize(int multiplier)
{
  return 3 * multiplier;
}

只有当 constexpr 函数的参数是常量表达式时,才能保证在编译时对其返回值进行评估,并且返回值用于需要编译时常量的地方。

// Compile-time evaluation
int myArray[getDefaultSize(10)];

如果在没有常量参数的情况下调用函数,它会像普通函数一样在运行时返回值。

// Run-time call
int mul = 10;
int size = getDefaultSize(mul);

构造器可以用 constexpr 声明,以构造一个常量表达式对象。这样的构造器一定是平凡的。

class Circle
{
public:
    int r;
    constexpr Circle(int x) : r(x) {}
};

当用常量表达式参数调用时,结果将是编译时生成的具有只读字段的对象。如果有其他参数,它将像普通的构造器一样工作。

// Compile-time object
constexpr Circle c1(5);

// Run-time object
int x = 5;
Circle c2(x);

不变准则

一般来说,如果不需要修改变量,最好总是将变量声明为常量。这确保了变量不会在程序的任何地方被错误地改变,这反过来将有助于防止错误。通过让编译器有机会将常量表达式硬编码到编译后的程序中,还可以提高性能。这允许表达式在编译期间只计算一次,而不是每次程序运行时都计算。

二十四、预处理器

预处理器是一个文本替换工具,它在编译之前修改源代码。这种修改是根据源文件中包含的预处理器指令完成的。这些指令很容易与普通的编程代码区分开来,因为它们都以一个散列符号(#)开始。它们必须始终作为一行中的第一个非空白字符出现,并且不以分号结束。下表显示了 C++ 中可用的预处理器指令及其功能。

|

管理的

|

描述

| | --- | --- | | #include | 文件包括 | | #define | 宏定义 | | #undef | undefined(未定义宏) | | #ifdef | 如果宏定义 | | #ifndef | 如果未定义宏 | | #if | 如果 | | #elif | 否则如果 | | #else | 其他 | | #endif | 如果…就会结束 | | #line | 设置行号 | | #error | 中止编译 | | #pragma | 设置编译器选项 |

包括源文件

指令将一个文件的内容插入到当前的源文件中。它最常见的用途是包含头文件,包括用户定义的头文件和库头文件。库头文件用尖括号(< >)括起来。这告诉预处理器在默认目录中搜索头文件,该目录被配置为查找标准头文件。

#include <iostream> // search library directory

您为自己的程序创建的头文件用双引号("")括起来。然后,预处理器将在当前文件所在的目录中搜索该文件。如果没有找到头文件,预处理器将在标准头文件中搜索。

#include "MyFile.h" // search current, then default

双引号形式也可用于指定文件的绝对或相对路径。

#include "C:\MyFile.h" // absolute path
#include "..\MyFile.h" // relative path

规定

另一个重要的指令是# define ,用来创建编译时常量,也叫宏。在这个指令之后,常量的名称被指定,后跟它将被替换的内容。

#define PI 3.14 // macro definition

预处理器将检查并改变这个常量的任何出现,以及它的定义中后面的任何内容,直到行尾。

float f = PI; // f = 3.14

按照惯例,常量应该用大写字母命名,每个单词用下划线隔开。这样,在阅读源代码时很容易发现它们。

不明确的

不应该使用#define指令直接覆盖先前定义的宏。这样做会给编译器一个警告。为了改变一个宏,首先需要使用#undef指令对其进行定义。尝试取消当前未定义的宏的定义不会生成警告。

#undef PI // undefine
#undef PI // allowed

预定义宏

编译器预定义了许多宏。为了区别于其他宏,它们的名字以两个下划线开始和结束。下表列出了这些标准宏。

|

管理的

|

描述

| | --- | --- | | __FILE__ | 当前文件的名称和路径。 | | __LINE__ | 当前行号。 | | __DATE__ | 以 MM DD YYYY 格式表示的编译日期。 | | __TIME__ | 以 HH:MM:SS 格式表示的编译时间。 | | __func__ | 当前函数的名称。在 C++11 中添加。 |

预定义宏的一个常见用途是提供调试信息。举个例子,下面的错误消息包括文件名和消息出现的行号。

cout << "Error in " << __FILE__ << " at line " << __LINE__;

宏功能

可以让宏接受参数。这允许他们定义编译时函数。例如,下面的宏函数给出了其参数的平方。

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

调用宏函数就像调用一个普通的 C++ 函数一样。请记住,要使这种函数工作,参数必须在编译时已知。

int x = SQUARE(2); // 4

请注意宏定义中的额外括号,它们用于避免运算符优先级的问题。如果没有括号,下面的示例将给出不正确的结果,因为乘法将在加法之前执行。

#define SQUARE(x) x*x

int main(void) {
  int x = SQUARE(1+1); // 1+1*1+1 = 3
}

要将一个宏函数分成几行,可以使用反斜杠字符。这将对标志预处理器指令结束的换行符进行转义。要做到这一点,反斜杠后面不能有任何空格。

#define MAX(a,b)  \
a>b ? \
a:b

尽管宏功能强大,但它们往往会使代码更难阅读和调试。因此,只有在绝对必要的情况下才应该使用宏,并且应该尽量简短。常量变量、枚举类和 constexpr 函数等 C++ 代码通常比#define指令更有效、更安全地完成相同的目标。

#define DEBUG 0
const bool DEBUG = 0;

#define FORWARD 1
#define STOP 0
#define BACKWARD -1
enum class DIR { FORWARD = 1, STOP = 0, BACKWARD = -1 };

#define MAX(a,b) a>b ? a:b
constexpr int MAX(int a, int b) return a>b ? a:b; }

条件编译

如果满足特定条件,用于条件编译的指令可以包含或排除部分源代码。首先,有#if#endif指令 ,它指定了一段代码,只有在#if指令之后的条件为真时才会被包含。请注意,该条件必须计算为常量表达式。

#define DEBUG_LEVEL 3

#if DEBUG_LEVEL > 2
 // ...
#endif

就像 C++ if 语句一样,可以包含任意数量的#elif (else if)指令和一个最终的#else指令。

#if DEBUG_LEVEL > 2
 // ...
#elif DEBUG_LEVEL == 2
 // ...
#else
 // ...
#endif

条件编译还提供了一种有用的方法,可以出于测试目的临时注释掉大块代码。这通常不能用常规的多行注释来实现,因为它们不能嵌套。

#if 0
 /* Removed from compilation */
#endif

如果已定义,则编译

有时,一段代码应该只在定义了某个宏的情况下才被编译,而不考虑它的值。为此,可以使用两个特殊操作符:defined!defined(未定义)。

#define DEBUG

#if defined DEBUG
 // ...
#elif !defined DEBUG
 // ...
#endif

分别使用指令#ifdef#ifndef也可以达到同样的效果。例如, #ifdef部分仅在指定的宏已经预先定义的情况下编译。请注意,即使没有给宏赋值,它也会被认为是已定义的。

#ifdef DEBUG
 // ...
#endif

#ifndef DEBUG
 // ...
#endif

错误

当遇到#error指令时,编译中止。该指令对于确定某一行代码是否正在编译非常有用。它可以选择接受一个指定所生成的编译错误的描述的参数。

#error Compilation aborted

线条

一个不太常用的指令是# line ,它可以改变编译过程中出现错误时显示的行号。遵循该指令,行号通常会为每一个连续的行增加一。该指令可以接受一个可选的字符串参数,该参数设置发生错误时将显示的文件名。

#line"myapp.cpp"

杂注

最后一个标准指令是# pragma ,即实用信息。此指令用于为编译器指定选项;因此,它们是特定于供应商的。举个例子,#pragma message可以和许多编译器一起使用,向构建窗口输出一个字符串。该指令的另一个常见参数是warning,它改变了编译器处理警告的方式。

// Show compiler message
#pragma message( "Hello Compiler" )

// Disable warning 4507
#pragma warning(disable : 4507)

属性

C++11 中引入了一种新的标准化语法,用于在源代码中提供编译器特定的信息,即所谓的属性。属性放在双方括号中,并且可以根据属性应用于任何代码实体。举个例子,在 C++14 中添加的一个标准属性是[[不推荐]],这表明不鼓励使用代码实体。

// Mark as deprecated
[[deprecated]] void foo() {}

每当使用这样的实体时,该属性允许编译器发出警告。此警告中可以包含一条消息,以描述不推荐使用该实体的原因。

[[deprecated("foo() is unsafe, use bar() instead")]]
void foo() {}

二十五、异常处理

异常处理允许程序员处理程序中可能出现的意外情况。

抛出异常

当函数遇到无法恢复的情况时,它可以生成一个异常来通知调用者函数已经失败。这是通过使用关键字后跟函数想要发送的信号来完成的。当到达该语句时,函数将停止执行,异常将传播到调用方,在调用方可以使用 try-catch 语句捕获异常。

nt divide(int x, int y)
{
  if (y == 0) throw 0;
  return x / y;
}

Try-catch 语句

try-catch 语句由一个 try 块和一个或多个 catch 子句组成,try 块包含可能导致异常的代码,catch 子句用于处理异常。在上面的例子中,抛出了一个整数,因此需要包含一个 catch 块来处理这种类型的异常。抛出的表达式将作为一个参数传递给这个异常处理程序,在这里它可以用来确定函数出了什么问题。注意,当异常被处理后,执行将在 try-catch 块之后继续运行,而不是在 throw 语句之后。

try {
  divide(10,0);
}
catch(int& e) {
  std::cout << "Error code: " << e;
}

异常处理程序可以通过值、引用或指针捕捉抛出的表达式。但是,应该避免按值捕捉,因为这会导致产生额外的副本。通过引用捕获通常是更可取的。如果 try 块中的代码可以抛出更多类型的异常,那么也需要添加更多的 catch 子句来处理它们。请记住,只有与抛出的表达式匹配的处理程序才会被执行。

catch(char& e) {
  std::cout << "Error char: " << e;
}

为了捕捉所有类型的异常,可以使用省略号(...)作为 catch 的参数。这个默认处理程序必须放在最后一个 catch 语句中,因为放在它后面的任何处理程序都不会被执行。

catch(...) { std::cout << "Error"; }

重新引发异常

如果一个异常处理程序不能从异常中恢复,它可以通过使用没有指定参数的关键字throw被再次抛出。这将在调用方堆栈中向上传递异常,直到遇到另一个 try-catch 块。但是要小心,因为如果一个异常从未被捕获,程序将因运行时错误而终止。

int main()
{
  try {
    trythrow 0; }
    catch(...) { throw; } // re-throw exception
  }
  catch(...) { throw; }   // run-time error
}

异常说明

默认情况下,函数允许抛出任何类型的异常。为了指定函数可能抛出的异常类型,可以将throw关键字追加到函数声明中。throw关键字后面是逗号分隔的允许类型列表,如果有的话,用括号括起来。

void error1() {}            // may throw any exceptions
void error2() throw(...) {} // may throw any exceptions
void error3() throw(int) {} // may only throw int
void error4() throw() {}    // may not throw exceptions

这种异常规范与 Java 中使用的非常不同,总的来说,没有什么理由在 C++ 中指定异常。编译器不会以任何方式强制执行指定的异常,也不会因此而进行任何优化。

在 C++11 中不赞成使用throw作为异常规范,它被 noexcept 说明符所取代。类似于throw(),这个说明符表明函数不打算抛出任何异常。主要区别在于 noexcept 支持某些编译器优化,因为如果由于某种原因异常仍然发生,说明符允许程序终止而不展开调用堆栈。

void foo() noexcept {} // may not throw exceptions

异常类

如前所述,任何数据类型都可以在 C++ 中抛出。然而,标准库确实提供了一个名为exception的基类,它是专门设计来声明要抛出的对象的。它在异常头文件中定义,位于std名称空间下。如下所示,该类可以用一个字符串来构造,该字符串成为异常的描述。

#include <exception>
void make_error()
{
  throw std::exception("My Error Description");
}

当捕捉到这个异常时,可以使用对象的函数what 来检索描述。

trymake_error(); }
catch (std::exception e) {
  std::cout << e.what();
}

二十六、类型转换

将表达式从一种类型转换为另一种类型被称为类型转换。这可以隐式地或显式地完成。

隐式转换

当表达式需要转换成它的兼容类型之一时,编译器会自动执行隐式转换。例如,原始数据类型之间的任何转换都可以隐式完成。

long a = 5;   // int implicitly converted to long
double b = a; // long implicitly converted to double

这些隐式原语转换可以进一步分为两种:晋升降级。当表达式隐式转换为较大的类型时,发生升级;当表达式转换为较小的类型时,发生降级。因为降级会导致信息丢失,所以这些转换会在大多数编译器上生成警告。如果潜在的信息丢失是有意的,可以通过使用显式的强制转换来抑制警告。

// Promotion
long   a = 5;  // int promoted to long
double b = a;  // long promoted to double

// Demotion
int  c = 10.5; // warning: possible loss of data
bool d = c;    // warning: possible loss of data

显式转换

第一种显式强制转换是从 C 继承而来的,通常称为 C 风格强制转换 。所需的数据类型只需放在需要转换的表达式左侧的括号中。

int  c = (int)10.5; // double demoted to int
char d = (char)c;   // int demoted to char

C++ 强制转换

C 样式的强制转换适用于基本数据类型之间的大多数转换。然而,当涉及到类和指针之间的转换时,它可能太强大了。为了更好地控制不同类型的转换,可能的 C++ 引入了四种新的类型转换,称为命名转换新型转换。这些转换是:静态、重新解释、常量和动态转换。

static_cast<new_type> (expression)
reinterpret_cast<new_type> (expression)
const_cast<new_type> (expression)
dynamic_cast<new_type> (expression)

如上所述,它们的格式是在转换的名称后面加上用尖括号括起来的新类型,然后是用括号括起来的要转换的表达式。这些类型转换可以更精确地控制转换的执行方式,从而使编译器更容易捕捉转换错误。相比之下,C 风格的强制转换在一个操作中包括静态、重新解释和常量强制转换。因此,如果使用不当,该类型转换更有可能执行细微的转换错误。

静态投

静态强制转换在兼容类型之间执行转换。它类似于 C 风格的类型转换,但更具限制性。例如,C 风格的强制转换允许一个整数指针指向一个字符。

char c = 10;       // 1 byte
int *p = (int*)&c; // 4 bytes

由于这会导致一个 4 字节的指针指向 1 字节的已分配内存,因此写入该指针将会导致运行时错误,或者会覆盖一些相邻的内存。

*p = 5; // run-time error: stack corruption

与 C 风格的强制转换不同,静态强制转换将允许编译器检查指针和指针对象数据类型是否兼容,这允许程序员在编译期间捕捉这种不正确的指针赋值。

int *q = static_cast<int*>(&c); // compile-time error

重新解释 Cast

为了强制进行指针转换,就像 C 风格的强制转换在后台进行的一样,可以使用 reinterpret 强制转换。

int *r = reinterpret_cast<int*>(&c); // forced conversion

这种强制转换处理某些不相关类型之间的转换,例如从一种指针类型转换到另一种不兼容的指针类型。它将简单地执行数据的二进制复制,而不改变底层的位模式。请注意,这种低级操作的结果是特定于系统的,因此不可移植。如果无法完全避免,则应谨慎使用。

Const Cast

第三种 C++ 强制转换是 const 强制转换。这个主要用于添加或删除变量的const修饰符。

const int myConst = 5;
int *nonConst = const_cast<int*>(&a); // removes const

尽管 const cast 允许更改常量的值,但这样做仍然是无效的代码,可能会导致运行时错误。例如,如果常量位于只读存储器的某个部分,就会出现这种情况。

*nonConst = 10; // potential run-time error

相反,Const cast 主要用于有一个采用非常量指针参数的函数时,即使它不修改指针对象。

void print(int *p) { std::cout << *p; }

然后,可以通过使用常量强制转换将常量变量传递给该函数。

print(&myConst); // error: cannot convert
                 // const int* to int*

print(nonConst); // allowed

c 风格和新型造型

请记住,C 风格的强制转换也可以删除const修饰符,但是同样,因为它在幕后进行这种转换,所以 C++ 强制转换更可取。使用 C++ 类型转换的另一个原因是它们比 C 风格类型转换更容易在源代码中找到。这很重要,因为造型错误可能很难发现。使用 C++ 强制转换的第三个原因是它们写起来不舒服。由于在许多情况下可以避免显式转换,所以这是有意为之,以便程序员可以寻找不同的解决方案。

动态投

第四个也是最后一个 C++ 强制转换是动态强制转换。这个只用于将对象指针和对象引用转换成继承层次结构中的其他指针或引用类型。通过执行运行时检查,确保指针指向目标类型的完整对象,这是确保所指向的对象可以被转换的唯一强制转换。为了使这种运行时检查成为可能,对象必须是多态的。也就是说,该类必须定义或继承至少一个虚函数。这是因为编译器只会为这些对象生成所需的运行时类型信息。

动态转换示例

在下面的例子中,使用动态转换将一个MyChild指针转换成一个MyBase指针。这个从派生到基的转换成功了,因为Child对象包含了一个完整的Base对象。

class MyBasepublic: virtual void test() {} };
class MyChildpublic MyBase {};

int main()
{
  MyChild *child = new MyChild();
  MyBase  *base = dynamic_cast<MyBase*>(child); // ok
}

下一个例子试图将一个MyBase指针转换成一个MyChild指针。由于Base对象不包含一个完整的Child对象,这个指针转换将会失败。为了表明这一点,动态强制转换返回一个空指针。这为在运行时检查转换是否成功提供了一种便捷的方法。

MyBase  *base = new MyBase();
MyChild *child = dynamic_cast<MyChild*>(base);

if (child == 0) std::cout << "Null pointer returned";

如果转换的是引用而不是指针,那么动态转换将会失败,抛出一个bad_cast异常。这需要使用 try-catch 语句来处理。

#include <exception>
// ...
try { MyChild &child = dynamic_cast<MyChild&>(*base); }
catch(std::bad_cast &e)
{
  std::cout << e.what(); // bad dynamic_cast
}

动态或静态转换

使用动态强制转换的优点是,它允许程序员在运行时检查转换是否成功。缺点是进行这种检查会带来性能开销。由于这个原因,在第一个例子中使用静态转换会更好,因为从派生到基的转换永远不会失败。

MyBase *base = static_cast<MyBase*>(child); // ok

但是,在第二个示例中,转换可能成功,也可能失败。如果MyBase对象包含一个MyBase实例,它将失败;如果它包含一个MyChild实例,它将成功。在某些情况下,这可能直到运行时才知道。在这种情况下,动态转换是比静态转换更好的选择。

// Succeeds for a MyChild object
MyChild *child = dynamic_cast<MyChild*>(base);

如果使用静态转换而不是动态转换来执行从基到派生的转换,转换就不会失败。它会返回一个指向不完整对象的指针。取消引用这样的指针会导致运行时错误。

// Allowed, but invalid
MyChild *child = static_cast<MyChild*>(base);

// Incomplete MyChild object dereferenced
(*child);

二十七、模板

模板提供了一种使类、函数或变量处理不同数据类型的方法,而不必为每种类型重写代码。

功能模板

下面的示例显示了一个交换两个整数参数的函数。

void swap(int& a, int& b)
{
  int tmp = a;
  a = b;
  b = tmp;
}

要将这个方法转换成可以处理任何类型的函数模板,第一步是在函数前添加一个模板参数声明。该声明包括关键字template,后跟关键字class模板参数的名称,两者都用尖括号括起来。模板参数的名称可以是任何名称,但通常以大写字母 t 命名。

template<class T>

或者,可以用关键字typename代替class。在这种情况下,它们是等价的。

template<typename T>

创建函数模板的第二步是用模板参数替换将要成为泛型的数据类型。

template<class T>
void swap(T& a, T& b)
{
  T tmp = a;
  a = b;
  b = tmp;
}

调用函数模板

函数模板现在完成了。要使用它,swap 可以像普通函数一样被调用,但是需要在函数参数前的尖括号中指定模板参数。在后台,编译器将实例化一个填充了这个模板参数的新函数,并且就是这个生成的函数将从这一行调用。

int a = 1, b = 2;
swap<int>(a,b); // calls int version of swap

每次用新类型调用函数模板时,编译器都会用模板实例化另一个函数。

bool c = true, d = false;
swap<bool>(c,d); // calls bool version of swap

在这个例子中,swap函数模板也可以在没有指定模板参数的情况下被调用。这是因为编译器可以自动确定类型,因为函数模板的参数使用模板类型。然而,如果不是这种情况,或者如果需要强制编译器选择函数模板的特定实例化,那么模板参数将需要在尖括号内明确指定。

int e = 1, f = 2;
swap(e,f); // calls int version of swap

多个模板参数

通过将模板添加到尖括号之间,可以将模板定义为接受多个模板参数。

template<class T, class U>
void swap(T& a, U& b)
{
  T tmp = a;
  a = b;
  b = tmp;
}

上例中的第二个模板参数允许用两个不同类型的参数调用swap

int main()
{
  int a = 1;
  long b = 2;
  swap<int, long>(a,b);
}

课程模板

类模板允许类成员使用模板参数作为类型。它们的创建方式与函数模板相同。

template<class T>
class myBox
{
 public:
  T a, b;
};

与函数模板不同,类模板必须总是用显式指定的模板参数进行实例化。

myBox<int> box;

使用类模板时要记住的另一件事是,如果一个方法是在类模板之外定义的,那么该定义之前也必须有模板声明。

template<class T>
class myBox
{
 public:
  T a, b;
  void swap();
};

template<class T>
void myBox<T>::swap()
{
  T tmp = a;
  a = b;
  b = tmp;
}

注意模板参数包含在类名限定符之后的swap模板函数定义中。这指定函数的模板参数与类的模板参数相同。

非类型参数

除了类型参数,模板还可以有类似函数的常规参数。例如,下面的int模板参数用于指定数组的大小。

template<class T, int N>
class myBox
{
 public:
  T store[N];
};

当这个类模板被实例化时,必须包含一个类型和一个整数。

myBox<int, 5> box;

默认类型和值

可以为类模板参数指定默认值和类型。

template<class T = int, int N = 5>

要使用这些默认值,在实例化类模板时只需将尖括号留空即可。

myBox<> box;

请注意,默认模板参数不能在函数模板中使用。

类模板专门化

如果当特定类型作为模板参数传递时,需要为模板定义不同的实现,可以声明一个模板专门化 。例如,在下面的类模板中,有一个输出模板变量的值的print方法。

#include <iostream>

template<class T>
class myBox
{
 public:
  T a;
  void print() { std::cout << a; }
};

当模板参数是一个bool时,该方法应该输出“真”或“假”而不是“1”或“0”。一种方法是创建一个类模板专门化 。然后在模板参数列表为空的地方创建类模板的重新实现。相反,一个bool特殊化参数被放置在类模板的名称之后,并且在整个实现过程中,这个数据类型被用来代替模板参数。

template<>
class myBox<bool>
{
 public:
  bool a;
  void print() { std::cout << (a ? "true""false"); }
};

当这个类模板被一个bool模板类型实例化时,这个模板专门化将被使用,而不是标准的。

int main()
{
  myBox<bool> box = { true };
  box.print(); // "true"
}

请注意,标准模板和专用模板之间没有成员继承。整个类将不得不被重新定义。

函数模板专门化

由于上例中的模板之间只有一个函数不同,更好的替代方法是创建一个函数模板 专门化 。这种专门化看起来非常类似于类模板专门化,但是只应用于单个函数而不是整个类。

#include <iostream>

template<class T>
class myBox
{
 public:
 T a;

  template<class T> void print() {
    std::cout << a;
  }

  template<> void print<bool>() {
    std::cout << (a ? "true""false");
  }
};

这样,只有print方法需要重新定义,而不是整个类。

int main()
{
  myBox<bool> box = { true };
  box.print<bool>(); // "true"
}

请注意,在调用专用函数时,必须指定模板参数。对于类模板专门化来说,情况并非如此。

可变模板

除了函数和类模板,C++14 允许变量模板化。这是使用常规模板语法实现的。

template<class T>
constexpr T pi = T(3.1415926535897932384626433L);

与 constexpr 说明符一起,该模板允许在编译时为给定类型计算变量值,而不必对该值进行类型强制转换。

int i = pi<int>;     // 3
float f = pi<float>; // 3.14...

可变模板

C++11 允许模板定义接受可变数量的类型参数。该功能可用于替代变量函数。举例来说,考虑下面的变量函数,它返回传递给它的任意数量的整数的和。

#include <iostream>
#include <initializer_list>
using namespace std;

int sum(initializer_list<int> numbers)
{
  int total = 0;
  for(auto& i : numbers) { total += i; }
  return total;
}

initializer_list 类型表明该函数接受一个用大括号括起来的列表作为它的参数,因此必须以这种方式调用该函数。

int main()
{
  cout << sum( { 1, 2, 3 } ); // "6"
}

下一个例子把这个函数变成了一个可变的模板函数。这样的函数是递归遍历的,而不是迭代遍历的,所以一旦处理了第一个参数,函数就用剩下的参数调用自己。

使用省略号(...)运算符,后跟一个名称。这定义了所谓的参数包。参数包在这里被绑定到函数中的一个参数(...rest),然后解包到单独的参数(rest...)当函数递归调用自身时。

int sum() return 0; } // end condition

template<class T0, class ... Ts>
decltype(auto) sum(T0 first, Ts ... rest)
{
  return first + sum(rest ...);
}

这个可变模板函数可以作为一个常规函数调用,可以有任意数量的参数。与前面定义的变量函数不同,这个模板函数接受任何类型的参数。

int main()
{
  cout << sum(1, 1.5, true); // "3.5"
}

二十八、头文件

当项目增长时,通常会将代码分割成不同的源文件。当这种情况发生时,接口和实现通常是分离的。接口放在头文件中,头文件通常与源文件同名,扩展名为. h。该头文件包含源文件实体的前向声明,这些实体需要可供项目中的其他编译单元访问。一个编译单元由一个源文件(cpp)加上任何包含的头文件(。h 或者。hpp)。

为什么要使用标题

C++ 要求所有东西在使用前都要声明。仅仅在同一个项目中编译源文件是不够的。例如,如果将一个函数放在 MyFunc.cpp 中,而同一个项目中名为 MyApp.cpp 的第二个文件试图调用它,编译器将报告找不到该函数。

// MyFunc.cpp
void myFunc() {}

// MyApp.cpp
int main()
{
  myFunc(); // error: myFunc identifier not found
}

为了实现这个功能,函数的原型必须包含在 MyApp.cpp 中。

// MyApp.cpp
void myFunc(); // prototype

int main()
{
  myFunc();     // ok
}

使用标题

如果将原型放在一个名为 MyFunc.h 的头文件中,并且通过使用#include指令将这个头文件包含在 MyApp.cpp 中,这样会更方便。这样,如果对 MyFunc 做了任何更改,就不需要更新 MyApp.cpp 中的原型。此外,任何想要使用 MyFunc 中的共享代码的源文件都可以只包含这个头文件。

// MyFunc.h
void myFunc(); // prototype

// MyApp.cpp
#include "MyFunc.h"

标题中包含的内容

就编译器而言,头文件和源文件没有区别。这种区别只是概念上的。关键的思想是头文件应该包含实现文件的接口——也就是其他源文件需要使用的代码。这可能包括共享常量、宏和类型别名。

// MyApp.h - Interface
#define DEBUG 0
const double E = 2.72;
typedef unsigned long ulong;

如前所述,头文件可以包含源文件中定义的共享函数的原型。

void myFunc(); // prototype

此外,共享类通常在头文件中指定,而它们的方法在源文件中实现。

// MyApp.h class MyClass
{
  public:
    void myMethod();
};

// MyApp.cpp
void MyClass::myMethod() {}

与函数一样,在包含全局变量定义的编译单元之外的编译单元中引用全局变量之前,有必要转发声明全局变量。这是通过将共享变量放在头部并用关键字 extern 标记来实现的。该关键字指示变量在另一个编译单元中初始化。默认情况下,函数是外部的,所以函数原型不需要包含这个说明符。请记住,在一个程序中,全局变量和函数可以在外部声明多次,但它们只能定义一次。

// MyApp.h
extern int myGlobal;

// MyApp.cpp
int myGlobal = 0;

应该注意的是,不鼓励使用共享全局变量。这是因为程序越大,就越难跟踪哪些函数访问和修改这些变量。首选的方法是只在需要时将变量传递给函数,以最小化这些变量的范围。

标头不应包含任何可执行语句,但有两个例外。首先,如果一个共享类方法或全局函数被声明为inline,那么这个函数必须在头文件中定义。否则,从另一个源文件调用内联函数将会产生一个无法解决的外部错误。请注意,内联修饰符取消了通常应用于代码实体的单个定义规则。

// MyApp.h
inline void inlineFunc() {}

class MyClass
{
  public:
          void inlineMethod() {}
};

第二个例外是共享模板。当遇到模板实例化时,编译器需要访问该模板的实现,以便创建一个填充了类型参数的实例。因此,模板的声明和实现通常都放在头文件中。

// MyApp.h
template<class T>
class MyTemp/* ... */ }

// MyApp.cpp
MyTemp<int> o;

在许多编译单元中实例化同一类型的模板会导致编译器和链接器做大量的冗余工作。为了防止这种情况,C++11 引入了外部模板声明。标记为 extern 的模板实例化向编译器发出信号,不要在此编译单元中实例化模板。

// MyApp.cpp
MyTemp<int> b; // instantiation is done here

// MyFunc.cpp
extern MyTemp<int> a; // supress redundant instantiation

如果一个头文件需要其他头文件,通常也包括这些文件,使头文件独立。这确保了所需的一切都以正确的顺序包含在内,解决了每个需要头文件的源文件的潜在依赖性问题。

// MyApp.h
#include <cstddef.h> // include size_t
void mySize(std::size_t);

请注意,由于头文件主要包含声明,所以包含的任何额外头文件都不会影响程序的大小,尽管它们可能会降低编译速度。

包括警卫

使用头文件时要记住的一件重要事情是,一个共享代码实体只能定义一次。因此,多次包含同一个头文件可能会导致编译错误。防止这种情况的标准方法是使用所谓的包括 守卫 。include guard 是通过将头文件的开头包含在一个#ifndef部分中来创建的,该部分检查特定于该头文件的宏。只有在未定义宏的情况下,文件才会被包含,然后再定义宏,这样可以有效地防止文件被再次包含。

// MyApp.h
#ifndef MYAPP_H
#define MYAPP_H
// ...
#endif // MYAPP_H