C++关键字的思考 (Boolan)

C++关键字的思考 (Boolan)

本章内容:
1 关键字的相关理解
1.1 const关键字
1.2 static关键字
1.3 非局部变量的初始化顺序
1.4 非局部变量的销毁顺序


1 关键字的相关理解

  • 在C++中,关键字const和static非常让人困惑。这两个关键字有很多的含义,每种用法都很微妙,下面内容将讲解各种具体的用法。

1.1 const关键字

  • constconstant的缩写,指保持不变的量。编译器会执行这一要求,任何尝试改变常量的行为都会当做错误处理。此外,当启用了优化时,编译器可以利用此信息生成更好的代码。关键字const有两种相关的用法,可以用这个关键字标记变量或者参数,也可以用其标记方法。本小节将讨论这两种含义。

1.1.1 const变量和参数

  • 可以使用const来“保护”变量不被修改。这个关键字的一个重要用法是替换#define来定义常量。这是const最直接的应用。例如,可以这样声明常量PI

      const double PI = 3.141592653589792;
    
  • 可以将任何变量标记为const,包括全局变量和类成员数据。

  • 还可以使用const指定函数或者方法的参数保持不变。例如,下面的函数接受一个const参数。在函数体内不能修改整数param。如果试图修改这个变量,编译器将会生成一个错误。

      void func(const int param)
      {
          // 不允许修改param...
      } 
    
  • 下面将讨论两种特殊的const变量或者参数:const指针和const引用。

(1) const指针

  • 当变量通过指针包含一层或者多层间接取值时,const的应用变得十分微妙。考虑如下代码:

      int* pIP;//(1)
      pIP = new int[10];//(2)
      pIP[4] = 5;//(3)
    
  • 假定要将const应用到pIP。暂时不考虑这么做有没有作用,只考虑这样做意味着什么。是想阻止修改pIP变量本身,还是阻止修改pIP所指向的值?

  • 为了阻止修改pIP所指向的值(第三行),可在pIP的声明中这样添加关键字const

      const int* pIP;(1)
      pIP = new int[10];//(2)
      pIP[4] = 5;//(3)    编译出错!
    
  • 现在无法修改pIP所指向的值。

  • 下面是在语义上等价的另一种方法:

      int const* pIP;(1)
      pIP = new int[10];//(2)
      pIP[4] = 5;//(3)    编译出错!
    
  • const放在int前面还是后面并不影响其功能。

  • 如果要将pIP本身标记为const(而不是pIP所指向的值),可以这样做:

      int* const pIP = nullptr;//(1)
      pIP = new int[10];//(2) 编译出错!
      pIP[4] = 5;//(3)    错误:不能为空指针赋值!
    
  • 现在pIP本身无法修改,编译器要求在声明pIP时就执行初始化,可以用前面的代码中的nullptr,也可以是用新分配的内存,如下所示:

      int* const pIP = new int[10];
      pIP[4] = 5;
    
  • 还可以将指针和所指的值全部标记为const,如下所示:

      const int* const pIP = nullptr;
    
  • 下面是另一种等价的语法:

      int const* const pIP = nullptr;
    
  • 尽管这些语法看上去有点混淆,但规则实际上很简单:const关键字应用于直接位于它左侧的任何内容,再次思考这一行:

      int const* const pIP = nullptr;
    
  • 从左到右,第一个const直接位于int的右边,因此const应用到pIP所指的int。从而指定无法修改pIP所指向的值。第二个const直接位于*的右边,因此,它应用于指向int的指针,也就是pIP变量。因此,无法修改pIP(指针)本身。

  • 这一条规则由于一个例外而变得令人费解:第一个const可以出现在变量前面,如下所示:

      const int* const pIP = nullptr;
    
  • 这个“异常的”语法比其他语法更加常见。

  • 可以将这个规则引用到任意层次的间接取值,例如:

      const int* const* const* const pIP = nullptr;
    

注意:另一种易于记忆的,用于指出复杂变量声明的规则:从右向左读,考虑示例int* const pIP;从右向左读这条语句,就可以知道pIP是一个指向intconst指针。另一方面,int const* pIP读作pIP是一个指向const int的指针。

(2) const引用

  • const应用于引用通常比它应用于指针更简单:首先,引用默认为const,无法改变引用所指的对象。因此,无需显示地将引用标记为const。其次,无法创建一个引用的引用,所以引用通常只有一层间接取值。获取多层间接取值的唯一方法是创建指针的引用。

  • 因此,C++程序员提到“const引用”时,含义如下所示:

      int z;
      const int& zRef = z;
      zRef = 4;   // 编译出错!
    
  • 由于将const引用到int,因此无法对zRef赋值,如前所示。记住,const int& zRef等价于int const& zRef。然而需注意,将zRef标记为constz没有影响。仍然可以修改z的值。具体做法是直接改变z,而不是通过引用。

  • const引用通常用作参数,这非常有用。如果为了提高效率,想按引用传递某个值,但不想修改这个值,可将其标记为const引用。例如:

      void doSomething(const BigClass& arg)
      {
          // do something here...
      }
    

(2) const方法

  • 常量对象的值不能改变。如果使用常量对象,常量对象的引用和指向常量对象的指针,编译器将不允许调用对象的任何方法,除非这些方法承诺不改变任何数据成员。为了确保方法不改变数据成员,可以使用const关键字来标记方法本身。下面的SpreadsheetCell类包含了用const标记的不改变任何数据成员的方法。

      Class SpreadsheetCell
      {
      public:
          double getValue() const;
          const string& getString() const;
      };
    
  • const规范是方法原型的一部分,必须放在方法的定义中:

      double SpreadsheetCell::getValue() const
      {
          return mValue;
      }
      const string& SpreadsheetCell::getString() const
      {
          return mString;
      }
    
  • 将方法标记为const,就是与客户代码立下契约,承诺不会在方法内改变对象内部的值。如果将实际上修改了数据成员的方法声明为const,编译器将会报错。不能将静态方法声明为const,因为这个是多余的,静态方法没有类的实例,因此不能改变类内部数据成员的值。const工作原理:是将方法内的数据成员都标记为const引用,因此如果试图修改数据成员,编译器会报错。

  • const对象可以调用const方法和非const方法。然而,const对象只能调用const方法,下面是一些示例:

      SpreadsheetCell myCell(5);
      cout << myCell.getValue() << endl;      // OK
      myCell.setString("6");                  // OK
      const SpreadsheetCell& anotherCell = myCell;
      cout << anotherCell.getValue() << endl; // OK
      anotherCell.setString("6");             // 编译错误!
    
  • 应该养成习惯,将不修改对象的所有方法声明为const,这样就可以在程序中引用const对象。注意const对象也会被销毁,它们的析构函数也会被调用,因此不应该将析构函数标记为const

mutable数据成员

  • 有时编写的方法“逻辑上”是const,但是碰巧改变了对象的数据成员。这个改动对于用户可见的数据没有任何影响,但在技术上确实做了改动,因此编译器不允许将这个方法声明为const。例如,假设电子表格应用程序要获取数据的读取频率。完成这个任务的基本办法是在SpreadsheetCell类中加入一个计数器,计算getValue()getString()调用的次数。遗憾的是,这样做使编译器认为这些方法是非const的,这并非你的本意。解决方法是将计数器变量设置为mutable,告诉编译器在const()方法中允许改变这个值。下面是新的SpreadsheetCell类的定义:

      class SpreadsheetCell
      {
      private:
          double mValue;
          string mString;
          mutable int mNumAccesses = 0;
      };
    
  • 下面是getValue()getString()的定义:

      double SpreadsheetCell::getValue() const
      {
          mNumAccesses++;
          return mValue;
      }
      const string& SpreadsheetCell::getString() const
      {
          mNumAccesses++;
          return mString;
      }
    

1.1.2 constexpr关键字

  • C++一直存在常量表达式的概念,在某些情况下需要常量表达式。例如,当定义数组时,数组的大小就必须是一个常量表达式。由于这一限制,下面的代码在C++中是无效的:

      const int getArraySize() { return 32; }
      int main(void)
      {
          int myArray[getArraySize()];        // 在C++中无效
          return 0;
      }
    
  • 可以使用constexpr关键字重新定义getArraySize()函数,把它变成常量表达式。常量表达式在编译时计算。(constexprC++11中的内容)

      constexpr int getArraySize() { return 32; }
      int main(void)
      {
          int myArray[getArraySize()];    // OK
          return 0;
      }
    
  • 甚至可以这样写:

      int myArray[getArraySize() + 1];    // OK
    

将函数声明为constexpr对函数的行为施加了一些限制,因为编译器必须在编译期间对constexpr函数求值,函数也不允许有任何副作用。下面是几个限制:

  • 函数体是一个return语句,它不包含goto语句或try catch块,也不能抛出异常,但是可以调用其他constexpr函数。
  • 函数的返回类型应该是字面量类型,返回值不能是void
  • 如果constexpr函数是类的一个成员,这个函数不能是虚函数。
  • 函数所有的参数都应该是字面量类型。
  • 在编译单元(translation unit)中定义了constexpr函数之后,才能调用这个函数,因为编译器需要知道完整的定义。
  • 不允许使用dynamic_cast
  • 不允许使用newdelete

通过定义constexpr构造函数,可以创建用户自定义类型的常量表达式变量。constexpr构造函数应该满足以下要求:

  • 构造函数的所有参数都应该是字面量类型。
  • 构造函数体不应该是function-try-block
  • 构造函数体应该满足于constexpr函数体相同的要求。
  • 所有数据成员都应该用常量表达式初始化。

例如,下面的Rect类定义了一个满足上述要求的constexpr构造函数,此外还定义了一个constexpr getArea()方法,执行一些计算。

    class Rect
    {
    public:
        constexpr Rect(int width, int height) : mWidth(width), mHeight(height) {}
        constexpr int getArea() const 
        { 
            return mWidth * mHeight; 
        }
    private:
        int mWidth;
        int mHeight;
    };
  • 使用这个类声明constexpr对象相当直接:

      constexpr Rect r(8, 2);
      int myArray[r.getArea()]; // OK
    

1.2 static关键字

  • 在C++中static有多种用法,这些用法之间没有太多关系。“重载”这个关键字的部分原因是避免在语言中引入新的关键字。

1.1.1 静态数据成员和方法

  • 可以声明类的静态数据成员和方法。静态数据成员与非静态数据成员不同,它不是对象的一部分。相反,这个数据成员只有一份副本,这个副本存在于类的任何对象之外。
  • 静态方法与此类型,存在于类层次(而不是对象层次)。静态方法不会在某个特定对象环境中执行。

(1) 静态数据成员

  • 有时让类的所有对象都包含某个变量的副本是没必要的,或者无法完成特定的任务。数据成员有可能只对类有意义,而每个对象都拥有其副本是不合适的。例如,每个电子表格或许需要一个唯一的数字ID,这需要一个从0开始的计数器,每个对象都可以从这个计数器得到自身的ID。电子表格的计数器确实属于Spreadsheet类,但没必要使每个Spreadsheet对象都包含这个计数器的副本,因为必须让所有的计数器都保持同步。

  • C++用静态(static)数据成员解决了这个问题。静态数据成员是属于类而不是对象的数据成员,可将静态数据成员当做类的全局变量。下面是Spreadsheet类的定义,其中包含了新的静态数据成员计数器:

      class Spreadsheet
      {
      private:
          static int sCounter;
      }
    
  • 不仅要在类定义中列出static类成员,还需要在源文件中为其分配内存,通常是定义类方法的那个源文件。在此还可以初始化静态成员,但注意与普通变量和数据成员不同,在默认情况下它会被初始化为0。static指针会初始化为nullptr。下面为sCounter分配空间并初始化的代码:

      int Spreadsheet::sCounter;
    
  • 这行代码在函数或者方法外部,与声明全局变量类似,只是使用了作用域解析Spreadsheet::指出这是Spreadsheet类的一部分。

    i. 在类方法内访问静态数据成员

  • 在类方法内部,可以像使用普通数据成员那样使用静态数据成员。例如,为Spreadsheet类创建一个mId成员,并在Spreadsheet构造函数中用sCounter成员初始化它。下面是包含了mId成员的Spreadsheet类定义:

      class Spreadsheet
      {
      public:
          int getId() const;
      private:
          static int sCounter;
          int mId;
      };
    
  • 下面是Spreadsheet构造函数的实现,在此赋予初始ID值:

      Spreadsheet::Spreadsheet(int inWidth, int inHeight) : mWidth(inWidth), mHeight(inHeight)
      {
          mId = sCounter++;
          mCells = new SpreadsheetCell* [mWidth];
          for (int i=0; i
  • 可以看出,构造函数可以访问sCounter,就像这是一个普通成员。在复制构造函数中,也要对ID赋值:

      Spreadsheet::Spreadsheet(const Spreadsheet& src)
      {
          mId = sCounter++;
          copyFrom(&src);
      }
    
  • 在赋值运算符中不应该复制ID。一旦给某个对象指定了ID,就不应该再改变。建议把mId设置为const数据成员。

    ii. 在方法外访问静态数据成员

  • 访问控制限定符适用于静态数据成员:sCounterprivate,因此不能在类方法之外访问。如果sCounter是公有的,就可以在类方法外访问,具体方法是用::作用域解析运算符指出这个变量是Spreadsheet类的一部分:

      int c = Spreadsheet::sCounter;
    
  • 然而,建议不要使用公有数据成员,应该提供公有get/set()方法来授予访问权限。如果要访问静态的数据成员,应该实现静态的get/set()方法。

(2) 静态方法

  • 与数据成员类似,方法有时会应用于全部类对象而不是单个对象,此时可以像静态数据成员那样编写静态方法。以SpreadsheetCell中的两个辅助方法为例:stringToDouble()doubleToString()。这些方法没有访问特定对象的信息,因此可以是静态的。下面的类定义将这些方法设置为静态:

      class SpreadsheetCell
      {
      private:
          static string doubleToString(double val);
          static double StringTodouble(string& str);
      };
    
  • 这两个方法的实现与前面的实现相同,在方法定义前不需要重复static关键字。然而,注意静态方法不属于特定对象,因此没有this指针,当用某个特定对象调用静态方法时,静态方法不会访问这个对象的非静态数据成员。实际上,静态方法就像一个普通函数,唯一的区别在于这个方法可以访问类的privateprotected静态数据成员。如果同一个类型的其他对象对于静态方法可见(例如传递了对象的指针或引用),静态方法也可以访问其他对象的privateprotected非静态数据成员。

  • 类中任何方法都可以像调用普通函数那样调用静态方法,因此SpreadsheetCell中所有方法的实现都没有改变。如果要在类的外面调用静态方法,需要用类名和作用域解析运算符来限定方法的名称(就像静态数据成员那样),静态方法的访问控制与普通方法一样。

  • stringToDouble()doubleToString()设置为public,这样类外面的代码也可以使用它们。此时,可以在任意位置这样调用这两个方法:

      string str = SpreadsheetCell::doubleToString(5.0);
    

1.1.2 静态链接(staitc Linkage)

  • 在解释用于链接的static关键字之前,首先要理解C++中链接的概念。C++每个源文件都是单独编译的,编译得到的目标文件会彼此链接。C++源文件中的每个名称,包括函数和全局变量,都有一个内部或者外部的链接。外部链接意味着这个名称在其他源文件中也有效,内部链接(也称作静态链接)意味着在其他源文件中无效。默认情况下:函数和全局变量都拥有外部链接。然而,可在声明前面使用关键字static指定内部(或者静态)链接。例如,假定有两个源文件FirstFile.cppAnotherFile.cpp,下面是FirstFile.cpp

      void f();
      int main(void)
      {
          f();
          return 0;
      }
    
  • 注意这个文件提供了f()的原型,但没有给出定义。下面是AnotherFile.cpp

      #include 
      using namespace std;
      void f();
      void f()
      {
          cout << "f()\n";
      }
    
  • 这个文件同时提供了f()的原型和定义。注意在两个不同文件中编写的相同函数的原型是合法的。如果将原型放在头文件中,并在每个源文件中都使用#include包含这个头文件,预处理器就会自动在每个源文件中给出函数原型。使用头文件的原因是便于维护(并保持同步)原型的一个副本。

  • 这两个源文件都可以编译成功,程序链接也没有问题:因为f()具有外部链接,main()可从另一个文件调用这个函数。

  • 现在假定在AnotherFile.cpp中将static应用到f()原型。注意不需要在f()的定义前面重复使用static关键字。只要在函数名称的第一个实例前面使用这个关键字,就不需要重复它:

      #include 
      using namespace std;
      static void f();
      void f()
      {
          cout << "f()\n";
      }
    
  • 现在每个源文件都可以成功编译,但是链接时将失败,因为f()具有内部(静态)链接,FirstFile.cpp无法使用这个函数。如果在源文件中定义了静态方法但是没有使用它,有些编译器会给出警告(指出这些方法不应该是静态的,因为其他文件可能会用到它们)。

  • static用于内部链接的另一种方式是使用匿名名称空间(anonymous namespace)。可将变量或者函数封装到一个没有名字的名称空间,而不是使用static,如下所示:

      #include 
      using namespace std;
      namespace {
          void f();
          void f()
          {
              cout << "f()\n";
          }
      }
    
  • 在同一源文件中,可在声明匿名名称空间之后的任何位置访问名称空间中的项,但不能在其他源文件中访问。这语义与static关键字相同。

extern关键字

  • extern关键字将它后面的名称指定为外部链接。在某些情况下面可以使用这种方法。例如,consttypedefe在默认情况下面是内部链接,可以使用extern使其变为外部链接。

  • 然而,extern有一点复杂。当指定某个名称为extern时,编译器将这条语句当做声明,而不是定义。对于变量而言,这意味着编译器不会为这个变量分配空间。必须为这个变量提供单独的、不适用extern关键字的定义行,例如:

      extern int x;
      int x = 4;
    
  • 也可以在extern行初始化x,这一行既是声明又是定义:

      extern int x = 1;
    
  • 这种情形下的extern并不是非常有用,因为x默认具有外部链接。当另一个源文件FirstFile.cpp使用x时,才会真正用到extern

      #include 
      using namespace std;
      extern int x;
      int main(void)
      {
          cout << x << endl;
      }
    
  • 在此FirstFile.cpp使用了extern声明,因此可以使用x。编译器需要一个x的声明,才能在main()函数中使用这个变量。然而,如果声明x时未使用extern关键字,编译器认为这是个定义,就会为x分配空间,导致链接步骤失败(因为有两个全局作用域的x变量)。使用extern,就可以在多个源代码中全局访问这个变量。

  • 然而,建议不要使用全局变量。全局变量会令人迷惑,并且容易出错,在大型程序中尤其如此。为了获取类似功能,可使用类的静态数据成员和方法。

1.1.3 函数中的静态变量

  • C++中static关键字的最终目的是创建离开和进入作用域时都可以保留值得局部变量。函数中的静态变量就像是一个只能在函数内部访问的全局变量。静态变量最常用的用法是“记住”某个函数是否执行了特定的初始化操作。例如,下面的代码就使用了这一技术:

      void performTask()
      {
          static bool initialized = false;
          if (!initialized)
          {
              cout << "Initializing\n";
              // Perform Initialization.
              initialized = true;
          }
          // Perform the desired task.
      }
    
  • 然而静态变量容易令人迷惑,在构建代码时通常有更好的方法,以避免使用静态变量。在此情况下,可编写一个类,用构造函数执行所需的初始化。

1.3 非局部变量的初始化顺序

  • 上面讨论了静态数据成员和全局变量的相关内容,以下讨论下这些变量的初始化顺序。程序中所有的全局变量和类的静态数据成员都会在main()开始之前初始化。给定源文件中的变量以在源文件中出现的顺序初始化。例如,在下面的文件中,Demo::x一定会在y之前初始化:

      class Demo
      {
      public:
          static int x;
      };
      int Demo::x = 4;
      int y = 5;
    
  • 然而,C++没有提供规范,说明在不同源文件中初始化非局部变量的顺序。如果在某个源文件中有一个全局变量x,在另一个文件中有一个全局变量y,无法知道哪个变量先初始化。通常,不需要关注这一规范的缺失,但是如果某个全局变量或者静态变量依赖于另一个变量,就可能引发问题。对象的初始化意味着调用构造函数,全局对象的构造函数可能会访问另一个全局对象,并假定另一个对象已经构建。如果这两个全局对象在不同的源文件中声明,就不能指望一个对象在另一个对象之前构建,也无法控制他们的初始化顺序。不同的编译器可能有不同的初始化顺序,即使同一编译器的不同版本也可能如此,甚至项目中添加另一个文件也会影响初始化顺序。

  • 警告:不同源文件中的非局部变量的初始化顺序是不确定的。

1.4 非局部变量的销毁顺序

  • 非局部变量按照其初始化的逆序进行销毁。不同源文件中的非局部变量的初始化顺序是不确定的。所以其销毁顺序也是不确定的。

你可能感兴趣的:(C++关键字的思考 (Boolan))